diff --git a/packages/@stimulus/core/src/tests/controllers/default_value_controller.ts b/packages/@stimulus/core/src/tests/controllers/default_value_controller.ts new file mode 100644 index 00000000..055d842c --- /dev/null +++ b/packages/@stimulus/core/src/tests/controllers/default_value_controller.ts @@ -0,0 +1,63 @@ +import { Controller } from "../../controller" +import { ValueDefinitionMap, ValueDescriptorMap } from "../../value_properties" + +export class DefaultValueController extends Controller { + static values: ValueDefinitionMap = { + defaultBoolean: false, + defaultBooleanTrue: { type: Boolean, default: true }, + defaultBooleanOverride: true, + + defaultString: "", + defaultStringHello: { type: String, default: "Hello" }, + defaultStringOverride: "Override me", + + defaultNumber: 0, + defaultNumberThousand: { type: Number, default: 1000 }, + defaultNumberOverride: 9999, + + defaultArray: [], + defaultArrayFilled: { type: Array, default: [1, 2, 3] }, + defaultArrayOverride: [9, 9, 9], + + defaultObject: {}, + defaultObjectPerson: { type: Object, default: { name: "David" } }, + defaultObjectOverride: { override: "me" } + } + + valueDescriptorMap!: ValueDescriptorMap + + defaultBooleanValue!: boolean + hasDefaultBooleanValue!: boolean + defaultBooleanTrueValue!: boolean + hasDefaultBooleanTrueValue!: boolean + defaultBooleanOverrideValue!: boolean + hasDefaultBooleanOverrideValue!: boolean + + defaultStringValue!: string + hasDefaultStringValue!: boolean + defaultStringHelloValue!: string + hasDefaultStringHelloValue!: boolean + defaultStringOverrideValue!: string + hasDefaultStringOverrideValue!: boolean + + defaultNumberValue!: number + hasDefaultNumberValue!: boolean + defaultNumberThousandValue!: number + hasDefaultNumberThousandValue!: boolean + defaultNumberOverrideValue!: number + hasDefaultNumberOverrideValue!: boolean + + defaultArrayValue!: any[] + hasDefaultArrayValue!: boolean + defaultArrayFilledValue!: { [key: string]: any } + hasDefaultArrayFilledValue!: boolean + defaultArrayOverrideValue!: { [key: string]: any } + hasDefaultArrayOverrideValue!: boolean + + defaultObjectValue!: object + hasDefaultObjectValue!: boolean + defaultObjectPersonValue!: object + hasDefaultObjectPersonValue!: boolean + defaultObjectOverrideValue!: object + hasDefaultObjectOverrideValue!: boolean +} diff --git a/packages/@stimulus/core/src/tests/modules/default_value_tests.ts b/packages/@stimulus/core/src/tests/modules/default_value_tests.ts new file mode 100644 index 00000000..567ec945 --- /dev/null +++ b/packages/@stimulus/core/src/tests/modules/default_value_tests.ts @@ -0,0 +1,185 @@ +import { ControllerTestCase } from "../cases/controller_test_case" +import { DefaultValueController } from "../controllers/default_value_controller" + +export default class DefaultValueTests extends ControllerTestCase(DefaultValueController) { + fixtureHTML = ` +
+ ` + + // Booleans + + "test custom default boolean values"() { + this.assert.deepEqual(this.controller.defaultBooleanValue, false) + this.assert.ok(this.controller.hasDefaultBooleanValue) + this.assert.deepEqual(this.get("default-boolean-value"), null) + + this.assert.deepEqual(this.controller.defaultBooleanTrueValue, true) + this.assert.ok(this.controller.hasDefaultBooleanTrueValue) + this.assert.deepEqual(this.get("default-boolean-true-value"), null) + } + + "test should be able to set a new value for custom default boolean values"() { + this.assert.deepEqual(this.get("default-boolean-true-value"), null) + this.assert.deepEqual(this.controller.defaultBooleanTrueValue, true) + this.assert.ok(this.controller.hasDefaultBooleanTrueValue) + + this.controller.defaultBooleanTrueValue = false + + this.assert.deepEqual(this.get("default-boolean-true-value"), "false") + this.assert.deepEqual(this.controller.defaultBooleanTrueValue, false) + this.assert.ok(this.controller.hasDefaultBooleanTrueValue) + } + + "test should override custom default boolean value with given data-attribute"() { + this.assert.deepEqual(this.get("default-boolean-override-value"), "false") + this.assert.deepEqual(this.controller.defaultBooleanOverrideValue, false) + this.assert.ok(this.controller.hasDefaultBooleanOverrideValue) + } + + // Strings + + "test custom default string values"() { + this.assert.deepEqual(this.controller.defaultStringValue, "") + this.assert.ok(this.controller.hasDefaultStringValue) + this.assert.deepEqual(this.get("default-string-value"), null) + + this.assert.deepEqual(this.controller.defaultStringHelloValue, "Hello") + this.assert.ok(this.controller.hasDefaultStringHelloValue) + this.assert.deepEqual(this.get("default-string-hello-value"), null) + + } + + "test should be able to set a new value for custom default string values"() { + this.assert.deepEqual(this.get("default-string-value"), null) + this.assert.deepEqual(this.controller.defaultStringValue, "") + this.assert.ok(this.controller.hasDefaultStringValue) + + this.controller.defaultStringValue = "New Value" + + this.assert.deepEqual(this.get("default-string-value"), "New Value") + this.assert.deepEqual(this.controller.defaultStringValue, "New Value") + this.assert.ok(this.controller.hasDefaultStringValue) + } + + "test should override custom default string value with given data-attribute"() { + this.assert.deepEqual(this.get("default-string-override-value"), "I am the expected value") + this.assert.deepEqual(this.controller.defaultStringOverrideValue, "I am the expected value") + this.assert.ok(this.controller.hasDefaultStringOverrideValue) + } + + // Numbers + + "test custom default number values"() { + this.assert.deepEqual(this.controller.defaultNumberValue, 0) + this.assert.ok(this.controller.hasDefaultNumberValue) + this.assert.deepEqual(this.get("default-number-value"), null) + + this.assert.deepEqual(this.controller.defaultNumberThousandValue, 1000) + this.assert.ok(this.controller.hasDefaultNumberThousandValue) + this.assert.deepEqual(this.get("default-number-thousand-value"), null) + } + + "test should be able to set a new value for custom default number values"() { + this.assert.deepEqual(this.get("default-number-value"), null) + this.assert.deepEqual(this.controller.defaultNumberValue, 0) + this.assert.ok(this.controller.hasDefaultNumberValue) + + this.controller.defaultNumberValue = 123 + + this.assert.deepEqual(this.get("default-number-value"), "123") + this.assert.deepEqual(this.controller.defaultNumberValue, 123) + this.assert.ok(this.controller.hasDefaultNumberValue) + } + + "test should override custom default number value with given data-attribute"() { + this.assert.deepEqual(this.get("default-number-override-value"), "42") + this.assert.deepEqual(this.controller.defaultNumberOverrideValue, 42) + this.assert.ok(this.controller.hasDefaultNumberOverrideValue) + } + + // Arrays + + "test custom default array values"() { + this.assert.deepEqual(this.controller.defaultArrayValue, []) + this.assert.ok(this.controller.hasDefaultArrayValue) + this.assert.deepEqual(this.get("default-array-value"), null) + + this.assert.deepEqual(this.controller.defaultArrayFilledValue, [1, 2, 3]) + this.assert.ok(this.controller.hasDefaultArrayFilledValue) + this.assert.deepEqual(this.get("default-array-filled-value"), null) + } + + "test should be able to set a new value for custom default array values"() { + this.assert.deepEqual(this.get("default-array-value"), null) + this.assert.deepEqual(this.controller.defaultArrayValue, []) + this.assert.ok(this.controller.hasDefaultArrayValue) + + this.controller.defaultArrayValue = [1, 2] + + this.assert.deepEqual(this.get("default-array-value"), "[1,2]") + this.assert.deepEqual(this.controller.defaultArrayValue, [1, 2]) + this.assert.ok(this.controller.hasDefaultArrayValue) + } + + "test should override custom default array value with given data-attribute"() { + this.assert.deepEqual(this.get("default-array-override-value"), "[9,8,7]") + this.assert.deepEqual(this.controller.defaultArrayOverrideValue, [9, 8, 7]) + this.assert.ok(this.controller.hasDefaultArrayOverrideValue) + } + + // Objects + + "test custom default object values"() { + this.assert.deepEqual(this.controller.defaultObjectValue, {}) + this.assert.ok(this.controller.hasDefaultObjectValue) + this.assert.deepEqual(this.get("default-object-value"), null) + + this.assert.deepEqual(this.controller.defaultObjectPersonValue, { name: "David" }) + this.assert.ok(this.controller.hasDefaultObjectPersonValue) + this.assert.deepEqual(this.get("default-object-filled-value"), null) + } + + "test should be able to set a new value for custom default object values"() { + this.assert.deepEqual(this.get("default-object-value"), null) + this.assert.deepEqual(this.controller.defaultObjectValue, {}) + this.assert.ok(this.controller.hasDefaultObjectValue) + + this.controller.defaultObjectValue = { new: "value" } + + this.assert.deepEqual(this.get("default-object-value"), "{\"new\":\"value\"}") + this.assert.deepEqual(this.controller.defaultObjectValue, { new: "value" }) + this.assert.ok(this.controller.hasDefaultObjectValue) + } + + "test should override custom default object value with given data-attribute"() { + this.assert.deepEqual(this.get("default-object-override-value"), "{\"expected\":\"value\"}") + this.assert.deepEqual(this.controller.defaultObjectOverrideValue, { expected: "value" }) + this.assert.ok(this.controller.hasDefaultObjectOverrideValue) + } + + has(name: string) { + return this.element.hasAttribute(this.attr(name)) + } + + get(name: string) { + return this.element.getAttribute(this.attr(name)) + } + + set(name: string, value: string) { + return this.element.setAttribute(this.attr(name), value) + } + + attr(name: string) { + return `data-${this.identifier}-${name}` + } + + get element() { + return this.controller.element + } +} diff --git a/packages/@stimulus/core/src/value_observer.ts b/packages/@stimulus/core/src/value_observer.ts index 7a7b1ab7..e6ea2563 100644 --- a/packages/@stimulus/core/src/value_observer.ts +++ b/packages/@stimulus/core/src/value_observer.ts @@ -45,43 +45,54 @@ export class ValueObserver implements StringMapObserverDelegate { const descriptor = this.valueDescriptorMap[attributeName] if (!this.hasValue(key)) { - this.invokeChangedCallbackForValue(key, descriptor.defaultValue) + this.invokeChangedCallback(key, descriptor.writer(this.receiver[key]), descriptor.writer(descriptor.defaultValue)) } } - stringMapValueChanged(value: string | null, name: string, oldValue: string | null) { - this.invokeChangedCallbackForValue(name, oldValue) + stringMapValueChanged(value: string, name: string, oldValue: string) { + const descriptor = this.valueDescriptorNameMap[name] + + if (value === null) return + + if (oldValue === null) { + oldValue = descriptor.writer(descriptor.defaultValue) + } + + this.invokeChangedCallback(name, value, oldValue) } - stringMapKeyRemoved(key: string, attributeName: string, oldValue: string | null) { + stringMapKeyRemoved(key: string, attributeName: string, oldValue: string) { + const descriptor = this.valueDescriptorNameMap[key] + if (this.hasValue(key)) { - this.invokeChangedCallbackForValue(key, oldValue) + this.invokeChangedCallback(key, descriptor.writer(this.receiver[key]), oldValue) + } else { + this.invokeChangedCallback(key, descriptor.writer(descriptor.defaultValue), oldValue) } } private invokeChangedCallbacksForDefaultValues() { - for (const { key, name, defaultValue } of this.valueDescriptors) { + for (const { key, name, defaultValue, writer } of this.valueDescriptors) { if (defaultValue != undefined && !this.controller.data.has(key)) { - this.invokeChangedCallbackForValue(name, null) + this.invokeChangedCallback(name, writer(defaultValue), undefined) } } } - private invokeChangedCallbackForValue(name: string, oldValue: string | null) { + private invokeChangedCallback(name: string, rawValue: string, rawOldValue: string | undefined) { const changedMethodName = `${name}Changed` const changedMethod = this.receiver[changedMethodName] if (typeof changedMethod == "function") { - const value = this.receiver[name] const descriptor = this.valueDescriptorNameMap[name] + const value = descriptor.reader(rawValue) + let oldValue = rawOldValue - if (oldValue) { - changedMethod.call(this.receiver, value, descriptor.reader(oldValue)) - } else if (this.hasValue(name)) { - changedMethod.call(this.receiver, value, descriptor.defaultValue) - } else { - changedMethod.call(this.receiver, value) + if (rawOldValue) { + oldValue = descriptor.reader(rawOldValue) } + + changedMethod.call(this.receiver, value, oldValue) } } diff --git a/packages/@stimulus/core/src/value_properties.ts b/packages/@stimulus/core/src/value_properties.ts index 95f366d3..5b50db11 100644 --- a/packages/@stimulus/core/src/value_properties.ts +++ b/packages/@stimulus/core/src/value_properties.ts @@ -4,7 +4,7 @@ import { readInheritableStaticObjectPairs } from "./inheritable_statics" import { camelize, capitalize, dasherize } from "./string_helpers" export function ValuePropertiesBlessing(constructor: Constructor) { - const valueDefinitionPairs = readInheritableStaticObjectPairs(constructor, "values") + const valueDefinitionPairs = readInheritableStaticObjectPairs(constructor, "values") const propertyDescriptorMap: PropertyDescriptorMap = { valueDescriptorMap: { get(this: Controller) { @@ -48,7 +48,7 @@ export function propertiesForValueDefinitionPair(valueDefinitionPair: ValueDe [`has${capitalize(name)}`]: { get(this: Controller): boolean { - return this.data.has(key) + return this.data.has(key) || definition.hasCustomDefaultValue } } } @@ -58,44 +58,97 @@ export type ValueDescriptor = { type: ValueType, key: string, name: string, - defaultValue: any, + defaultValue: ValueTypeDefault, + hasCustomDefaultValue: boolean, reader: Reader, writer: Writer } export type ValueDescriptorMap = { [attributeName: string]: ValueDescriptor } -export type ValueDefinitionMap = { [token: string]: ValueTypeConstant } +export type ValueDefinitionMap = { [token: string]: ValueTypeDefinition } -export type ValueDefinitionPair = [string, ValueTypeConstant] +export type ValueDefinitionPair = [string, ValueTypeDefinition] export type ValueTypeConstant = typeof Array | typeof Boolean | typeof Number | typeof Object | typeof String +export type ValueTypeDefault = Array | Boolean | Number | Object | String + +export type ValueTypeObject = { type: ValueTypeConstant, default: ValueTypeDefault } + +export type ValueTypeDefinition = ValueTypeConstant | ValueTypeDefault | ValueTypeObject + export type ValueType = "array" | "boolean" | "number" | "object" | "string" -function parseValueDefinitionPair([token, typeConstant]: ValueDefinitionPair): ValueDescriptor { - const type = parseValueTypeConstant(typeConstant) - return valueDescriptorForTokenAndType(token, type) +function parseValueDefinitionPair([token, typeDefinition]: ValueDefinitionPair): ValueDescriptor { + return valueDescriptorForTokenAndTypeDefinition(token, typeDefinition) } -function parseValueTypeConstant(typeConstant: ValueTypeConstant) { - switch (typeConstant) { +function parseValueTypeConstant(constant: ValueTypeConstant) { + switch (constant) { case Array: return "array" case Boolean: return "boolean" case Number: return "number" case Object: return "object" case String: return "string" } - throw new Error(`Unknown value type constant "${typeConstant}"`) } -function valueDescriptorForTokenAndType(token: string, type: ValueType) { +function parseValueTypeDefault(defaultValue: ValueTypeDefault) { + switch (typeof defaultValue) { + case "boolean": return "boolean" + case "number": return "number" + case "string": return "string" + } + + if (Array.isArray(defaultValue)) return "array" + if (Object.prototype.toString.call(defaultValue) === "[object Object]") return "object" +} + +function parseValueTypeObject(typeObject: ValueTypeObject) { + const typeFromObject = parseValueTypeConstant(typeObject.type) + + if (typeFromObject) { + const defaultValueType = parseValueTypeDefault(typeObject.default) + + if (typeFromObject !== defaultValueType) { + throw new Error(`Type "${typeFromObject}" must match the type of the default value. Given default value: "${typeObject.default}" as "${defaultValueType}"`) + } + + return typeFromObject + } +} + +function parseValueTypeDefinition(typeDefinition: ValueTypeDefinition): ValueType { + const typeFromObject = parseValueTypeObject(typeDefinition as ValueTypeObject) + const typeFromDefaultValue = parseValueTypeDefault(typeDefinition as ValueTypeDefault) + const typeFromConstant = parseValueTypeConstant(typeDefinition as ValueTypeConstant) + + const type = typeFromObject || typeFromDefaultValue || typeFromConstant + if (type) return type + + throw new Error(`Unknown value type "${typeDefinition}"`) +} + +function defaultValueForDefinition(typeDefinition: ValueTypeDefinition): ValueTypeDefault { + const constant = parseValueTypeConstant(typeDefinition as ValueTypeConstant) + + if (constant) return defaultValuesByType[constant] + + const defaultValue = (typeDefinition as ValueTypeObject).default + + return defaultValue || typeDefinition +} + +function valueDescriptorForTokenAndTypeDefinition(token: string, typeDefinition: ValueTypeDefinition) { const key = `${dasherize(token)}-value` + const type = parseValueTypeDefinition(typeDefinition) return { type, key, name: camelize(key), - get defaultValue() { return defaultValuesByType[type] }, + get defaultValue() { return defaultValueForDefinition(typeDefinition) }, + get hasCustomDefaultValue() { return parseValueTypeDefault(typeDefinition) !== undefined }, reader: readers[type], writer: writers[type] || writers.default }