diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 7e5baeb..b144740 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -26,6 +26,18 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringCodec[A]( + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = + JsonCodec.string.transformOrFail( + factory.apply andThen fromValidation, + factory.getter + ) + + given fromValidatedStringCodec[A](using + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = validatedStringCodec(factory) + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) @@ -33,11 +45,10 @@ given JsonCodec[UserId] = JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - given JsonCodec[Email] = textCodec(Email.apply) + given JsonCodec[Email] = validatedStringCodec(Email) - given JsonCodec[UserName] = textCodec(UserName.apply) - given JsonCodec[UserRole] = textCodec(UserRole.apply) - given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[UserRole] = validatedStringCodec(UserRole) + given JsonCodec[Avatar] = validatedStringCodec(Avatar) given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] @@ -50,6 +61,10 @@ given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] trait TapirCodecs extends CustomTapir: + given fromValidatedStringSchema[A](using + ValidatedStringFactory[A] + ): Schema[A] = Schema.string + given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 7e5baeb..b144740 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -26,6 +26,18 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringCodec[A]( + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = + JsonCodec.string.transformOrFail( + factory.apply andThen fromValidation, + factory.getter + ) + + given fromValidatedStringCodec[A](using + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = validatedStringCodec(factory) + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) @@ -33,11 +45,10 @@ given JsonCodec[UserId] = JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - given JsonCodec[Email] = textCodec(Email.apply) + given JsonCodec[Email] = validatedStringCodec(Email) - given JsonCodec[UserName] = textCodec(UserName.apply) - given JsonCodec[UserRole] = textCodec(UserRole.apply) - given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[UserRole] = validatedStringCodec(UserRole) + given JsonCodec[Avatar] = validatedStringCodec(Avatar) given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] @@ -50,6 +61,10 @@ given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] trait TapirCodecs extends CustomTapir: + given fromValidatedStringSchema[A](using + ValidatedStringFactory[A] + ): Schema[A] = Schema.string + given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala index 1ef0c7c..8e406d8 100644 --- a/core/shared/src/main/scala/works/iterative/core/Avatar.scala +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -5,11 +5,10 @@ // TODO: validate URL - or should we go with URI? opaque type Avatar = String -object Avatar: +object Avatar extends ValidatedStringFactory[Avatar](a => a): def apply(avatar: String): Validated[Avatar] = Validated.nonEmptyString("avatar")(avatar) def apply(avatar: URI): Validated[Avatar] = Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) - def unsafe(avatar: String): Avatar = avatar extension (a: Avatar) def url: String = a diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 7e5baeb..b144740 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -26,6 +26,18 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringCodec[A]( + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = + JsonCodec.string.transformOrFail( + factory.apply andThen fromValidation, + factory.getter + ) + + given fromValidatedStringCodec[A](using + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = validatedStringCodec(factory) + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) @@ -33,11 +45,10 @@ given JsonCodec[UserId] = JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - given JsonCodec[Email] = textCodec(Email.apply) + given JsonCodec[Email] = validatedStringCodec(Email) - given JsonCodec[UserName] = textCodec(UserName.apply) - given JsonCodec[UserRole] = textCodec(UserRole.apply) - given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[UserRole] = validatedStringCodec(UserRole) + given JsonCodec[Avatar] = validatedStringCodec(Avatar) given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] @@ -50,6 +61,10 @@ given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] trait TapirCodecs extends CustomTapir: + given fromValidatedStringSchema[A](using + ValidatedStringFactory[A] + ): Schema[A] = Schema.string + given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala index 1ef0c7c..8e406d8 100644 --- a/core/shared/src/main/scala/works/iterative/core/Avatar.scala +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -5,11 +5,10 @@ // TODO: validate URL - or should we go with URI? opaque type Avatar = String -object Avatar: +object Avatar extends ValidatedStringFactory[Avatar](a => a): def apply(avatar: String): Validated[Avatar] = Validated.nonEmptyString("avatar")(avatar) def apply(avatar: URI): Validated[Avatar] = Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) - def unsafe(avatar: String): Avatar = avatar extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala index fa0e853..ee68434 100644 --- a/core/shared/src/main/scala/works/iterative/core/Email.scala +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -4,7 +4,7 @@ opaque type Email = String -object Email: +object Email extends ValidatedStringFactory[Email](e => e): def apply(value: String): Validated[Email] = // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) // and is the most permissive regex we can use that still conforms to the spec. @@ -16,7 +16,3 @@ Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( _.matches(regex) ) - - def unsafe(value: String): Email = apply(value).orThrow - - extension (email: Email) def value: String = email diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 7e5baeb..b144740 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -26,6 +26,18 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringCodec[A]( + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = + JsonCodec.string.transformOrFail( + factory.apply andThen fromValidation, + factory.getter + ) + + given fromValidatedStringCodec[A](using + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = validatedStringCodec(factory) + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) @@ -33,11 +45,10 @@ given JsonCodec[UserId] = JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - given JsonCodec[Email] = textCodec(Email.apply) + given JsonCodec[Email] = validatedStringCodec(Email) - given JsonCodec[UserName] = textCodec(UserName.apply) - given JsonCodec[UserRole] = textCodec(UserRole.apply) - given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[UserRole] = validatedStringCodec(UserRole) + given JsonCodec[Avatar] = validatedStringCodec(Avatar) given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] @@ -50,6 +61,10 @@ given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] trait TapirCodecs extends CustomTapir: + given fromValidatedStringSchema[A](using + ValidatedStringFactory[A] + ): Schema[A] = Schema.string + given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala index 1ef0c7c..8e406d8 100644 --- a/core/shared/src/main/scala/works/iterative/core/Avatar.scala +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -5,11 +5,10 @@ // TODO: validate URL - or should we go with URI? opaque type Avatar = String -object Avatar: +object Avatar extends ValidatedStringFactory[Avatar](a => a): def apply(avatar: String): Validated[Avatar] = Validated.nonEmptyString("avatar")(avatar) def apply(avatar: URI): Validated[Avatar] = Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) - def unsafe(avatar: String): Avatar = avatar extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala index fa0e853..ee68434 100644 --- a/core/shared/src/main/scala/works/iterative/core/Email.scala +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -4,7 +4,7 @@ opaque type Email = String -object Email: +object Email extends ValidatedStringFactory[Email](e => e): def apply(value: String): Validated[Email] = // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) // and is the most permissive regex we can use that still conforms to the spec. @@ -16,7 +16,3 @@ Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( _.matches(regex) ) - - def unsafe(value: String): Email = apply(value).orThrow - - extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index c4ea9d5..4bd6b6b 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -90,7 +90,7 @@ given string2FirstLineEmpty: Conversion[String, PlainOneLine] = firstLineEmpty(_) - extension (p: PlainOneLine) def toString: String = p + extension (p: PlainOneLine) def asString: String = p opaque type Markdown = String @@ -104,4 +104,4 @@ def optDirect(text: String): Option[Markdown] = Text.nonEmpty(text) - extension (p: Markdown) def toString: String = p + extension (p: Markdown) def asString: String = p diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 7e5baeb..b144740 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -26,6 +26,18 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringCodec[A]( + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = + JsonCodec.string.transformOrFail( + factory.apply andThen fromValidation, + factory.getter + ) + + given fromValidatedStringCodec[A](using + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = validatedStringCodec(factory) + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) @@ -33,11 +45,10 @@ given JsonCodec[UserId] = JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - given JsonCodec[Email] = textCodec(Email.apply) + given JsonCodec[Email] = validatedStringCodec(Email) - given JsonCodec[UserName] = textCodec(UserName.apply) - given JsonCodec[UserRole] = textCodec(UserRole.apply) - given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[UserRole] = validatedStringCodec(UserRole) + given JsonCodec[Avatar] = validatedStringCodec(Avatar) given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] @@ -50,6 +61,10 @@ given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] trait TapirCodecs extends CustomTapir: + given fromValidatedStringSchema[A](using + ValidatedStringFactory[A] + ): Schema[A] = Schema.string + given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala index 1ef0c7c..8e406d8 100644 --- a/core/shared/src/main/scala/works/iterative/core/Avatar.scala +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -5,11 +5,10 @@ // TODO: validate URL - or should we go with URI? opaque type Avatar = String -object Avatar: +object Avatar extends ValidatedStringFactory[Avatar](a => a): def apply(avatar: String): Validated[Avatar] = Validated.nonEmptyString("avatar")(avatar) def apply(avatar: URI): Validated[Avatar] = Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) - def unsafe(avatar: String): Avatar = avatar extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala index fa0e853..ee68434 100644 --- a/core/shared/src/main/scala/works/iterative/core/Email.scala +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -4,7 +4,7 @@ opaque type Email = String -object Email: +object Email extends ValidatedStringFactory[Email](e => e): def apply(value: String): Validated[Email] = // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) // and is the most permissive regex we can use that still conforms to the spec. @@ -16,7 +16,3 @@ Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( _.matches(regex) ) - - def unsafe(value: String): Email = apply(value).orThrow - - extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index c4ea9d5..4bd6b6b 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -90,7 +90,7 @@ given string2FirstLineEmpty: Conversion[String, PlainOneLine] = firstLineEmpty(_) - extension (p: PlainOneLine) def toString: String = p + extension (p: PlainOneLine) def asString: String = p opaque type Markdown = String @@ -104,4 +104,4 @@ def optDirect(text: String): Option[Markdown] = Text.nonEmpty(text) - extension (p: Markdown) def toString: String = p + extension (p: Markdown) def asString: String = p diff --git a/core/shared/src/main/scala/works/iterative/core/UserName.scala b/core/shared/src/main/scala/works/iterative/core/UserName.scala index 9ee185a..a404cd5 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserName.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserName.scala @@ -3,11 +3,7 @@ // Full name of the user opaque type UserName = String -object UserName: +object UserName extends ValidatedStringFactory[UserName](u => u): def apply(value: String): Validated[UserName] = // Validate that the value is not empty Validated.nonEmptyString("user.name")(value) - - def unsafe(value: String): UserName = value - - extension (un: UserName) def value: String = un diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 7e5baeb..b144740 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -26,6 +26,18 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringCodec[A]( + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = + JsonCodec.string.transformOrFail( + factory.apply andThen fromValidation, + factory.getter + ) + + given fromValidatedStringCodec[A](using + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = validatedStringCodec(factory) + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) @@ -33,11 +45,10 @@ given JsonCodec[UserId] = JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - given JsonCodec[Email] = textCodec(Email.apply) + given JsonCodec[Email] = validatedStringCodec(Email) - given JsonCodec[UserName] = textCodec(UserName.apply) - given JsonCodec[UserRole] = textCodec(UserRole.apply) - given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[UserRole] = validatedStringCodec(UserRole) + given JsonCodec[Avatar] = validatedStringCodec(Avatar) given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] @@ -50,6 +61,10 @@ given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] trait TapirCodecs extends CustomTapir: + given fromValidatedStringSchema[A](using + ValidatedStringFactory[A] + ): Schema[A] = Schema.string + given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala index 1ef0c7c..8e406d8 100644 --- a/core/shared/src/main/scala/works/iterative/core/Avatar.scala +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -5,11 +5,10 @@ // TODO: validate URL - or should we go with URI? opaque type Avatar = String -object Avatar: +object Avatar extends ValidatedStringFactory[Avatar](a => a): def apply(avatar: String): Validated[Avatar] = Validated.nonEmptyString("avatar")(avatar) def apply(avatar: URI): Validated[Avatar] = Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) - def unsafe(avatar: String): Avatar = avatar extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala index fa0e853..ee68434 100644 --- a/core/shared/src/main/scala/works/iterative/core/Email.scala +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -4,7 +4,7 @@ opaque type Email = String -object Email: +object Email extends ValidatedStringFactory[Email](e => e): def apply(value: String): Validated[Email] = // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) // and is the most permissive regex we can use that still conforms to the spec. @@ -16,7 +16,3 @@ Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( _.matches(regex) ) - - def unsafe(value: String): Email = apply(value).orThrow - - extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index c4ea9d5..4bd6b6b 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -90,7 +90,7 @@ given string2FirstLineEmpty: Conversion[String, PlainOneLine] = firstLineEmpty(_) - extension (p: PlainOneLine) def toString: String = p + extension (p: PlainOneLine) def asString: String = p opaque type Markdown = String @@ -104,4 +104,4 @@ def optDirect(text: String): Option[Markdown] = Text.nonEmpty(text) - extension (p: Markdown) def toString: String = p + extension (p: Markdown) def asString: String = p diff --git a/core/shared/src/main/scala/works/iterative/core/UserName.scala b/core/shared/src/main/scala/works/iterative/core/UserName.scala index 9ee185a..a404cd5 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserName.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserName.scala @@ -3,11 +3,7 @@ // Full name of the user opaque type UserName = String -object UserName: +object UserName extends ValidatedStringFactory[UserName](u => u): def apply(value: String): Validated[UserName] = // Validate that the value is not empty Validated.nonEmptyString("user.name")(value) - - def unsafe(value: String): UserName = value - - extension (un: UserName) def value: String = un diff --git a/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala new file mode 100644 index 0000000..3e8fd5f --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala @@ -0,0 +1,10 @@ +package works.iterative.core + +/** Common methods for opaque validated String types + */ +trait ValidatedStringFactory[A](val getter: A => String): + def apply(s: String): Validated[A] + def unsafe(s: String): A = apply(s).orThrow + extension (a: A) def value: String = getter(a) + + given ValidatedStringFactory[A] = this diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 7e5baeb..b144740 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -26,6 +26,18 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringCodec[A]( + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = + JsonCodec.string.transformOrFail( + factory.apply andThen fromValidation, + factory.getter + ) + + given fromValidatedStringCodec[A](using + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = validatedStringCodec(factory) + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) @@ -33,11 +45,10 @@ given JsonCodec[UserId] = JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - given JsonCodec[Email] = textCodec(Email.apply) + given JsonCodec[Email] = validatedStringCodec(Email) - given JsonCodec[UserName] = textCodec(UserName.apply) - given JsonCodec[UserRole] = textCodec(UserRole.apply) - given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[UserRole] = validatedStringCodec(UserRole) + given JsonCodec[Avatar] = validatedStringCodec(Avatar) given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] @@ -50,6 +61,10 @@ given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] trait TapirCodecs extends CustomTapir: + given fromValidatedStringSchema[A](using + ValidatedStringFactory[A] + ): Schema[A] = Schema.string + given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala index 1ef0c7c..8e406d8 100644 --- a/core/shared/src/main/scala/works/iterative/core/Avatar.scala +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -5,11 +5,10 @@ // TODO: validate URL - or should we go with URI? opaque type Avatar = String -object Avatar: +object Avatar extends ValidatedStringFactory[Avatar](a => a): def apply(avatar: String): Validated[Avatar] = Validated.nonEmptyString("avatar")(avatar) def apply(avatar: URI): Validated[Avatar] = Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) - def unsafe(avatar: String): Avatar = avatar extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala index fa0e853..ee68434 100644 --- a/core/shared/src/main/scala/works/iterative/core/Email.scala +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -4,7 +4,7 @@ opaque type Email = String -object Email: +object Email extends ValidatedStringFactory[Email](e => e): def apply(value: String): Validated[Email] = // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) // and is the most permissive regex we can use that still conforms to the spec. @@ -16,7 +16,3 @@ Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( _.matches(regex) ) - - def unsafe(value: String): Email = apply(value).orThrow - - extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index c4ea9d5..4bd6b6b 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -90,7 +90,7 @@ given string2FirstLineEmpty: Conversion[String, PlainOneLine] = firstLineEmpty(_) - extension (p: PlainOneLine) def toString: String = p + extension (p: PlainOneLine) def asString: String = p opaque type Markdown = String @@ -104,4 +104,4 @@ def optDirect(text: String): Option[Markdown] = Text.nonEmpty(text) - extension (p: Markdown) def toString: String = p + extension (p: Markdown) def asString: String = p diff --git a/core/shared/src/main/scala/works/iterative/core/UserName.scala b/core/shared/src/main/scala/works/iterative/core/UserName.scala index 9ee185a..a404cd5 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserName.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserName.scala @@ -3,11 +3,7 @@ // Full name of the user opaque type UserName = String -object UserName: +object UserName extends ValidatedStringFactory[UserName](u => u): def apply(value: String): Validated[UserName] = // Validate that the value is not empty Validated.nonEmptyString("user.name")(value) - - def unsafe(value: String): UserName = value - - extension (un: UserName) def value: String = un diff --git a/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala new file mode 100644 index 0000000..3e8fd5f --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala @@ -0,0 +1,10 @@ +package works.iterative.core + +/** Common methods for opaque validated String types + */ +trait ValidatedStringFactory[A](val getter: A => String): + def apply(s: String): Validated[A] + def unsafe(s: String): A = apply(s).orThrow + extension (a: A) def value: String = getter(a) + + given ValidatedStringFactory[A] = this diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala index 2a2c200..32595d1 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -1,15 +1,10 @@ -package works.iterative.core.auth - -import works.iterative.core.Validated +package works.iterative.core +package auth // Unique identifier of the user opaque type UserId = String -object UserId: +object UserId extends ValidatedStringFactory[UserId](u => u): def apply(value: String): Validated[UserId] = // Validate that the value is not empty Validated.nonEmptyString("user.id")(value) - - def unsafe(value: String): UserId = value - - extension (u: UserId) def value: String = u diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 7e5baeb..b144740 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -26,6 +26,18 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringCodec[A]( + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = + JsonCodec.string.transformOrFail( + factory.apply andThen fromValidation, + factory.getter + ) + + given fromValidatedStringCodec[A](using + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = validatedStringCodec(factory) + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) @@ -33,11 +45,10 @@ given JsonCodec[UserId] = JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - given JsonCodec[Email] = textCodec(Email.apply) + given JsonCodec[Email] = validatedStringCodec(Email) - given JsonCodec[UserName] = textCodec(UserName.apply) - given JsonCodec[UserRole] = textCodec(UserRole.apply) - given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[UserRole] = validatedStringCodec(UserRole) + given JsonCodec[Avatar] = validatedStringCodec(Avatar) given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] @@ -50,6 +61,10 @@ given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] trait TapirCodecs extends CustomTapir: + given fromValidatedStringSchema[A](using + ValidatedStringFactory[A] + ): Schema[A] = Schema.string + given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala index 1ef0c7c..8e406d8 100644 --- a/core/shared/src/main/scala/works/iterative/core/Avatar.scala +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -5,11 +5,10 @@ // TODO: validate URL - or should we go with URI? opaque type Avatar = String -object Avatar: +object Avatar extends ValidatedStringFactory[Avatar](a => a): def apply(avatar: String): Validated[Avatar] = Validated.nonEmptyString("avatar")(avatar) def apply(avatar: URI): Validated[Avatar] = Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) - def unsafe(avatar: String): Avatar = avatar extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala index fa0e853..ee68434 100644 --- a/core/shared/src/main/scala/works/iterative/core/Email.scala +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -4,7 +4,7 @@ opaque type Email = String -object Email: +object Email extends ValidatedStringFactory[Email](e => e): def apply(value: String): Validated[Email] = // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) // and is the most permissive regex we can use that still conforms to the spec. @@ -16,7 +16,3 @@ Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( _.matches(regex) ) - - def unsafe(value: String): Email = apply(value).orThrow - - extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index c4ea9d5..4bd6b6b 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -90,7 +90,7 @@ given string2FirstLineEmpty: Conversion[String, PlainOneLine] = firstLineEmpty(_) - extension (p: PlainOneLine) def toString: String = p + extension (p: PlainOneLine) def asString: String = p opaque type Markdown = String @@ -104,4 +104,4 @@ def optDirect(text: String): Option[Markdown] = Text.nonEmpty(text) - extension (p: Markdown) def toString: String = p + extension (p: Markdown) def asString: String = p diff --git a/core/shared/src/main/scala/works/iterative/core/UserName.scala b/core/shared/src/main/scala/works/iterative/core/UserName.scala index 9ee185a..a404cd5 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserName.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserName.scala @@ -3,11 +3,7 @@ // Full name of the user opaque type UserName = String -object UserName: +object UserName extends ValidatedStringFactory[UserName](u => u): def apply(value: String): Validated[UserName] = // Validate that the value is not empty Validated.nonEmptyString("user.name")(value) - - def unsafe(value: String): UserName = value - - extension (un: UserName) def value: String = un diff --git a/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala new file mode 100644 index 0000000..3e8fd5f --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala @@ -0,0 +1,10 @@ +package works.iterative.core + +/** Common methods for opaque validated String types + */ +trait ValidatedStringFactory[A](val getter: A => String): + def apply(s: String): Validated[A] + def unsafe(s: String): A = apply(s).orThrow + extension (a: A) def value: String = getter(a) + + given ValidatedStringFactory[A] = this diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala index 2a2c200..32595d1 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -1,15 +1,10 @@ -package works.iterative.core.auth - -import works.iterative.core.Validated +package works.iterative.core +package auth // Unique identifier of the user opaque type UserId = String -object UserId: +object UserId extends ValidatedStringFactory[UserId](u => u): def apply(value: String): Validated[UserId] = // Validate that the value is not empty Validated.nonEmptyString("user.id")(value) - - def unsafe(value: String): UserId = value - - extension (u: UserId) def value: String = u diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala index cf21353..72348cd 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala @@ -3,9 +3,6 @@ opaque type UserRole = String -object UserRole: +object UserRole extends ValidatedStringFactory[UserRole](r => r): def apply(role: String): Validated[UserRole] = Validated.nonEmptyString("user.role")(role) - def unsafe(role: String): UserRole = role - - extension (r: UserRole) def value: String = r diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 7e5baeb..b144740 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -26,6 +26,18 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringCodec[A]( + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = + JsonCodec.string.transformOrFail( + factory.apply andThen fromValidation, + factory.getter + ) + + given fromValidatedStringCodec[A](using + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = validatedStringCodec(factory) + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) @@ -33,11 +45,10 @@ given JsonCodec[UserId] = JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - given JsonCodec[Email] = textCodec(Email.apply) + given JsonCodec[Email] = validatedStringCodec(Email) - given JsonCodec[UserName] = textCodec(UserName.apply) - given JsonCodec[UserRole] = textCodec(UserRole.apply) - given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[UserRole] = validatedStringCodec(UserRole) + given JsonCodec[Avatar] = validatedStringCodec(Avatar) given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] @@ -50,6 +61,10 @@ given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] trait TapirCodecs extends CustomTapir: + given fromValidatedStringSchema[A](using + ValidatedStringFactory[A] + ): Schema[A] = Schema.string + given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala index 1ef0c7c..8e406d8 100644 --- a/core/shared/src/main/scala/works/iterative/core/Avatar.scala +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -5,11 +5,10 @@ // TODO: validate URL - or should we go with URI? opaque type Avatar = String -object Avatar: +object Avatar extends ValidatedStringFactory[Avatar](a => a): def apply(avatar: String): Validated[Avatar] = Validated.nonEmptyString("avatar")(avatar) def apply(avatar: URI): Validated[Avatar] = Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) - def unsafe(avatar: String): Avatar = avatar extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala index fa0e853..ee68434 100644 --- a/core/shared/src/main/scala/works/iterative/core/Email.scala +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -4,7 +4,7 @@ opaque type Email = String -object Email: +object Email extends ValidatedStringFactory[Email](e => e): def apply(value: String): Validated[Email] = // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) // and is the most permissive regex we can use that still conforms to the spec. @@ -16,7 +16,3 @@ Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( _.matches(regex) ) - - def unsafe(value: String): Email = apply(value).orThrow - - extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index c4ea9d5..4bd6b6b 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -90,7 +90,7 @@ given string2FirstLineEmpty: Conversion[String, PlainOneLine] = firstLineEmpty(_) - extension (p: PlainOneLine) def toString: String = p + extension (p: PlainOneLine) def asString: String = p opaque type Markdown = String @@ -104,4 +104,4 @@ def optDirect(text: String): Option[Markdown] = Text.nonEmpty(text) - extension (p: Markdown) def toString: String = p + extension (p: Markdown) def asString: String = p diff --git a/core/shared/src/main/scala/works/iterative/core/UserName.scala b/core/shared/src/main/scala/works/iterative/core/UserName.scala index 9ee185a..a404cd5 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserName.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserName.scala @@ -3,11 +3,7 @@ // Full name of the user opaque type UserName = String -object UserName: +object UserName extends ValidatedStringFactory[UserName](u => u): def apply(value: String): Validated[UserName] = // Validate that the value is not empty Validated.nonEmptyString("user.name")(value) - - def unsafe(value: String): UserName = value - - extension (un: UserName) def value: String = un diff --git a/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala new file mode 100644 index 0000000..3e8fd5f --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala @@ -0,0 +1,10 @@ +package works.iterative.core + +/** Common methods for opaque validated String types + */ +trait ValidatedStringFactory[A](val getter: A => String): + def apply(s: String): Validated[A] + def unsafe(s: String): A = apply(s).orThrow + extension (a: A) def value: String = getter(a) + + given ValidatedStringFactory[A] = this diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala index 2a2c200..32595d1 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -1,15 +1,10 @@ -package works.iterative.core.auth - -import works.iterative.core.Validated +package works.iterative.core +package auth // Unique identifier of the user opaque type UserId = String -object UserId: +object UserId extends ValidatedStringFactory[UserId](u => u): def apply(value: String): Validated[UserId] = // Validate that the value is not empty Validated.nonEmptyString("user.id")(value) - - def unsafe(value: String): UserId = value - - extension (u: UserId) def value: String = u diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala index cf21353..72348cd 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala @@ -3,9 +3,6 @@ opaque type UserRole = String -object UserRole: +object UserRole extends ValidatedStringFactory[UserRole](r => r): def apply(role: String): Validated[UserRole] = Validated.nonEmptyString("user.role")(role) - def unsafe(role: String): UserRole = role - - extension (r: UserRole) def value: String = r diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala index 5fdb73b..9091760 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -36,7 +36,7 @@ initialValue: Option[A] ): FormComponent[A] = val codec = summon[InputCodec[A]] - InputField( + Input( fieldDescriptor, initialValue.map(codec.encode), Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) @@ -52,7 +52,7 @@ initialValue: Option[Option[A]] ): FormComponent[Option[A]] = val codec = summon[InputCodec[A]] - InputField[Option[A]]( + Input[Option[A]]( fieldDescriptor, initialValue.flatten.map(codec.encode), (v: Option[String]) => @@ -141,7 +141,7 @@ ) ) - class InputField[A]( + class Input[A]( desc: FieldDescriptor, initialValue: Option[String] = None, validation: Option[String] => Validated[A] @@ -153,12 +153,12 @@ rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - renderInputField( + InputField( desc, initialValue, validated, rawValue.writer - ) + ).elements class FileField[A]( desc: FieldDescriptor, @@ -173,51 +173,6 @@ override val elements: Seq[HtmlElement] = renderFileInputField(desc, rawValue.writer.contramapSome) - def renderInputField( - desc: FieldDescriptor, - initialValue: Option[String], - validated: Signal[Validated[_]], - observer: Observer[Option[String]] - )(using fctx: FormBuilderContext): Seq[HtmlElement] = - - val hadFocus: Var[Boolean] = Var(false) - - val touched: Var[Boolean] = Var(false) - - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) - - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) - - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.map(L.value(_)), - onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => - Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) - ) - ) - ) - ) - ) - class ChoiceField[A]( desc: FieldDescriptor, initialValue: Option[A], diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 7e5baeb..b144740 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -26,6 +26,18 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringCodec[A]( + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = + JsonCodec.string.transformOrFail( + factory.apply andThen fromValidation, + factory.getter + ) + + given fromValidatedStringCodec[A](using + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = validatedStringCodec(factory) + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) @@ -33,11 +45,10 @@ given JsonCodec[UserId] = JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - given JsonCodec[Email] = textCodec(Email.apply) + given JsonCodec[Email] = validatedStringCodec(Email) - given JsonCodec[UserName] = textCodec(UserName.apply) - given JsonCodec[UserRole] = textCodec(UserRole.apply) - given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[UserRole] = validatedStringCodec(UserRole) + given JsonCodec[Avatar] = validatedStringCodec(Avatar) given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] @@ -50,6 +61,10 @@ given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] trait TapirCodecs extends CustomTapir: + given fromValidatedStringSchema[A](using + ValidatedStringFactory[A] + ): Schema[A] = Schema.string + given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala index 1ef0c7c..8e406d8 100644 --- a/core/shared/src/main/scala/works/iterative/core/Avatar.scala +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -5,11 +5,10 @@ // TODO: validate URL - or should we go with URI? opaque type Avatar = String -object Avatar: +object Avatar extends ValidatedStringFactory[Avatar](a => a): def apply(avatar: String): Validated[Avatar] = Validated.nonEmptyString("avatar")(avatar) def apply(avatar: URI): Validated[Avatar] = Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) - def unsafe(avatar: String): Avatar = avatar extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala index fa0e853..ee68434 100644 --- a/core/shared/src/main/scala/works/iterative/core/Email.scala +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -4,7 +4,7 @@ opaque type Email = String -object Email: +object Email extends ValidatedStringFactory[Email](e => e): def apply(value: String): Validated[Email] = // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) // and is the most permissive regex we can use that still conforms to the spec. @@ -16,7 +16,3 @@ Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( _.matches(regex) ) - - def unsafe(value: String): Email = apply(value).orThrow - - extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index c4ea9d5..4bd6b6b 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -90,7 +90,7 @@ given string2FirstLineEmpty: Conversion[String, PlainOneLine] = firstLineEmpty(_) - extension (p: PlainOneLine) def toString: String = p + extension (p: PlainOneLine) def asString: String = p opaque type Markdown = String @@ -104,4 +104,4 @@ def optDirect(text: String): Option[Markdown] = Text.nonEmpty(text) - extension (p: Markdown) def toString: String = p + extension (p: Markdown) def asString: String = p diff --git a/core/shared/src/main/scala/works/iterative/core/UserName.scala b/core/shared/src/main/scala/works/iterative/core/UserName.scala index 9ee185a..a404cd5 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserName.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserName.scala @@ -3,11 +3,7 @@ // Full name of the user opaque type UserName = String -object UserName: +object UserName extends ValidatedStringFactory[UserName](u => u): def apply(value: String): Validated[UserName] = // Validate that the value is not empty Validated.nonEmptyString("user.name")(value) - - def unsafe(value: String): UserName = value - - extension (un: UserName) def value: String = un diff --git a/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala new file mode 100644 index 0000000..3e8fd5f --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala @@ -0,0 +1,10 @@ +package works.iterative.core + +/** Common methods for opaque validated String types + */ +trait ValidatedStringFactory[A](val getter: A => String): + def apply(s: String): Validated[A] + def unsafe(s: String): A = apply(s).orThrow + extension (a: A) def value: String = getter(a) + + given ValidatedStringFactory[A] = this diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala index 2a2c200..32595d1 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -1,15 +1,10 @@ -package works.iterative.core.auth - -import works.iterative.core.Validated +package works.iterative.core +package auth // Unique identifier of the user opaque type UserId = String -object UserId: +object UserId extends ValidatedStringFactory[UserId](u => u): def apply(value: String): Validated[UserId] = // Validate that the value is not empty Validated.nonEmptyString("user.id")(value) - - def unsafe(value: String): UserId = value - - extension (u: UserId) def value: String = u diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala index cf21353..72348cd 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala @@ -3,9 +3,6 @@ opaque type UserRole = String -object UserRole: +object UserRole extends ValidatedStringFactory[UserRole](r => r): def apply(role: String): Validated[UserRole] = Validated.nonEmptyString("user.role")(role) - def unsafe(role: String): UserRole = role - - extension (r: UserRole) def value: String = r diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala index 5fdb73b..9091760 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -36,7 +36,7 @@ initialValue: Option[A] ): FormComponent[A] = val codec = summon[InputCodec[A]] - InputField( + Input( fieldDescriptor, initialValue.map(codec.encode), Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) @@ -52,7 +52,7 @@ initialValue: Option[Option[A]] ): FormComponent[Option[A]] = val codec = summon[InputCodec[A]] - InputField[Option[A]]( + Input[Option[A]]( fieldDescriptor, initialValue.flatten.map(codec.encode), (v: Option[String]) => @@ -141,7 +141,7 @@ ) ) - class InputField[A]( + class Input[A]( desc: FieldDescriptor, initialValue: Option[String] = None, validation: Option[String] => Validated[A] @@ -153,12 +153,12 @@ rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - renderInputField( + InputField( desc, initialValue, validated, rawValue.writer - ) + ).elements class FileField[A]( desc: FieldDescriptor, @@ -173,51 +173,6 @@ override val elements: Seq[HtmlElement] = renderFileInputField(desc, rawValue.writer.contramapSome) - def renderInputField( - desc: FieldDescriptor, - initialValue: Option[String], - validated: Signal[Validated[_]], - observer: Observer[Option[String]] - )(using fctx: FormBuilderContext): Seq[HtmlElement] = - - val hadFocus: Var[Boolean] = Var(false) - - val touched: Var[Boolean] = Var(false) - - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) - - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) - - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.map(L.value(_)), - onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => - Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) - ) - ) - ) - ) - ) - class ChoiceField[A]( desc: FieldDescriptor, initialValue: Option[A], diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala index 1971da4..9ee9b11 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -3,11 +3,18 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.* import zio.prelude.Validation +import works.iterative.ui.components.ComponentContext trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) + def buildForm[A]( + schema: FormSchema[A], + submit: Observer[A] + ): HtmlFormSchemaBuilder[A] = + HtmlFormSchemaBuilder[A](schema, submit) + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): def build(initialValue: Option[A])(using fctx: FormBuilderContext @@ -26,3 +33,65 @@ ) ) ) + + case class HtmlFormSchemaBuilder[A]( + schema: FormSchema[A], + submit: Observer[A] + ): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext, + cctx: ComponentContext[?] + ): FormComponent[A] = + val f = buildForm(schema)(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) + + def buildForm[A](schema: FormSchema[A])(initialValue: Option[A])(using + fctx: FormBuilderContext, + cctx: ComponentContext[?] + ): FormComponent[A] = + import FormSchema.* + import works.iterative.ui.components.laminar.LaminarExtensions.* + import works.iterative.ui.components.laminar.HtmlRenderable.given + schema match + case FormSchema.Unit => FormComponent.empty + case Section(name, inner) => + val desc = SectionDescriptor(name) + buildForm(inner)(initialValue).wrap( + fctx.formUIFactory + .section(desc.title, desc.subtitle.map(textToTextNode))(_*) + ) + case Control(name, required, decode, validation) => + val desc = FieldDescriptor(name) + FieldBuilder + .Input( + desc, + initialValue.map(decode(_)), + validation + ) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory + .label(desc.label, required = required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + case z @ Zip(left, right) => + val leftComponent = buildForm(left)(initialValue.map(z.toLeft)) + val rightComponent = buildForm(right)(initialValue.map(z.toRight)) + leftComponent.zip(rightComponent) + case BiMap(inner, to, from) => + buildForm(inner)(initialValue.map(from)).map(to) diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 7e5baeb..b144740 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -26,6 +26,18 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringCodec[A]( + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = + JsonCodec.string.transformOrFail( + factory.apply andThen fromValidation, + factory.getter + ) + + given fromValidatedStringCodec[A](using + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = validatedStringCodec(factory) + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) @@ -33,11 +45,10 @@ given JsonCodec[UserId] = JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - given JsonCodec[Email] = textCodec(Email.apply) + given JsonCodec[Email] = validatedStringCodec(Email) - given JsonCodec[UserName] = textCodec(UserName.apply) - given JsonCodec[UserRole] = textCodec(UserRole.apply) - given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[UserRole] = validatedStringCodec(UserRole) + given JsonCodec[Avatar] = validatedStringCodec(Avatar) given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] @@ -50,6 +61,10 @@ given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] trait TapirCodecs extends CustomTapir: + given fromValidatedStringSchema[A](using + ValidatedStringFactory[A] + ): Schema[A] = Schema.string + given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala index 1ef0c7c..8e406d8 100644 --- a/core/shared/src/main/scala/works/iterative/core/Avatar.scala +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -5,11 +5,10 @@ // TODO: validate URL - or should we go with URI? opaque type Avatar = String -object Avatar: +object Avatar extends ValidatedStringFactory[Avatar](a => a): def apply(avatar: String): Validated[Avatar] = Validated.nonEmptyString("avatar")(avatar) def apply(avatar: URI): Validated[Avatar] = Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) - def unsafe(avatar: String): Avatar = avatar extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala index fa0e853..ee68434 100644 --- a/core/shared/src/main/scala/works/iterative/core/Email.scala +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -4,7 +4,7 @@ opaque type Email = String -object Email: +object Email extends ValidatedStringFactory[Email](e => e): def apply(value: String): Validated[Email] = // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) // and is the most permissive regex we can use that still conforms to the spec. @@ -16,7 +16,3 @@ Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( _.matches(regex) ) - - def unsafe(value: String): Email = apply(value).orThrow - - extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index c4ea9d5..4bd6b6b 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -90,7 +90,7 @@ given string2FirstLineEmpty: Conversion[String, PlainOneLine] = firstLineEmpty(_) - extension (p: PlainOneLine) def toString: String = p + extension (p: PlainOneLine) def asString: String = p opaque type Markdown = String @@ -104,4 +104,4 @@ def optDirect(text: String): Option[Markdown] = Text.nonEmpty(text) - extension (p: Markdown) def toString: String = p + extension (p: Markdown) def asString: String = p diff --git a/core/shared/src/main/scala/works/iterative/core/UserName.scala b/core/shared/src/main/scala/works/iterative/core/UserName.scala index 9ee185a..a404cd5 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserName.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserName.scala @@ -3,11 +3,7 @@ // Full name of the user opaque type UserName = String -object UserName: +object UserName extends ValidatedStringFactory[UserName](u => u): def apply(value: String): Validated[UserName] = // Validate that the value is not empty Validated.nonEmptyString("user.name")(value) - - def unsafe(value: String): UserName = value - - extension (un: UserName) def value: String = un diff --git a/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala new file mode 100644 index 0000000..3e8fd5f --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala @@ -0,0 +1,10 @@ +package works.iterative.core + +/** Common methods for opaque validated String types + */ +trait ValidatedStringFactory[A](val getter: A => String): + def apply(s: String): Validated[A] + def unsafe(s: String): A = apply(s).orThrow + extension (a: A) def value: String = getter(a) + + given ValidatedStringFactory[A] = this diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala index 2a2c200..32595d1 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -1,15 +1,10 @@ -package works.iterative.core.auth - -import works.iterative.core.Validated +package works.iterative.core +package auth // Unique identifier of the user opaque type UserId = String -object UserId: +object UserId extends ValidatedStringFactory[UserId](u => u): def apply(value: String): Validated[UserId] = // Validate that the value is not empty Validated.nonEmptyString("user.id")(value) - - def unsafe(value: String): UserId = value - - extension (u: UserId) def value: String = u diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala index cf21353..72348cd 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala @@ -3,9 +3,6 @@ opaque type UserRole = String -object UserRole: +object UserRole extends ValidatedStringFactory[UserRole](r => r): def apply(role: String): Validated[UserRole] = Validated.nonEmptyString("user.role")(role) - def unsafe(role: String): UserRole = role - - extension (r: UserRole) def value: String = r diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala index 5fdb73b..9091760 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -36,7 +36,7 @@ initialValue: Option[A] ): FormComponent[A] = val codec = summon[InputCodec[A]] - InputField( + Input( fieldDescriptor, initialValue.map(codec.encode), Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) @@ -52,7 +52,7 @@ initialValue: Option[Option[A]] ): FormComponent[Option[A]] = val codec = summon[InputCodec[A]] - InputField[Option[A]]( + Input[Option[A]]( fieldDescriptor, initialValue.flatten.map(codec.encode), (v: Option[String]) => @@ -141,7 +141,7 @@ ) ) - class InputField[A]( + class Input[A]( desc: FieldDescriptor, initialValue: Option[String] = None, validation: Option[String] => Validated[A] @@ -153,12 +153,12 @@ rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - renderInputField( + InputField( desc, initialValue, validated, rawValue.writer - ) + ).elements class FileField[A]( desc: FieldDescriptor, @@ -173,51 +173,6 @@ override val elements: Seq[HtmlElement] = renderFileInputField(desc, rawValue.writer.contramapSome) - def renderInputField( - desc: FieldDescriptor, - initialValue: Option[String], - validated: Signal[Validated[_]], - observer: Observer[Option[String]] - )(using fctx: FormBuilderContext): Seq[HtmlElement] = - - val hadFocus: Var[Boolean] = Var(false) - - val touched: Var[Boolean] = Var(false) - - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) - - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) - - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.map(L.value(_)), - onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => - Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) - ) - ) - ) - ) - ) - class ChoiceField[A]( desc: FieldDescriptor, initialValue: Option[A], diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala index 1971da4..9ee9b11 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -3,11 +3,18 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.* import zio.prelude.Validation +import works.iterative.ui.components.ComponentContext trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) + def buildForm[A]( + schema: FormSchema[A], + submit: Observer[A] + ): HtmlFormSchemaBuilder[A] = + HtmlFormSchemaBuilder[A](schema, submit) + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): def build(initialValue: Option[A])(using fctx: FormBuilderContext @@ -26,3 +33,65 @@ ) ) ) + + case class HtmlFormSchemaBuilder[A]( + schema: FormSchema[A], + submit: Observer[A] + ): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext, + cctx: ComponentContext[?] + ): FormComponent[A] = + val f = buildForm(schema)(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) + + def buildForm[A](schema: FormSchema[A])(initialValue: Option[A])(using + fctx: FormBuilderContext, + cctx: ComponentContext[?] + ): FormComponent[A] = + import FormSchema.* + import works.iterative.ui.components.laminar.LaminarExtensions.* + import works.iterative.ui.components.laminar.HtmlRenderable.given + schema match + case FormSchema.Unit => FormComponent.empty + case Section(name, inner) => + val desc = SectionDescriptor(name) + buildForm(inner)(initialValue).wrap( + fctx.formUIFactory + .section(desc.title, desc.subtitle.map(textToTextNode))(_*) + ) + case Control(name, required, decode, validation) => + val desc = FieldDescriptor(name) + FieldBuilder + .Input( + desc, + initialValue.map(decode(_)), + validation + ) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory + .label(desc.label, required = required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + case z @ Zip(left, right) => + val leftComponent = buildForm(left)(initialValue.map(z.toLeft)) + val rightComponent = buildForm(right)(initialValue.map(z.toRight)) + leftComponent.zip(rightComponent) + case BiMap(inner, to, from) => + buildForm(inner)(initialValue.map(from)).map(to) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala index 4c202dd..17e366e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -1,6 +1,5 @@ package works.iterative.ui.components.laminar.forms -import com.raquo.airstream.core.Signal import com.raquo.laminar.api.L.* import works.iterative.core.Validated import zio.prelude.* @@ -23,6 +22,11 @@ override def validated = v override def elements = e + def empty: FormComponent[EmptyTuple.type] = apply( + Val(Validation.succeed(Tuple())), + Nil + ) + extension [A](fc: FormComponent[A]) def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = FormComponent(fc.validated, Seq(wrapper(fc.elements))) diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 7e5baeb..b144740 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -26,6 +26,18 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringCodec[A]( + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = + JsonCodec.string.transformOrFail( + factory.apply andThen fromValidation, + factory.getter + ) + + given fromValidatedStringCodec[A](using + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = validatedStringCodec(factory) + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) @@ -33,11 +45,10 @@ given JsonCodec[UserId] = JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - given JsonCodec[Email] = textCodec(Email.apply) + given JsonCodec[Email] = validatedStringCodec(Email) - given JsonCodec[UserName] = textCodec(UserName.apply) - given JsonCodec[UserRole] = textCodec(UserRole.apply) - given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[UserRole] = validatedStringCodec(UserRole) + given JsonCodec[Avatar] = validatedStringCodec(Avatar) given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] @@ -50,6 +61,10 @@ given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] trait TapirCodecs extends CustomTapir: + given fromValidatedStringSchema[A](using + ValidatedStringFactory[A] + ): Schema[A] = Schema.string + given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala index 1ef0c7c..8e406d8 100644 --- a/core/shared/src/main/scala/works/iterative/core/Avatar.scala +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -5,11 +5,10 @@ // TODO: validate URL - or should we go with URI? opaque type Avatar = String -object Avatar: +object Avatar extends ValidatedStringFactory[Avatar](a => a): def apply(avatar: String): Validated[Avatar] = Validated.nonEmptyString("avatar")(avatar) def apply(avatar: URI): Validated[Avatar] = Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) - def unsafe(avatar: String): Avatar = avatar extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala index fa0e853..ee68434 100644 --- a/core/shared/src/main/scala/works/iterative/core/Email.scala +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -4,7 +4,7 @@ opaque type Email = String -object Email: +object Email extends ValidatedStringFactory[Email](e => e): def apply(value: String): Validated[Email] = // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) // and is the most permissive regex we can use that still conforms to the spec. @@ -16,7 +16,3 @@ Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( _.matches(regex) ) - - def unsafe(value: String): Email = apply(value).orThrow - - extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index c4ea9d5..4bd6b6b 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -90,7 +90,7 @@ given string2FirstLineEmpty: Conversion[String, PlainOneLine] = firstLineEmpty(_) - extension (p: PlainOneLine) def toString: String = p + extension (p: PlainOneLine) def asString: String = p opaque type Markdown = String @@ -104,4 +104,4 @@ def optDirect(text: String): Option[Markdown] = Text.nonEmpty(text) - extension (p: Markdown) def toString: String = p + extension (p: Markdown) def asString: String = p diff --git a/core/shared/src/main/scala/works/iterative/core/UserName.scala b/core/shared/src/main/scala/works/iterative/core/UserName.scala index 9ee185a..a404cd5 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserName.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserName.scala @@ -3,11 +3,7 @@ // Full name of the user opaque type UserName = String -object UserName: +object UserName extends ValidatedStringFactory[UserName](u => u): def apply(value: String): Validated[UserName] = // Validate that the value is not empty Validated.nonEmptyString("user.name")(value) - - def unsafe(value: String): UserName = value - - extension (un: UserName) def value: String = un diff --git a/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala new file mode 100644 index 0000000..3e8fd5f --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala @@ -0,0 +1,10 @@ +package works.iterative.core + +/** Common methods for opaque validated String types + */ +trait ValidatedStringFactory[A](val getter: A => String): + def apply(s: String): Validated[A] + def unsafe(s: String): A = apply(s).orThrow + extension (a: A) def value: String = getter(a) + + given ValidatedStringFactory[A] = this diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala index 2a2c200..32595d1 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -1,15 +1,10 @@ -package works.iterative.core.auth - -import works.iterative.core.Validated +package works.iterative.core +package auth // Unique identifier of the user opaque type UserId = String -object UserId: +object UserId extends ValidatedStringFactory[UserId](u => u): def apply(value: String): Validated[UserId] = // Validate that the value is not empty Validated.nonEmptyString("user.id")(value) - - def unsafe(value: String): UserId = value - - extension (u: UserId) def value: String = u diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala index cf21353..72348cd 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala @@ -3,9 +3,6 @@ opaque type UserRole = String -object UserRole: +object UserRole extends ValidatedStringFactory[UserRole](r => r): def apply(role: String): Validated[UserRole] = Validated.nonEmptyString("user.role")(role) - def unsafe(role: String): UserRole = role - - extension (r: UserRole) def value: String = r diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala index 5fdb73b..9091760 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -36,7 +36,7 @@ initialValue: Option[A] ): FormComponent[A] = val codec = summon[InputCodec[A]] - InputField( + Input( fieldDescriptor, initialValue.map(codec.encode), Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) @@ -52,7 +52,7 @@ initialValue: Option[Option[A]] ): FormComponent[Option[A]] = val codec = summon[InputCodec[A]] - InputField[Option[A]]( + Input[Option[A]]( fieldDescriptor, initialValue.flatten.map(codec.encode), (v: Option[String]) => @@ -141,7 +141,7 @@ ) ) - class InputField[A]( + class Input[A]( desc: FieldDescriptor, initialValue: Option[String] = None, validation: Option[String] => Validated[A] @@ -153,12 +153,12 @@ rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - renderInputField( + InputField( desc, initialValue, validated, rawValue.writer - ) + ).elements class FileField[A]( desc: FieldDescriptor, @@ -173,51 +173,6 @@ override val elements: Seq[HtmlElement] = renderFileInputField(desc, rawValue.writer.contramapSome) - def renderInputField( - desc: FieldDescriptor, - initialValue: Option[String], - validated: Signal[Validated[_]], - observer: Observer[Option[String]] - )(using fctx: FormBuilderContext): Seq[HtmlElement] = - - val hadFocus: Var[Boolean] = Var(false) - - val touched: Var[Boolean] = Var(false) - - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) - - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) - - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.map(L.value(_)), - onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => - Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) - ) - ) - ) - ) - ) - class ChoiceField[A]( desc: FieldDescriptor, initialValue: Option[A], diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala index 1971da4..9ee9b11 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -3,11 +3,18 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.* import zio.prelude.Validation +import works.iterative.ui.components.ComponentContext trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) + def buildForm[A]( + schema: FormSchema[A], + submit: Observer[A] + ): HtmlFormSchemaBuilder[A] = + HtmlFormSchemaBuilder[A](schema, submit) + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): def build(initialValue: Option[A])(using fctx: FormBuilderContext @@ -26,3 +33,65 @@ ) ) ) + + case class HtmlFormSchemaBuilder[A]( + schema: FormSchema[A], + submit: Observer[A] + ): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext, + cctx: ComponentContext[?] + ): FormComponent[A] = + val f = buildForm(schema)(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) + + def buildForm[A](schema: FormSchema[A])(initialValue: Option[A])(using + fctx: FormBuilderContext, + cctx: ComponentContext[?] + ): FormComponent[A] = + import FormSchema.* + import works.iterative.ui.components.laminar.LaminarExtensions.* + import works.iterative.ui.components.laminar.HtmlRenderable.given + schema match + case FormSchema.Unit => FormComponent.empty + case Section(name, inner) => + val desc = SectionDescriptor(name) + buildForm(inner)(initialValue).wrap( + fctx.formUIFactory + .section(desc.title, desc.subtitle.map(textToTextNode))(_*) + ) + case Control(name, required, decode, validation) => + val desc = FieldDescriptor(name) + FieldBuilder + .Input( + desc, + initialValue.map(decode(_)), + validation + ) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory + .label(desc.label, required = required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + case z @ Zip(left, right) => + val leftComponent = buildForm(left)(initialValue.map(z.toLeft)) + val rightComponent = buildForm(right)(initialValue.map(z.toRight)) + leftComponent.zip(rightComponent) + case BiMap(inner, to, from) => + buildForm(inner)(initialValue.map(from)).map(to) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala index 4c202dd..17e366e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -1,6 +1,5 @@ package works.iterative.ui.components.laminar.forms -import com.raquo.airstream.core.Signal import com.raquo.laminar.api.L.* import works.iterative.core.Validated import zio.prelude.* @@ -23,6 +22,11 @@ override def validated = v override def elements = e + def empty: FormComponent[EmptyTuple.type] = apply( + Val(Validation.succeed(Tuple())), + Nil + ) + extension [A](fc: FormComponent[A]) def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = FormComponent(fc.validated, Seq(wrapper(fc.elements))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala new file mode 100644 index 0000000..65de5e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.* + +sealed trait FormSchema[A]: + def zip[B <: Tuple](that: FormSchema[B]): FormSchema[A *: B] = + FormSchema.Zip(this, that) + def map[B](f: A => B)(g: B => A): FormSchema[B] = + FormSchema.BiMap(this, f, g) + +object FormSchema: + extension [A](f: FormSchema[A]) + def *:[B <: Tuple](that: FormSchema[B]): FormSchema[A *: B] = f.zip(that) + case object Unit extends FormSchema[EmptyTuple] + case class Control[A]( + name: String, + required: Boolean, + decode: A => String, + validation: Option[String] => Validated[A] + ) extends FormSchema[A] + object Control: + def apply[A](name: String)(using ic: InputCodec[A]): Control[A] = + Control(name, ic.required, ic.encode, ic.decodeOptional(name)) + case class Section[A](name: String, content: FormSchema[A]) + extends FormSchema[A] + case class Zip[A, B <: Tuple](left: FormSchema[A], right: FormSchema[B]) + extends FormSchema[A *: B]: + def toLeft(value: A *: B): A = value.head + def toRight(value: A *: B): B = value.tail + case class BiMap[A, B](form: FormSchema[A], f: A => B, g: B => A) + extends FormSchema[B] diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 7e5baeb..b144740 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -26,6 +26,18 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringCodec[A]( + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = + JsonCodec.string.transformOrFail( + factory.apply andThen fromValidation, + factory.getter + ) + + given fromValidatedStringCodec[A](using + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = validatedStringCodec(factory) + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) @@ -33,11 +45,10 @@ given JsonCodec[UserId] = JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - given JsonCodec[Email] = textCodec(Email.apply) + given JsonCodec[Email] = validatedStringCodec(Email) - given JsonCodec[UserName] = textCodec(UserName.apply) - given JsonCodec[UserRole] = textCodec(UserRole.apply) - given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[UserRole] = validatedStringCodec(UserRole) + given JsonCodec[Avatar] = validatedStringCodec(Avatar) given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] @@ -50,6 +61,10 @@ given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] trait TapirCodecs extends CustomTapir: + given fromValidatedStringSchema[A](using + ValidatedStringFactory[A] + ): Schema[A] = Schema.string + given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala index 1ef0c7c..8e406d8 100644 --- a/core/shared/src/main/scala/works/iterative/core/Avatar.scala +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -5,11 +5,10 @@ // TODO: validate URL - or should we go with URI? opaque type Avatar = String -object Avatar: +object Avatar extends ValidatedStringFactory[Avatar](a => a): def apply(avatar: String): Validated[Avatar] = Validated.nonEmptyString("avatar")(avatar) def apply(avatar: URI): Validated[Avatar] = Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) - def unsafe(avatar: String): Avatar = avatar extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala index fa0e853..ee68434 100644 --- a/core/shared/src/main/scala/works/iterative/core/Email.scala +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -4,7 +4,7 @@ opaque type Email = String -object Email: +object Email extends ValidatedStringFactory[Email](e => e): def apply(value: String): Validated[Email] = // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) // and is the most permissive regex we can use that still conforms to the spec. @@ -16,7 +16,3 @@ Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( _.matches(regex) ) - - def unsafe(value: String): Email = apply(value).orThrow - - extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index c4ea9d5..4bd6b6b 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -90,7 +90,7 @@ given string2FirstLineEmpty: Conversion[String, PlainOneLine] = firstLineEmpty(_) - extension (p: PlainOneLine) def toString: String = p + extension (p: PlainOneLine) def asString: String = p opaque type Markdown = String @@ -104,4 +104,4 @@ def optDirect(text: String): Option[Markdown] = Text.nonEmpty(text) - extension (p: Markdown) def toString: String = p + extension (p: Markdown) def asString: String = p diff --git a/core/shared/src/main/scala/works/iterative/core/UserName.scala b/core/shared/src/main/scala/works/iterative/core/UserName.scala index 9ee185a..a404cd5 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserName.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserName.scala @@ -3,11 +3,7 @@ // Full name of the user opaque type UserName = String -object UserName: +object UserName extends ValidatedStringFactory[UserName](u => u): def apply(value: String): Validated[UserName] = // Validate that the value is not empty Validated.nonEmptyString("user.name")(value) - - def unsafe(value: String): UserName = value - - extension (un: UserName) def value: String = un diff --git a/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala new file mode 100644 index 0000000..3e8fd5f --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala @@ -0,0 +1,10 @@ +package works.iterative.core + +/** Common methods for opaque validated String types + */ +trait ValidatedStringFactory[A](val getter: A => String): + def apply(s: String): Validated[A] + def unsafe(s: String): A = apply(s).orThrow + extension (a: A) def value: String = getter(a) + + given ValidatedStringFactory[A] = this diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala index 2a2c200..32595d1 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -1,15 +1,10 @@ -package works.iterative.core.auth - -import works.iterative.core.Validated +package works.iterative.core +package auth // Unique identifier of the user opaque type UserId = String -object UserId: +object UserId extends ValidatedStringFactory[UserId](u => u): def apply(value: String): Validated[UserId] = // Validate that the value is not empty Validated.nonEmptyString("user.id")(value) - - def unsafe(value: String): UserId = value - - extension (u: UserId) def value: String = u diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala index cf21353..72348cd 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala @@ -3,9 +3,6 @@ opaque type UserRole = String -object UserRole: +object UserRole extends ValidatedStringFactory[UserRole](r => r): def apply(role: String): Validated[UserRole] = Validated.nonEmptyString("user.role")(role) - def unsafe(role: String): UserRole = role - - extension (r: UserRole) def value: String = r diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala index 5fdb73b..9091760 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -36,7 +36,7 @@ initialValue: Option[A] ): FormComponent[A] = val codec = summon[InputCodec[A]] - InputField( + Input( fieldDescriptor, initialValue.map(codec.encode), Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) @@ -52,7 +52,7 @@ initialValue: Option[Option[A]] ): FormComponent[Option[A]] = val codec = summon[InputCodec[A]] - InputField[Option[A]]( + Input[Option[A]]( fieldDescriptor, initialValue.flatten.map(codec.encode), (v: Option[String]) => @@ -141,7 +141,7 @@ ) ) - class InputField[A]( + class Input[A]( desc: FieldDescriptor, initialValue: Option[String] = None, validation: Option[String] => Validated[A] @@ -153,12 +153,12 @@ rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - renderInputField( + InputField( desc, initialValue, validated, rawValue.writer - ) + ).elements class FileField[A]( desc: FieldDescriptor, @@ -173,51 +173,6 @@ override val elements: Seq[HtmlElement] = renderFileInputField(desc, rawValue.writer.contramapSome) - def renderInputField( - desc: FieldDescriptor, - initialValue: Option[String], - validated: Signal[Validated[_]], - observer: Observer[Option[String]] - )(using fctx: FormBuilderContext): Seq[HtmlElement] = - - val hadFocus: Var[Boolean] = Var(false) - - val touched: Var[Boolean] = Var(false) - - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) - - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) - - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.map(L.value(_)), - onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => - Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) - ) - ) - ) - ) - ) - class ChoiceField[A]( desc: FieldDescriptor, initialValue: Option[A], diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala index 1971da4..9ee9b11 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -3,11 +3,18 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.* import zio.prelude.Validation +import works.iterative.ui.components.ComponentContext trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) + def buildForm[A]( + schema: FormSchema[A], + submit: Observer[A] + ): HtmlFormSchemaBuilder[A] = + HtmlFormSchemaBuilder[A](schema, submit) + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): def build(initialValue: Option[A])(using fctx: FormBuilderContext @@ -26,3 +33,65 @@ ) ) ) + + case class HtmlFormSchemaBuilder[A]( + schema: FormSchema[A], + submit: Observer[A] + ): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext, + cctx: ComponentContext[?] + ): FormComponent[A] = + val f = buildForm(schema)(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) + + def buildForm[A](schema: FormSchema[A])(initialValue: Option[A])(using + fctx: FormBuilderContext, + cctx: ComponentContext[?] + ): FormComponent[A] = + import FormSchema.* + import works.iterative.ui.components.laminar.LaminarExtensions.* + import works.iterative.ui.components.laminar.HtmlRenderable.given + schema match + case FormSchema.Unit => FormComponent.empty + case Section(name, inner) => + val desc = SectionDescriptor(name) + buildForm(inner)(initialValue).wrap( + fctx.formUIFactory + .section(desc.title, desc.subtitle.map(textToTextNode))(_*) + ) + case Control(name, required, decode, validation) => + val desc = FieldDescriptor(name) + FieldBuilder + .Input( + desc, + initialValue.map(decode(_)), + validation + ) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory + .label(desc.label, required = required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + case z @ Zip(left, right) => + val leftComponent = buildForm(left)(initialValue.map(z.toLeft)) + val rightComponent = buildForm(right)(initialValue.map(z.toRight)) + leftComponent.zip(rightComponent) + case BiMap(inner, to, from) => + buildForm(inner)(initialValue.map(from)).map(to) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala index 4c202dd..17e366e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -1,6 +1,5 @@ package works.iterative.ui.components.laminar.forms -import com.raquo.airstream.core.Signal import com.raquo.laminar.api.L.* import works.iterative.core.Validated import zio.prelude.* @@ -23,6 +22,11 @@ override def validated = v override def elements = e + def empty: FormComponent[EmptyTuple.type] = apply( + Val(Validation.succeed(Tuple())), + Nil + ) + extension [A](fc: FormComponent[A]) def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = FormComponent(fc.validated, Seq(wrapper(fc.elements))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala new file mode 100644 index 0000000..65de5e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.* + +sealed trait FormSchema[A]: + def zip[B <: Tuple](that: FormSchema[B]): FormSchema[A *: B] = + FormSchema.Zip(this, that) + def map[B](f: A => B)(g: B => A): FormSchema[B] = + FormSchema.BiMap(this, f, g) + +object FormSchema: + extension [A](f: FormSchema[A]) + def *:[B <: Tuple](that: FormSchema[B]): FormSchema[A *: B] = f.zip(that) + case object Unit extends FormSchema[EmptyTuple] + case class Control[A]( + name: String, + required: Boolean, + decode: A => String, + validation: Option[String] => Validated[A] + ) extends FormSchema[A] + object Control: + def apply[A](name: String)(using ic: InputCodec[A]): Control[A] = + Control(name, ic.required, ic.encode, ic.decodeOptional(name)) + case class Section[A](name: String, content: FormSchema[A]) + extends FormSchema[A] + case class Zip[A, B <: Tuple](left: FormSchema[A], right: FormSchema[B]) + extends FormSchema[A *: B]: + def toLeft(value: A *: B): A = value.head + def toRight(value: A *: B): B = value.tail + case class BiMap[A, B](form: FormSchema[A], f: A => B, g: B => A) + extends FormSchema[B] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala index 851a47d..e4cd029 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -1,23 +1,54 @@ package works.iterative.ui.components.laminar.forms -import works.iterative.core.{Email, PlainMultiLine, Validated} +import works.iterative.core.* import zio.prelude.Validation +import works.iterative.core.UserMessage trait InputCodec[A]: def encode(a: A): String def decode(s: String): Validated[A] + def decodeOptional(label: String)(s: Option[String]): Validated[A] = + Validation.fail(UserMessage("error.value.required", label)) + def required: Boolean = true object InputCodec: - given InputCodec[String] with + def apply[A]( + encodeF: A => String, + decodeF: String => Validated[A] + ): InputCodec[A] = + new InputCodec[A]: + override def encode(a: A): String = encodeF(a) + override def decode(s: String): Validated[A] = decodeF(s) + + def fromValidatedString[A]( + factory: ValidatedStringFactory[A] + ): InputCodec[A] = new InputCodec: + override def encode(a: A): String = factory.getter(a) + override def decode(s: String): Validated[A] = factory(s) + + given withValidatedStringFactory[A](using + factory: ValidatedStringFactory[A] + ): InputCodec[A] = fromValidatedString[A](factory) + + given validatedStringToInputCodec[A] + : Conversion[ValidatedStringFactory[A], InputCodec[A]] = + fromValidatedString(_) + + given string: InputCodec[String] with override def encode(a: String): String = a override def decode(s: String): Validated[String] = Validation.succeed(s) - given InputCodec[PlainMultiLine] with + given plainOneLine: InputCodec[PlainOneLine] with + override def encode(a: PlainOneLine): String = a.asString + override def decode(s: String): Validated[PlainOneLine] = + PlainOneLine(s) + + given plainMultiLine: InputCodec[PlainMultiLine] with override def encode(a: PlainMultiLine): String = a.asString override def decode(s: String): Validated[PlainMultiLine] = PlainMultiLine(s) - given InputCodec[Email] with + given email: InputCodec[Email] with def encode(a: Email): String = a.value def decode(s: String): Validated[Email] = Email(s) @@ -27,3 +58,8 @@ def decode(s: String): Validated[Option[A]] = if s.isEmpty then Validation.succeed(None) else codec.decode(s).map(Some(_)) + override def decodeOptional(label: String)( + s: Option[String] + ): Validated[Option[A]] = + Validation.succeed(None) + override def required: Boolean = false diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 7e5baeb..b144740 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -26,6 +26,18 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringCodec[A]( + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = + JsonCodec.string.transformOrFail( + factory.apply andThen fromValidation, + factory.getter + ) + + given fromValidatedStringCodec[A](using + factory: ValidatedStringFactory[A] + ): JsonCodec[A] = validatedStringCodec(factory) + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) @@ -33,11 +45,10 @@ given JsonCodec[UserId] = JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) - given JsonCodec[Email] = textCodec(Email.apply) + given JsonCodec[Email] = validatedStringCodec(Email) - given JsonCodec[UserName] = textCodec(UserName.apply) - given JsonCodec[UserRole] = textCodec(UserRole.apply) - given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[UserRole] = validatedStringCodec(UserRole) + given JsonCodec[Avatar] = validatedStringCodec(Avatar) given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] given JsonCodec[FileRef] = DeriveJsonCodec.gen[FileRef] @@ -50,6 +61,10 @@ given JsonCodec[EventRecord] = DeriveJsonCodec.gen[EventRecord] trait TapirCodecs extends CustomTapir: + given fromValidatedStringSchema[A](using + ValidatedStringFactory[A] + ): Schema[A] = Schema.string + given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala index 1ef0c7c..8e406d8 100644 --- a/core/shared/src/main/scala/works/iterative/core/Avatar.scala +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -5,11 +5,10 @@ // TODO: validate URL - or should we go with URI? opaque type Avatar = String -object Avatar: +object Avatar extends ValidatedStringFactory[Avatar](a => a): def apply(avatar: String): Validated[Avatar] = Validated.nonEmptyString("avatar")(avatar) def apply(avatar: URI): Validated[Avatar] = Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) - def unsafe(avatar: String): Avatar = avatar extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala index fa0e853..ee68434 100644 --- a/core/shared/src/main/scala/works/iterative/core/Email.scala +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -4,7 +4,7 @@ opaque type Email = String -object Email: +object Email extends ValidatedStringFactory[Email](e => e): def apply(value: String): Validated[Email] = // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) // and is the most permissive regex we can use that still conforms to the spec. @@ -16,7 +16,3 @@ Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( _.matches(regex) ) - - def unsafe(value: String): Email = apply(value).orThrow - - extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index c4ea9d5..4bd6b6b 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -90,7 +90,7 @@ given string2FirstLineEmpty: Conversion[String, PlainOneLine] = firstLineEmpty(_) - extension (p: PlainOneLine) def toString: String = p + extension (p: PlainOneLine) def asString: String = p opaque type Markdown = String @@ -104,4 +104,4 @@ def optDirect(text: String): Option[Markdown] = Text.nonEmpty(text) - extension (p: Markdown) def toString: String = p + extension (p: Markdown) def asString: String = p diff --git a/core/shared/src/main/scala/works/iterative/core/UserName.scala b/core/shared/src/main/scala/works/iterative/core/UserName.scala index 9ee185a..a404cd5 100644 --- a/core/shared/src/main/scala/works/iterative/core/UserName.scala +++ b/core/shared/src/main/scala/works/iterative/core/UserName.scala @@ -3,11 +3,7 @@ // Full name of the user opaque type UserName = String -object UserName: +object UserName extends ValidatedStringFactory[UserName](u => u): def apply(value: String): Validated[UserName] = // Validate that the value is not empty Validated.nonEmptyString("user.name")(value) - - def unsafe(value: String): UserName = value - - extension (un: UserName) def value: String = un diff --git a/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala new file mode 100644 index 0000000..3e8fd5f --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/ValidatedStringFactory.scala @@ -0,0 +1,10 @@ +package works.iterative.core + +/** Common methods for opaque validated String types + */ +trait ValidatedStringFactory[A](val getter: A => String): + def apply(s: String): Validated[A] + def unsafe(s: String): A = apply(s).orThrow + extension (a: A) def value: String = getter(a) + + given ValidatedStringFactory[A] = this diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala index 2a2c200..32595d1 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -1,15 +1,10 @@ -package works.iterative.core.auth - -import works.iterative.core.Validated +package works.iterative.core +package auth // Unique identifier of the user opaque type UserId = String -object UserId: +object UserId extends ValidatedStringFactory[UserId](u => u): def apply(value: String): Validated[UserId] = // Validate that the value is not empty Validated.nonEmptyString("user.id")(value) - - def unsafe(value: String): UserId = value - - extension (u: UserId) def value: String = u diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala index cf21353..72348cd 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala @@ -3,9 +3,6 @@ opaque type UserRole = String -object UserRole: +object UserRole extends ValidatedStringFactory[UserRole](r => r): def apply(role: String): Validated[UserRole] = Validated.nonEmptyString("user.role")(role) - def unsafe(role: String): UserRole = role - - extension (r: UserRole) def value: String = r diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala index 5fdb73b..9091760 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -36,7 +36,7 @@ initialValue: Option[A] ): FormComponent[A] = val codec = summon[InputCodec[A]] - InputField( + Input( fieldDescriptor, initialValue.map(codec.encode), Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) @@ -52,7 +52,7 @@ initialValue: Option[Option[A]] ): FormComponent[Option[A]] = val codec = summon[InputCodec[A]] - InputField[Option[A]]( + Input[Option[A]]( fieldDescriptor, initialValue.flatten.map(codec.encode), (v: Option[String]) => @@ -141,7 +141,7 @@ ) ) - class InputField[A]( + class Input[A]( desc: FieldDescriptor, initialValue: Option[String] = None, validation: Option[String] => Validated[A] @@ -153,12 +153,12 @@ rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - renderInputField( + InputField( desc, initialValue, validated, rawValue.writer - ) + ).elements class FileField[A]( desc: FieldDescriptor, @@ -173,51 +173,6 @@ override val elements: Seq[HtmlElement] = renderFileInputField(desc, rawValue.writer.contramapSome) - def renderInputField( - desc: FieldDescriptor, - initialValue: Option[String], - validated: Signal[Validated[_]], - observer: Observer[Option[String]] - )(using fctx: FormBuilderContext): Seq[HtmlElement] = - - val hadFocus: Var[Boolean] = Var(false) - - val touched: Var[Boolean] = Var(false) - - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) - - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) - - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.map(L.value(_)), - onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => - Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) - ) - ) - ) - ) - ) - class ChoiceField[A]( desc: FieldDescriptor, initialValue: Option[A], diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala index 1971da4..9ee9b11 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -3,11 +3,18 @@ import com.raquo.laminar.api.L import com.raquo.laminar.api.L.* import zio.prelude.Validation +import works.iterative.ui.components.ComponentContext trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) + def buildForm[A]( + schema: FormSchema[A], + submit: Observer[A] + ): HtmlFormSchemaBuilder[A] = + HtmlFormSchemaBuilder[A](schema, submit) + case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): def build(initialValue: Option[A])(using fctx: FormBuilderContext @@ -26,3 +33,65 @@ ) ) ) + + case class HtmlFormSchemaBuilder[A]( + schema: FormSchema[A], + submit: Observer[A] + ): + def build(initialValue: Option[A])(using + fctx: FormBuilderContext, + cctx: ComponentContext[?] + ): FormComponent[A] = + val f = buildForm(schema)(initialValue) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) + ) + ) + ) + + def buildForm[A](schema: FormSchema[A])(initialValue: Option[A])(using + fctx: FormBuilderContext, + cctx: ComponentContext[?] + ): FormComponent[A] = + import FormSchema.* + import works.iterative.ui.components.laminar.LaminarExtensions.* + import works.iterative.ui.components.laminar.HtmlRenderable.given + schema match + case FormSchema.Unit => FormComponent.empty + case Section(name, inner) => + val desc = SectionDescriptor(name) + buildForm(inner)(initialValue).wrap( + fctx.formUIFactory + .section(desc.title, desc.subtitle.map(textToTextNode))(_*) + ) + case Control(name, required, decode, validation) => + val desc = FieldDescriptor(name) + FieldBuilder + .Input( + desc, + initialValue.map(decode(_)), + validation + ) + .wrap( + fctx.formUIFactory.field( + fctx.formUIFactory + .label(desc.label, required = required)() + )( + _, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) + ) + case z @ Zip(left, right) => + val leftComponent = buildForm(left)(initialValue.map(z.toLeft)) + val rightComponent = buildForm(right)(initialValue.map(z.toRight)) + leftComponent.zip(rightComponent) + case BiMap(inner, to, from) => + buildForm(inner)(initialValue.map(from)).map(to) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala index 4c202dd..17e366e 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -1,6 +1,5 @@ package works.iterative.ui.components.laminar.forms -import com.raquo.airstream.core.Signal import com.raquo.laminar.api.L.* import works.iterative.core.Validated import zio.prelude.* @@ -23,6 +22,11 @@ override def validated = v override def elements = e + def empty: FormComponent[EmptyTuple.type] = apply( + Val(Validation.succeed(Tuple())), + Nil + ) + extension [A](fc: FormComponent[A]) def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = FormComponent(fc.validated, Seq(wrapper(fc.elements))) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala new file mode 100644 index 0000000..65de5e2 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.* + +sealed trait FormSchema[A]: + def zip[B <: Tuple](that: FormSchema[B]): FormSchema[A *: B] = + FormSchema.Zip(this, that) + def map[B](f: A => B)(g: B => A): FormSchema[B] = + FormSchema.BiMap(this, f, g) + +object FormSchema: + extension [A](f: FormSchema[A]) + def *:[B <: Tuple](that: FormSchema[B]): FormSchema[A *: B] = f.zip(that) + case object Unit extends FormSchema[EmptyTuple] + case class Control[A]( + name: String, + required: Boolean, + decode: A => String, + validation: Option[String] => Validated[A] + ) extends FormSchema[A] + object Control: + def apply[A](name: String)(using ic: InputCodec[A]): Control[A] = + Control(name, ic.required, ic.encode, ic.decodeOptional(name)) + case class Section[A](name: String, content: FormSchema[A]) + extends FormSchema[A] + case class Zip[A, B <: Tuple](left: FormSchema[A], right: FormSchema[B]) + extends FormSchema[A *: B]: + def toLeft(value: A *: B): A = value.head + def toRight(value: A *: B): B = value.tail + case class BiMap[A, B](form: FormSchema[A], f: A => B, g: B => A) + extends FormSchema[B] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala index 851a47d..e4cd029 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -1,23 +1,54 @@ package works.iterative.ui.components.laminar.forms -import works.iterative.core.{Email, PlainMultiLine, Validated} +import works.iterative.core.* import zio.prelude.Validation +import works.iterative.core.UserMessage trait InputCodec[A]: def encode(a: A): String def decode(s: String): Validated[A] + def decodeOptional(label: String)(s: Option[String]): Validated[A] = + Validation.fail(UserMessage("error.value.required", label)) + def required: Boolean = true object InputCodec: - given InputCodec[String] with + def apply[A]( + encodeF: A => String, + decodeF: String => Validated[A] + ): InputCodec[A] = + new InputCodec[A]: + override def encode(a: A): String = encodeF(a) + override def decode(s: String): Validated[A] = decodeF(s) + + def fromValidatedString[A]( + factory: ValidatedStringFactory[A] + ): InputCodec[A] = new InputCodec: + override def encode(a: A): String = factory.getter(a) + override def decode(s: String): Validated[A] = factory(s) + + given withValidatedStringFactory[A](using + factory: ValidatedStringFactory[A] + ): InputCodec[A] = fromValidatedString[A](factory) + + given validatedStringToInputCodec[A] + : Conversion[ValidatedStringFactory[A], InputCodec[A]] = + fromValidatedString(_) + + given string: InputCodec[String] with override def encode(a: String): String = a override def decode(s: String): Validated[String] = Validation.succeed(s) - given InputCodec[PlainMultiLine] with + given plainOneLine: InputCodec[PlainOneLine] with + override def encode(a: PlainOneLine): String = a.asString + override def decode(s: String): Validated[PlainOneLine] = + PlainOneLine(s) + + given plainMultiLine: InputCodec[PlainMultiLine] with override def encode(a: PlainMultiLine): String = a.asString override def decode(s: String): Validated[PlainMultiLine] = PlainMultiLine(s) - given InputCodec[Email] with + given email: InputCodec[Email] with def encode(a: Email): String = a.value def decode(s: String): Validated[Email] = Email(s) @@ -27,3 +58,8 @@ def decode(s: String): Validated[Option[A]] = if s.isEmpty then Validation.succeed(None) else codec.decode(s).map(Some(_)) + override def decodeOptional(label: String)( + s: Option[String] + ): Validated[Option[A]] = + Validation.succeed(None) + override def required: Boolean = false diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputField.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputField.scala new file mode 100644 index 0000000..7faa9eb --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputField.scala @@ -0,0 +1,51 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.* +import works.iterative.core.* + +case class InputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] +)(using fctx: FormBuilderContext): + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + val elements: Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(value(_)), + onInput.mapToValue.setAsValue --> observer.contramap { (v: String) => + Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + val element: Div = div(elements*)