diff --git a/guinep/src/main/scala/macros.scala b/guinep/src/main/scala/macros.scala index d0426b9..368e8e0 100644 --- a/guinep/src/main/scala/macros.scala +++ b/guinep/src/main/scala/macros.scala @@ -141,6 +141,12 @@ private[guinep] object macros { FormElement.FloatingNumberInput(paramName, Types.FloatingType.Double) case AppliedType(ntpe: NamedType, List(tpeArg)) if listLikeSymbolsTypes.contains(ntpe.typeSymbol) => FormElement.ListInput(paramName, functionFormElementFromTreeWithCaching("elem", tpeArg), listLikeSymbolsTypes(ntpe.typeSymbol)) + case OrType(ltpe, rtpe) if ltpe =:= TypeRepr.of[Null] || rtpe =:= TypeRepr.of[Null] => + val innerForm = if ltpe =:= TypeRepr.of[Null] then + functionFormElementFromTreeWithCaching(paramName, rtpe) + else + functionFormElementFromTreeWithCaching(paramName, ltpe) + FormElement.Nullable(paramName, innerForm) case ntpe if isProductTpe(ntpe) => val classSymbol = ntpe.typeSymbol val typeDefParams = classSymbol.primaryConstructor.paramSymss.flatten.filter(_.isTypeParam) @@ -255,6 +261,9 @@ private[guinep] object macros { param.select(s"asInstanceOf").appliedToType(ntpe) case AppliedType(ntpe: NamedType, List(tpeArg)) if listLikeSymbolsTypes.contains(ntpe.typeSymbol) => param.select("asInstanceOf").appliedToType(paramTpe) + case OrType(ltpe, rtpe) if ltpe =:= TypeRepr.of[Null] || rtpe =:= TypeRepr.of[Null] => + val castedParam = param.select("asInstanceOf").appliedToType(paramTpe) + '{ if ${param.asExpr} == null then null else ${castedParam.asExpr} }.asTerm case ntpe if isCaseObjectTpe(ntpe) && ntpe.typeSymbol.flags.is(Flags.Module) => Ref(ntpe.typeSymbol.companionModule) case ntpe if isCaseObjectTpe(ntpe) => diff --git a/guinep/src/main/scala/model.scala b/guinep/src/main/scala/model.scala index 691df08..26cda6e 100644 --- a/guinep/src/main/scala/model.scala +++ b/guinep/src/main/scala/model.scala @@ -47,7 +47,6 @@ private[guinep] object model { case List case Seq case Vector - case Array object ListType: given ToExpr[ListType] with @@ -74,6 +73,7 @@ private[guinep] object model { case PasswordInput(override val name: String) extends FormElement(name) case FieldSet(override val name: String, elements: List[FormElement]) extends FormElement(name) case NamedRef(override val name: String, ref: String) extends FormElement(name) + case Nullable(override val name: String, element: FormElement) extends FormElement(name) def constrOrd: Int = this match case TextInput(_) => 0 @@ -89,6 +89,7 @@ private[guinep] object model { case PasswordInput(_) => 7 case FieldSet(_, _) => 8 case NamedRef(_, _) => 9 + case Nullable(_, elem) => elem.constrOrd object FormElement: given ToExpr[FormElement] with @@ -119,6 +120,8 @@ private[guinep] object model { '{ FormElement.PasswordInput(${Expr(name)}) } case FormElement.NamedRef(name, ref) => '{ FormElement.NamedRef(${Expr(name)}, ${Expr(ref)}) } + case FormElement.Nullable(name, element) => + '{ FormElement.Nullable(${Expr(name)}, ${Expr(element)}) } // This ordering is a hack to avoid placing recursive constructors as first options in a dropdown given Ordering[FormElement] = new Ordering[FormElement] { @@ -130,6 +133,10 @@ private[guinep] object model { elems1.size - elems2.size case (FormElement.Dropdown(_, opts1), FormElement.Dropdown(_, opts2)) => opts1.size - opts2.size + case (FormElement.Nullable(_, elem1), FormElement.Nullable(_, elem2)) => + compare(elem1, elem2) + case (FormElement.ListInput(_, elem1, _), FormElement.ListInput(_, elem2, _)) => + compare(elem1, elem2) case _ => 0 } } diff --git a/guinep/src/test/scala/formgentests.scala b/guinep/src/test/scala/formgentests.scala index ee5af12..4e8eb84 100644 --- a/guinep/src/test/scala/formgentests.scala +++ b/guinep/src/test/scala/formgentests.scala @@ -265,6 +265,17 @@ class FormGenTests extends munit.FunSuite { ) ) + checkGeneratedFormEquals( + "showNullableInt", + showNullableInt, + Form( + Seq( + FormElement.Nullable("i", FormElement.NumberInput("i", Types.IntType.Int)) + ), + Map.empty + ) + ) + checkGeneratedFormEquals( "isInTree", isInTree, diff --git a/guinep/src/test/scala/rungentests.scala b/guinep/src/test/scala/rungentests.scala index 8d18ceb..36580d3 100644 --- a/guinep/src/test/scala/rungentests.scala +++ b/guinep/src/test/scala/rungentests.scala @@ -207,6 +207,20 @@ class RunGenTests extends munit.FunSuite { "6.0" ) + checkGeneratedRunResultEquals( + "showNullableInt", + showNullableInt, + List(null), + "null" + ) + + checkGeneratedRunResultEquals( + "showNullableInt", + showNullableInt, + List(1), + "1" + ) + checkGeneratedRunResultEquals( "isInTree", isInTree, diff --git a/guinep/src/test/scala/testsdata.scala b/guinep/src/test/scala/testsdata.scala index 3f7814b..c3838cc 100644 --- a/guinep/src/test/scala/testsdata.scala +++ b/guinep/src/test/scala/testsdata.scala @@ -82,6 +82,9 @@ object TestsData { def productSeq(s: Seq[Float]): Float = s.product + def showNullableInt(i: Int | Null): String = + if i == null then "null" else i.toString + enum IntTree: case Leaf case Node(left: IntTree, value: Int, right: IntTree) diff --git a/testcases/src/main/scala/main.scala b/testcases/src/main/scala/main.scala index 84619fc..ec2caca 100644 --- a/testcases/src/main/scala/main.scala +++ b/testcases/src/main/scala/main.scala @@ -102,29 +102,35 @@ def sumVector(v: Vector[Int]): Int = def seqProduct(seq: Seq[Float]): Float = seq.product +def showNullableInt(i: Int | Null): String = + if i == null then "null" else i.toString + @main def run: Unit = - guinep.web( - upperCaseText, - add, - concat, - giveALongText, - addObj, - greetMaybeName, - greetInLanguage, - nameWithPossiblePrefix, - nameWithPossiblePrefix1, - roll20, - roll6(), - concatAll, - showDouble, - divideFloats, - codeOfChar, - isInTree, - listProduct, - sumVector, - seqProduct, - // isInTreeExt - // addManyParamLists - // printsWeirdGADT - ) + guinep.web + .withModifyConfig(_.copy(requireNonNullableInputs = true)) + .apply( + upperCaseText, + add, + concat, + giveALongText, + addObj, + greetMaybeName, + greetInLanguage, + nameWithPossiblePrefix, + nameWithPossiblePrefix1, + roll20, + roll6(), + concatAll, + showDouble, + divideFloats, + codeOfChar, + isInTree, + listProduct, + sumVector, + seqProduct, + showNullableInt + // isInTreeExt + // addManyParamLists + // printsWeirdGADT + ) diff --git a/web/src/main/scala/api.scala b/web/src/main/scala/api.scala index c7ad39e..6bad335 100644 --- a/web/src/main/scala/api.scala +++ b/web/src/main/scala/api.scala @@ -31,10 +31,10 @@ object GuinepWebConfig: GuinepWebConfig() private case class GuinepWeb(config: GuinepWebConfig = GuinepWebConfig.default) { - def withSetConfig(config: GuinepWebConfig): Unit = + def withSetConfig(config: GuinepWebConfig): GuinepWeb = this.copy(config = config) - def withModifyConfig(f: GuinepWebConfig => GuinepWebConfig): Unit = + def withModifyConfig(f: GuinepWebConfig => GuinepWebConfig): GuinepWeb = withSetConfig(f(config)) /** diff --git a/web/src/main/scala/htmlgen.scala b/web/src/main/scala/htmlgen.scala index 84432de..e32e0ee 100644 --- a/web/src/main/scala/htmlgen.scala +++ b/web/src/main/scala/htmlgen.scala @@ -159,6 +159,12 @@ private[guinep] trait HtmlGen { input.name = formElem.name; input.id = formElem.name; input.placeholder = formElem.name; + if (formElem.nullable) { + input.setAttribute('nullable', formElem.nullable); + } + if (${config.requireNonNullableInputs} && !formElem.nullable) { + input.setAttribute('required', !formElem.nullable); + } form.insertBefore(input, before); form.insertBefore(br.cloneNode(), before); } else if (formElem.type == 'char') { @@ -172,6 +178,12 @@ private[guinep] trait HtmlGen { input.name = formElem.name; input.id = formElem.name; input.placeholder = formElem.name; + if (formElem.nullable) { + input.setAttribute('nullable', formElem.nullable); + } + if (${config.requireNonNullableInputs} && !formElem.nullable) { + input.setAttribute('required', !formElem.nullable); + } form.insertBefore(input, before); form.insertBefore(br.cloneNode(), before); } else { @@ -184,6 +196,12 @@ private[guinep] trait HtmlGen { input.name = formElem.name; input.id = formElem.name; input.placeholder = formElem.name; + if (formElem.nullable) { + input.setAttribute('nullable', formElem.nullable); + } + if (${config.requireNonNullableInputs} && !formElem.nullable) { + input.setAttribute('required', !formElem.nullable); + } form.insertBefore(input, before); form.insertBefore(br.cloneNode(), before); } @@ -240,6 +258,8 @@ private[guinep] trait HtmlGen { const value = element.value; if (element.type === 'checkbox') { return [name, element.checked]; + } else if (element.getAttribute('nullable') && value === '') { + return [name, null]; } else { return [name, value]; } diff --git a/web/src/main/scala/serialization.scala b/web/src/main/scala/serialization.scala index 42aa889..f74cfd2 100644 --- a/web/src/main/scala/serialization.scala +++ b/web/src/main/scala/serialization.scala @@ -77,45 +77,51 @@ private[guinep] object serialization: case guinep.model.Types.ListType.List => res case guinep.model.Types.ListType.Seq => res.toSeq case guinep.model.Types.ListType.Vector => res.toVector + case FormElement.Nullable(_, element) => + value match + case Null => Right(null) + case _ => element.parseJSONValue(value) case _ => Left(s"Unsupported form element: $formElement") extension (form: Form) def formElementsJSONRepr = - val elems = form.inputs.map(_.toJSONRepr).mkString(",") + val elems = form.inputs.map(_.toJSONRepr()).mkString(",") s"[$elems]" def namedFormElementsJSONRepr: String = val entries = form.namedFormElements.toList.map { (name, formElement) => - s""""$name": ${formElement.toJSONRepr}""" + s""""$name": ${formElement.toJSONRepr()}""" } .mkString(",") s"{$entries}" extension (formElement: FormElement) - def toJSONRepr: String = formElement match + def toJSONRepr(nullable: Boolean = false): String = formElement match case FormElement.FieldSet(name, elements) => - s"""{ "name": '$name', "type": 'fieldset', "elements": [${elements.map(_.toJSONRepr).mkString(",")}] }""" + s"""{ "name": '$name', "type": 'fieldset', "elements": [${elements.map(_.toJSONRepr()).mkString(",")}]}""" case FormElement.TextInput(name) => - s"""{ "name": '$name', "type": 'text' }""" + s"""{ "name": '$name', "type": 'text', "nullable": $nullable }""" case FormElement.CharInput(name) => - s"""{ "name": '$name', "type": 'char' }""" + s"""{ "name": '$name', "type": 'char', "nullable": $nullable }""" case FormElement.NumberInput(name, _) => - s"""{ "name": '$name', "type": 'number' }""" + s"""{ "name": '$name', "type": 'number', "nullable": $nullable }""" case FormElement.FloatingNumberInput(name, _) => - s"""{ "name": '$name', "type": 'float' }""" + s"""{ "name": '$name', "type": 'float', "nullable": $nullable }""" case FormElement.CheckboxInput(name) => s"""{ "name": '$name', "type": 'checkbox' }""" case FormElement.Dropdown(name, options) => // TODO(kπ) this sortBy isn't 100% sure to be working (the only requirement is for the first constructor to not be recursive; this is a graph problem, sorta) - s"""{ "name": '$name', "type": 'dropdown', "options": [${options.sortBy(_._2).map { case (k, v) => s"""{"name": "$k", "value": ${v.toJSONRepr}}""" }.mkString(",")}] }""" + s"""{ "name": '$name', "type": 'dropdown', "options": [${options.sortBy(_._2).map { case (k, v) => s"""{"name": "$k", "value": ${v.toJSONRepr()}}""" }.mkString(",")}] }""" case FormElement.ListInput(name, element, _) => - s"""{ "name": '$name', "type": 'list', "element": ${element.toJSONRepr} }""" + s"""{ "name": '$name', "type": 'list', "element": ${element.toJSONRepr()} }""" case FormElement.TextArea(name, rows, cols) => - s"""{ "name": '$name', "type": 'textarea', "rows": ${rows.getOrElse("")}, "cols": ${cols.getOrElse("")} }""" + s"""{ "name": '$name', "type": 'textarea', "rows": ${rows.getOrElse("")}, "cols": ${cols.getOrElse("")}, "nullable": $nullable }""" case FormElement.DateInput(name) => - s"""{ "name": '$name', "type": 'date' }""" + s"""{ "name": '$name', "type": 'date', "nullable": $nullable }""" case FormElement.EmailInput(name) => - s"""{ "name": '$name', "type": 'email' }""" + s"""{ "name": '$name', "type": 'email', "nullable": $nullable }""" case FormElement.PasswordInput(name) => - s"""{ "name": '$name', "type": 'password' }""" + s"""{ "name": '$name', "type": 'password', "nullable": $nullable }""" case FormElement.NamedRef(name, ref) => s"""{ "name": '$name', "ref": '$ref', "type": 'namedref' }""" + case FormElement.Nullable(_, element) => + element.toJSONRepr(nullable = true)