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
}