diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..1a04dae --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,14 @@ +package works.iterative.core.auth + +import zio.* + +trait PermissionService: + def isAllowed(subj: UserInfo, action: String, obj: String): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: UserInfo, + action: String, + obj: String + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..1a04dae --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,14 @@ +package works.iterative.core.auth + +import zio.* + +trait PermissionService: + def isAllowed(subj: UserInfo, action: String, obj: String): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: UserInfo, + action: String, + obj: String + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) 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 new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..1a04dae --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,14 @@ +package works.iterative.core.auth + +import zio.* + +trait PermissionService: + def isAllowed(subj: UserInfo, action: String, obj: String): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: UserInfo, + action: String, + obj: String + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) 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 new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..1a04dae --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,14 @@ +package works.iterative.core.auth + +import zio.* + +trait PermissionService: + def isAllowed(subj: UserInfo, action: String, obj: String): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: UserInfo, + action: String, + obj: String + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) 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 new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId 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 7cb5ee2..218674f 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 @@ -12,12 +12,14 @@ import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue -trait FormBuilderModule(using fctx: FormBuilderContext): +trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): - def build(initialValue: Option[A]): FormComponent[A] = + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = val f = form.build(initialValue) f.wrap( fctx.formUIFactory.form( diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..1a04dae --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,14 @@ +package works.iterative.core.auth + +import zio.* + +trait PermissionService: + def isAllowed(subj: UserInfo, action: String, obj: String): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: UserInfo, + action: String, + obj: String + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) 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 new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId 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 7cb5ee2..218674f 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 @@ -12,12 +12,14 @@ import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue -trait FormBuilderModule(using fctx: FormBuilderContext): +trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): - def build(initialValue: Option[A]): FormComponent[A] = + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = val f = form.build(initialValue) f.wrap( fctx.formUIFactory.form( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..1a04dae --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,14 @@ +package works.iterative.core.auth + +import zio.* + +trait PermissionService: + def isAllowed(subj: UserInfo, action: String, obj: String): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: UserInfo, + action: String, + obj: String + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) 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 new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId 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 7cb5ee2..218674f 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 @@ -12,12 +12,14 @@ import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue -trait FormBuilderModule(using fctx: FormBuilderContext): +trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): - def build(initialValue: Option[A]): FormComponent[A] = + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = val f = form.build(initialValue) f.wrap( fctx.formUIFactory.form( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..1a04dae --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,14 @@ +package works.iterative.core.auth + +import zio.* + +trait PermissionService: + def isAllowed(subj: UserInfo, action: String, obj: String): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: UserInfo, + action: String, + obj: String + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) 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 new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId 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 7cb5ee2..218674f 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 @@ -12,12 +12,14 @@ import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue -trait FormBuilderModule(using fctx: FormBuilderContext): +trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): - def build(initialValue: Option[A]): FormComponent[A] = + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = val f = form.build(initialValue) f.wrap( fctx.formUIFactory.form( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..1a04dae --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,14 @@ +package works.iterative.core.auth + +import zio.* + +trait PermissionService: + def isAllowed(subj: UserInfo, action: String, obj: String): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: UserInfo, + action: String, + obj: String + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) 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 new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId 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 7cb5ee2..218674f 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 @@ -12,12 +12,14 @@ import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue -trait FormBuilderModule(using fctx: FormBuilderContext): +trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): - def build(initialValue: Option[A]): FormComponent[A] = + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = val f = form.build(initialValue) f.wrap( fctx.formUIFactory.form( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..1a04dae --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,14 @@ +package works.iterative.core.auth + +import zio.* + +trait PermissionService: + def isAllowed(subj: UserInfo, action: String, obj: String): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: UserInfo, + action: String, + obj: String + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) 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 new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId 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 7cb5ee2..218674f 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 @@ -12,12 +12,14 @@ import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue -trait FormBuilderModule(using fctx: FormBuilderContext): +trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): - def build(initialValue: Option[A]): FormComponent[A] = + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = val f = form.build(initialValue) f.wrap( fctx.formUIFactory.form( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..1a04dae --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,14 @@ +package works.iterative.core.auth + +import zio.* + +trait PermissionService: + def isAllowed(subj: UserInfo, action: String, obj: String): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: UserInfo, + action: String, + obj: String + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) 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 new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId 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 7cb5ee2..218674f 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 @@ -12,12 +12,14 @@ import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue -trait FormBuilderModule(using fctx: FormBuilderContext): +trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): - def build(initialValue: Option[A]): FormComponent[A] = + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = val f = form.build(initialValue) f.wrap( fctx.formUIFactory.form( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..1a04dae --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,14 @@ +package works.iterative.core.auth + +import zio.* + +trait PermissionService: + def isAllowed(subj: UserInfo, action: String, obj: String): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: UserInfo, + action: String, + obj: String + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) 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 new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId 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 7cb5ee2..218674f 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 @@ -12,12 +12,14 @@ import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue -trait FormBuilderModule(using fctx: FormBuilderContext): +trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): - def build(initialValue: Option[A]): FormComponent[A] = + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = val f = form.build(initialValue) f.wrap( fctx.formUIFactory.form( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..1a04dae --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,14 @@ +package works.iterative.core.auth + +import zio.* + +trait PermissionService: + def isAllowed(subj: UserInfo, action: String, obj: String): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: UserInfo, + action: String, + obj: String + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) 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 new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId 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 7cb5ee2..218674f 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 @@ -12,12 +12,14 @@ import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue -trait FormBuilderModule(using fctx: FormBuilderContext): +trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): - def build(initialValue: Option[A]): FormComponent[A] = + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = val f = form.build(initialValue) f.wrap( fctx.formUIFactory.form( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..1a04dae --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,14 @@ +package works.iterative.core.auth + +import zio.* + +trait PermissionService: + def isAllowed(subj: UserInfo, action: String, obj: String): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: UserInfo, + action: String, + obj: String + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) 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 new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId 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 7cb5ee2..218674f 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 @@ -12,12 +12,14 @@ import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue -trait FormBuilderModule(using fctx: FormBuilderContext): +trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): - def build(initialValue: Option[A]): FormComponent[A] = + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = val f = form.build(initialValue) f.wrap( fctx.formUIFactory.form( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..1a04dae --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,14 @@ +package works.iterative.core.auth + +import zio.* + +trait PermissionService: + def isAllowed(subj: UserInfo, action: String, obj: String): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: UserInfo, + action: String, + obj: String + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) 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 new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId 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 7cb5ee2..218674f 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 @@ -12,12 +12,14 @@ import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue -trait FormBuilderModule(using fctx: FormBuilderContext): +trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): - def build(initialValue: Option[A]): FormComponent[A] = + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = val f = form.build(initialValue) f.wrap( fctx.formUIFactory.form( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..1a04dae --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,14 @@ +package works.iterative.core.auth + +import zio.* + +trait PermissionService: + def isAllowed(subj: UserInfo, action: String, obj: String): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: UserInfo, + action: String, + obj: String + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) 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 new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId 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 7cb5ee2..218674f 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 @@ -12,12 +12,14 @@ import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue -trait FormBuilderModule(using fctx: FormBuilderContext): +trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): - def build(initialValue: Option[A]): FormComponent[A] = + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = val f = form.build(initialValue) f.wrap( fctx.formUIFactory.form( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala new file mode 100644 index 0000000..1a04dae --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/PermissionService.scala @@ -0,0 +1,14 @@ +package works.iterative.core.auth + +import zio.* + +trait PermissionService: + def isAllowed(subj: UserInfo, action: String, obj: String): UIO[Boolean] + +object PermissionService: + def isAllowed( + subj: UserInfo, + action: String, + obj: String + ): URIO[PermissionService, Boolean] = + ZIO.serviceWithZIO(_.isAllowed(subj, action, obj)) 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 new file mode 100644 index 0000000..ceac729 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserId.scala @@ -0,0 +1,9 @@ +package works.iterative.core.auth + +/** Opaque type to distinguish user identifiers */ +opaque type UserId = String + +object UserId: + def apply(value: String): UserId = value + + extension (userId: UserId) def value: String = userId diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala new file mode 100644 index 0000000..0441f92 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserInfo.scala @@ -0,0 +1,8 @@ +package works.iterative.core.auth + +/** Data object representing something with user information + * + * This is meant to be subclassed by app-specific implementations. + */ +trait UserInfo: + def subjectId: UserId 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 7cb5ee2..218674f 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 @@ -12,12 +12,14 @@ import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue -trait FormBuilderModule(using fctx: FormBuilderContext): +trait FormBuilderModule: def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): - def build(initialValue: Option[A]): FormComponent[A] = + def build(initialValue: Option[A])(using + fctx: FormBuilderContext + ): FormComponent[A] = val f = form.build(initialValue) f.wrap( fctx.formUIFactory.form( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala new file mode 100644 index 0000000..c7632a4 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.Tag +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.laminar.forms.FormBuilderModule + +trait FormPage[T: Tag: Form, K] + extends FormPageModel[T] + with FormPageZIOHandler[T, K] + with FormPageView[T] + with FormPageComponent[T] + with FormBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala new file mode 100644 index 0000000..65e2cea --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageComponent.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageComponent[T]: + self: FormPageModel[T] with FormPageView[T] with FormBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala new file mode 100644 index 0000000..a9d6613 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.core.UserMessage + +trait FormPageHandler[T, K]: + def initialValue(key: K): UIO[Option[T]] + def submit(key: K, value: T): UIO[Unit] + def cancel(key: K): UIO[Unit] + def reportError(message: UserMessage): UIO[Unit] + +object FormPageHandler: + def initialValue[K: Tag, T: Tag]( + key: K + ): URIO[FormPageHandler[T, K], Option[T]] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.initialValue(key)) + def submit[K: Tag, T: Tag]( + key: K, + value: T + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.submit(key, value)) + def cancel[K: Tag, T: Tag](key: K): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.cancel(key)) + def reportError[K: Tag, T: Tag]( + message: UserMessage + ): URIO[FormPageHandler[T, K], Unit] = + ZIO.serviceWithZIO[FormPageHandler[T, K]](_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala new file mode 100644 index 0000000..75d96ab --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageModel.scala @@ -0,0 +1,50 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import works.iterative.ui.model.Computable +import works.iterative.core.UserMessage + +trait FormPageModel[T]: + + enum Action: + case SetInitialValue(value: Option[T]) + case Submit(value: T) + case Submitted + case SetError(message: UserMessage) + case Cancel + + enum Effect: + case LoadInitialValue + case Submit(value: T) + case ReportError(message: UserMessage) + case Cancel + + case class Model( + initialValue: Computable[Option[T]], + submitted: Boolean = false + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model(Computable.Uninitialized) -> Some(Effect.LoadInitialValue) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetInitialValue(value) => + model.copy(initialValue = model.initialValue.update(value)) -> None + case Action.Submit(value) => + model -> Some(Effect.Submit(value)) + case Action.Cancel => + model -> Some(Effect.Cancel) + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.Submitted => + model.copy(submitted = true) -> None + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala new file mode 100644 index 0000000..fac5f49 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageView.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.forms.FormBuilderModule +import works.iterative.ui.components.laminar.forms.Form +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.forms.FormBuilderContext + +trait FormPageView[T: Form]: + self: FormPageModel[T] with FormBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_], + fctx: FormBuilderContext + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map( + _.initialValue.map(renderForm).map(div(_)) + ) + ).element + + def renderForm( + initialValue: Option[T] + ): Seq[HtmlElement] = + buildForm[T](summon[Form[T]], actions.contramap(Action.Submit(_))) + .build(initialValue) + .elements diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala new file mode 100644 index 0000000..2364cb3 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/formpage/FormPageZIOHandler.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.laminar.modules.formpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait FormPageZIOHandler[T: Tag, K: Tag]: + self: FormPageModel[T] => + + type HandlerEnv = FormPageZIOHandler.Env[T, K] + + class Handler(key: K) extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadInitialValue => + fromZIO( + FormPageHandler.initialValue(key).map(Action.SetInitialValue(_)) + ) + case Effect.Submit(value) => + fromZIO(FormPageHandler.submit(key, value).as(Action.Submitted)) + case Effect.ReportError(msg) => + fromZIOUnit(FormPageHandler.reportError(msg)) + case Effect.Cancel => + fromZIOUnit(FormPageHandler.cancel(key)) + +object FormPageZIOHandler: + type Env[T, K] = FormPageHandler[T, K] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala new file mode 100644 index 0000000..6812d90 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPage.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.Tag +import works.iterative.ui.components.laminar.HtmlTabular +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPage[T: Tag: HtmlTabular] + extends ListPageModel[T] + with ListPageZIOHandler[T] + with ListPageView[T] + with ListPageComponent[T] + with HtmlTableBuilderModule diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala new file mode 100644 index 0000000..c220dac --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageComponent.scala @@ -0,0 +1,20 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.laminar.EffectHandler +import works.iterative.ui.components.laminar.LaminarComponent +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageComponent[T]: + self: ListPageView[T] with ListPageModel[T] with HtmlTableBuilderModule => + + class Component(effectHandler: EffectHandler[Effect, Action])(using + ctx: ComponentContext[_] + ) extends LaminarComponent[Model, Action, Effect, HtmlElement](effectHandler) + with Module: + override def render( + m: Signal[Model], + actions: Observer[Action] + ): HtmlElement = + View(m, actions).element diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala new file mode 100644 index 0000000..028e2cd --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageHandler.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.core.UserMessage + +trait ListPageHandler[T]: + def loadItems(): UIO[List[T]] + def reportError(message: UserMessage): UIO[Unit] + +object ListPageHandler: + def loadItems[T: Tag](): URIO[ListPageHandler[T], List[T]] = + ZIO.serviceWithZIO(_.loadItems()) + def reportError[T: Tag]( + message: UserMessage + ): URIO[ListPageHandler[T], Unit] = + ZIO.serviceWithZIO(_.reportError(message)) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala new file mode 100644 index 0000000..c34ce82 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageModel.scala @@ -0,0 +1,44 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import works.iterative.core.UserMessage +import works.iterative.ui.model.Computable +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler + +trait ListPageModel[T]: + + enum Action: + case SetItems(items: List[T]) + case VisitDetail(item: T) + case SetError(message: UserMessage) + + enum Effect: + case LoadItems + case ReportError(message: UserMessage) + case VisitDetail(item: T) + + case class Model( + items: Computable[List[T]] = Computable.Uninitialized + ) + + trait Module extends works.iterative.ui.Module[Model, Action, Effect]: + override def init: (Model, Option[Effect]) = + Model() -> Some(Effect.LoadItems) + + override def handle(action: Action, model: Model): (Model, Option[Effect]) = + action match + case Action.SetItems(items) => + model.copy(items = model.items.update(items)) -> None + case Action.SetError(msg) => + model -> Some(Effect.ReportError(msg)) + case Action.VisitDetail(item) => + model -> Some(Effect.VisitDetail(item)) + + override def handleFailure: PartialFunction[Throwable, Option[Action]] = { + case e: Throwable => + Some( + Action.SetError( + UserMessage("error.operation.failed", e.getMessage) + ) + ) + } diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala new file mode 100644 index 0000000..1012c6c --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageView.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.prelude.* +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.ComponentContext +import works.iterative.ui.components.laminar.ComputableComponent +import works.iterative.ui.components.laminar.HtmlTabular +import io.laminext.syntax.core.* +import works.iterative.ui.components.laminar.tables.HtmlTableBuilderModule + +trait ListPageView[T: HtmlTabular]: + self: ListPageModel[T] with HtmlTableBuilderModule => + + class View(model: Signal[Model], actions: Observer[Action])(using + ctx: ComponentContext[_] + ): + + val element: HtmlElement = + ComputableComponent(div)( + model.map(_.items.map(renderItem)) + ).element + + private def renderItem(items: List[T]): HtmlElement = + buildTable(items) + .dataRowMod(item => + nodeSeq( + // TODO: no styling info in the generic module + cls("cursor-pointer hover:bg-gray-100"), + onClick.mapTo(Action.VisitDetail(item)) --> actions + ) + ) + .build diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala new file mode 100644 index 0000000..5798aba --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/modules/listpage/ListPageZIOHandler.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.laminar.modules.listpage + +import zio.* +import works.iterative.ui.services.UserNotificationService +import works.iterative.ui.ZIOEffectHandler +import zio.stream.ZStream + +trait ListPageZIOHandler[T: Tag]: + self: ListPageModel[T] => + + type HandlerEnv = ListPageZIOHandler.Env[T] + + class Handler(onVisitDetail: T => UIO[Unit]) + extends ZIOEffectHandler[HandlerEnv, Effect, Action]: + override def handle(e: Effect): ZStream[HandlerEnv, Throwable, Action] = + e match + case Effect.LoadItems => + fromZIO( + ListPageHandler.loadItems[T]().map(Action.SetItems(_)) + ) + case Effect.ReportError(msg) => + fromZIOUnit(ListPageHandler.reportError[T](msg)) + case Effect.VisitDetail(item) => + fromZIOUnit(onVisitDetail(item)) + +object ListPageZIOHandler: + type Env[T] = ListPageHandler[T]