diff --git a/forms4s-core/src/main/scala/forms4s/DefaultValues.scala b/forms4s-core/src/main/scala/forms4s/DefaultValues.scala new file mode 100644 index 0000000..20a8c64 --- /dev/null +++ b/forms4s-core/src/main/scala/forms4s/DefaultValues.scala @@ -0,0 +1,41 @@ +package forms4s + +import scala.quoted.* + +object DefaultValues { + + inline def extract[T]: Map[String, Any] = ${ extractImpl[T] } + + private def extractImpl[T](using quotes: Quotes, tpe: Type[T]): Expr[Map[String, Any]] = { + import quotes.reflect.* + + val sym = TypeTree.of[T].symbol + + // Get the companion module + val companionSym = sym.companionModule + if (companionSym == Symbol.noSymbol) { + return '{ Map.empty[String, Any] } + } + + val comp = sym.companionClass + val mod = Ref(companionSym) + + // Get field names that have defaults + val names = sym.caseFields.filter(p => p.flags.is(Flags.HasDefault)).map(_.name) + + val namesExpr: Expr[List[String]] = Expr.ofList(names.map(Expr(_))) + + // Get the companion class body + val body = comp.tree.asInstanceOf[ClassDef].body + + // Find default value methods ($lessinit$greater$default$N) + val idents: List[Ref] = body.collect { + case deff @ DefDef(name, _, _, _) if name.startsWith("$lessinit$greater$default") => + mod.select(deff.symbol) + } + + val identsExpr: Expr[List[Any]] = Expr.ofList(idents.map(_.asExpr)) + + '{ $namesExpr.zip($identsExpr).toMap } + } +} diff --git a/forms4s-core/src/main/scala/forms4s/FormElement.scala b/forms4s-core/src/main/scala/forms4s/FormElement.scala index e90146a..8518cd7 100644 --- a/forms4s-core/src/main/scala/forms4s/FormElement.scala +++ b/forms4s-core/src/main/scala/forms4s/FormElement.scala @@ -49,6 +49,6 @@ object FormElement { case class State(selected: Int, states: Vector[FormElementState]) } - case class Core[-T](id: String, label: String, description: Option[String], validators: Seq[Validator[T]]) + case class Core[-T](id: String, label: String, description: Option[String], validators: Seq[Validator[T]], defaultValue: Option[Any] = None) } diff --git a/forms4s-core/src/main/scala/forms4s/FormElementState.scala b/forms4s-core/src/main/scala/forms4s/FormElementState.scala index 0fe9571..4cb75d9 100644 --- a/forms4s-core/src/main/scala/forms4s/FormElementState.scala +++ b/forms4s-core/src/main/scala/forms4s/FormElementState.scala @@ -61,11 +61,32 @@ object FormElementState { def empty[T <: FormElement](elem: T): ForElem[T] = { def go[T <: FormElement](elem: T, parentPath: FormElementPath): ForElem[T] = elem match { - case x: FormElement.Text => Text(x, "", Nil, parentPath) - case x: FormElement.Select => Select(x, x.options.headOption.getOrElse(""), Nil, parentPath) - case x: FormElement.Checkbox => Checkbox(x, false, Nil, parentPath) + case x: FormElement.Text => + val defaultValue = x.core.defaultValue.flatMap { + case s: String => Some(s) + case _ => None + }.getOrElse("") + Text(x, defaultValue, Nil, parentPath) + case x: FormElement.Select => + val defaultValue = x.core.defaultValue.flatMap { + case s: String => Some(s) + case _ => None + }.getOrElse(x.options.headOption.getOrElse("")) + Select(x, defaultValue, Nil, parentPath) + case x: FormElement.Checkbox => + val defaultValue = x.core.defaultValue.flatMap { + case b: Boolean => Some(b) + case _ => None + }.getOrElse(false) + Checkbox(x, defaultValue, Nil, parentPath) case x: FormElement.Group => Group(x, x.elements.map(go(_, parentPath / x.core.id)), Nil, parentPath) - case x: FormElement.Number => Number(x, None, Nil, parentPath) + case x: FormElement.Number => + val defaultValue = x.core.defaultValue.flatMap { + case i: Int => Some(i.toDouble) + case d: Double => Some(d) + case _ => None + } + Number(x, defaultValue, Nil, parentPath) case x: FormElement.Multivalue => Multivalue(x, Vector(), Nil, parentPath) case x: FormElement.Alternative => Alternative(x, FormElement.Alternative.State(0, x.variants.toVector.map(go(_, parentPath / x.core.id))), Seq(), parentPath) diff --git a/forms4s-core/src/main/scala/forms4s/ToFormElem.scala b/forms4s-core/src/main/scala/forms4s/ToFormElem.scala index 3a8f7cf..ca1b524 100644 --- a/forms4s-core/src/main/scala/forms4s/ToFormElem.scala +++ b/forms4s-core/src/main/scala/forms4s/ToFormElem.scala @@ -48,6 +48,7 @@ object ToFormElem { // get compile-time labels & ToFormElem instances val labels = elemLabels[p.MirroredElemLabels] val elems = summonAll[p.MirroredElemTypes] + val defaults = DefaultValues.extract[T] // build a Group StaticToFormElem( @@ -60,8 +61,9 @@ object ToFormElem { ), elements = elems.zip(labels).map { (tf, name) => val fe = tf.get + val defaultValue = defaults.get(name) // copy into each element its field‐name as id & human label - updateCore(fe)(id = name, label = name.capitalize) + updateCore(fe)(id = name, label = name.capitalize, defaultValue = defaultValue) }, ), ) @@ -101,13 +103,13 @@ object ToFormElem { } // –– Helpers: update the Core.id & Core.label of any FormElement ––– - private def updateCore(fe: FormElement)(id: String, label: String): FormElement = fe match { - case Text(core, fmt) => Text(core.copy(id = id, label = label), fmt) - case Number(core, isInt) => Number(core.copy(id = id, label = label), isInt) - case Select(core, opts) => Select(core.copy(id = id, label = label), opts) - case Checkbox(core) => Checkbox(core.copy(id = id, label = label)) - case Group(core, elems) => Group(core.copy(id = id, label = label), elems) - case Multivalue(core, item) => Multivalue(core.copy(id = id, label = label), item) - case Alternative(core, vs, d) => Alternative(core.copy(id = id, label = label), vs, d) + private def updateCore(fe: FormElement)(id: String, label: String, defaultValue: Option[Any]): FormElement = fe match { + case Text(core, fmt) => Text(core.copy(id = id, label = label, defaultValue = defaultValue), fmt) + case Number(core, isInt) => Number(core.copy(id = id, label = label, defaultValue = defaultValue), isInt) + case Select(core, opts) => Select(core.copy(id = id, label = label, defaultValue = defaultValue), opts) + case Checkbox(core) => Checkbox(core.copy(id = id, label = label, defaultValue = defaultValue)) + case Group(core, elems) => Group(core.copy(id = id, label = label, defaultValue = defaultValue), elems) + case Multivalue(core, item) => Multivalue(core.copy(id = id, label = label, defaultValue = defaultValue), item) + case Alternative(core, vs, d) => Alternative(core.copy(id = id, label = label, defaultValue = defaultValue), vs, d) } } diff --git a/forms4s-core/src/test/scala/forms4s/ToFormElemTest.scala b/forms4s-core/src/test/scala/forms4s/ToFormElemTest.scala index 9f5691b..d5247f4 100644 --- a/forms4s-core/src/test/scala/forms4s/ToFormElemTest.scala +++ b/forms4s-core/src/test/scala/forms4s/ToFormElemTest.scala @@ -12,6 +12,8 @@ class ToFormElemTest extends AnyFreeSpec { case class Card(number: String) extends Payment derives ToFormElem } + case class PersonWithDefaults(name: String = "John Doe", age: Int = 30, isMember: Boolean = true) derives ToFormElem + "group" in { assert( summon[ToFormElem[Person]].get == FormElement.Group( @@ -43,4 +45,27 @@ class ToFormElemTest extends AnyFreeSpec { ) } + "default values in form elements" in { + val formElem = summon[ToFormElem[PersonWithDefaults]].get + formElem match { + case FormElement.Group(_, elements) => + assert(elements(0).core.defaultValue.contains("John Doe")) + assert(elements(1).core.defaultValue.contains(30)) + assert(elements(2).core.defaultValue.contains(true)) + case _ => fail("Expected Group") + } + } + + "default values in form state" in { + val formElem = summon[ToFormElem[PersonWithDefaults]].get + val state = FormElementState.empty(formElem) + state match { + case FormElementState.Group(_, values, _, _) => + assert(values(0).asInstanceOf[FormElementState.Text].value == "John Doe") + assert(values(1).asInstanceOf[FormElementState.Number].value.contains(30.0)) + assert(values(2).asInstanceOf[FormElementState.Checkbox].value == true) + case _ => fail("Expected Group") + } + } + } diff --git a/forms4s-jsonschema/src/main/scala/forms4s/jsonschema/FormFromJsonSchema.scala b/forms4s-jsonschema/src/main/scala/forms4s/jsonschema/FormFromJsonSchema.scala index 4c0a004..c928f52 100644 --- a/forms4s-jsonschema/src/main/scala/forms4s/jsonschema/FormFromJsonSchema.scala +++ b/forms4s-jsonschema/src/main/scala/forms4s/jsonschema/FormFromJsonSchema.scala @@ -90,7 +90,13 @@ object FormFromJsonSchema { ): Ior[List[String], FormElement] = { val name = nameOverride.getOrElse(schema.title.getOrElse("unknown")) val label = schema.title.getOrElse(capitalizeAndSplitWords(name)) - def core[T](validators: Seq[Validator[T]]) = FormElement.Core(name, label, schema.description, validators) + + val defaultValue = schema.default.flatMap { + case ExampleSingleValue(v) => Some(v) + case _ => None + } + + def core[T](validators: Seq[Validator[T]]) = FormElement.Core(name, label, schema.description, validators, defaultValue) val variants = schema.oneOf ++ schema.anyOf if (variants.nonEmpty) { diff --git a/forms4s-jsonschema/src/test/scala/forms4s/jsonschema/FormFromJsonSchemaSpec.scala b/forms4s-jsonschema/src/test/scala/forms4s/jsonschema/FormFromJsonSchemaSpec.scala index 03f782b..9ea6bd8 100644 --- a/forms4s-jsonschema/src/test/scala/forms4s/jsonschema/FormFromJsonSchemaSpec.scala +++ b/forms4s-jsonschema/src/test/scala/forms4s/jsonschema/FormFromJsonSchemaSpec.scala @@ -237,6 +237,33 @@ class FormFromJsonSchemaSpec extends AnyFreeSpec { } } + "default values" in { + import sttp.apispec.{Schema => ASchema, ExampleSingleValue, SchemaType} + import scala.collection.immutable.ListMap + + val schema = ASchema( + title = Some("WithDefaults"), + `type` = Some(List(SchemaType.Object)), + properties = ListMap( + "a" -> ASchema( + `type` = Some(List(SchemaType.String)), + default = Some(ExampleSingleValue("default")) + ) + ) + ) + + val form = FormFromJsonSchema.convert(schema) + + val expected = FormElement.Group( + simpleCore("WithDefaults"), + List( + FormElement.Text(simpleCore("a", "A").copy(defaultValue = Some("default")), Format.Raw), + ), + ) + + assert(form == expected) + } + } def getForm[T](nullableOptions: Boolean = false)(implicit tschema: TSchema[T]): FormElement = {