diff --git a/README.md b/README.md index 9fc37ff..df15287 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ libraryDependencies += "io.github.arashi01" %%% "tausi-cats" % "0.0.1-SNAPSHOT" // Optional: ZIO Integration libraryDependencies += "io.github.arashi01" %%% "tausi-zio" % "0.0.1-SNAPSHOT" + +// Optional: Laminar Integration (stream-to-Laminar bridge) +libraryDependencies += "io.github.arashi01" %%% "tausi-laminar" % "0.0.1-SNAPSHOT" ``` ## Usage @@ -171,6 +174,60 @@ button( ) ``` +### Laminar Integration (Stream-to-Laminar Bridge) + +Tausi provides a type-safe bridge between effect streams (ZIO ZStream, fs2 Stream) and Laminar observables: + +```scala +// Add dependency +libraryDependencies += "io.github.arashi01" %%% "tausi-laminar" % "0.0.1-SNAPSHOT" +``` + +```scala +import tausi.laminar.* +import tausi.zio.ZStreamIO +import tausi.zio.ZStreamIO.given +import zio.stream.ZStream + +// Create a ZIO stream +val counter: ZStreamIO[Int] = ZStream.iterate(0)(_ + 1).take(10) + +// Tier 1: Unsafe (errors logged, dropped) - for prototyping +val eventStream: EventStream[Int] = counter.toStreamUnsafe + +// Tier 2: Either-based (errors as values) +val signal: Signal[Either[TauriError, Int]] = + counter.toSignal(Left(TauriError.StreamError("Loading..."))) + +// Tier 3: Full state (recommended for production) +val stateSignal: Signal[StreamState[Int]] = counter.toStateSignal +``` + +The `StreamState` ADT provides full lifecycle visibility: + +```scala +enum StreamState[+A]: + case Running // Stream is loading + case Value(value: A) // Latest value received + case Failed(error: TauriError) // Stream failed + case Completed // Stream completed (no final value) + case CompletedWith(value: A) // Stream completed with final value +``` + +Usage in Laminar: + +```scala +div( + child <-- myStream.toStateSignal.map { + case StreamState.Running => div(cls := "spinner", "Loading...") + case StreamState.Value(data) => renderData(data) + case StreamState.Failed(err) => div(cls := "error", err.message) + case StreamState.Completed => div("Done") + case StreamState.CompletedWith(d) => renderData(d) + } +) +``` + ## Defining Custom Commands For custom Tauri plugins, define commands using the `Command.define` factory: diff --git a/build.sbt b/build.sbt index e460bcf..d35ea5c 100644 --- a/build.sbt +++ b/build.sbt @@ -87,14 +87,27 @@ val `tausi-zio` = .settings(libraryDependencies += libraries.`zio-streams`.value) .settings(libraryDependencies += libraries.`munit-zio`.value) +val `tausi-laminar` = + project + .in(file("modules/laminar")) + .enablePlugins(ScalaJSPlugin) + .settings(compilerSettings) + .settings(unitTestSettings) + .settings(fileHeaderSettings) + .settings(publishSettings) + .dependsOn(`tausi-api`.js) + .settings(libraryDependencies += libraries.laminar.value) + val `tausi-sample` = project .in(file("modules/sample")) .enablePlugins(ScalaJSPlugin) - .dependsOn(`tausi-zio`) + .dependsOn(`tausi-zio`, `tausi-laminar`) + .settings(unitTestSettings) .settings(libraryDependencies += libraries.laminar.value) .settings(libraryDependencies += libraries.waypoint.value) .settings(libraryDependencies += libraries.`scala-java-time`.value) + .settings(libraryDependencies += libraries.`munit-zio`.value) .settings(scalaJSUseMainModuleInitializer := true) .settings(scalaJSLinkerConfig ~= { c => import org.scalajs.linker.interface.* @@ -118,7 +131,8 @@ val `tausi-js` = .aggregate( `tausi-api`.js, `tausi-cats`, - `tausi-zio` + `tausi-zio`, + `tausi-laminar` ) lazy val `tausi-root` = diff --git a/modules/api/js/src/main/scala/tausi/api/TauriError.scala b/modules/api/js/src/main/scala/tausi/api/TauriError.scala index a9c4fb1..759708b 100644 --- a/modules/api/js/src/main/scala/tausi/api/TauriError.scala +++ b/modules/api/js/src/main/scala/tausi/api/TauriError.scala @@ -115,6 +115,16 @@ object TauriError: cause: Option[Throwable] = None ) extends TauriError(message, cause) + /** Error occurred during stream operations. + * + * @param message Description of what went wrong + * @param cause Optional underlying exception + */ + final case class StreamError( + message: String, + cause: Option[Throwable] = None + ) extends TauriError(message, cause) + /** Error occurred during event operations. * * @param eventName The event name @@ -180,6 +190,11 @@ object TauriError: def apply(channelId: CallbackId, message: String): ChannelError = new ChannelError(channelId, message, None) + object StreamError: + /** Create StreamError without cause */ + def apply(message: String): StreamError = + new StreamError(message, None) + object EventError: /** Create EventError without cause */ def apply(eventName: String, message: String): EventError = @@ -199,7 +214,7 @@ object TauriError: extension (error: TauriError) /** Extract human-readable error message from any TauriError variant. */ - def message: String = error match + inline def message: String = error match case e: InvokeError => e.message case e: PluginError => e.message case e: PermissionError => e.message @@ -207,12 +222,13 @@ object TauriError: case e: ConversionError => e.message case e: CallbackError => e.message case e: ChannelError => e.message + case e: StreamError => e.message case e: EventError => e.message case e: GenericError => e.message case e: TauriNotAvailableError => e.message /** Extract optional underlying cause from any TauriError variant. */ - def cause: Option[Throwable] = error match + inline def cause: Option[Throwable] = error match case e: InvokeError => e.cause case e: PluginError => e.cause case e: PermissionError => e.cause @@ -220,6 +236,7 @@ object TauriError: case e: ConversionError => e.cause case e: CallbackError => e.cause case e: ChannelError => e.cause + case e: StreamError => e.cause case e: EventError => e.cause case e: GenericError => e.cause case _: TauriNotAvailableError => None @@ -231,12 +248,9 @@ object TauriError: /** Wrap any Throwable into a TauriError. * - * If already a TauriError, returns as-is. Otherwise, wraps in GenericError. - * - * @param t The throwable to wrap - * @return TauriError variant + * Returns the input unchanged if already a TauriError, otherwise wraps in [[GenericError]]. */ - def fromThrowable(t: Throwable): TauriError = t match + inline def fromThrowable(t: Throwable): TauriError = t match case e: TauriError => e case e => GenericError(s"Unexpected error: ${e.getMessage}", Some(e)) diff --git a/modules/api/js/src/main/scala/tausi/api/stream/StreamSource.scala b/modules/api/js/src/main/scala/tausi/api/stream/StreamSource.scala new file mode 100644 index 0000000..ab30b3a --- /dev/null +++ b/modules/api/js/src/main/scala/tausi/api/stream/StreamSource.scala @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Tausi contributors. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package tausi.api.stream + +import tausi.api.TauriError + +/** Type class for subscribing to effect streams via callbacks. + * + * This abstraction enables UI frameworks (such as Laminar) to consume streams from any effect + * system (ZIO, Cats Effect, or custom) without depending on those effect systems directly. Effect + * modules provide instances; UI modules consume them through the type class interface. + * + * The callback-based design allows bridging between push-based effect streams and push-based UI + * observables without buffering or complex synchronisation. + * + * ==Type Parameters== + * - `S[_]`: The stream type constructor (e.g., `ZStream[Any, TauriError, *]` or `Stream[IO, *]`) + * + * ==Error Handling== + * All errors are normalised to [[TauriError]] variants, ensuring consistent error handling + * across effect systems. This follows the errors-as-values principle - errors are never + * thrown or silently dropped. + * + * ==Thread Safety== + * Implementations must ensure that: + * - Callbacks may be invoked from any thread + * - The returned [[StreamSubscription]] is safe to cancel from any thread + * - Cancellation stops further callback invocations (though in-flight calls may complete) + * + * ==Lifecycle== + * The subscription lifecycle is: + * 1. [[subscribe]] is called with callbacks + * 2. `onNext` is invoked for each stream element + * 3. Either `onError` or `onComplete` is invoked exactly once when the stream terminates + * 4. The returned [[StreamSubscription]] can be used to cancel early + * + * @see + * [[StreamSubscription]] for the cancellation handle + */ +trait StreamSource[S[_]]: + + extension [A](stream: S[A]) + /** Subscribe to the stream with callbacks for elements, errors, and completion. + * + * The subscription begins immediately upon calling this method. Elements are delivered via + * `onNext`, and the stream terminates with either `onError` or `onComplete` (never both). + * + * All errors are normalised to [[TauriError]] variants, ensuring consistent error handling + * across effect systems. Upstream errors are wrapped in [[TauriError.StreamError]]. + * + * @param onNext + * Callback invoked for each element emitted by the stream + * @param onError + * Callback invoked if the stream fails with a [[TauriError]] (terminal) + * @param onComplete + * Callback invoked when the stream completes successfully (terminal) + * @return + * A [[StreamSubscription]] that can be used to cancel the subscription + */ + def subscribe(onNext: A => Unit, onError: TauriError => Unit, onComplete: () => Unit): StreamSubscription + end extension + +end StreamSource + +/** Companion for [[StreamSource]]. Provides summoner method. */ +object StreamSource: + + /** Summoner for type class instances. + * + * @tparam S + * The stream type constructor + * @return + * The [[StreamSource]] instance for `S` + */ + inline def apply[S[_]](using source: StreamSource[S]): StreamSource[S] = source diff --git a/modules/api/js/src/main/scala/tausi/api/stream/StreamSubscription.scala b/modules/api/js/src/main/scala/tausi/api/stream/StreamSubscription.scala new file mode 100644 index 0000000..da8fcd8 --- /dev/null +++ b/modules/api/js/src/main/scala/tausi/api/stream/StreamSubscription.scala @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Tausi contributors. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package tausi.api.stream + +/** Handle for cancelling a stream subscription. + * + * Represents a leaky resource that must be cleaned up when the subscription is no longer needed. + * Calling [[cancel]] releases all resources associated with the subscription and stops receiving + * new elements. + * + * [[cancel]] is idempotent and safe to call concurrently; only the first + * invocation has any effect. + * + * @see + * [[StreamSource]] for creating subscriptions from effect streams + */ +trait StreamSubscription: + + /** Cancel the subscription and release all associated resources. + * + * This method is idempotent; calling it multiple times has no additional effect after the first + * call. Once cancelled, no further elements will be delivered to the subscriber. + * + * Implementations must not throw exceptions. + */ + def cancel(): Unit + +/** Companion for [[StreamSubscription]]. Provides factory method. */ +object StreamSubscription: + + /** Concrete implementation of [[StreamSubscription]]. */ + final class Impl @scala.annotation.publicInBinary private[StreamSubscription] ( + cancelFn: () => Unit + ) extends StreamSubscription: + private val cancelled = new java.util.concurrent.atomic.AtomicBoolean(false) + + override def cancel(): Unit = + if cancelled.compareAndSet(false, true) then cancelFn() + + /** Create a subscription from a cancel function. + * + * The returned subscription guarantees `cancelFn` is invoked at most once, + * even when [[cancel]] is called concurrently from multiple threads. + * + * @param cancelFn + * Function to invoke on cancellation. Must be idempotent and must not throw. + */ + inline def apply(cancelFn: () => Unit): StreamSubscription = + new Impl(cancelFn) + + given CanEqual[StreamSubscription, StreamSubscription] = CanEqual.derived +end StreamSubscription diff --git a/modules/cats/src/main/scala/tausi/cats/Fs2StreamIO.scala b/modules/cats/src/main/scala/tausi/cats/Fs2StreamIO.scala new file mode 100644 index 0000000..dcffab1 --- /dev/null +++ b/modules/cats/src/main/scala/tausi/cats/Fs2StreamIO.scala @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Tausi contributors. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package tausi.cats + +import _root_.cats.effect.IO +import _root_.cats.effect.std.Dispatcher + +import _root_.fs2.Stream + +import tausi.api.TauriError +import tausi.api.stream.StreamSource +import tausi.api.stream.StreamSubscription + +/** Type alias for fs2 streams with `IO` effect. + * + * This alias provides a concrete stream type suitable for the [[StreamSource]] type class, + * enabling effect-agnostic stream integration with UI frameworks like Laminar. + * + * @tparam A + * The element type of the stream + */ +type Fs2StreamIO[A] = Stream[IO, A] + +/** Companion for [[Fs2StreamIO]]. Provides [[StreamSource]] instance. */ +object Fs2StreamIO: + + /** [[StreamSource]] instance for fs2 streams with IO effect. + * + * Requires an implicit [[Dispatcher]] to execute the stream. The dispatcher is typically + * obtained from [[Dispatcher.parallel]] or [[Dispatcher.sequential]] within an IOApp or a + * Resource. + * + * ==Usage== + * {{{ + * import tausi.cats.Fs2StreamIO.given + * + * Dispatcher.parallel[IO].use { implicit dispatcher => + * val stream: Fs2StreamIO[Int] = Stream(1, 2, 3) + * val sub = stream.subscribe( + * onNext = println, + * onError = err => println(s"Error: $$err"), + * onComplete = () => println("Done") + * ) + * // ... + * } + * }}} + */ + given streamSource(using dispatcher: Dispatcher[IO]): StreamSource[Fs2StreamIO] with + extension [A](stream: Fs2StreamIO[A]) + override def subscribe( + onNext: A => Unit, + onError: TauriError => Unit, + onComplete: () => Unit + ): StreamSubscription = + // Wrap upstream error into TauriError.StreamError + inline def wrapError(t: Throwable): TauriError = t match + case e: TauriError => e + case e => TauriError.StreamError(e.getMessage, Some(e)) + + val effect: IO[Unit] = + stream + .evalMap(a => IO(onNext(a))) + .compile + .drain + .attempt + .flatMap { + case Right(()) => IO(onComplete()) + case Left(err) => IO(onError(wrapError(err))) + } + + val (_, cancel) = dispatcher.unsafeToFutureCancelable(effect) + + StreamSubscription { () => + cancel(): Unit + } + end extension + end streamSource + +end Fs2StreamIO diff --git a/modules/cats/src/main/scala/tausi/cats/package.scala b/modules/cats/src/main/scala/tausi/cats/package.scala index 852a3be..59898ac 100644 --- a/modules/cats/src/main/scala/tausi/cats/package.scala +++ b/modules/cats/src/main/scala/tausi/cats/package.scala @@ -487,28 +487,36 @@ package object cats: * The effect is run via the Dispatcher with fire-and-forget semantics. The callback receives * the result as an Either, with Left for errors and Right for success. * + * All errors are normalised to [[TauriError]], ensuring consistent error handling + * across the entire Tausi API. + * * @param onResult * called with the result (success or failure as Either) */ - inline def runWith(onResult: Either[Throwable, A] => Unit)(using dispatcher: Dispatcher[IO]): Unit = + inline def runWith(onResult: Either[TauriError, A] => Unit)(using dispatcher: Dispatcher[IO]): Unit = dispatcher.unsafeRunAndForget( - effect.attempt.flatMap(result => IO(onResult(result))) + effect.attempt.flatMap { result => + val normalised: Either[TauriError, A] = result.left.map(TauriError.fromThrowable) + IO(onResult(normalised)) + } ) /** Execute the effect, invoking separate callbacks for success and failure. * * The effect is run via the Dispatcher with fire-and-forget semantics. + * All errors are normalised to [[TauriError]], ensuring consistent error handling + * across the entire Tausi API. * * @param onSuccess * called if the effect succeeds * @param onError - * called if the effect fails + * called if the effect fails (with normalised [[TauriError]]) */ - inline def runWith(onSuccess: A => Unit, onError: Throwable => Unit)(using dispatcher: Dispatcher[IO]): Unit = + inline def runWith(onSuccess: A => Unit, onError: TauriError => Unit)(using dispatcher: Dispatcher[IO]): Unit = dispatcher.unsafeRunAndForget( effect.attempt.flatMap { case Right(a) => IO(onSuccess(a)) - case Left(e) => IO(onError(e)) + case Left(e) => IO(onError(TauriError.fromThrowable(e))) } ) diff --git a/modules/cats/src/test/scala/tausi/cats/EffectExecutionSpec.scala b/modules/cats/src/test/scala/tausi/cats/EffectExecutionSpec.scala index 7b17a58..e26d94a 100644 --- a/modules/cats/src/test/scala/tausi/cats/EffectExecutionSpec.scala +++ b/modules/cats/src/test/scala/tausi/cats/EffectExecutionSpec.scala @@ -27,6 +27,8 @@ import cats.effect.std.Dispatcher import munit.CatsEffectSuite +import tausi.api.TauriError + /** Tests for effect execution extensions. * * Tests the `runWith` overloads for executing Cats Effect IO from callback contexts. @@ -44,7 +46,7 @@ class EffectExecutionSpec extends CatsEffectSuite: dispatcherFixture.test("runWith(Either) should invoke callback with Right on success"): dispatcher => given Dispatcher[IO] = dispatcher for - deferred <- Deferred[IO, Either[Throwable, Int]] + deferred <- Deferred[IO, Either[TauriError, Int]] effect = IO.pure(42) _ = effect.runWith(result => dispatcher.unsafeRunAndForget(deferred.complete(result))) result <- deferred.get.timeout(1.second) @@ -53,13 +55,15 @@ class EffectExecutionSpec extends CatsEffectSuite: dispatcherFixture.test("runWith(Either) should invoke callback with Left on failure"): dispatcher => given Dispatcher[IO] = dispatcher for - deferred <- Deferred[IO, Either[Throwable, Int]] + deferred <- Deferred[IO, Either[TauriError, Int]] effect: IO[Int] = IO.raiseError(TestException("boom")) _ = effect.runWith(result => dispatcher.unsafeRunAndForget(deferred.complete(result))) result <- deferred.get.timeout(1.second) yield assert(result.isLeft) - assertEquals(result.left.toOption.map(_.getMessage), Some("boom")) + // The error is wrapped in TauriError.GenericError via fromThrowable + assert(result.left.toOption.exists(_.isInstanceOf[TauriError.GenericError])) // scalafix:ok + assert(result.left.toOption.exists(_.message.contains("boom"))) // ==================== // runWith(onSuccess, onError) tests @@ -84,10 +88,14 @@ class EffectExecutionSpec extends CatsEffectSuite: effect: IO[Int] = IO.raiseError(TestException("failure")) _ = effect.runWith( onSuccess = _ => (), - onError = e => dispatcher.unsafeRunAndForget(deferred.complete(e.getMessage)) + // onError receives TauriError, use .message + onError = e => dispatcher.unsafeRunAndForget(deferred.complete(e.message)) ) result <- deferred.get.timeout(1.second) - yield assertEquals(result, "failure") + yield + // Error is wrapped in TauriError.UnexpectedError with message "Unexpected error: failure" + assert(result.contains("failure")) + end for // ==================== // runWithUnsafe() (fire-and-forget) tests diff --git a/modules/laminar/src/main/scala/tausi/laminar/StreamState.scala b/modules/laminar/src/main/scala/tausi/laminar/StreamState.scala new file mode 100644 index 0000000..6377980 --- /dev/null +++ b/modules/laminar/src/main/scala/tausi/laminar/StreamState.scala @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2025 Tausi contributors. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package tausi.laminar + +import tausi.api.TauriError + +/** State ADT for stream-to-signal conversion with full lifecycle visibility. + * + * Represents the complete lifecycle of a stream subscription, enabling UI components to display + * appropriate feedback for loading states, errors, and successful values. + * + * ==States== + * - [[Running]]: Stream is active but no value has been received yet (loading state) + * - [[Value]]: Stream emitted a value and is still running + * - [[Failed]]: Stream terminated with an error (represented as [[TauriError]]) + * - [[Completed]]: Stream completed successfully without emitting a final value + * - [[CompletedWith]]: Stream completed successfully with a final value + * + * ==Usage== + * {{{ + * import tausi.laminar.* + * import tausi.zio.ZStreamIO.given + * + * val stateSignal: Signal[StreamState[Int]] = myStream.toStateSignal + * + * div( + * child <-- stateSignal.map { + * case StreamState.Running => span("Loading...") + * case StreamState.Value(n) => span(s"Current: $$n") + * case StreamState.Failed(err) => span(cls := "error", s"Error: $${err.message}") + * case StreamState.Completed => span("Done") + * case StreamState.CompletedWith(n) => span(s"Final: $$n") + * } + * ) + * }}} + * + * @tparam A + * The value type + */ +enum StreamState[+A]: + + /** Stream is running but no value has been received yet. + * + * This is the initial state before any elements are emitted. Use this to display loading + * indicators or placeholder content. + */ + case Running + + /** Stream emitted a value and is still running. + * + * @param value + * The most recent value emitted by the stream + */ + case Value(value: A) + + /** Stream terminated with an error. + * + * @param error + * The [[TauriError]] that caused the stream to fail + */ + case Failed(error: TauriError) + + /** Stream completed successfully without emitting a final value. + * + * This occurs when the stream completes but no value was ever emitted, or when the completion + * should be treated as a distinct terminal state. + */ + case Completed + + /** Stream completed successfully with a final value. + * + * @param lastValue + * The last value emitted before completion + */ + case CompletedWith(lastValue: A) + +end StreamState + +/** Companion for [[StreamState]]. Provides extension methods and instances. */ +object StreamState: + + given [A]: CanEqual[StreamState[A], StreamState[A]] = CanEqual.derived + + extension [A](state: StreamState[A]) + + /** Check if the stream is still running (either no value yet or has emitted values). */ + inline def isRunning: Boolean = state match + case Running => true + case Value(_) => true + case _ => false + + /** Check if the stream has terminated (either successfully or with an error). */ + inline def isTerminated: Boolean = !isRunning + + /** Check if the stream terminated with an error. */ + inline def isFailed: Boolean = state match + case Failed(_) => true + case _ => false + + /** Check if the stream completed successfully (with or without a final value). */ + inline def isCompleted: Boolean = state match + case Completed => true + case CompletedWith(_) => true + case _ => false + + /** Get the current value if one exists. */ + def valueOption: Option[A] = state match + case Value(v) => Some(v) + case CompletedWith(v) => Some(v) + case _ => None + + /** Get the error if the stream failed. */ + def errorOption: Option[TauriError] = state match + case Failed(e) => Some(e) + case _ => None + + /** Fold over all possible states. + * + * @param onRunning + * Handler for initial running state + * @param onValue + * Handler for value state + * @param onFailed + * Handler for error state + * @param onCompleted + * Handler for completion without value + * @param onCompletedWith + * Handler for completion with value + */ + inline def fold[B]( + onRunning: => B, + onValue: A => B, + onFailed: TauriError => B, + onCompleted: => B, + onCompletedWith: A => B + ): B = state match + case Running => onRunning + case Value(v) => onValue(v) + case Failed(e) => onFailed(e) + case Completed => onCompleted + case CompletedWith(v) => onCompletedWith(v) + + end extension + +end StreamState diff --git a/modules/laminar/src/main/scala/tausi/laminar/extensions.scala b/modules/laminar/src/main/scala/tausi/laminar/extensions.scala new file mode 100644 index 0000000..8e5a5d2 --- /dev/null +++ b/modules/laminar/src/main/scala/tausi/laminar/extensions.scala @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2025 Tausi contributors. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package tausi.laminar + +import com.raquo.airstream.core.EventStream +import com.raquo.airstream.core.Signal +import com.raquo.airstream.eventbus.EventBus +import com.raquo.airstream.ownership.Owner +import com.raquo.airstream.ownership.Subscription +import com.raquo.airstream.state.Var + +import tausi.api.TauriError +import tausi.api.stream.StreamSource +import tausi.api.stream.StreamSubscription + +/** Extension methods for converting effect streams to Laminar observables. + * + * These extensions require a [[StreamSource]] instance for the stream type, which is typically + * provided by importing a given from the effect module (e.g., `tausi.zio.ZStreamIO.given` or + * `tausi.cats.Fs2StreamIO.given`). + */ +extension [S[_]: StreamSource, A](stream: S[A]) + + // ================== + // Tier 1: Unsafe + // ================== + + /** Convert the effect stream to a Laminar EventStream. + * + * '''WARNING''': Errors are logged to console and dropped from the stream. The stream will + * simply stop emitting on error. Use only for prototyping or when you explicitly don't care + * about errors. + * + * Prefer [[toSignal]] or [[toStateSignal]] for proper error handling. + * + * @param owner + * The Laminar Owner that manages the subscription lifecycle. The subscription is + * automatically cancelled when the owner is killed (e.g., when a component unmounts). + * @return + * A Laminar [[EventStream]] that emits values from the effect stream + * + * @example + * {{{ + * import tausi.laminar.* + * import tausi.zio.ZStreamIO.given + * + * div( + * onMountUnmountCallback( + * mount = ctx => { + * given Owner = ctx.owner + * val eventStream = myStream.toStreamUnsafe + * // Use eventStream... + * } + * ) + * ) + * }}} + */ + def toStreamUnsafe(using owner: Owner): EventStream[A] = + val bus = new EventBus[A] + val subscription = stream.subscribe( + onNext = a => bus.emit(a), + onError = err => org.scalajs.dom.console.error(s"Stream error (dropped): $err"), + onComplete = () => () // EventStream has no completion concept + ) + registerCleanup(owner, subscription) + bus.events + + /** Convert the effect stream to a Laminar Signal with an initial value. + * + * '''WARNING''': Errors are logged to console and the signal retains its last value. Use only + * for prototyping or when you explicitly don't care about errors. + * + * Prefer the `toSignal(Either[...])` overload or [[toStateSignal]] for proper error handling. + * + * @param initial + * The initial value before the stream emits + * @param owner + * The Laminar Owner that manages the subscription lifecycle + * @return + * A Laminar [[Signal]] that holds the latest value from the effect stream + */ + def toSignalUnsafe(initial: A)(using owner: Owner): Signal[A] = + val variable = Var(initial) + val subscription = stream.subscribe( + onNext = a => variable.set(a), + onError = err => org.scalajs.dom.console.error(s"Stream error (dropped): $err"), + onComplete = () => () + ) + registerCleanup(owner, subscription) + variable.signal + + // ================== + // Tier 2: Either-based + // ================== + + /** Convert the effect stream to a Laminar Signal with Either-based error handling. + * + * Errors from the stream are surfaced as `Left` values, allowing explicit error handling in + * the UI. The initial value must also be an `Either`, typically `Left(error)` to indicate a + * loading state or `Right(defaultValue)` for a default. + * + * @param initial + * The initial Either value. Use `Left(...)` for loading state or `Right(...)` for default. + * @param owner + * The Laminar Owner that manages the subscription lifecycle + * @return + * A Laminar [[Signal]] that holds `Right(value)` for success or `Left(error)` for failure + * + * @example + * {{{ + * import tausi.laminar.* + * import tausi.zio.ZStreamIO.given + * + * // Use Left as loading indicator + * val signal = myStream.toSignal(Left(TauriError.StreamError("Loading..."))) + * + * div( + * child <-- signal.map { + * case Right(value) => span(s"Got: $$value") + * case Left(err) => span(cls := "loading", err.message) + * } + * ) + * }}} + */ + def toSignal(initial: Either[TauriError, A])(using owner: Owner): Signal[Either[TauriError, A]] = + val variable = Var(initial) + val subscription = stream.subscribe( + onNext = a => variable.set(Right(a)), + onError = err => variable.set(Left(err)), + onComplete = () => () + ) + registerCleanup(owner, subscription) + variable.signal + + /** Convert the effect stream to a Laminar Signal with Option semantics. + * + * Values are wrapped in `Some`, and the initial value is `None`. Errors are surfaced via the + * separate error callback parameter. + * + * @param onError + * Callback invoked when the stream fails + * @param owner + * The Laminar Owner that manages the subscription lifecycle + * @return + * A Laminar [[Signal]] that holds `Some(value)` when available or `None` initially + * + * @example + * {{{ + * import tausi.laminar.* + * import tausi.zio.ZStreamIO.given + * + * val errorVar = Var[Option[TauriError]](None) + * val signal = myStream.toSignal(err => errorVar.set(Some(err))) + * + * div( + * child.maybe <-- signal.map(_.map(v => span(s"Value: $$v"))), + * child.maybe <-- errorVar.signal.map(_.map(e => span(cls := "error", e.message))) + * ) + * }}} + */ + def toSignal(onError: TauriError => Unit)(using owner: Owner): Signal[Option[A]] = + val variable = Var[Option[A]](None) + val subscription = stream.subscribe( + onNext = a => variable.set(Some(a)), + onError = onError, + onComplete = () => () + ) + registerCleanup(owner, subscription) + variable.signal + + // ================== + // Tier 3: Full State + // ================== + + /** Convert the effect stream to a Laminar Signal with full lifecycle visibility. + * + * This is the recommended method for production use. The [[StreamState]] ADT provides + * visibility into all lifecycle phases: running (loading), values, errors, and completion. + * + * @param owner + * The Laminar Owner that manages the subscription lifecycle + * @return + * A Laminar [[Signal]] holding the current [[StreamState]] + * + * @example + * {{{ + * import tausi.laminar.* + * import tausi.zio.ZStreamIO.given + * + * div( + * child <-- myStream.toStateSignal.map { + * case StreamState.Running => + * div(cls := "spinner", "Loading...") + * case StreamState.Value(data) => + * renderData(data) + * case StreamState.Failed(err) => + * div(cls := "error", s"Failed: $${err.message}") + * case StreamState.Completed => + * div("Stream completed") + * case StreamState.CompletedWith(finalData) => + * renderData(finalData) + * } + * ) + * }}} + */ + def toStateSignal(using owner: Owner): Signal[StreamState[A]] = + val variable = Var[StreamState[A]](StreamState.Running) + val subscription = stream.subscribe( + onNext = a => variable.set(StreamState.Value(a)), + onError = err => variable.set(StreamState.Failed(err)), + onComplete = () => + variable.now() match + case StreamState.Value(lastValue) => variable.set(StreamState.CompletedWith(lastValue)) + case _ => variable.set(StreamState.Completed) + ) + registerCleanup(owner, subscription) + variable.signal + end toStateSignal + +end extension + +// Bridge StreamSubscription lifecycle to Laminar's Owner lifecycle +private inline def registerCleanup( + owner: Owner, + subscription: StreamSubscription +): Unit = + val _ = new Subscription(owner, () => subscription.cancel()) diff --git a/modules/sample/README.md b/modules/sample/README.md index 1569669..333ad07 100644 --- a/modules/sample/README.md +++ b/modules/sample/README.md @@ -1,16 +1,16 @@ -# Tausi Sample: Customer Survey Application +# Tausi Survey Wizard Sample -A comprehensive sample application demonstrating the Tausi API for building Tauri desktop applications with Scala.js, Laminar, and ZIO. +A real-world sample application demonstrating the Tausi API for building Tauri desktop applications with Scala.js, Laminar, and ZIO. ## Overview -This sample implements a multi-page customer survey application with: +This sample showcases the core Tausi features through a multi-step survey wizard: -- **Welcome page** - Introduction and survey overview -- **Contact Information** - Collect user details with validation -- **Survey Page 1** - Rating-based questions (1-5 scale) -- **Survey Page 2** - Multi-choice and text response questions -- **Submit** - Review and submit with file persistence +- **Multi-page wizard navigation** with reactive state management +- **Type-safe command invocation** with Rust backend integration +- **Event emission and streaming** with Laminar signal integration +- **Form state management** using Laminar Var/Signal +- **Stream-to-Laminar bridge** demonstrating the `toStateSignal` pattern ## Architecture @@ -18,150 +18,136 @@ This sample implements a multi-page customer survey application with: ``` tausi.sample/ -├── Main.scala # Application entry point -├── App.scala # Root Laminar component +├── Main.scala # Application entry point, patterns documented ├── commands/ -│ └── SurveyCommands.scala # Tauri command definitions +│ └── SurveyCommands.scala # Custom Tauri command definitions +├── events/ +│ └── SurveyEvents.scala # Custom event definitions ├── components/ -│ ├── Buttons.scala # Button components -│ ├── FormInputs.scala # Form input components -│ ├── Layout.scala # Layout components -│ └── ProgressIndicator.scala -├── config/ -│ └── SurveyConfig.scala # Survey question configuration +│ ├── Buttons.scala # Reusable button components +│ ├── CodeBlock.scala # Code example display +│ ├── EventLog.scala # Stream-to-Laminar demo (toStateSignal) +│ └── Layout.scala # Layout components ├── model/ -│ ├── Page.scala # Navigation state -│ └── SurveyData.scala # Data models +│ ├── DemoModels.scala # Domain models with Codec derivation +│ └── Page.scala # Navigation state ├── pages/ -│ ├── WelcomePage.scala -│ ├── ContactInfoPage.scala -│ ├── SurveyPageOne.scala -│ ├── SurveyPageTwo.scala -│ └── SubmitPage.scala -├── services/ -│ ├── SurveyService.scala # Synchronous service -│ ├── ZioSurveyService.scala # ZIO-based async service -│ └── EventDemos.scala # Event system demos -├── state/ -│ └── AppState.scala # Reactive state management -└── validation/ - └── Validators.scala # Form validation logic +│ ├── WelcomePage.scala # Step 1: Welcome and instructions +│ ├── ContactInfoPage.scala # Step 2: Contact details form +│ ├── SurveyQuestionsPage.scala # Step 3: Survey questions +│ ├── ReviewPage.scala # Step 4: Review before submission +│ └── CompletePage.scala # Step 5: Submit with command invocation +└── state/ + └── AppState.scala # Reactive state management ``` -## Tausi API Usage +## Feature Demonstrations -### Command Invocation (ZIO) +### Command Invocation + +Type-safe Tauri command invocation with ZIO integration: ```scala -import tausi.zio.* import tausi.sample.commands.survey.{given, *} +import tausi.sample.model.* +import tausi.zio.* -given Runtime[Any] = Runtime.default +// 1. Define domain models with Codec derivation +final case class SurveySubmission( + contactDetails: ContactDetails, + answers: SurveyAnswers, + submittedAt: String +) derives Codec -// Invoke a command with ZIO -val result: IO[TauriError, Unit] = invoke(SaveSurveyRequest(submission)) +// 2. Define request wrapper (field name must match Rust param) +final case class SaveSurveyRequest(submission: SurveySubmission) derives Codec + +// 3. Define command using factory method +given saveSurvey: Command[SaveSurveyRequest, SaveSurveyResponse] = + Command.define("save_survey") -// Run with callbacks for Laminar integration -result.runWith( - onSuccess = _ => showSuccess(), +// 4. Invoke with typed request +invoke(SaveSurveyRequest(submission)).runWith( + onSuccess = response => showSuccess(s"Saved to: ${response.filePath}"), onError = err => showError(err.message) ) - -// Or with unified Either callback -result.runWith { - case Right(_) => showSuccess() - case Left(err) => showError(err.message) -} - -// Or fire-and-forget (errors silently dropped - use with caution) -result.runWithUnsafe() ``` -### Defining Custom Commands - -```scala -import tausi.api.Command -import tausi.api.codec.Codec - -// Request type with Codec derivation -final case class SaveSurveyRequest(submission: SurveySubmission) derives Codec - -// Command definition using factory method -given saveSurvey: Command[SaveSurveyRequest, Unit] = - Command.define[SaveSurveyRequest, Unit]("save_survey") -``` +### Event Emission -For commands with custom encoders/decoders: +Type-safe event emission for frontend-to-backend communication: ```scala -import tausi.api.Command -import tausi.api.codec.{Encoder, Decoder} +import tausi.sample.events.SurveyEvents.{given, *} +import tausi.zio.* -given customCommand: Command[MyRequest, MyResponse] = - Command.defineWith[MyRequest, MyResponse]("my_command")( - myCustomEncoder, - myCustomDecoder - ) +// 1. Define event payload with Codec +final case class SurveySubmittedEvent( + success: Boolean, + filePath: String, + error: Option[String] +) derives Codec + +// 2. Define event as given instance +given surveySubmitted: Event[SurveySubmittedEvent] = + Event.define("survey-submitted") + +// 3. Emit typed events +events.emit(SurveySubmittedEvent( + success = true, + filePath = response.filePath, + error = None +)).runWith( + onSuccess = _ => (), + onError = err => logError(err.message) +) ``` -### Event System (ZIO) +### Stream-to-Laminar Bridge (EventLog Component) -Events use a type-safe `Event[A]` abstraction that couples event identity with payload type: +Converting ZIO streams to Laminar signals with full lifecycle visibility: ```scala -import tausi.api.Event -import tausi.api.codec.Codec -import tausi.zio.events - -// 1. Define event payload types -final case class SurveyEvent(eventType: String, data: String) derives Codec - -// 2. Define events as given instances -given surveyEvent: Event[SurveyEvent] = Event.define("survey-event") -given backendReady: Event[String] = Event.define("backend-ready") -given frontendReady: Event[Unit] = Event.define0("frontend-ready") - -// 3. Listen for events - handler receives Either for error handling -val handle: IO[TauriError, EventHandle] = events.listen[SurveyEvent] { - case Right(msg) => handleEvent(msg.payload) - case Left(err) => println(s"Decode error: ${err.message}") -} - -// 4. Listen for single event -val once: IO[TauriError, EventHandle] = events.once[String] { - case Right(msg) => println(s"Backend ready: ${msg.payload}") - case Left(err) => println(s"Error: ${err.message}") +import tausi.laminar.* +import tausi.zio.ZStreamIO.given +import com.raquo.laminar.api.L.* + +// Create a stream subscription for events +val submissionStream = events.stream[SurveySubmittedEvent] + +// Convert to Laminar Signal with full lifecycle state +val stateSignal: Signal[StreamState[EventMessage[SurveySubmittedEvent]]] = + submissionStream.toStateSignal + +// Render based on stream state with exhaustive pattern matching +child <-- stateSignal.map { + case StreamState.Running => + div("Listening for events...") + case StreamState.Value(msg) => + div(s"Event received: ${msg.payload}") + case StreamState.Failed(err) => + div(cls := "text-error", s"Error: ${err.message}") + case StreamState.Completed => + div("Stream completed") + case StreamState.CompletedWith(last) => + div(s"Final event: ${last.payload}") } - -// 5. Emit events - type verified against Event instance -val emit: IO[TauriError, Unit] = events.emit(()) // Uses frontendReady -val emitWithPayload: IO[TauriError, Unit] = events.emit(SurveyEvent("submit", "data")) ``` -### Codec Derivation - -```scala -import tausi.api.codec.Codec - -// Automatic derivation for case classes -final case class ContactDetails( - firstName: String, - lastName: String, - phoneNumber: String -) - -object ContactDetails: - given Codec[ContactDetails] = Codec.derived +#### StreamState ADT -// Enums also supported -enum QuestionType: - case Rating, Text, MultiChoice +The `StreamState` ADT provides visibility into all lifecycle phases: -object QuestionType: - given Codec[QuestionType] = Codec.derived +```scala +enum StreamState[+A]: + case Running // Stream active, no values yet + case Value(value: A) // Latest value received + case Failed(error: TauriError) // Stream terminated with error + case Completed // Stream completed (no final value) + case CompletedWith(value: A) // Stream completed with final value ``` -## Laminar Patterns +## Form Patterns ### Reactive State with Var/Signal @@ -169,13 +155,15 @@ object QuestionType: final case class AppState( currentPage: Var[Page], contactDetails: Var[ContactDetails], - surveyAnswers: Var[Map[String, String]] -): - def navigateNext(): Unit = - Page.next(currentPage.now()).foreach(navigateTo) - - def setAnswer(questionId: String, answer: String): Unit = - surveyAnswers.update(_ + (questionId -> answer)) + surveyAnswers: Var[SurveyAnswers] +) + +object AppState: + def initial: AppState = AppState( + currentPage = Var(Page.Welcome), + contactDetails = Var(ContactDetails.empty), + surveyAnswers = Var(SurveyAnswers.empty) + ) ``` ### Controlled Inputs @@ -184,83 +172,66 @@ final case class AppState( input( controlled( value <-- valueSignal, - onInput.mapToValue --> { v => onValueChange(v) } - ) + onInput.mapToValue --> valueVar.set + ), + onKeyDown --> { e => + if e.key == "Enter" then handleSubmit() + } ) ``` ### Dynamic Children ```scala +// Single child based on state div( - child <-- currentPage.signal.map { page => - renderPage(page) - } + child <-- currentPage.signal.map(renderPage) ) +// List of children div( - children <-- answersSignal.map { answers => - answers.map(renderAnswer) + children <-- featuresSignal.map { features => + features.map(f => renderFeature(f)) } ) +// Optional child div( - child.maybe <-- errorSignal.map { - case Some(err) => Some(errorComponent(err)) - case None => None - } + child.maybe <-- errorSignal.map(_.map(renderError)) ) ``` -## Rust Backend - -The Rust backend implements the `save_survey` command: - -```rust -#[tauri::command] -fn save_survey(app: AppHandle, request: SaveSurveyRequest) -> Result<(), String> { - let submission = request.submission; - let surveys_dir = app.path().app_data_dir()?.join("surveys"); - fs::create_dir_all(&surveys_dir)?; - - let filename = format!("survey_{}_{}.txt", - submission.contact_details.last_name, - chrono::Utc::now().format("%Y%m%d_%H%M%S") - ); - - fs::write(surveys_dir.join(&filename), format_survey(&submission))?; - Ok(()) -} -``` - -### Survey File Location - -Submitted surveys are saved to the Tauri app data directory under a `surveys/` subdirectory: +## Running the Sample -| Platform | Location | -|----------|----------| -| **Linux** | `~/.local/share/tausi.sample/surveys/` | -| **macOS** | `~/Library/Application Support/tausi.sample/surveys/` | -| **Windows** | `C:\Users\\AppData\Roaming\tausi.sample\surveys\` | +### Prerequisites -Files are named using the pattern: `survey__.txt` +- [Rust](https://www.rust-lang.org/tools/install) and Cargo +- [Node.js](https://nodejs.org/) 18+ +- [sbt](https://www.scala-sbt.org/) 1.x -Example: `survey_smith_20251220_143052.txt` +### Development -To view saved surveys on Linux: ```bash -ls -la ~/.local/share/tausi.sample/surveys/ -cat ~/.local/share/tausi.sample/surveys/survey_*.txt +# From project root +cd modules/sample +npm install +npm run tauri dev ``` -## Running the Sample +The Scala.js code is compiled by Vite via the `scalaJSVite` plugin. + +### Production Build ```bash -# Development cd modules/sample -npm install -npm run tauri dev - -# Build npm run tauri build ``` + +## Project Dependencies + +| Dependency | Version | Purpose | +|------------|---------|---------| +| Laminar | 17.x | Reactive UI framework | +| ZIO | 2.x | Effect system | +| Tauri | 2.x | Desktop framework | +| Vite | 6.x | Build tool | diff --git a/modules/sample/package.json b/modules/sample/package.json index f1adb80..43456e4 100644 --- a/modules/sample/package.json +++ b/modules/sample/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "build": "vite build", "preview": "vite preview", "tauri": "tauri" }, diff --git a/modules/sample/src-tauri/Cargo.toml b/modules/sample/src-tauri/Cargo.toml index 942901b..6c0949b 100644 --- a/modules/sample/src-tauri/Cargo.toml +++ b/modules/sample/src-tauri/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "tausi-sample" version = "0.1.0" -description = "A Tauri App" -authors = ["you"] +description = "Tausi Demo - Type-safe Scala 3 toolkit for Tauri" +authors = ["Tausi contributors"] edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/modules/sample/src-tauri/capabilities/default.json b/modules/sample/src-tauri/capabilities/default.json index f5c766d..3988e10 100644 --- a/modules/sample/src-tauri/capabilities/default.json +++ b/modules/sample/src-tauri/capabilities/default.json @@ -1,12 +1,17 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Capability for the main window", + "description": "Capability for the main window with demo permissions", "windows": [ "main" ], "permissions": [ "core:default", - "opener:default" + "opener:default", + "core:window:allow-create", + "core:window:allow-toggle-maximize", + "core:window:allow-set-icon", + "core:window:allow-monitor-from-point", + "core:event:default" ] } diff --git a/modules/sample/src-tauri/src/lib.rs b/modules/sample/src-tauri/src/lib.rs index 0801823..8527bcc 100644 --- a/modules/sample/src-tauri/src/lib.rs +++ b/modules/sample/src-tauri/src/lib.rs @@ -1,182 +1,171 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +//! Tausi Sample - Customer Survey Application +//! +//! A real-world example demonstrating Tausi patterns for Tauri integration: +//! +//! - Custom command with typed request/response (save_survey) +//! - File system persistence via Tauri's app data directory +//! - Clean Rust backend matching Scala frontend models + use serde::{Deserialize, Serialize}; use std::fs; -use std::io::Write; -use std::thread; -use std::time::Duration; -use tauri::{AppHandle, Emitter, Listener, Manager}; - -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) -} +use tauri::{AppHandle, Manager}; -// === Survey Types === +// ============================================================================ +// Data Models (matching Scala models with Codec derivation) +// ============================================================================ #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ContactDetails { first_name: String, last_name: String, - phone_number: String, + email: String, + company: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SurveyAnswers { + satisfaction: Option, + recommendation: Option, + features: Vec, + feedback: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SurveySubmission { contact_details: ContactDetails, - answers: std::collections::HashMap, + answers: SurveyAnswers, submitted_at: String, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SaveSurveyResponse { + file_path: String, + message: String, +} + +// ============================================================================ +// Commands +// ============================================================================ + +/// Save a completed survey to the app's data directory. +/// +/// This command demonstrates real Tauri backend integration: +/// - Receives typed data from Scala frontend (via Codec serialization) +/// - Writes to the platform-specific app data directory +/// - Returns structured response +/// +/// Scala frontend defines this as: +/// ```scala +/// given saveSurvey: Command[SurveySubmission, SaveSurveyResponse] = +/// Command.define("save_survey") +/// ``` #[tauri::command] -fn save_survey(app: AppHandle, submission: SurveySubmission) -> Result<(), String> { - // Get app data directory +fn save_survey(app: AppHandle, submission: SurveySubmission) -> Result { + // Get the app data directory (platform-specific) let app_data_dir = app .path() .app_data_dir() .map_err(|e| format!("Failed to get app data directory: {}", e))?; - - // Create surveys subdirectory if it doesn't exist + + // Create surveys subdirectory let surveys_dir = app_data_dir.join("surveys"); fs::create_dir_all(&surveys_dir) .map_err(|e| format!("Failed to create surveys directory: {}", e))?; - - // Generate filename with timestamp - let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string(); - let filename = format!( - "survey_{}_{}.txt", - submission.contact_details.last_name.to_lowercase().replace(" ", "_"), - timestamp - ); - let filepath = surveys_dir.join(&filename); - + + // Generate filename from last name and timestamp + let safe_name = submission + .contact_details + .last_name + .chars() + .filter(|c| c.is_alphanumeric()) + .collect::() + .to_lowercase(); + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); + let filename = format!("survey_{}_{}.txt", safe_name, timestamp); + let file_path = surveys_dir.join(&filename); + // Format survey content - let content = format_survey_content(&submission); - + let content = format_survey(&submission); + // Write to file - let mut file = fs::File::create(&filepath) - .map_err(|e| format!("Failed to create survey file: {}", e))?; - file.write_all(content.as_bytes()) - .map_err(|e| format!("Failed to write survey content: {}", e))?; - - println!("Survey saved to: {:?}", filepath); - - Ok(()) -} + fs::write(&file_path, content) + .map_err(|e| format!("Failed to write survey file: {}", e))?; -fn format_survey_content(submission: &SurveySubmission) -> String { - let contact = &submission.contact_details; - let mut content = String::new(); - - content.push_str("=====================================\n"); - content.push_str(" CUSTOMER SATISFACTION SURVEY \n"); - content.push_str("=====================================\n\n"); - - content.push_str(&format!("Submitted: {}\n\n", submission.submitted_at)); - - content.push_str("--- Contact Information ---\n"); - content.push_str(&format!("Name: {} {}\n", contact.first_name, contact.last_name)); - content.push_str(&format!("Phone: {}\n\n", contact.phone_number)); - - content.push_str("--- Survey Responses ---\n"); - - // Sort answers by key for consistent output - let mut sorted_answers: Vec<_> = submission.answers.iter().collect(); - sorted_answers.sort_by_key(|(k, _)| k.as_str()); - - for (question_id, answer) in sorted_answers { - let question_label = format_question_label(question_id); - content.push_str(&format!("\n{}\n", question_label)); - content.push_str(&format!("Answer: {}\n", answer)); - } - - content.push_str("\n=====================================\n"); - content.push_str(" Thank you for your feedback! \n"); - content.push_str("=====================================\n"); - - content + let path_str = file_path.to_string_lossy().to_string(); + + Ok(SaveSurveyResponse { + file_path: path_str, + message: "Thank you! Your survey has been saved.".to_string(), + }) } -fn format_question_label(question_id: &str) -> String { - match question_id { - "overall_satisfaction" => "Overall Satisfaction (1-5)".to_string(), - "recommendation_likelihood" => "Likelihood to Recommend (1-5)".to_string(), - "service_quality" => "Service Quality Rating (1-5)".to_string(), - "communication_rating" => "Communication Rating (1-5)".to_string(), - "industry" => "Industry".to_string(), - "product_line" => "Product Line".to_string(), - "feedback_topic" => "Feedback Focus Area".to_string(), - "best_aspect" => "What do you like most?".to_string(), - "improvement_suggestion" => "What could we do better?".to_string(), - "additional_feedback" => "Additional Comments".to_string(), - _ => question_id.replace("_", " ").to_string(), +/// Format survey submission as human-readable text. +fn format_survey(submission: &SurveySubmission) -> String { + let mut lines = Vec::new(); + + lines.push("=".repeat(60)); + lines.push("CUSTOMER SURVEY SUBMISSION".to_string()); + lines.push("=".repeat(60)); + lines.push(String::new()); + + lines.push("CONTACT INFORMATION".to_string()); + lines.push("-".repeat(40)); + lines.push(format!( + "Name: {} {}", + submission.contact_details.first_name, submission.contact_details.last_name + )); + lines.push(format!("Email: {}", submission.contact_details.email)); + if !submission.contact_details.company.is_empty() { + lines.push(format!("Company: {}", submission.contact_details.company)); } -} + lines.push(String::new()); -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CounterUpdate { - count: i32, - timestamp: u64, -} + lines.push("RATINGS".to_string()); + lines.push("-".repeat(40)); + if let Some(sat) = submission.answers.satisfaction { + lines.push(format!("Overall Satisfaction: {}/5", sat)); + } + if let Some(rec) = submission.answers.recommendation { + lines.push(format!("Recommendation Likelihood: {}/5", rec)); + } + lines.push(String::new()); -#[tauri::command] -async fn start_counter(app: AppHandle, max: i32) -> Result { - let app_clone = app.clone(); - - thread::spawn(move || { - for i in 1..=max { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - let update = CounterUpdate { - count: i, - timestamp, - }; - - app_clone.emit("counter-update", &update).ok(); - thread::sleep(Duration::from_millis(500)); + if !submission.answers.features.is_empty() { + lines.push("IMPORTANT FEATURES".to_string()); + lines.push("-".repeat(40)); + for feature in &submission.answers.features { + lines.push(format!("• {}", feature)); } + lines.push(String::new()); + } - app_clone.emit("counter-finished", ()).ok(); - }); + if !submission.answers.feedback.is_empty() { + lines.push("ADDITIONAL FEEDBACK".to_string()); + lines.push("-".repeat(40)); + lines.push(submission.answers.feedback.clone()); + lines.push(String::new()); + } - Ok(format!("Counter started with max value: {}", max)) -} + lines.push("=".repeat(60)); + lines.push(format!("Submitted: {}", submission.submitted_at)); + lines.push("=".repeat(60)); -#[derive(Deserialize)] -struct EchoPayload { - message: String, + lines.join("\n") } -#[tauri::command] -fn echo(payload: EchoPayload) -> Result { - if payload.message.is_empty() { - Err("Message cannot be empty".to_string()) - } else { - Ok(format!("Echo: {}", payload.message)) - } -} +// ============================================================================ +// Application Entry Point +// ============================================================================ #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet, start_counter, echo, save_survey]) - .setup(|app| { - // Listen for frontend events - let app_handle = app.handle().clone(); - app.listen("frontend-ready", move |_event| { - println!("Frontend is ready!"); - app_handle - .emit("backend-ready", "Backend initialized successfully") - .ok(); - }); - Ok(()) - }) + .invoke_handler(tauri::generate_handler![save_survey]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/modules/sample/src-tauri/tauri.conf.json b/modules/sample/src-tauri/tauri.conf.json index d31276d..f7e2e38 100644 --- a/modules/sample/src-tauri/tauri.conf.json +++ b/modules/sample/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "Customer Survey", + "productName": "Tausi Demo", "version": "0.1.0", "identifier": "tausi.sample", "build": { @@ -13,11 +13,12 @@ "withGlobalTauri": true, "windows": [ { - "title": "Customer Satisfaction Survey", - "width": 860, - "height": 960, - "minWidth": 480, - "minHeight": 640, + "label": "main", + "title": "Tausi Demo", + "width": 960, + "height": 800, + "minWidth": 640, + "minHeight": 480, "resizable": true, "center": true, "decorations": true, diff --git a/modules/sample/src/main/scala/tausi/sample/Main.scala b/modules/sample/src/main/scala/tausi/sample/Main.scala index 48abc1f..d5d3120 100644 --- a/modules/sample/src/main/scala/tausi/sample/Main.scala +++ b/modules/sample/src/main/scala/tausi/sample/Main.scala @@ -7,67 +7,121 @@ package tausi.sample import com.raquo.laminar.api.L.* import org.scalajs.dom +import zio.Runtime + import tausi.sample.components.* -import tausi.sample.config.SurveyConfig import tausi.sample.model.Page import tausi.sample.pages.* -import tausi.sample.services.SurveyService import tausi.sample.state.AppState -/** Main application entry point. */ +/** Application entry point. + * + * This sample demonstrates a real-world Tausi application with the following patterns: + * + * ==Command Invocation== + * Type-safe Tauri command invocation with ZIO integration: + * {{{ + * import tausi.zio.* + * import tausi.sample.commands.survey.{given, *} + * + * // The wrapper type's field name must match the Rust parameter name + * invoke(SaveSurveyRequest(submission)).runWith( + * onSuccess = response => handleSuccess(response), + * onError = err => handleError(err.message) + * ) + * }}} + * + * ==Event Emission== + * Type-safe event emission for frontend-to-backend communication: + * {{{ + * import tausi.zio.* + * import tausi.sample.events.SurveyEvents.{given, *} + * + * events.emit(SurveySubmittedEvent(success = true, filePath, None)) + * }}} + * + * ==Stream-to-Laminar Bridge== + * Converting ZIO streams to Laminar signals (see [[EventLog]]): + * {{{ + * import tausi.laminar.* + * import tausi.zio.ZStreamIO.given + * + * val stateSignal = events.stream[T].toStateSignal + * child <-- stateSignal.map { + * case StreamState.Running => spinner() + * case StreamState.Value(msg) => render(msg) + * case StreamState.Failed(err) => error(err) + * case StreamState.Completed => done() + * case StreamState.CompletedWith(last) => final(last) + * } + * }}} + * + * ==Form State Management== + * Uses Laminar's Var/Signal for reactive form state with controlled inputs. + * + * ==Codec Derivation== + * All data models use `derives Codec` for automatic JSON serialisation. + */ object Main: + // ZIO runtime for effect execution + given Runtime[Any] = Runtime.default + def main(args: Array[String]): Unit = - // Initialize state and services val state = AppState.initial - val surveyService = SurveyService.live // Use live Tauri backend - - // Render the application val appContainer = dom.document.getElementById("app") - render(appContainer, App(state, surveyService)) -end Main + render(appContainer, App(state)) -/** Main application component. */ +/** Main application component with wizard layout. */ object App: - def apply(state: AppState, surveyService: SurveyService): HtmlElement = - Layout.container( - // Header with company branding - headerTag( - cls := "text-center mb-6", - div( - cls := "inline-flex items-center gap-2 text-primary font-semibold", - companyLogo, - span(SurveyConfig.companyName) - ) - ), - // Progress indicator (hidden on welcome page) - child.maybe <-- state.currentPage.signal.map { page => - if page == Page.Welcome then None - else Some(ProgressIndicator(state.currentPage.signal)) - }, - // Main content area + def apply(state: AppState): HtmlElement = + div( + cls := "min-h-screen bg-surface-900 flex flex-col", + // Header + Header(), + // Progress indicator + ProgressIndicator(state.currentPage), + // Main content mainTag( - cls := "transition-opacity duration-300", - child <-- state.currentPage.signal.map { page => - renderPage(page, state, surveyService) - } + cls := "flex-1 container mx-auto px-4 py-6 max-w-2xl", + child <-- state.currentPage.signal.map(renderPage(_, state)) ), // Footer - Layout.footer(SurveyConfig.companyName) + footerTag( + cls := "text-center text-sm text-text-muted py-4 border-t border-border", + "Tausi Sample — Customer Survey Application" + ), + // Event log panel - demonstrates stream-to-Laminar integration + // See EventLog.scala for the StreamState pattern documentation + EventLog() ) - private def renderPage( - page: Page, - state: AppState, - surveyService: SurveyService - ): HtmlElement = + private def renderPage(page: Page, state: AppState): HtmlElement = page match - case Page.Welcome => WelcomePage(state) - case Page.ContactInfo => ContactInfoPage(state) - case Page.SurveyPageOne => SurveyPageOne(state) - case Page.SurveyPageTwo => SurveyPageTwo(state) - case Page.Submit => SubmitPage(state, surveyService) + case Page.Welcome => WelcomePage(state) + case Page.ContactInfo => ContactInfoPage(state) + case Page.SurveyQuestions => SurveyQuestionsPage(state) + case Page.Review => ReviewPage(state) + case Page.Complete => CompletePage(state) + +/** Application header. */ +object Header: + def apply(): HtmlElement = + headerTag( + cls := "bg-surface-800 border-b border-border", + div( + cls := "container mx-auto px-4 max-w-2xl", + div( + cls := "flex items-center justify-center h-16", + div( + cls := "flex items-center gap-3 text-primary font-semibold text-xl", + Logo(), + span("Customer Survey") + ) + ) + ) + ) - private def companyLogo: Element = + private def Logo(): Element = svg.svg( svg.cls := "w-8 h-8", svg.viewBox := "0 0 24 24", @@ -75,9 +129,57 @@ object App: svg.stroke := "currentColor", svg.strokeWidth := "2", svg.path( - svg.d := "M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4", + svg.d := "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z", svg.strokeLineCap := "round", svg.strokeLineJoin := "round" ) ) -end App + +/** Progress indicator showing wizard steps. */ +object ProgressIndicator: + def apply(currentPage: Var[Page]): HtmlElement = + div( + cls := "bg-surface-800 py-4", + div( + cls := "container mx-auto px-4 max-w-2xl", + div( + cls := "flex items-center justify-between", + Page.all.zipWithIndex.flatMap { case (page, index) => + val stepElements = List(StepDot(page, currentPage)) + if index < Page.all.length - 1 then + stepElements :+ StepConnector(page, currentPage) + else stepElements + } + ) + ) + ) + + private def StepDot(page: Page, currentPage: Var[Page]): HtmlElement = + div( + cls := "flex flex-col items-center", + div( + cls <-- currentPage.signal.map { current => + val base = "w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors" + if page.stepNumber < current.stepNumber then + s"$base bg-primary text-surface-900" // Completed + else if page.stepNumber == current.stepNumber then + s"$base bg-primary text-surface-900 ring-2 ring-primary ring-offset-2 ring-offset-surface-800" // Current + else s"$base bg-surface-600 text-text-muted" // Future + }, + page.stepNumber.toString + ), + span( + cls := "text-xs text-text-muted mt-1 hidden sm:block", + page.title + ) + ) + + private def StepConnector(page: Page, currentPage: Var[Page]): HtmlElement = + div( + cls <-- currentPage.signal.map { current => + val base = "flex-1 h-0.5 mx-2" + if page.stepNumber < current.stepNumber then s"$base bg-primary" + else s"$base bg-surface-600" + } + ) + diff --git a/modules/sample/src/main/scala/tausi/sample/commands/SurveyCommands.scala b/modules/sample/src/main/scala/tausi/sample/commands/SurveyCommands.scala index 0068099..891be84 100644 --- a/modules/sample/src/main/scala/tausi/sample/commands/SurveyCommands.scala +++ b/modules/sample/src/main/scala/tausi/sample/commands/SurveyCommands.scala @@ -5,18 +5,34 @@ package tausi.sample.commands import tausi.api.Command -import tausi.api.codec.* -import tausi.sample.model.SurveySubmission +import tausi.sample.model.* -/** Survey-related Tauri commands. */ +/** Custom Tauri commands for the survey application. + * + * Demonstrates the recommended pattern for defining application-specific + * commands using Command.define. + * + * @example + * {{{ + * import tausi.sample.commands.survey.{given, *} + * import tausi.zio.* + * + * invoke(SaveSurveyRequest(submission)).runWith( + * onSuccess = response => println(s"Saved to: ${response.filePath}"), + * onError = err => println(s"Error: ${err.message}") + * ) + * }}} + */ object survey: - /** Request to save a survey submission. */ - final case class SaveSurveyRequest(submission: SurveySubmission) derives Codec - /** Command to save survey to file. + /** Command to save a completed survey to file storage. * - * Uses Command.define factory for concise definition. + * This command invokes the Rust backend's `save_survey` function, + * which persists the survey data to the app's data directory. + * + * The `SaveSurveyRequest` wrapper has a field named `submission` which + * matches the Rust function parameter, enabling Tauri's IPC layer to + * correctly deserialize the request. */ - given saveSurvey: Command[SaveSurveyRequest, Unit] = - Command.define[SaveSurveyRequest, Unit]("save_survey") -end survey + given saveSurvey: Command[SaveSurveyRequest, SaveSurveyResponse] = + Command.define[SaveSurveyRequest, SaveSurveyResponse]("save_survey") diff --git a/modules/sample/src/main/scala/tausi/sample/components/CodeBlock.scala b/modules/sample/src/main/scala/tausi/sample/components/CodeBlock.scala new file mode 100644 index 0000000..37ca557 --- /dev/null +++ b/modules/sample/src/main/scala/tausi/sample/components/CodeBlock.scala @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Tausi contributors. + * See LICENSE file for terms. + */ +package tausi.sample.components + +import com.raquo.laminar.api.L.* + +/** Code block component for displaying code examples. */ +object CodeBlock: + def apply(title: String, codeText: String): HtmlElement = + div( + cls := "mb-6", + div( + cls := "flex items-center justify-between px-4 py-2 bg-surface-900 rounded-t-lg border border-border border-b-0", + span(cls := "text-sm font-medium text-text-secondary", title), + span(cls := "text-xs text-text-muted", "Scala") + ), + pre( + cls := "p-4 bg-surface-900 rounded-b-lg border border-border overflow-x-auto", + code( + cls := "text-sm font-mono text-text-primary leading-relaxed", + codeText + ) + ) + ) + + def apply(codeText: String): HtmlElement = + pre( + cls := "p-4 bg-surface-900 rounded-lg border border-border overflow-x-auto", + code( + cls := "text-sm font-mono text-text-primary leading-relaxed", + codeText + ) + ) diff --git a/modules/sample/src/main/scala/tausi/sample/components/EventLog.scala b/modules/sample/src/main/scala/tausi/sample/components/EventLog.scala new file mode 100644 index 0000000..8a781ae --- /dev/null +++ b/modules/sample/src/main/scala/tausi/sample/components/EventLog.scala @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2025 Tausi contributors. + * See LICENSE file for terms. + */ +package tausi.sample.components + +import com.raquo.laminar.api.L.* + +import zio.Runtime + +import tausi.api.EventMessage +import tausi.laminar.* +import tausi.sample.events.SurveyEvents.{given, *} +import tausi.sample.model.* +import tausi.zio.* +import tausi.zio.ZStreamIO.given + +/** Event log panel demonstrating stream-to-Laminar integration. + * + * This component showcases the key Tausi pattern for converting effect + * streams to Laminar signals using the [[StreamSource]] typeclass and + * [[StreamState]] ADT. + * + * ==Pattern Demonstrated== + * {{{ + * // 1. Import the stream source for your effect system + * import tausi.zio.ZStreamIO.given + * + * // 2. Import the Laminar extensions + * import tausi.laminar.* + * + * // 3. Convert the stream to a Laminar Signal with full lifecycle visibility + * val stateSignal: Signal[StreamState[EventMessage[T]]] = + * events.stream[T].toStateSignal + * + * // 4. Pattern match on StreamState for comprehensive UI feedback + * child <-- stateSignal.map { + * case StreamState.Running => div("Waiting for events...") + * case StreamState.Value(msg) => renderEvent(msg) + * case StreamState.Failed(err) => renderError(err) + * case StreamState.Completed => div("Stream completed") + * case StreamState.CompletedWith(last) => renderFinal(last) + * } + * }}} + * + * The [[StreamState]] ADT provides visibility into all lifecycle phases: + * - `Running`: Stream active, no values received yet (loading state) + * - `Value(a)`: Stream emitted a value (may emit more) + * - `Failed(err)`: Stream terminated with an error + * - `Completed`: Stream completed with no final value + * - `CompletedWith(a)`: Stream completed with a final value + */ +object EventLog: + given Runtime[Any] = Runtime.default + + def apply(): HtmlElement = + // State var to hold the current stream state + val stateVar: Var[StreamState[EventMessage[SurveySubmittedEvent]]] = + Var(StreamState.Running) + + div( + cls := "fixed bottom-4 right-4 w-80 bg-surface-800 border border-border rounded-lg shadow-lg overflow-hidden", + // Set up stream subscription when mounted + onMountUnmountCallback( + mount = ctx => { + given Owner = ctx.owner + + org.scalajs.dom.console.log("[EventLog] Component mounted, setting up stream subscription") + + // Create ZStream for survey submission events + // This stream emits EventMessage[SurveySubmittedEvent] for each event + val submissionStream = events.stream[SurveySubmittedEvent] + + org.scalajs.dom.console.log("[EventLog] Created submissionStream, calling toStateSignal") + + // Convert to Laminar Signal with full lifecycle state + // StreamSource[ZStreamIO] instance (from tausi.zio.ZStreamIO.given) + // bridges ZIO streams to Laminar observables + val stateSignal: Signal[StreamState[EventMessage[SurveySubmittedEvent]]] = + submissionStream.toStateSignal + + org.scalajs.dom.console.log("[EventLog] toStateSignal returned, subscribing to signal") + + // Forward stream state to our Var + stateSignal.foreach { state => + org.scalajs.dom.console.log(s"[EventLog] Signal state changed: $state") + stateVar.set(state) + } + + org.scalajs.dom.console.log("[EventLog] Stream subscription complete") + }, + unmount = _ => { + org.scalajs.dom.console.log("[EventLog] Component unmounted") + } + ), + // Header + div( + cls := "bg-surface-700 px-4 py-2 border-b border-border flex items-center justify-between", + div( + cls := "flex items-center gap-2", + div(cls := "w-2 h-2 rounded-full bg-success animate-pulse"), + span(cls := "text-sm font-medium text-text-primary", "Event Stream") + ), + span(cls := "text-xs text-text-muted", "Live") + ), + // Stream content - renders based on stream state + div( + cls := "p-4 max-h-48 overflow-y-auto", + child <-- stateVar.signal.map(renderStreamState) + ), + // Footer with pattern documentation + div( + cls := "bg-surface-700/50 px-4 py-2 border-t border-border", + pre( + cls := "text-xs text-text-muted font-mono overflow-x-auto", + "stream.toStateSignal" + ) + ) + ) + + /** Render the stream state with appropriate UI feedback. + * + * This demonstrates the recommended pattern for handling all + * [[StreamState]] variants with exhaustive pattern matching. + */ + private def renderStreamState(state: StreamState[EventMessage[SurveySubmittedEvent]]): HtmlElement = + state match + case StreamState.Running => + // Loading state - stream active but no events yet + div( + cls := "flex items-center gap-2 text-text-muted", + div(cls := "w-4 h-4 border-2 border-text-muted border-t-transparent rounded-full animate-spin"), + span(cls := "text-sm", "Waiting for events...") + ) + + case StreamState.Value(msg) => + // Received an event - stream still active + renderEvent(msg.payload) + + case StreamState.Failed(err) => + // Stream terminated with error + div( + cls := "text-error text-sm", + span(cls := "font-medium", "Stream error: "), + span(err.message) + ) + + case StreamState.Completed => + // Stream completed without final value + div( + cls := "text-text-muted text-sm italic", + "Stream completed" + ) + + case StreamState.CompletedWith(msg) => + // Stream completed with final value + div( + cls := "space-y-2", + renderEvent(msg.payload), + div( + cls := "text-xs text-text-muted italic border-t border-border pt-2", + "Stream completed" + ) + ) + + private def renderEvent(event: SurveySubmittedEvent): HtmlElement = + val statusClass = + if event.success then "w-2 h-2 rounded-full bg-success" + else "w-2 h-2 rounded-full bg-error" + val statusText = + if event.success then "Survey Submitted" else "Submission Failed" + + div( + cls := "bg-surface-700 rounded p-2 text-sm", + div( + cls := "flex items-center gap-2 mb-1", + div(cls := statusClass), + span(cls := "font-medium text-text-primary", statusText) + ), + event.filePath.map { path => + div( + cls := "text-xs text-text-muted font-mono truncate", + path.split("/").lastOption.getOrElse(path) + ) + }.getOrElse(emptyNode), + event.errorMessage.map { err => + div(cls := "text-xs text-error", err) + }.getOrElse(emptyNode) + ) diff --git a/modules/sample/src/main/scala/tausi/sample/components/FormInputs.scala b/modules/sample/src/main/scala/tausi/sample/components/FormInputs.scala deleted file mode 100644 index 030b620..0000000 --- a/modules/sample/src/main/scala/tausi/sample/components/FormInputs.scala +++ /dev/null @@ -1,275 +0,0 @@ -/* - * Copyright (c) 2025 Tausi contributors. - * See LICENSE file for terms. - */ -package tausi.sample.components - -import com.raquo.laminar.api.L.* -import tausi.sample.model.ValidationResult - -/** Reusable form input components with dark corporate Tailwind styling. */ -object FormInputs: - - /** Common input styling classes - dark theme with proper contrast. */ - val inputBaseClasses: String = - "w-full px-4 py-3 bg-surface-700 border rounded-lg text-text-primary placeholder-text-muted transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary" - - val inputValidClasses: String = - "border-border hover:border-text-muted" - - val inputInvalidClasses: String = - "border-error/60 hover:border-error focus:ring-error/40 focus:border-error" - - val labelClasses: String = - "block text-sm font-medium text-text-primary mb-2" - - val errorClasses: String = - "text-sm text-error mt-1.5 flex items-center gap-1.5" - - /** Text input component with ARIA support. - * - * @param id Input element id - * @param labelText Label text to display - * @param placeholderText Placeholder text - * @param valueSignal Signal containing the current value - * @param onValueChange Callback when value changes - * @param validationSignal Signal containing validation result - */ - def textInput( - id: String, - labelText: String, - placeholderText: String, - valueSignal: Signal[String], - onValueChange: String => Unit, - validationSignal: Signal[Option[ValidationResult]] - ): HtmlElement = - val errorId = s"$id-error" - div( - cls := "mb-5", - label( - cls := labelClasses, - forId := id, - labelText - ), - input( - cls <-- validationSignal.map { validation => - val validClass = validation match - case Some(ValidationResult.Invalid(_)) => inputInvalidClasses - case _ => inputValidClasses - s"$inputBaseClasses $validClass" - }, - idAttr := id, - nameAttr := id, - typ := "text", - placeholder := placeholderText, - aria.describedBy <-- validationSignal.map { - case Some(ValidationResult.Invalid(_)) => errorId - case _ => "" - }, - aria.invalid <-- validationSignal.map { - case Some(ValidationResult.Invalid(_)) => "true" - case _ => "false" - }, - controlled( - value <-- valueSignal, - onInput.mapToValue --> { v => onValueChange(v) } - ) - ), - child.maybe <-- validationSignal.map { - case Some(ValidationResult.Invalid(message)) => - Some(p(cls := errorClasses, idAttr := errorId, role := "alert", errorIcon, message)) - case _ => None - } - ) - - /** Phone number input component with ARIA support. - * - * @param id Input element id - * @param labelText Label text to display - * @param valueSignal Signal containing the current value - * @param onValueChange Callback when value changes - * @param validationSignal Signal containing validation result - */ - def phoneInput( - id: String, - labelText: String, - valueSignal: Signal[String], - onValueChange: String => Unit, - validationSignal: Signal[Option[ValidationResult]] - ): HtmlElement = - val errorId = s"$id-error" - div( - cls := "mb-5", - label( - cls := labelClasses, - forId := id, - labelText - ), - input( - cls <-- validationSignal.map { validation => - val validClass = validation match - case Some(ValidationResult.Invalid(_)) => inputInvalidClasses - case _ => inputValidClasses - s"$inputBaseClasses $validClass" - }, - idAttr := id, - nameAttr := id, - typ := "tel", - autoComplete := "tel", - placeholder := "+254 xxx xxxxxx", - aria.describedBy <-- validationSignal.map { - case Some(ValidationResult.Invalid(_)) => errorId - case _ => "" - }, - aria.invalid <-- validationSignal.map { - case Some(ValidationResult.Invalid(_)) => "true" - case _ => "false" - }, - controlled( - value <-- valueSignal, - onInput.mapToValue --> { v => onValueChange(v) } - ) - ), - child.maybe <-- validationSignal.map { - case Some(ValidationResult.Invalid(message)) => - Some(p(cls := errorClasses, idAttr := errorId, role := "alert", errorIcon, message)) - case _ => None - } - ) - - /** Textarea input component. - * - * @param id Input element id - * @param labelText Label text to display - * @param placeholderText Placeholder text - * @param valueSignal Signal containing the current value - * @param onValueChange Callback when value changes - * @param required Whether the field is required - */ - def textAreaInput( - id: String, - labelText: String, - placeholderText: String, - valueSignal: Signal[String], - onValueChange: String => Unit, - required: Boolean - ): HtmlElement = - div( - cls := "mb-5", - label( - cls := labelClasses, - forId := id, - labelText, - if !required then span(cls := "text-text-muted font-normal ml-1.5", "(Optional)") - else emptyNode - ), - textArea( - cls := s"$inputBaseClasses $inputValidClasses min-h-24 resize-y", - idAttr := id, - nameAttr := id, - placeholder := placeholderText, - aria.required := required, - controlled( - value <-- valueSignal, - onInput.mapToValue --> { v => onValueChange(v) } - ) - ) - ) - - /** Rating input component (1-5 scale) with keyboard support. - * - * @param id Input element id - * @param labelText Label text to display - * @param valueSignal Signal containing the current value (1-5 as string) - * @param onValueChange Callback when value changes - */ - def ratingInput( - id: String, - labelText: String, - valueSignal: Signal[Option[String]], - onValueChange: String => Unit - ): HtmlElement = - div( - cls := "mb-6", - role := "group", - aria.label := labelText, - label( - cls := labelClasses, - labelText - ), - div( - cls := "flex gap-2 mt-2", - (1 to 5).map { rating => - button( - cls <-- valueSignal.map { current => - val isSelected = current.flatMap(_.toIntOption).contains(rating) - val baseClasses = - "w-11 h-11 sm:w-12 sm:h-12 rounded-lg font-semibold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/60 focus:ring-offset-2 focus:ring-offset-surface-800" - if isSelected then s"$baseClasses bg-primary text-surface-900 shadow-lg shadow-primary/30" - else s"$baseClasses bg-surface-600 text-text-secondary hover:bg-surface-500 hover:text-text-primary" - }, - typ := "button", - aria.label := s"Rate $rating out of 5", - aria.pressed <-- valueSignal.map(_.flatMap(_.toIntOption).contains(rating).toString), - rating.toString, - onClick --> { _ => onValueChange(rating.toString) } - ) - } - ), - div( - cls := "flex justify-between text-xs text-text-muted mt-2 px-0.5", - span("Poor"), - span("Excellent") - ) - ) - - /** Multi-choice select component. - * - * @param id Input element id - * @param labelText Label text to display - * @param options List of options to display - * @param valueSignal Signal containing the current value - * @param onValueChange Callback when value changes - */ - def multiChoiceInput( - id: String, - labelText: String, - options: List[String], - valueSignal: Signal[Option[String]], - onValueChange: String => Unit - ): HtmlElement = - div( - cls := "mb-5", - label( - cls := labelClasses, - forId := id, - labelText - ), - select( - cls := s"$inputBaseClasses $inputValidClasses cursor-pointer appearance-none bg-[url('data:image/svg+xml;charset=UTF-8,%3csvg%20xmlns%3d%22http%3a//www.w3.org/2000/svg%22%20viewBox%3d%220%200%2020%2020%22%20fill%3d%22%2394a3b8%22%3e%3cpath%20fill-rule%3d%22evenodd%22%20d%3d%22M5.23%207.21a.75.75%200%20011.06.02L10%2011.168l3.71-3.938a.75.75%200%20111.08%201.04l-4.25%204.5a.75.75%200%2001-1.08%200l-4.25-4.5a.75.75%200%2001.02-1.06z%22%20clip-rule%3d%22evenodd%22/%3e%3c/svg%3e')] bg-[length:1.25rem] bg-[right_0.75rem_center] bg-no-repeat pr-10", - idAttr := id, - nameAttr := id, - controlled( - value <-- valueSignal.map(_.getOrElse("")), - onChange.mapToValue --> { v => onValueChange(v) } - ), - option(value := "", disabled := true, selected := true, "Select an option..."), - options.map { opt => - option(value := opt, opt) - } - ) - ) - - /** Small error icon for validation messages. */ - private def errorIcon: Element = - svg.svg( - svg.cls := "w-4 h-4 flex-shrink-0", - svg.viewBox := "0 0 20 20", - svg.fill := "currentColor", - svg.path( - svg.fillRule := "evenodd", - svg.d := "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z", - svg.clipRule := "evenodd" - ) - ) -end FormInputs diff --git a/modules/sample/src/main/scala/tausi/sample/components/ProgressIndicator.scala b/modules/sample/src/main/scala/tausi/sample/components/ProgressIndicator.scala deleted file mode 100644 index dde2ac3..0000000 --- a/modules/sample/src/main/scala/tausi/sample/components/ProgressIndicator.scala +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright (c) 2025 Tausi contributors. - * See LICENSE file for terms. - */ -package tausi.sample.components - -import com.raquo.laminar.api.L.* -import tausi.sample.model.Page - -/** Progress indicator component showing survey progress with dark theme. */ -object ProgressIndicator: - - /** Render the progress indicator. - * - * @param currentPageSignal Signal containing the current page - */ - def apply(currentPageSignal: Signal[Page]): HtmlElement = - div( - cls := "w-full mb-6 sm:mb-8", - role := "navigation", - aria.label := "Survey progress", - // Step indicators - div( - cls := "flex justify-between items-center relative px-2", - // Connection line (background) - div( - cls := "absolute top-4 left-4 right-4 h-0.5 bg-surface-600" - ), - // Progress line (foreground) - div( - cls := "absolute top-4 left-4 h-0.5 bg-primary transition-all duration-500 ease-out", - width <-- currentPageSignal.map { page => - // Calculate width as percentage of the connection line - val progress = (page.stepNumber - 1).toDouble / (Page.totalSteps - 1).toDouble * 100 - s"calc(${progress}% - ${if progress == 100 then 0 else 0}px)" - } - ), - // Step circles - Page.values.toList.map { page => - stepCircle(page, currentPageSignal) - } - ), - // Step labels - div( - cls := "hidden sm:flex justify-between mt-3 px-2", - Page.values.toList.map { page => - stepLabel(page, currentPageSignal) - } - ) - ) - - private def stepCircle(page: Page, currentPageSignal: Signal[Page]): HtmlElement = - div( - cls <-- currentPageSignal.map { current => - val baseClasses = "relative z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300" - val stepNum = page.stepNumber - val currentNum = current.stepNumber - - if stepNum < currentNum then - // Completed step - s"$baseClasses bg-primary text-surface-900" - else if stepNum == currentNum then - // Current step - s"$baseClasses bg-primary text-surface-900 ring-4 ring-primary/30" - else - // Future step - s"$baseClasses bg-surface-600 text-text-muted" - }, - aria.current <-- currentPageSignal.map { current => - if page.stepNumber == current.stepNumber then "step" else "" - }, - child <-- currentPageSignal.map { current => - val stepNum = page.stepNumber - val currentNum = current.stepNumber - if stepNum < currentNum then checkIcon - else span(stepNum.toString) - } - ) - - private def stepLabel(page: Page, currentPageSignal: Signal[Page]): HtmlElement = - span( - cls <-- currentPageSignal.map { current => - val baseClasses = "text-xs text-center max-w-20 leading-tight transition-colors duration-300" - if page.stepNumber < current.stepNumber then - s"$baseClasses text-primary" - else if page.stepNumber == current.stepNumber then - s"$baseClasses text-primary font-medium" - else - s"$baseClasses text-text-muted" - }, - page.title - ) - - private def checkIcon: Element = - svg.svg( - svg.cls := "w-4 h-4", - svg.viewBox := "0 0 20 20", - svg.fill := "currentColor", - svg.path( - svg.fillRule := "evenodd", - svg.d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", - svg.clipRule := "evenodd" - ) - ) -end ProgressIndicator diff --git a/modules/sample/src/main/scala/tausi/sample/components/QuestionRenderers.scala b/modules/sample/src/main/scala/tausi/sample/components/QuestionRenderers.scala deleted file mode 100644 index 4db5031..0000000 --- a/modules/sample/src/main/scala/tausi/sample/components/QuestionRenderers.scala +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (c) 2025 Tausi contributors. - * See LICENSE file for terms. - */ -package tausi.sample.components - -import com.raquo.laminar.api.L.* - -import tausi.sample.model.{QuestionType, SurveyQuestion} -import tausi.sample.state.AppState - -/** Renders survey questions based on their type. - * - * Centralizes the mapping from `SurveyQuestion` to appropriate form input components, - * eliminating duplication across survey pages. - */ -object QuestionRenderers: - - /** Render a survey question using the appropriate form input component. - * - * @param question The question configuration - * @param state The application state for reading/writing answers - * @return An HtmlElement for the rendered question - */ - def render(question: SurveyQuestion, state: AppState): HtmlElement = - question.questionType match - case QuestionType.Rating => - FormInputs.ratingInput( - id = question.id, - labelText = question.text, - valueSignal = state.surveyAnswers.signal.map(_.get(question.id)), - onValueChange = v => state.setAnswer(question.id, v) - ) - - case QuestionType.Text => - FormInputs.textAreaInput( - id = question.id, - labelText = question.text, - placeholderText = "Enter your response...", - valueSignal = state.surveyAnswers.signal.map(_.getOrElse(question.id, "")), - onValueChange = v => state.setAnswer(question.id, v), - required = question.required - ) - - case QuestionType.MultiChoice => - FormInputs.multiChoiceInput( - id = question.id, - labelText = question.text, - options = question.options.getOrElse(List.empty), - valueSignal = state.surveyAnswers.signal.map(_.get(question.id)), - onValueChange = v => state.setAnswer(question.id, v) - ) -end QuestionRenderers diff --git a/modules/sample/src/main/scala/tausi/sample/config/SurveyConfig.scala b/modules/sample/src/main/scala/tausi/sample/config/SurveyConfig.scala deleted file mode 100644 index a27b49f..0000000 --- a/modules/sample/src/main/scala/tausi/sample/config/SurveyConfig.scala +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright (c) 2025 Tausi contributors. - * See LICENSE file for terms. - */ -package tausi.sample.config - -import tausi.sample.model.* - -/** Configuration for the survey. */ -object SurveyConfig: - - /** Survey questions for page one. */ - val pageOneQuestions: List[SurveyQuestion] = List( - SurveyQuestion( - id = "overall_satisfaction", - text = "How satisfied are you with our service overall?", - questionType = QuestionType.Rating, - required = true, - options = None - ), - SurveyQuestion( - id = "recommendation_likelihood", - text = "How likely are you to recommend us to a colleague?", - questionType = QuestionType.Rating, - required = true, - options = None - ), - SurveyQuestion( - id = "service_quality", - text = "How would you rate the quality of our products/services?", - questionType = QuestionType.Rating, - required = true, - options = None - ), - SurveyQuestion( - id = "communication_rating", - text = "How would you rate our communication with you?", - questionType = QuestionType.Rating, - required = true, - options = None - ) - ) - - // === Dynamic field configuration === - - /** Industry options. */ - val industries: List[String] = List( - "Technology", - "Healthcare", - "Finance", - "Manufacturing", - "Retail" - ) - - /** Product lines per industry. */ - val productsByIndustry: Map[String, List[String]] = Map( - "Technology" -> List("Cloud Services", "Enterprise Software", "Security Solutions", "Data Analytics"), - "Healthcare" -> List("Patient Management", "Clinical Analytics", "Telehealth", "Compliance Tools"), - "Finance" -> List("Trading Platform", "Risk Management", "Payment Processing", "Regulatory Reporting"), - "Manufacturing" -> List("Supply Chain", "Quality Control", "IoT Monitoring", "Predictive Maintenance"), - "Retail" -> List("E-commerce Platform", "Inventory Management", "Customer Analytics", "POS Systems") - ) - - /** Feedback focus areas based on industry + product combination. */ - val feedbackTopics: Map[(String, String), List[String]] = Map( - // Technology - ("Technology", "Cloud Services") -> List("Uptime & Reliability", "Scalability", "Cost Optimization", "Migration Support"), - ("Technology", "Enterprise Software") -> List("User Experience", "Integration", "Customization", "Training"), - ("Technology", "Security Solutions") -> List("Threat Detection", "Compliance", "Incident Response", "Reporting"), - ("Technology", "Data Analytics") -> List("Query Performance", "Visualization", "Data Connectors", "Real-time Processing"), - // Healthcare - ("Healthcare", "Patient Management") -> List("Workflow Efficiency", "Interoperability", "Patient Portal", "Scheduling"), - ("Healthcare", "Clinical Analytics") -> List("Report Accuracy", "Dashboard Usability", "Data Quality", "Alert System"), - ("Healthcare", "Telehealth") -> List("Video Quality", "Patient Experience", "Provider Interface", "Integration"), - ("Healthcare", "Compliance Tools") -> List("Audit Trails", "Policy Management", "Training Modules", "Reporting"), - // Finance - ("Finance", "Trading Platform") -> List("Execution Speed", "Market Data", "Order Management", "Analytics"), - ("Finance", "Risk Management") -> List("Model Accuracy", "Stress Testing", "Reporting", "Alert Configuration"), - ("Finance", "Payment Processing") -> List("Transaction Speed", "Fraud Detection", "Reconciliation", "Multi-currency"), - ("Finance", "Regulatory Reporting") -> List("Accuracy", "Timeliness", "Format Support", "Audit Trail"), - // Manufacturing - ("Manufacturing", "Supply Chain") -> List("Visibility", "Demand Planning", "Supplier Integration", "Cost Tracking"), - ("Manufacturing", "Quality Control") -> List("Defect Detection", "Traceability", "Reporting", "Workflow"), - ("Manufacturing", "IoT Monitoring") -> List("Device Management", "Alert System", "Data Collection", "Dashboard"), - ("Manufacturing", "Predictive Maintenance") -> List("Prediction Accuracy", "Alert Timing", "Integration", "ROI Tracking"), - // Retail - ("Retail", "E-commerce Platform") -> List("Site Performance", "Checkout Experience", "Mobile Experience", "Search"), - ("Retail", "Inventory Management") -> List("Accuracy", "Reorder Alerts", "Multi-location", "Reporting"), - ("Retail", "Customer Analytics") -> List("Segmentation", "Journey Mapping", "Attribution", "Personalization"), - ("Retail", "POS Systems") -> List("Transaction Speed", "Hardware Reliability", "Integration", "Reporting") - ) - - /** Get products for selected industry. */ - def getProducts(industry: String): List[String] = - productsByIndustry.getOrElse(industry, List.empty) - - /** Get feedback topics for industry + product combination. */ - def getFeedbackTopics(industry: String, product: String): List[String] = - feedbackTopics.getOrElse((industry, product), List.empty) - - /** Survey questions for page two (static questions only - dynamic ones rendered separately). */ - val pageTwoQuestions: List[SurveyQuestion] = List( - SurveyQuestion( - id = "best_aspect", - text = "What do you like most about working with us?", - questionType = QuestionType.Text, - required = true, - options = None - ), - SurveyQuestion( - id = "improvement_suggestion", - text = "What is one thing we could do better?", - questionType = QuestionType.Text, - required = false, - options = None - ), - SurveyQuestion( - id = "additional_feedback", - text = "Any additional comments or feedback?", - questionType = QuestionType.Text, - required = false, - options = None - ) - ) - - /** All survey pages. */ - val surveyPages: List[SurveyPage] = List( - SurveyPage( - id = "page_one", - title = "Service Experience", - description = "Please rate your experience with our services.", - questions = pageOneQuestions - ), - SurveyPage( - id = "page_two", - title = "Feedback & Suggestions", - description = "Help us improve by sharing your thoughts.", - questions = pageTwoQuestions - ) - ) - - /** Company name for branding. */ - val companyName: String = "Acme Corporation" - - /** Survey title. */ - val surveyTitle: String = "Customer Satisfaction Survey" - - /** Survey subtitle/description. */ - val surveyDescription: String = - "Your feedback helps us improve. This survey takes approximately 3-5 minutes to complete." -end SurveyConfig diff --git a/modules/sample/src/main/scala/tausi/sample/events/SurveyEvents.scala b/modules/sample/src/main/scala/tausi/sample/events/SurveyEvents.scala new file mode 100644 index 0000000..96127e6 --- /dev/null +++ b/modules/sample/src/main/scala/tausi/sample/events/SurveyEvents.scala @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Tausi contributors. + * See LICENSE file for terms. + */ +package tausi.sample.events + +import tausi.api.Event +import tausi.sample.model.* + +/** Custom Tauri events for the survey application. + * + * Demonstrates the recommended pattern for defining application-specific + * events using Event.define. + * + * @example + * {{{ + * import tausi.sample.events.SurveyEvents.{given, *} + * import tausi.zio.* + * + * // Listen for progress updates + * events.listen[SurveyProgressEvent] { + * case Right(msg) => updateProgress(msg.payload) + * case Left(err) => logError(err.message) + * } + * + * // Emit progress + * events.emit(SurveyProgressEvent(2, 5, "Contact Info")) + * }}} + */ +object SurveyEvents: + + /** Event emitted when survey progress changes. */ + given surveyProgress: Event[SurveyProgressEvent] = + Event.define("survey-progress") + + /** Event emitted when a survey is submitted. */ + given surveySubmitted: Event[SurveySubmittedEvent] = + Event.define("survey-submitted") diff --git a/modules/sample/src/main/scala/tausi/sample/model/DemoModels.scala b/modules/sample/src/main/scala/tausi/sample/model/DemoModels.scala new file mode 100644 index 0000000..81886d8 --- /dev/null +++ b/modules/sample/src/main/scala/tausi/sample/model/DemoModels.scala @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Tausi contributors. + * See LICENSE file for terms. + */ +package tausi.sample.model + +import tausi.api.codec.Codec + +/** Survey application models demonstrating Tausi's Codec derivation. + * + * All models use `derives Codec` for automatic JSON serialisation, + * enabling type-safe IPC with the Tauri backend. + */ + +// ============================================================================= +// Core Domain Models +// ============================================================================= + +/** Contact details for survey respondent. */ +final case class ContactDetails( + firstName: String, + lastName: String, + email: String, + company: String +) derives Codec + +object ContactDetails: + given CanEqual[ContactDetails, ContactDetails] = CanEqual.derived + + def empty: ContactDetails = ContactDetails("", "", "", "") + + extension (c: ContactDetails) + def isComplete: Boolean = + c.firstName.nonEmpty && c.lastName.nonEmpty && c.email.nonEmpty + +/** Survey answers container. */ +final case class SurveyAnswers( + satisfaction: Option[Int], + recommendation: Option[Int], + features: List[String], + feedback: String +) derives Codec + +object SurveyAnswers: + given CanEqual[SurveyAnswers, SurveyAnswers] = CanEqual.derived + + def empty: SurveyAnswers = SurveyAnswers(None, None, List.empty, "") + + extension (a: SurveyAnswers) + def isComplete: Boolean = + a.satisfaction.isDefined && a.recommendation.isDefined + +/** Complete survey submission sent to the Tauri backend. */ +final case class SurveySubmission( + contactDetails: ContactDetails, + answers: SurveyAnswers, + submittedAt: String +) derives Codec + +object SurveySubmission: + given CanEqual[SurveySubmission, SurveySubmission] = CanEqual.derived + +// ============================================================================= +// Command Request/Response Types +// ============================================================================= + +/** Request wrapper for saving a survey via Tauri command. + * + * The field name `submission` must match the Rust function parameter name + * so that Tauri's IPC layer can correctly deserialize the request. + */ +final case class SaveSurveyRequest(submission: SurveySubmission) derives Codec + +object SaveSurveyRequest: + given CanEqual[SaveSurveyRequest, SaveSurveyRequest] = CanEqual.derived + +/** Response from successful survey save. */ +final case class SaveSurveyResponse(filePath: String, message: String) derives Codec + +object SaveSurveyResponse: + given CanEqual[SaveSurveyResponse, SaveSurveyResponse] = CanEqual.derived + +// ============================================================================= +// Event Payload Types +// ============================================================================= + +/** Event payload for survey progress updates. */ +final case class SurveyProgressEvent( + currentStep: Int, + totalSteps: Int, + stepName: String +) derives Codec + +object SurveyProgressEvent: + given CanEqual[SurveyProgressEvent, SurveyProgressEvent] = CanEqual.derived + +/** Event payload for survey submission result. */ +final case class SurveySubmittedEvent( + success: Boolean, + filePath: Option[String], + errorMessage: Option[String] +) derives Codec + +object SurveySubmittedEvent: + given CanEqual[SurveySubmittedEvent, SurveySubmittedEvent] = CanEqual.derived diff --git a/modules/sample/src/main/scala/tausi/sample/model/Page.scala b/modules/sample/src/main/scala/tausi/sample/model/Page.scala index e215e33..98e4b40 100644 --- a/modules/sample/src/main/scala/tausi/sample/model/Page.scala +++ b/modules/sample/src/main/scala/tausi/sample/model/Page.scala @@ -4,42 +4,54 @@ */ package tausi.sample.model -/** Application navigation pages. */ -enum Page(val title: String): - case Welcome extends Page("Welcome") - case ContactInfo extends Page("Contact Info") - case SurveyPageOne extends Page("Experience") - case SurveyPageTwo extends Page("Feedback") - case Submit extends Page("Submit") - - /** Get the step number (1-indexed) for progress indicator. */ - def stepNumber: Int = this match - case Page.Welcome => 1 - case Page.ContactInfo => 2 - case Page.SurveyPageOne => 3 - case Page.SurveyPageTwo => 4 - case Page.Submit => 5 -end Page +/** Navigation pages for the customer survey wizard. + * + * The survey follows a linear wizard flow with progress tracking. + */ +enum Page(val stepNumber: Int): + case Welcome extends Page(1) + case ContactInfo extends Page(2) + case SurveyQuestions extends Page(3) + case Review extends Page(4) + case Complete extends Page(5) object Page: given CanEqual[Page, Page] = CanEqual.derived - /** Get the next page in the flow. */ - def next(current: Page): Option[Page] = current match - case Page.Welcome => Some(Page.ContactInfo) - case Page.ContactInfo => Some(Page.SurveyPageOne) - case Page.SurveyPageOne => Some(Page.SurveyPageTwo) - case Page.SurveyPageTwo => Some(Page.Submit) - case Page.Submit => None - - /** Get the previous page in the flow. */ - def previous(current: Page): Option[Page] = current match - case Page.Welcome => None - case Page.ContactInfo => Some(Page.Welcome) - case Page.SurveyPageOne => Some(Page.ContactInfo) - case Page.SurveyPageTwo => Some(Page.SurveyPageOne) - case Page.Submit => Some(Page.SurveyPageTwo) - - /** Total number of steps. */ + /** Total number of wizard steps. */ val totalSteps: Int = 5 -end Page + + /** All pages in wizard order. */ + val all: List[Page] = List(Welcome, ContactInfo, SurveyQuestions, Review, Complete) + + extension (p: Page) + /** Display title for the page. */ + def title: String = p match + case Welcome => "Welcome" + case ContactInfo => "Contact Info" + case SurveyQuestions => "Survey" + case Review => "Review" + case Complete => "Complete" + + /** Next page in the wizard, if any. */ + def next: Option[Page] = p match + case Welcome => Some(ContactInfo) + case ContactInfo => Some(SurveyQuestions) + case SurveyQuestions => Some(Review) + case Review => Some(Complete) + case Complete => None + + /** Previous page in the wizard, if any. */ + def previous: Option[Page] = p match + case Welcome => None + case ContactInfo => Some(Welcome) + case SurveyQuestions => Some(ContactInfo) + case Review => Some(SurveyQuestions) + case Complete => None + + /** Whether this is the first page. */ + def isFirst: Boolean = p == Welcome + + /** Whether this is the last page. */ + def isLast: Boolean = p == Complete + diff --git a/modules/sample/src/main/scala/tausi/sample/model/SurveyData.scala b/modules/sample/src/main/scala/tausi/sample/model/SurveyData.scala deleted file mode 100644 index 92f8213..0000000 --- a/modules/sample/src/main/scala/tausi/sample/model/SurveyData.scala +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2025 Tausi contributors. - * See LICENSE file for terms. - */ -package tausi.sample.model - -import tausi.api.codec.Codec - -/** Contact details collected at the start of the survey. */ -final case class ContactDetails( - firstName: String, - lastName: String, - phoneNumber: String -) - -object ContactDetails: - given CanEqual[ContactDetails, ContactDetails] = CanEqual.derived - given Codec[ContactDetails] = Codec.derived - - val empty: ContactDetails = ContactDetails("", "", "") -end ContactDetails - -/** A single survey question. */ -final case class SurveyQuestion( - id: String, - text: String, - questionType: QuestionType, - required: Boolean, - options: Option[List[String]] -) - -object SurveyQuestion: - given CanEqual[SurveyQuestion, SurveyQuestion] = CanEqual.derived - given Codec[SurveyQuestion] = Codec.derived -end SurveyQuestion - -/** Types of survey questions. */ -enum QuestionType: - case Rating - case Text - case MultiChoice - -object QuestionType: - given CanEqual[QuestionType, QuestionType] = CanEqual.derived - given Codec[QuestionType] = Codec.derived -end QuestionType - -/** A page of survey questions. */ -final case class SurveyPage( - id: String, - title: String, - description: String, - questions: List[SurveyQuestion] -) - -object SurveyPage: - given CanEqual[SurveyPage, SurveyPage] = CanEqual.derived - given Codec[SurveyPage] = Codec.derived -end SurveyPage - -/** Complete survey submission. */ -final case class SurveySubmission( - contactDetails: ContactDetails, - answers: Map[String, String], - submittedAt: String -) - -object SurveySubmission: - given CanEqual[SurveySubmission, SurveySubmission] = CanEqual.derived - given Codec[SurveySubmission] = Codec.derived -end SurveySubmission - -/** Validation result for form fields. */ -enum ValidationResult: - case Valid - case Invalid(message: String) - -object ValidationResult: - given CanEqual[ValidationResult, ValidationResult] = CanEqual.derived - - extension (result: ValidationResult) - def isValid: Boolean = result match - case Valid => true - case Invalid(_) => false - - def errorMessage: Option[String] = result match - case Valid => None - case Invalid(msg) => Some(msg) - end extension -end ValidationResult diff --git a/modules/sample/src/main/scala/tausi/sample/pages/CompletePage.scala b/modules/sample/src/main/scala/tausi/sample/pages/CompletePage.scala new file mode 100644 index 0000000..53b324e --- /dev/null +++ b/modules/sample/src/main/scala/tausi/sample/pages/CompletePage.scala @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2025 Tausi contributors. + * See LICENSE file for terms. + */ +package tausi.sample.pages + +import com.raquo.laminar.api.L.* + +import zio.Runtime + +import tausi.sample.commands.survey.{given, *} +import tausi.sample.components.* +import tausi.sample.events.SurveyEvents.{given, *} +import tausi.sample.model.* +import tausi.sample.state.AppState +import tausi.zio.* + +/** Complete page - submits survey and shows result. + * + * This page demonstrates the key Tausi patterns: + * - Command invocation with `invoke(request).runWith(...)` + * - Event emission for notifications + * - Error handling with TauriError + * - Loading state management + */ +object CompletePage: + given Runtime[Any] = Runtime.default + + def apply(state: AppState): HtmlElement = + // Submit on mount if not already submitted + div( + onMountCallback { _ => + val hasResult = state.submissionResult.now().isDefined + val hasError = state.submissionError.now().isDefined + val isSubmitting = state.isSubmitting.now() + + if !hasResult && !hasError && !isSubmitting then submitSurvey(state) + }, + Layout.card( + // Dynamic content based on submission state + child <-- state.isSubmitting.signal.combineWith( + state.submissionResult.signal, + state.submissionError.signal + ).map { + case (true, _, _) => LoadingView() + case (_, Some(result), _) => SuccessView(result, state) + case (_, _, Some(error)) => ErrorView(error, state) + case (false, None, None) => LoadingView() // Initial state, will trigger submit + } + ) + ) + + /** Submit the survey using Tausi command invocation. */ + private def submitSurvey(state: AppState): Unit = + state.isSubmitting.set(true) + state.submissionError.set(None) + + // Build the submission + val submission = SurveySubmission( + contactDetails = state.contactDetails.now(), + answers = state.surveyAnswers.now(), + submittedAt = java.time.Instant.now().toString + ) + + // Invoke the save_survey command + // This demonstrates the core Tausi pattern: type-safe command invocation + // SaveSurveyRequest wraps the submission with a field name matching the + // Rust function parameter for proper IPC serialization + invoke(SaveSurveyRequest(submission)).runWith( + onSuccess = response => { + state.isSubmitting.set(false) + state.submissionResult.set(Some(response)) + + org.scalajs.dom.console.log("[CompletePage] Emitting success event") + // Emit success event (demonstrates event emission) + events.emit(SurveySubmittedEvent( + success = true, + filePath = Some(response.filePath), + errorMessage = None + )).runWith( + onSuccess = _ => org.scalajs.dom.console.log("[CompletePage] Success event emitted successfully"), + onError = err => org.scalajs.dom.console.error(s"[CompletePage] Failed to emit success event: ${err.message}") + ) + }, + onError = err => { + state.isSubmitting.set(false) + state.submissionError.set(Some(err.message)) + + org.scalajs.dom.console.log("[CompletePage] Emitting failure event") + // Emit failure event + events.emit(SurveySubmittedEvent( + success = false, + filePath = None, + errorMessage = Some(err.message) + )).runWith( + onSuccess = _ => org.scalajs.dom.console.log("[CompletePage] Failure event emitted successfully"), + onError = err => org.scalajs.dom.console.error(s"[CompletePage] Failed to emit failure event: ${err.message}") + ) + } + ) + + private def LoadingView(): HtmlElement = + div( + cls := "text-center py-12", + // Spinner + div( + cls := "inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/20 mb-6", + div(cls := "w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin") + ), + h2(cls := "text-xl font-semibold text-text-primary mb-2", "Submitting Survey..."), + p(cls := "text-text-secondary", "Saving your responses to file storage") + ) + + private def SuccessView(result: SaveSurveyResponse, state: AppState): HtmlElement = + div( + cls := "text-center py-8", + // Success icon + div( + cls := "inline-flex items-center justify-center w-16 h-16 rounded-full bg-success/20 mb-6", + svg.svg( + svg.cls := "w-8 h-8 text-success", + svg.viewBox := "0 0 24 24", + svg.fill := "none", + svg.stroke := "currentColor", + svg.strokeWidth := "2", + svg.path( + svg.d := "M5 13l4 4L19 7", + svg.strokeLineCap := "round", + svg.strokeLineJoin := "round" + ) + ) + ), + h2(cls := "text-xl font-semibold text-text-primary mb-2", "Survey Submitted!"), + p(cls := "text-text-secondary mb-6", result.message), + // File location + div( + cls := "bg-surface-700 rounded-lg p-4 text-left mb-8", + div(cls := "text-xs text-text-muted uppercase tracking-wide mb-1", "Saved to"), + div(cls := "text-text-primary font-mono text-sm break-all", result.filePath) + ), + // Pattern demonstration note + div( + cls := "bg-primary/10 border border-primary/30 rounded-lg p-4 text-left mb-8", + h3(cls := "text-primary font-medium mb-2", "Tausi Pattern Demonstrated"), + pre( + cls := "text-xs text-text-secondary font-mono overflow-x-auto", + """invoke(SaveSurveyRequest(submission)).runWith( + onSuccess = response => showSuccess(response), + onError = err => showError(err.message) +)""" + ) + ), + // Start new survey button + Buttons.primary("Start New Survey", () => state.reset()) + ) + + private def ErrorView(error: String, state: AppState): HtmlElement = + div( + cls := "text-center py-8", + // Error icon + div( + cls := "inline-flex items-center justify-center w-16 h-16 rounded-full bg-error/20 mb-6", + svg.svg( + svg.cls := "w-8 h-8 text-error", + svg.viewBox := "0 0 24 24", + svg.fill := "none", + svg.stroke := "currentColor", + svg.strokeWidth := "2", + svg.path( + svg.d := "M6 18L18 6M6 6l12 12", + svg.strokeLineCap := "round", + svg.strokeLineJoin := "round" + ) + ) + ), + h2(cls := "text-xl font-semibold text-text-primary mb-2", "Submission Failed"), + p(cls := "text-text-secondary mb-4", "There was an error saving your survey."), + // Error details + div( + cls := "bg-error/10 border border-error/30 rounded-lg p-4 text-left mb-8", + div(cls := "text-xs text-error uppercase tracking-wide mb-1", "Error"), + div(cls := "text-text-primary text-sm", error) + ), + // Actions + div( + cls := "flex justify-center gap-4", + Buttons.secondary("Go Back", () => state.navigatePrevious()), + Buttons.primary("Try Again", () => { + state.submissionError.set(None) + submitSurvey(state) + }) + ) + ) diff --git a/modules/sample/src/main/scala/tausi/sample/pages/ContactInfoPage.scala b/modules/sample/src/main/scala/tausi/sample/pages/ContactInfoPage.scala index ab90467..efa8c8f 100644 --- a/modules/sample/src/main/scala/tausi/sample/pages/ContactInfoPage.scala +++ b/modules/sample/src/main/scala/tausi/sample/pages/ContactInfoPage.scala @@ -5,82 +5,81 @@ package tausi.sample.pages import com.raquo.laminar.api.L.* + import tausi.sample.components.* -import tausi.sample.model.ValidationResult +import tausi.sample.model.ContactDetails import tausi.sample.state.AppState -import tausi.sample.validation.Validators -/** Contact information page view. */ +/** Contact information page - collects user details with validation. */ object ContactInfoPage: - def apply(state: AppState): HtmlElement = - val showValidation = Var(false) - - val firstNameValidation: Signal[Option[ValidationResult]] = - showValidation.signal.combineWith(state.contactDetails.signal).map { - case (false, _) => None - case (true, contact) => - Some(Validators.nonEmpty(contact.firstName, "First name")) - } - - val lastNameValidation: Signal[Option[ValidationResult]] = - showValidation.signal.combineWith(state.contactDetails.signal).map { - case (false, _) => None - case (true, contact) => - Some(Validators.nonEmpty(contact.lastName, "Last name")) - } - - val phoneValidation: Signal[Option[ValidationResult]] = - showValidation.signal.combineWith(state.contactDetails.signal).map { - case (false, _) => None - case (true, contact) => - if contact.phoneNumber.trim.isEmpty then - Some(ValidationResult.Invalid("Phone number is required")) - else - Some(Validators.phoneNumber(contact.phoneNumber)) - } - - val isValid: Signal[Boolean] = - state.contactDetails.signal.map(Validators.isContactValid) - - def handleNext(): Unit = - showValidation.set(true) - if Validators.isContactValid(state.contactDetails.now()) then state.navigateNext() - Layout.card( - Layout.sectionHeader( - "Contact Information", - Some("Please provide your contact details so we can follow up if needed.") - ), - form( - onSubmit.preventDefault --> { _ => handleNext() }, - FormInputs.textInput( - id = "firstName", + Layout.pageHeader("Contact Information", Some("Please provide your details")), + div( + cls := "space-y-6", + // First Name + FormField( labelText = "First Name", - placeholderText = "Enter your first name", + required = true, valueSignal = state.contactDetails.signal.map(_.firstName), - onValueChange = v => state.updateContactDetails(_.copy(firstName = v)), - validationSignal = firstNameValidation + onUpdate = v => state.contactDetails.update(_.copy(firstName = v)), + placeholderText = "Enter your first name" ), - FormInputs.textInput( - id = "lastName", + // Last Name + FormField( labelText = "Last Name", - placeholderText = "Enter your last name", + required = true, valueSignal = state.contactDetails.signal.map(_.lastName), - onValueChange = v => state.updateContactDetails(_.copy(lastName = v)), - validationSignal = lastNameValidation + onUpdate = v => state.contactDetails.update(_.copy(lastName = v)), + placeholderText = "Enter your last name" ), - FormInputs.phoneInput( - id = "phoneNumber", - labelText = "Phone Number", - valueSignal = state.contactDetails.signal.map(_.phoneNumber), - onValueChange = v => state.updateContactDetails(_.copy(phoneNumber = v)), - validationSignal = phoneValidation + // Email + FormField( + labelText = "Email Address", + required = true, + valueSignal = state.contactDetails.signal.map(_.email), + onUpdate = v => state.contactDetails.update(_.copy(email = v)), + placeholderText = "you@company.com", + inputType = "email" ), - Layout.buttonGroup( - Buttons.secondary("Back", () => state.navigatePrevious()), - Buttons.primary("Continue", () => handleNext()) + // Company + FormField( + labelText = "Company", + required = false, + valueSignal = state.contactDetails.signal.map(_.company), + onUpdate = v => state.contactDetails.update(_.copy(company = v)), + placeholderText = "Your company name (optional)" + ) + ), + // Navigation + div( + cls := "flex justify-between mt-8", + Buttons.secondary("Back", () => state.navigatePrevious()), + Buttons.primary("Continue", () => state.navigateNext(), state.isContactComplete.map(!_)) + ) + ) + + private def FormField( + labelText: String, + required: Boolean, + valueSignal: Signal[String], + onUpdate: String => Unit, + placeholderText: String, + inputType: String = "text" + ): HtmlElement = + div( + label( + cls := "block text-sm font-medium text-text-primary mb-2", + labelText, + if required then span(cls := "text-error ml-1", "*") else emptyNode + ), + input( + cls := "w-full px-4 py-3 bg-surface-700 border border-border rounded-lg text-text-primary placeholder-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary transition-colors", + typ := inputType, + placeholder := placeholderText, + controlled( + value <-- valueSignal, + onInput.mapToValue --> onUpdate ) ) ) -end ContactInfoPage diff --git a/modules/sample/src/main/scala/tausi/sample/pages/ReviewPage.scala b/modules/sample/src/main/scala/tausi/sample/pages/ReviewPage.scala new file mode 100644 index 0000000..49f67b3 --- /dev/null +++ b/modules/sample/src/main/scala/tausi/sample/pages/ReviewPage.scala @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Tausi contributors. + * See LICENSE file for terms. + */ +package tausi.sample.pages + +import com.raquo.laminar.api.L.* + +import tausi.sample.components.* +import tausi.sample.state.AppState + +/** Review page - shows summary before submission. */ +object ReviewPage: + def apply(state: AppState): HtmlElement = + Layout.card( + Layout.pageHeader("Review Your Responses", Some("Please verify your information before submitting")), + div( + cls := "space-y-6", + // Contact Details Section + Section( + "Contact Information", + div( + cls := "grid grid-cols-2 gap-4", + ReviewField("First Name", state.contactDetails.signal.map(_.firstName)), + ReviewField("Last Name", state.contactDetails.signal.map(_.lastName)), + ReviewField("Email", state.contactDetails.signal.map(_.email)), + ReviewField("Company", state.contactDetails.signal.map(c => if c.company.isEmpty then "Not provided" else c.company)) + ) + ), + // Ratings Section + Section( + "Your Ratings", + div( + cls := "grid grid-cols-2 gap-4", + ReviewField( + "Satisfaction", + state.surveyAnswers.signal.map(a => a.satisfaction.map(r => s"$r / 5").getOrElse("Not rated")) + ), + ReviewField( + "Recommendation", + state.surveyAnswers.signal.map(a => a.recommendation.map(r => s"$r / 5").getOrElse("Not rated")) + ) + ) + ), + // Features Section + Section( + "Important Features", + child <-- state.surveyAnswers.signal.map { answers => + if answers.features.isEmpty then + p(cls := "text-text-muted italic", "No features selected") + else + div( + cls := "flex flex-wrap gap-2", + answers.features.map { feature => + span( + cls := "px-3 py-1 bg-primary/20 text-primary rounded-full text-sm", + feature + ) + } + ) + } + ), + // Feedback Section + Section( + "Additional Feedback", + child <-- state.surveyAnswers.signal.map { answers => + if answers.feedback.isEmpty then + p(cls := "text-text-muted italic", "No feedback provided") + else + p(cls := "text-text-secondary", answers.feedback) + } + ) + ), + // Warning + div( + cls := "bg-warning/10 border border-warning/30 rounded-lg p-4 mt-6", + p( + cls := "text-sm text-warning", + "By submitting, your responses will be saved to a file on your device." + ) + ), + // Navigation + div( + cls := "flex justify-between mt-8", + Buttons.secondary("Back", () => state.navigatePrevious()), + Buttons.primary("Submit Survey", () => state.navigateNext()) + ) + ) + + private def Section(title: String, content: Modifier[HtmlElement]*): HtmlElement = + div( + cls := "bg-surface-700 rounded-lg p-4", + h3(cls := "text-text-primary font-medium mb-3", title), + content + ) + + private def ReviewField(label: String, valueSignal: Signal[String]): HtmlElement = + div( + div(cls := "text-xs text-text-muted uppercase tracking-wide", label), + div(cls := "text-text-primary font-medium", child.text <-- valueSignal) + ) diff --git a/modules/sample/src/main/scala/tausi/sample/pages/SubmitPage.scala b/modules/sample/src/main/scala/tausi/sample/pages/SubmitPage.scala deleted file mode 100644 index a5e5cdb..0000000 --- a/modules/sample/src/main/scala/tausi/sample/pages/SubmitPage.scala +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright (c) 2025 Tausi contributors. - * See LICENSE file for terms. - */ -package tausi.sample.pages - -import com.raquo.laminar.api.L.* - -import zio.Runtime - -import tausi.sample.components.* -import tausi.sample.config.SurveyConfig -import tausi.sample.model.* -import tausi.sample.services.SurveyService -import tausi.sample.state.AppState -import tausi.zio.* - -/** Submit page view with review and submission. - * - * Uses ZIO-based SurveyService with proper error channel handling. - * The TauriError message is displayed directly to users when submission fails. - */ -object SubmitPage: - - given Runtime[Any] = Runtime.default - - def apply(state: AppState, surveyService: SurveyService): HtmlElement = - val isSubmitting = Var(false) - val isSubmitted = Var(false) - val errorMessage = Var(Option.empty[String]) - - def handleSubmit(): Unit = - isSubmitting.set(true) - errorMessage.set(None) - - val submission = SurveySubmission( - contactDetails = state.contactDetails.now(), - answers = state.surveyAnswers.now(), - submittedAt = new scalajs.js.Date().toISOString() - ) - - // Use ZIO effect with proper error channel - surveyService.submitSurvey(submission).runWith( - onSuccess = _ => { - isSubmitting.set(false) - isSubmitted.set(true) - }, - onError = tauriError => { - isSubmitting.set(false) - // Display the TauriError.message directly - it contains user-friendly error text - errorMessage.set(Some(tauriError.message)) - } - ) - - div( - child <-- isSubmitted.signal.map { submitted => - if submitted then renderSuccess(state) - else renderReview(state, isSubmitting, errorMessage, () => handleSubmit()) - } - ) - - private def renderSuccess(state: AppState): HtmlElement = - Layout.card( - Layout.successMessage( - "Thank You!", - "Your feedback has been submitted successfully. We appreciate your time and input." - ), - div( - cls := "text-center mt-8", - Buttons.primary("Start New Survey", () => state.reset()) - ) - ) - - private def renderReview( - state: AppState, - isSubmitting: Var[Boolean], - errorMessage: Var[Option[String]], - onSubmit: () => Unit - ): HtmlElement = - Layout.card( - Layout.sectionHeader( - "Review Your Responses", - Some("Please review your answers before submitting.") - ), - // Contact details summary - div( - cls := "bg-surface-700/50 rounded-lg p-4 mb-6 border border-border/50", - h3(cls := "font-semibold text-text-primary mb-3", "Contact Information"), - div( - cls := "grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm", - children <-- state.contactDetails.signal.map { contact => - List( - summaryItem("First Name", contact.firstName), - summaryItem("Last Name", contact.lastName), - summaryItem("Phone", contact.phoneNumber) - ) - } - ) - ), - // Survey answers summary - div( - cls := "space-y-3 mb-6", - h3(cls := "font-semibold text-text-primary mb-3", "Survey Responses"), - children <-- state.surveyAnswers.signal.map { answers => - SurveyConfig.surveyPages.flatMap { page => - page.questions.flatMap { q => - answers.get(q.id).filter(_.nonEmpty).map { answer => - answerSummaryItem(q.text, answer) - } - } - } - } - ), - // Error message - child.maybe <-- errorMessage.signal.map { - case Some(msg) => - Some( - div( - cls := "bg-error/10 border border-error/30 text-error px-4 py-3 rounded-lg mb-4", - role := "alert", - msg - ) - ) - case None => None - }, - Layout.buttonGroup( - Buttons.secondary( - "Back to Edit", - () => state.navigatePrevious(), - isSubmitting.signal - ), - child <-- isSubmitting.signal.map { submitting => - if submitting then - button( - cls := s"${Buttons.buttonBaseClasses} bg-primary/70 text-surface-900 flex items-center justify-center gap-2 cursor-wait", - typ := "button", - disabled := true, - aria.busy := true, - spinnerIcon, - span("Submitting...") - ) - else Buttons.primary("Submit Survey", onSubmit) - } - ) - ) - - private def summaryItem(labelText: String, value: String): HtmlElement = - div( - span(cls := "text-text-muted", s"$labelText: "), - span(cls := "text-text-primary font-medium", value) - ) - - private def answerSummaryItem(question: String, answer: String): HtmlElement = - div( - cls := "bg-surface-700/50 rounded-lg p-3 border border-border/50", - p(cls := "text-sm text-text-secondary mb-1", question), - p(cls := "text-text-primary", answer) - ) - - private def spinnerIcon: Element = - svg.svg( - svg.cls := "animate-spin h-5 w-5", - svg.viewBox := "0 0 24 24", - svg.fill := "none", - svg.circle( - svg.cls := "opacity-25", - svg.cx := "12", - svg.cy := "12", - svg.r := "10", - svg.stroke := "currentColor", - svg.strokeWidth := "4" - ), - svg.path( - svg.cls := "opacity-75", - svg.fill := "currentColor", - svg.d := "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" - ) - ) -end SubmitPage diff --git a/modules/sample/src/main/scala/tausi/sample/pages/SurveyPageOne.scala b/modules/sample/src/main/scala/tausi/sample/pages/SurveyPageOne.scala deleted file mode 100644 index b075241..0000000 --- a/modules/sample/src/main/scala/tausi/sample/pages/SurveyPageOne.scala +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2025 Tausi contributors. - * See LICENSE file for terms. - */ -package tausi.sample.pages - -import com.raquo.laminar.api.L.* - -import tausi.sample.components.* -import tausi.sample.config.SurveyConfig -import tausi.sample.state.AppState - -/** Survey page one view - rating questions. */ -object SurveyPageOne: - - def apply(state: AppState): HtmlElement = - val surveyPage = SurveyConfig.surveyPages.head - - val allAnswered: Signal[Boolean] = - state.surveyAnswers.signal.map { answers => - surveyPage.questions.filter(_.required).forall { q => - answers.get(q.id).exists(_.nonEmpty) - } - } - - Layout.card( - Layout.sectionHeader( - surveyPage.title, - Some(surveyPage.description) - ), - div( - cls := "space-y-6", - surveyPage.questions.map(QuestionRenderers.render(_, state)) - ), - Layout.buttonGroup( - Buttons.secondary("Back", () => state.navigatePrevious()), - Buttons.primary( - "Continue", - () => state.navigateNext(), - allAnswered.map(!_) - ) - ) - ) -end SurveyPageOne diff --git a/modules/sample/src/main/scala/tausi/sample/pages/SurveyPageTwo.scala b/modules/sample/src/main/scala/tausi/sample/pages/SurveyPageTwo.scala deleted file mode 100644 index c00f508..0000000 --- a/modules/sample/src/main/scala/tausi/sample/pages/SurveyPageTwo.scala +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright (c) 2025 Tausi contributors. - * See LICENSE file for terms. - */ -package tausi.sample.pages - -import com.raquo.laminar.api.L.* - -import tausi.sample.components.* -import tausi.sample.config.SurveyConfig -import tausi.sample.state.AppState - -/** Survey page two view - demonstrates reactive dependent fields using Laminar signals. */ -object SurveyPageTwo: - - // Field IDs for the dynamic cascade - private val IndustryId = "industry" - private val ProductId = "product_line" - private val FeedbackTopicId = "feedback_topic" - - def apply(state: AppState): HtmlElement = - // Derived signals for the cascading dropdowns - val industrySignal: Signal[Option[String]] = - state.surveyAnswers.signal.map(_.get(IndustryId).filter(_.nonEmpty)) - - val productSignal: Signal[Option[String]] = - state.surveyAnswers.signal.map(_.get(ProductId).filter(_.nonEmpty)) - - // Available products depend on selected industry - val availableProducts: Signal[List[String]] = - industrySignal.map { - case Some(industry) => SurveyConfig.getProducts(industry) - case None => List.empty - } - - // Available feedback topics depend on industry + product - val availableFeedbackTopics: Signal[List[String]] = - industrySignal.combineWith(productSignal).map { - case (Some(industry), Some(product)) => SurveyConfig.getFeedbackTopics(industry, product) - case _ => List.empty - } - - // Validation: all required dynamic fields answered - val dynamicFieldsValid: Signal[Boolean] = - state.surveyAnswers.signal.map { answers => - val hasIndustry = answers.get(IndustryId).exists(_.nonEmpty) - val hasProduct = answers.get(ProductId).exists(_.nonEmpty) - val hasTopic = answers.get(FeedbackTopicId).exists(_.nonEmpty) - hasIndustry && hasProduct && hasTopic - } - - // Validation: static required fields answered - val staticFieldsValid: Signal[Boolean] = - state.surveyAnswers.signal.map { answers => - SurveyConfig.pageTwoQuestions.filter(_.required).forall { q => - answers.get(q.id).exists(_.nonEmpty) - } - } - - val allValid: Signal[Boolean] = - dynamicFieldsValid.combineWith(staticFieldsValid).map { case (d, s) => d && s } - - // Clear dependent fields when parent changes - def onIndustryChange(value: String): Unit = - state.setAnswer(IndustryId, value) - state.clearAnswer(ProductId) - state.clearAnswer(FeedbackTopicId) - - def onProductChange(value: String): Unit = - state.setAnswer(ProductId, value) - state.clearAnswer(FeedbackTopicId) - - Layout.card( - Layout.sectionHeader( - "Product Feedback", - Some("Help us understand your experience with our solutions.") - ), - div( - cls := "space-y-5", - // Industry selector (static options) - FormInputs.multiChoiceInput( - id = IndustryId, - labelText = "What industry are you in?", - options = SurveyConfig.industries, - valueSignal = industrySignal, - onValueChange = onIndustryChange - ), - // Product selector (options depend on industry) - child.maybe <-- availableProducts.map { products => - Option.when(products.nonEmpty)( - div( - cls := "animate-in fade-in duration-300", - FormInputs.multiChoiceInput( - id = ProductId, - labelText = "Which product line do you use most?", - options = products, - valueSignal = productSignal, - onValueChange = onProductChange - ) - ) - ) - }, - // Feedback topic (options depend on industry + product) - child.maybe <-- availableFeedbackTopics.map { topics => - Option.when(topics.nonEmpty)( - div( - cls := "animate-in fade-in duration-300", - FormInputs.multiChoiceInput( - id = FeedbackTopicId, - labelText = "What area would you like to give feedback on?", - options = topics, - valueSignal = state.surveyAnswers.signal.map(_.get(FeedbackTopicId)), - onValueChange = v => state.setAnswer(FeedbackTopicId, v) - ) - ) - ) - }, - // Static questions - div( - cls := "pt-4 border-t border-border/50", - SurveyConfig.pageTwoQuestions.map(QuestionRenderers.render(_, state)) - ) - ), - Layout.buttonGroup( - Buttons.secondary("Back", () => state.navigatePrevious()), - Buttons.primary( - "Review & Submit", - () => state.navigateNext(), - allValid.map(!_) - ) - ) - ) -end SurveyPageTwo diff --git a/modules/sample/src/main/scala/tausi/sample/pages/SurveyQuestionsPage.scala b/modules/sample/src/main/scala/tausi/sample/pages/SurveyQuestionsPage.scala new file mode 100644 index 0000000..1cbb26d --- /dev/null +++ b/modules/sample/src/main/scala/tausi/sample/pages/SurveyQuestionsPage.scala @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Tausi contributors. + * See LICENSE file for terms. + */ +package tausi.sample.pages + +import com.raquo.laminar.api.L.* + +import tausi.sample.components.* +import tausi.sample.state.AppState + +/** Survey questions page - collects ratings and preferences. */ +object SurveyQuestionsPage: + private val featureOptions = List( + "Performance" -> "Fast and responsive", + "Reliability" -> "Stable and dependable", + "Ease of Use" -> "Intuitive interface", + "Documentation" -> "Clear guides and examples", + "Support" -> "Helpful customer service", + "Pricing" -> "Good value for money" + ) + + def apply(state: AppState): HtmlElement = + Layout.card( + Layout.pageHeader("Survey Questions", Some("Rate your experience")), + div( + cls := "space-y-8", + // Satisfaction rating + RatingQuestion( + question = "How satisfied are you with our product overall?", + description = "1 = Very Unsatisfied, 5 = Very Satisfied", + valueSignal = state.surveyAnswers.signal.map(_.satisfaction), + onSelect = rating => state.surveyAnswers.update(_.copy(satisfaction = Some(rating))) + ), + // Recommendation rating + RatingQuestion( + question = "How likely are you to recommend us to others?", + description = "1 = Very Unlikely, 5 = Very Likely", + valueSignal = state.surveyAnswers.signal.map(_.recommendation), + onSelect = rating => state.surveyAnswers.update(_.copy(recommendation = Some(rating))) + ), + // Feature selection + FeatureSelection( + question = "Which features are most important to you?", + description = "Select all that apply", + options = featureOptions, + selectedSignal = state.surveyAnswers.signal.map(_.features), + onToggle = feature => + state.surveyAnswers.update { answers => + val newFeatures = + if answers.features.contains(feature) then answers.features.filterNot(_ == feature) + else answers.features :+ feature + answers.copy(features = newFeatures) + } + ), + // Feedback + TextAreaQuestion( + question = "Any additional feedback?", + description = "Optional - share your thoughts", + valueSignal = state.surveyAnswers.signal.map(_.feedback), + onUpdate = feedback => state.surveyAnswers.update(_.copy(feedback = feedback)), + placeholderText = "What could we do better? What do you love about our product?" + ) + ), + // Navigation + div( + cls := "flex justify-between mt-8", + Buttons.secondary("Back", () => state.navigatePrevious()), + Buttons.primary("Review Answers", () => state.navigateNext(), state.isSurveyComplete.map(!_)) + ) + ) + + private def RatingQuestion( + question: String, + description: String, + valueSignal: Signal[Option[Int]], + onSelect: Int => Unit + ): HtmlElement = + div( + cls := "space-y-3", + div( + h3(cls := "text-text-primary font-medium", question), + p(cls := "text-sm text-text-muted", description) + ), + div( + cls := "flex gap-2", + (1 to 5).map { rating => + button( + cls <-- valueSignal.map { selected => + val base = "w-12 h-12 rounded-lg font-medium transition-all" + if selected.contains(rating) then s"$base bg-primary text-surface-900" + else s"$base bg-surface-700 text-text-secondary hover:bg-surface-600" + }, + rating.toString, + onClick --> { _ => onSelect(rating) } + ) + } + ) + ) + + private def FeatureSelection( + question: String, + description: String, + options: List[(String, String)], + selectedSignal: Signal[List[String]], + onToggle: String => Unit + ): HtmlElement = + div( + cls := "space-y-3", + div( + h3(cls := "text-text-primary font-medium", question), + p(cls := "text-sm text-text-muted", description) + ), + div( + cls := "grid grid-cols-2 gap-3", + options.map { case (key, label) => + button( + cls <-- selectedSignal.map { selected => + val base = "p-3 rounded-lg text-left transition-all border" + if selected.contains(key) then s"$base bg-primary/20 border-primary text-text-primary" + else s"$base bg-surface-700 border-border text-text-secondary hover:border-text-muted" + }, + div(cls := "font-medium text-sm", key), + div(cls := "text-xs opacity-75", label), + onClick --> { _ => onToggle(key) } + ) + } + ) + ) + + private def TextAreaQuestion( + question: String, + description: String, + valueSignal: Signal[String], + onUpdate: String => Unit, + placeholderText: String + ): HtmlElement = + div( + cls := "space-y-3", + div( + h3(cls := "text-text-primary font-medium", question), + p(cls := "text-sm text-text-muted", description) + ), + textArea( + cls := "w-full px-4 py-3 bg-surface-700 border border-border rounded-lg text-text-primary placeholder-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary transition-colors resize-none", + rows := 4, + placeholder := placeholderText, + controlled( + value <-- valueSignal, + onInput.mapToValue --> onUpdate + ) + ) + ) diff --git a/modules/sample/src/main/scala/tausi/sample/pages/WelcomePage.scala b/modules/sample/src/main/scala/tausi/sample/pages/WelcomePage.scala index 69e7ff5..94f8491 100644 --- a/modules/sample/src/main/scala/tausi/sample/pages/WelcomePage.scala +++ b/modules/sample/src/main/scala/tausi/sample/pages/WelcomePage.scala @@ -5,70 +5,63 @@ package tausi.sample.pages import com.raquo.laminar.api.L.* + import tausi.sample.components.* -import tausi.sample.config.SurveyConfig import tausi.sample.state.AppState -/** Welcome page view with dark corporate theme. */ +/** Welcome page - first step of the survey wizard. */ object WelcomePage: - def apply(state: AppState): HtmlElement = Layout.card( - Layout.hero( - iconContent = clipboardIcon, - title = SurveyConfig.surveyTitle, - description = SurveyConfig.surveyDescription - ), - div( - cls := "space-y-3 mt-6 sm:mt-8", - featureItem("Quick", "Takes only 3-5 minutes to complete"), - featureItem("Confidential", "Your responses are kept private"), - featureItem("Impactful", "Helps us serve you better") + Layout.pageHeader( + "Welcome to the Customer Survey", + Some("Help us improve our products and services") ), div( - cls := "text-center mt-6 sm:mt-8", - Buttons.primary("Begin Survey", () => state.navigateNext()) - ) - ) - - private def featureItem(title: String, description: String): HtmlElement = - div( - cls := "flex items-start gap-3 sm:gap-4 p-3 sm:p-4 bg-surface-700/50 rounded-lg border border-border/50", - div( - cls := "w-8 h-8 bg-primary/20 rounded-full flex items-center justify-center flex-shrink-0", - checkIcon + cls := "space-y-4 text-text-secondary", + p( + "Thank you for taking the time to complete this survey. Your feedback is valuable ", + "and helps us understand how we can better serve you." + ), + p("This survey takes approximately ", strong("2-3 minutes"), " to complete."), + div( + cls := "bg-surface-700 rounded-lg p-4 mt-6", + h3(cls := "text-text-primary font-medium mb-2", "What to expect:"), + ul( + cls := "list-disc list-inside space-y-1 text-sm", + li("Contact information (name, email, company)"), + li("Satisfaction and recommendation ratings"), + li("Feature preferences and feedback"), + li("Review and submit") + ) + ), + div( + cls := "bg-primary/10 border border-primary/30 rounded-lg p-4 mt-4", + div(cls := "flex items-start gap-3"), + div( + cls := "text-primary", + svg.svg( + svg.cls := "w-5 h-5 mt-0.5", + svg.viewBox := "0 0 24 24", + svg.fill := "none", + svg.stroke := "currentColor", + svg.strokeWidth := "2", + svg.path( + svg.d := "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z", + svg.strokeLineCap := "round", + svg.strokeLineJoin := "round" + ) + ) + ), + p( + cls := "text-sm", + "Your responses are saved locally to your device using Tauri's secure file storage." + ) + ) ), + // Navigation div( - h3(cls := "font-semibold text-text-primary", title), - p(cls := "text-sm text-text-secondary", description) - ) - ) - - private def checkIcon: Element = - svg.svg( - svg.cls := "w-4 h-4 text-primary", - svg.viewBox := "0 0 24 24", - svg.fill := "none", - svg.stroke := "currentColor", - svg.strokeWidth := "2", - svg.path( - svg.d := "M5 13l4 4L19 7", - svg.strokeLineCap := "round", - svg.strokeLineJoin := "round" - ) - ) - - private def clipboardIcon: Element = - svg.svg( - svg.cls := "w-8 h-8 sm:w-10 sm:h-10 text-primary", - svg.viewBox := "0 0 24 24", - svg.fill := "none", - svg.stroke := "currentColor", - svg.strokeWidth := "1.5", - svg.path( - svg.d := "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4", - svg.strokeLineCap := "round", - svg.strokeLineJoin := "round" + cls := "flex justify-end mt-8", + Buttons.primary("Get Started", () => state.navigateNext()) ) ) -end WelcomePage diff --git a/modules/sample/src/main/scala/tausi/sample/services/EventDemos.scala b/modules/sample/src/main/scala/tausi/sample/services/EventDemos.scala deleted file mode 100644 index 450d373..0000000 --- a/modules/sample/src/main/scala/tausi/sample/services/EventDemos.scala +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright (c) 2025 Tausi contributors. - * See LICENSE file for terms. - */ -package tausi.sample.services - -import zio.* -import zio.stream.* - -import tausi.api.Event -import tausi.api.EventMessage -import tausi.api.TauriError -import tausi.api.codec.* -import tausi.zio.events - -/** Demonstrates Tausi's event system with ZIO integration. - * - * This module shows how to use Tauri's event system for communication between the frontend - * (Scala.js) and the Rust backend using ZIO effects. - * - * Events are defined as type-safe `Event[A]` instances that couple the event name with its - * payload type, ensuring compile-time verification that emit and listen sites agree on types. - */ -object EventDemos: - - // ========================================================================== - // Event Payloads - // ========================================================================== - - /** Event payload for survey events. */ - final case class SurveyEvent(eventType: String, data: String) - - object SurveyEvent: - given Codec[SurveyEvent] = Codec.derived - given CanEqual[SurveyEvent, SurveyEvent] = CanEqual.derived - - // ========================================================================== - // Event Definitions (type-safe Event[A] instances) - // ========================================================================== - - /** Survey event for frontend-backend communication. */ - given surveyEvent: Event[SurveyEvent] = Event.define("survey-event") - - /** Signal that the frontend is ready. */ - given frontendReady: Event[Unit] = Event.define0("frontend-ready") - - /** Signal that the backend is ready with a message. */ - given backendReady: Event[String] = Event.define("backend-ready") - - // ========================================================================== - // Event Operations - // ========================================================================== - - /** Listen for backend events using ZIO. - * - * This demonstrates the basic event listening pattern with ZIO's IO effect type. The listener - * will receive events until the handle is unlistened. - * - * The handler receives an Either to properly handle decode errors as values. - * - * @param handler - * Callback function to handle incoming events or decode errors - * @return - * IO effect that registers the listener and returns a handle - */ - def listenForSurveyEvents( - handler: Either[TauriError.EventError, EventMessage[SurveyEvent]] => Unit - ): IO[TauriError, tausi.api.EventHandle] = - events.listen(handler) - - /** Listen for backend events with simplified handler (success only). - * - * Convenience method that handles only successful decodes and logs errors. - * - * @param handler - * Callback function to handle successfully decoded events - * @return - * IO effect that registers the listener and returns a handle - */ - def listenForSurveyEventsSimple( - handler: SurveyEvent => Unit - ): IO[TauriError, tausi.api.EventHandle] = - events.listen[SurveyEvent] { - case Right(msg) => handler(msg.payload) - case Left(err) => org.scalajs.dom.console.error(s"Event decode error: ${err.message}") - } - - /** Emit an event to the backend using ZIO. - * - * This demonstrates sending events from the frontend to the Rust backend using ZIO's effect - * system. The event type is resolved from the implicit Event[SurveyEvent] instance. - * - * @param event - * The event payload to emit - * @return - * IO effect that emits the event - */ - def emitSurveyEvent(event: SurveyEvent): IO[TauriError, Unit] = - events.emit(event) - - /** Notify the backend that the frontend is ready. - * - * This is a common pattern for coordinating startup between frontend and backend. - * Uses the Event[Unit] instance for events without payload. - * - * @return - * IO effect that emits the ready event - */ - def notifyFrontendReady: IO[TauriError, Unit] = - events.emit(()) - - /** Listen for the backend ready signal. - * - * This demonstrates using `once` to listen for a single event occurrence. - * The handler receives an Either for proper error handling. - * - * @param onReady - * Callback to invoke when backend is ready (receives Either for error handling) - * @return - * IO effect that registers the one-time listener - */ - def awaitBackendReady( - onReady: Either[TauriError.EventError, EventMessage[String]] => Unit - ): IO[TauriError, tausi.api.EventHandle] = - events.once(onReady) - - /** Listen for the backend ready signal with simplified handler. - * - * Convenience method for when you only care about successful events. - * - * @param onReady - * Callback to invoke when backend is ready - * @return - * IO effect that registers the one-time listener - */ - def awaitBackendReadySimple(onReady: String => Unit): IO[TauriError, tausi.api.EventHandle] = - events.once[String] { - case Right(msg) => onReady(msg.payload) - case Left(err) => org.scalajs.dom.console.error(s"Backend ready decode error: ${err.message}") - } -end EventDemos diff --git a/modules/sample/src/main/scala/tausi/sample/services/SurveyService.scala b/modules/sample/src/main/scala/tausi/sample/services/SurveyService.scala deleted file mode 100644 index efff8f2..0000000 --- a/modules/sample/src/main/scala/tausi/sample/services/SurveyService.scala +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (c) 2025 Tausi contributors. - * See LICENSE file for terms. - */ -package tausi.sample.services - -import zio.* - -import tausi.api.TauriError -import tausi.sample.commands.survey.{given, *} -import tausi.sample.model.SurveySubmission - -/** Service for survey-related operations using ZIO. - * - * Uses ZIO's IO type with TauriError in the error channel for proper typed error handling. - */ -trait SurveyService: - /** Submit a survey to be saved to a file. - * - * @param submission The survey submission data - * @return IO effect that may fail with TauriError - */ - def submitSurvey(submission: SurveySubmission): IO[TauriError, Unit] -end SurveyService - -object SurveyService: - /** Create a live implementation that uses Tauri commands via ZIO. */ - def live: SurveyService = LiveSurveyService - - /** Create a mock implementation for testing. */ - def mock: SurveyService = MockSurveyService -end SurveyService - -/** Live implementation using Tauri IPC via ZIO. */ -private object LiveSurveyService extends SurveyService: - override def submitSurvey(submission: SurveySubmission): IO[TauriError, Unit] = - tausi.zio.invoke(SaveSurveyRequest(submission)) -end LiveSurveyService - -/** Mock implementation for development/testing. */ -private object MockSurveyService extends SurveyService: - override def submitSurvey(submission: SurveySubmission): IO[TauriError, Unit] = - ZIO.succeed { - org.scalajs.dom.console.log("Survey Submission (mock):") - org.scalajs.dom.console.log(formatSubmission(submission)) - }.delay(500.millis) - - private def formatSubmission(submission: SurveySubmission): String = - val contact = submission.contactDetails - val answers = submission.answers.map { case (k, v) => s" $k: $v" }.mkString("\n") - - s"""=== Survey Submission === - |Submitted at: ${submission.submittedAt} - |Contact: ${contact.firstName} ${contact.lastName} - |Phone: ${contact.phoneNumber} - |Answers: - |$answers""".stripMargin -end MockSurveyService diff --git a/modules/sample/src/main/scala/tausi/sample/state/AppState.scala b/modules/sample/src/main/scala/tausi/sample/state/AppState.scala index 7f1b542..0b32aca 100644 --- a/modules/sample/src/main/scala/tausi/sample/state/AppState.scala +++ b/modules/sample/src/main/scala/tausi/sample/state/AppState.scala @@ -8,56 +8,58 @@ import com.raquo.laminar.api.L.* import tausi.sample.model.* -/** Global application state container. +/** Application state container using Laminar's reactive primitives. * - * Uses Laminar's Var for reactive state management. This is an instance-based class to allow for - * easier testing and multiple instances if needed. + * Demonstrates the recommended pattern for managing state in a + * Tausi + Laminar application. */ final case class AppState( currentPage: Var[Page], contactDetails: Var[ContactDetails], - surveyAnswers: Var[Map[String, String]] -): - /** Reset all state to initial values. */ - def reset(): Unit = - currentPage.set(Page.Welcome) - contactDetails.set(ContactDetails.empty) - surveyAnswers.set(Map.empty) - - /** Navigate to a specific page. */ - def navigateTo(page: Page): Unit = - currentPage.set(page) - - /** Navigate to the next page. */ - def navigateNext(): Unit = - Page.next(currentPage.now()).foreach(navigateTo) - - /** Navigate to the previous page. */ - def navigatePrevious(): Unit = - Page.previous(currentPage.now()).foreach(navigateTo) - - /** Update contact details. */ - def updateContactDetails(f: ContactDetails => ContactDetails): Unit = - contactDetails.update(f) - - /** Update a survey answer. */ - def setAnswer(questionId: String, answer: String): Unit = - surveyAnswers.update(_ + (questionId -> answer)) - - /** Clear a survey answer (for cascading field resets). */ - def clearAnswer(questionId: String): Unit = - surveyAnswers.update(_ - questionId) - - /** Get an answer by question ID. */ - def getAnswer(questionId: String): Option[String] = - surveyAnswers.now().get(questionId) -end AppState + surveyAnswers: Var[SurveyAnswers], + submissionResult: Var[Option[SaveSurveyResponse]], + submissionError: Var[Option[String]], + isSubmitting: Var[Boolean] +) object AppState: - /** Create a new AppState with initial values. */ - def initial: AppState = new AppState( + /** Create initial application state. */ + def initial: AppState = AppState( currentPage = Var(Page.Welcome), contactDetails = Var(ContactDetails.empty), - surveyAnswers = Var(Map.empty) + surveyAnswers = Var(SurveyAnswers.empty), + submissionResult = Var(None), + submissionError = Var(None), + isSubmitting = Var(false) ) -end AppState + + extension (state: AppState) + /** Navigate to the next page in the wizard. */ + def navigateNext(): Unit = + state.currentPage.now().next.foreach(state.currentPage.set) + + /** Navigate to the previous page in the wizard. */ + def navigatePrevious(): Unit = + state.currentPage.now().previous.foreach(state.currentPage.set) + + /** Navigate to a specific page. */ + def navigateTo(page: Page): Unit = + state.currentPage.set(page) + + /** Check if contact details are complete. */ + def isContactComplete: Signal[Boolean] = + state.contactDetails.signal.map(_.isComplete) + + /** Check if survey answers are complete. */ + def isSurveyComplete: Signal[Boolean] = + state.surveyAnswers.signal.map(_.isComplete) + + /** Reset to start a new survey. */ + def reset(): Unit = + state.currentPage.set(Page.Welcome) + state.contactDetails.set(ContactDetails.empty) + state.surveyAnswers.set(SurveyAnswers.empty) + state.submissionResult.set(None) + state.submissionError.set(None) + state.isSubmitting.set(false) + diff --git a/modules/sample/src/main/scala/tausi/sample/validation/Validators.scala b/modules/sample/src/main/scala/tausi/sample/validation/Validators.scala deleted file mode 100644 index 11373a0..0000000 --- a/modules/sample/src/main/scala/tausi/sample/validation/Validators.scala +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (c) 2025 Tausi contributors. - * See LICENSE file for terms. - */ -package tausi.sample.validation - -import tausi.sample.model.* - -/** Validation logic for form fields and survey data. */ -object Validators: - - /** Validate a non-empty string. */ - def nonEmpty(value: String, fieldName: String): ValidationResult = - if value.trim.nonEmpty then ValidationResult.Valid - else ValidationResult.Invalid(s"$fieldName is required") - - /** Validate a phone number format. */ - def phoneNumber(value: String): ValidationResult = - val cleaned = value.replaceAll("[\\s\\-()]", "") - val phonePattern = "^\\+?[0-9]{10,15}$".r - if phonePattern.matches(cleaned) then ValidationResult.Valid - else ValidationResult.Invalid("Please enter a valid phone number") - - /** Validate contact details. */ - def contactDetails(contact: ContactDetails): Map[String, ValidationResult] = - Map( - "firstName" -> nonEmpty(contact.firstName, "First name"), - "lastName" -> nonEmpty(contact.lastName, "Last name"), - "phoneNumber" -> ( - if contact.phoneNumber.trim.isEmpty then - ValidationResult.Invalid("Phone number is required") - else - phoneNumber(contact.phoneNumber) - ) - ) - - /** Check if contact details are valid. */ - def isContactValid(contact: ContactDetails): Boolean = - contactDetails(contact).values.forall(_.isValid) - - /** Validate a rating (1-5). */ - def rating(value: Option[String]): ValidationResult = - value match - case None => ValidationResult.Invalid("Please select a rating") - case Some(v) => - v.toIntOption match - case Some(n) if n >= 1 && n <= 5 => ValidationResult.Valid - case _ => ValidationResult.Invalid("Rating must be between 1 and 5") - - /** Validate a text response. */ - def textResponse(value: Option[String], required: Boolean): ValidationResult = - if required then - value match - case None | Some("") => ValidationResult.Invalid("This field is required") - case Some(_) => ValidationResult.Valid - else - ValidationResult.Valid - - /** Validate a multi-choice selection. */ - def multiChoice(value: Option[String]): ValidationResult = - value match - case None | Some("") => ValidationResult.Invalid("Please select an option") - case Some(_) => ValidationResult.Valid -end Validators diff --git a/modules/zio/src/main/scala/tausi/zio/ZStreamIO.scala b/modules/zio/src/main/scala/tausi/zio/ZStreamIO.scala new file mode 100644 index 0000000..ee1d2c4 --- /dev/null +++ b/modules/zio/src/main/scala/tausi/zio/ZStreamIO.scala @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Tausi contributors. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package tausi.zio + +import _root_.zio.* +import _root_.zio.stream.ZStream + +import tausi.api.TauriError +import tausi.api.stream.StreamSource +import tausi.api.stream.StreamSubscription + +/** Type alias for ZIO streams with [[TauriError]] error channel. + * + * This alias provides a concrete stream type suitable for the [[StreamSource]] type class, + * enabling effect-agnostic stream integration with UI frameworks like Laminar. + * + * All errors are represented as [[TauriError]] variants, ensuring consistent error handling + * across the entire Tausi API. This follows the errors-as-values principle. + * + * @tparam A + * The element type of the stream + */ +type ZStreamIO[A] = ZStream[Any, TauriError, A] + +/** Companion for [[ZStreamIO]]. Provides [[StreamSource]] instance. */ +object ZStreamIO: + + /** [[StreamSource]] instance for ZIO streams. + * + * Requires an implicit [[Runtime]] to execute the stream. The runtime is typically provided by + * the application entry point or a scoped resource. + * + * ==Usage== + * {{{ + * import tausi.zio.ZStreamIO.given + * + * given Runtime[Any] = Runtime.default + * + * val stream: ZStreamIO[Int] = ZStream(1, 2, 3) + * val sub = stream.subscribe( + * onNext = println, + * onError = err => println(s"Error: $$err"), + * onComplete = () => println("Done") + * ) + * }}} + */ + given streamSource(using runtime: Runtime[Any]): StreamSource[ZStreamIO] with + extension [A](stream: ZStreamIO[A]) + override def subscribe( + onNext: A => Unit, + onError: TauriError => Unit, + onComplete: () => Unit + ): StreamSubscription = + val effect: ZIO[Any, TauriError, Unit] = + stream + .runForeach(a => ZIO.succeed(onNext(a))) + .foldCauseZIO( + failure = cause => + ZIO.succeed { + cause.failureOption match + case Some(err) => onError(err) + case None => + cause.dieOption match + case Some(defect) => + // Defects are unexpected failures - wrap them + onError(TauriError.StreamError(defect.getMessage, Some(defect))) + case None => onComplete() // Interrupted counts as complete + }, + success = _ => ZIO.succeed(onComplete()) + ) + + // Fork is synchronous - starts fiber and returns handle immediately + val fibre: Fiber.Runtime[TauriError, Unit] = Unsafe.unsafe { (unsafe: Unsafe) => + given Unsafe = unsafe + runtime.unsafe.fork(effect) + } + + StreamSubscription { () => + // Use interruptFork (fire-and-forget) rather than interrupt. + // The interrupt effect cannot be run synchronously in JS because + // unsafe.run would block waiting for the fiber to complete. + Unsafe.unsafe { (unsafe: Unsafe) => + given Unsafe = unsafe + runtime.unsafe.run(fibre.interruptFork).getOrThrowFiberFailure() + } + } + end extension + end streamSource + +end ZStreamIO diff --git a/modules/zio/src/main/scala/tausi/zio/package.scala b/modules/zio/src/main/scala/tausi/zio/package.scala index f36b2aa..5ed5330 100644 --- a/modules/zio/src/main/scala/tausi/zio/package.scala +++ b/modules/zio/src/main/scala/tausi/zio/package.scala @@ -304,19 +304,28 @@ package object zio: ev: tausi.api.Event[A], trace: Trace ): ZStream[Any, TauriError, EventMessage[A]] = + org.scalajs.dom.console.log(s"[events.stream] Creating stream for event: ${ev.name}") ZStream.scoped { + org.scalajs.dom.console.log("[events.stream] Scoped effect starting") for queue <- ZIO.acquireRelease(Queue.unbounded[Either[TauriError.EventError, EventMessage[A]]])(_.shutdown) + _ = org.scalajs.dom.console.log("[events.stream] Queue created") eitherHandler: (Either[TauriError.EventError, EventMessage[A]] => Unit) = result => + org.scalajs.dom.console.log(s"[events.stream] Event handler invoked with: $result") _root_.zio.Unsafe.unsafe { implicit unsafe => _root_.zio.Runtime.default.unsafe.run(queue.offer(result).unit).getOrThrowFiberFailure() } - _ <- ZIO.acquireRelease( - listen(eitherHandler, options) - )(handle => unlisten(handle).orDie) + handle <- ZIO.acquireRelease( + ZIO.succeed(org.scalajs.dom.console.log("[events.stream] Registering event listener")) *> + listen(eitherHandler, options).tap(h => + ZIO.succeed(org.scalajs.dom.console.log(s"[events.stream] Listener registered with handle: $h")) + ) + )(handle => unlisten(handle).orDie) yield ZStream.fromQueue(queue).mapZIO(ZIO.fromEither(_).mapError(identity)) + end for }.flatten + end stream /** Convert an effect-based handler to an Either-based callback. *