Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ event.emit("frontend-ready", "Hello from Scala.js")

```scala
import cats.effect.IO
import cats.effect.std.Dispatcher
import tausi.cats.*
import tausi.api.commands.window.{given, *}

Expand All @@ -104,6 +105,56 @@ val program: ZIO[Any, TauriError, Unit] = for
yield ()
```

### UI Integration (Effect Execution Extensions)

Both effect modules provide `runWith` extension methods for executing effects from UI event handlers:

#### ZIO with Laminar

```scala
import zio.*
import tausi.zio.*

given Runtime[Any] = Runtime.default

button(
onClick --> { _ =>
myCommand.runWith(
onSuccess = result => updateUI(result),
onError = err => showError(err.message)
)
}
)

// Or with unified Either callback
myEffect.runWith {
case Right(result) => handleSuccess(result)
case Left(error) => handleError(error)
}

// Fire-and-forget (errors silently dropped)
loggingEffect.runWith()
```

#### Cats Effect with Laminar

```scala
import cats.effect.IO
import cats.effect.std.Dispatcher
import tausi.cats.*

given Dispatcher[IO] = ??? // from IOApp or Resource

button(
onClick --> { _ =>
myCommand.runWith(
onSuccess = result => updateUI(result),
onError = err => showError(err.getMessage)
)
}
)
```

## Defining Custom Commands

For custom Tauri plugins, define commands using the `Command.define` factory:
Expand Down
61 changes: 61 additions & 0 deletions modules/cats/src/main/scala/tausi/cats/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -414,4 +414,65 @@ package object cats:
IO.executionContext.flatMap: ec =>
IO.fromFuture(IO(closeable.close(r)(using ec)))
end extension

// ====================
// Effect Execution Extensions
// ====================

/** Extension methods for executing Cats Effect IO from UI callbacks.
*
* These extensions provide ergonomic, type-safe effect execution suitable for integration with
* UI frameworks like Laminar. All methods require an implicit `Dispatcher[IO]` in scope.
*
* @example
* {{{
* import tausi.cats.*
*
* given Dispatcher[IO] = ??? // from IOApp or Resource
*
* button(
* onClick --> { _ => submitCommand.runWith(handleSuccess, handleError) }
* )
* }}}
*/
extension [A](effect: IO[A])

/** Execute the effect, invoking a callback on completion.
*
* 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.
*
* @param onResult
* called with the result (success or failure as Either)
*/
inline def runWith(onResult: Either[Throwable, A] => Unit)(using dispatcher: Dispatcher[IO]): Unit =
dispatcher.unsafeRunAndForget(
effect.attempt.flatMap(result => IO(onResult(result)))
)

/** Execute the effect, invoking separate callbacks for success and failure.
*
* The effect is run via the Dispatcher with fire-and-forget semantics.
*
* @param onSuccess
* called if the effect succeeds
* @param onError
* called if the effect fails
*/
inline def runWith(onSuccess: A => Unit, onError: Throwable => Unit)(using dispatcher: Dispatcher[IO]): Unit =
dispatcher.unsafeRunAndForget(
effect.attempt.flatMap {
case Right(a) => IO(onSuccess(a))
case Left(e) => IO(onError(e))
}
)

/** Execute the effect, ignoring the result.
*
* '''Warning:''' Errors are silently dropped. Use the callback-accepting overloads for
* proper error handling.
*/
inline def runWith()(using dispatcher: Dispatcher[IO]): Unit =
dispatcher.unsafeRunAndForget(effect.void.handleError(_ => ()))
end extension
end cats
145 changes: 145 additions & 0 deletions modules/cats/src/test/scala/tausi/cats/EffectExecutionSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* 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 scala.concurrent.duration.*

import cats.effect.*
import cats.effect.std.Dispatcher

import munit.CatsEffectSuite

/** Tests for effect execution extensions.
*
* Tests the `runWith` overloads for executing Cats Effect IO from callback contexts.
*/
class EffectExecutionSpec extends CatsEffectSuite:

// Fixture providing a Dispatcher for all tests
val dispatcherFixture: SyncIO[FunFixture[Dispatcher[IO]]] =
ResourceFunFixture(Dispatcher.parallel[IO])

// ====================
// runWith(onResult: Either => Unit) tests
// ====================

dispatcherFixture.test("runWith(Either) should invoke callback with Right on success"): dispatcher =>
given Dispatcher[IO] = dispatcher
for
deferred <- Deferred[IO, Either[Throwable, Int]]
effect = IO.pure(42)
_ = effect.runWith(result => dispatcher.unsafeRunAndForget(deferred.complete(result)))
result <- deferred.get.timeout(1.second)
yield assertEquals(result, Right(42))

dispatcherFixture.test("runWith(Either) should invoke callback with Left on failure"): dispatcher =>
given Dispatcher[IO] = dispatcher
for
deferred <- Deferred[IO, Either[Throwable, 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"))

// ====================
// runWith(onSuccess, onError) tests
// ====================

dispatcherFixture.test("runWith(success, error) should invoke onSuccess callback on success"): dispatcher =>
given Dispatcher[IO] = dispatcher
for
deferred <- Deferred[IO, Int]
effect = IO.pure(42)
_ = effect.runWith(
onSuccess = a => dispatcher.unsafeRunAndForget(deferred.complete(a)),
onError = _ => ()
)
result <- deferred.get.timeout(1.second)
yield assertEquals(result, 42)

dispatcherFixture.test("runWith(success, error) should invoke onError callback on failure"): dispatcher =>
given Dispatcher[IO] = dispatcher
for
deferred <- Deferred[IO, String]
effect: IO[Int] = IO.raiseError(TestException("failure"))
_ = effect.runWith(
onSuccess = _ => (),
onError = e => dispatcher.unsafeRunAndForget(deferred.complete(e.getMessage))
)
result <- deferred.get.timeout(1.second)
yield assertEquals(result, "failure")

// ====================
// runWith() (fire-and-forget) tests
// ====================

dispatcherFixture.test("runWith() should complete successfully for successful effects"): dispatcher =>
given Dispatcher[IO] = dispatcher
for
ref <- Ref[IO].of(0)
effect = ref.set(42)
_ = effect.runWith()
_ <- IO.sleep(50.millis) // Allow async execution to complete
result <- ref.get
yield assertEquals(result, 42)

dispatcherFixture.test("runWith() should not throw on failure (errors silently dropped)"): dispatcher =>
given Dispatcher[IO] = dispatcher
for
ref <- Ref[IO].of(false)
effect: IO[Unit] = IO.raiseError[Unit](TestException("ignored")).guarantee(ref.set(true))
_ = effect.runWith()
_ <- IO.sleep(50.millis) // Allow async execution to complete
// Effect should have run (and failed), triggering guarantee
executed <- ref.get
yield assert(executed)

// ====================
// Edge cases
// ====================

dispatcherFixture.test("runWith should handle effects that return Unit"): dispatcher =>
given Dispatcher[IO] = dispatcher
for
deferred <- Deferred[IO, Either[Throwable, Unit]]
effect = IO.unit
_ = effect.runWith(result => dispatcher.unsafeRunAndForget(deferred.complete(result)))
result <- deferred.get.timeout(1.second)
yield assertEquals(result, Right(()))

dispatcherFixture.test("runWith should handle effects that return complex types"): dispatcher =>
given Dispatcher[IO] = dispatcher
for
deferred <- Deferred[IO, Either[Throwable, List[String]]]
effect = IO.pure(List("a", "b", "c"))
_ = effect.runWith(result => dispatcher.unsafeRunAndForget(deferred.complete(result)))
result <- deferred.get.timeout(1.second)
yield assertEquals(result, Right(List("a", "b", "c")))

end EffectExecutionSpec

/** Test exception type for effect execution tests. */
final case class TestException(message: String) extends Exception(message)

object TestException:
given CanEqual[TestException, TestException] = CanEqual.derived
22 changes: 14 additions & 8 deletions modules/sample/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,25 @@ tausi.sample/
import tausi.zio.*
import tausi.sample.commands.survey.{given, *}

given Runtime[Any] = Runtime.default

// Invoke a command with ZIO
val result: IO[TauriError, Unit] = invoke(SaveSurveyRequest(submission))

// Run with callbacks for Laminar integration
Unsafe.unsafe { implicit unsafe =>
Runtime.default.unsafe
.runToFuture(result)
.future
.onComplete {
case Success(_) => onSuccess()
case Failure(ex) => onError(TauriError.fromThrowable(ex))
}(using ExecutionContext.global)
result.runWith(
onSuccess = _ => showSuccess(),
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)
result.runWith()
```

### Defining Custom Commands
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ 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.
*
Expand All @@ -19,6 +22,8 @@ import tausi.sample.state.AppState
*/
object SubmitPage:

given Runtime[Any] = Runtime.default

def apply(state: AppState, surveyService: SurveyService): HtmlElement =
val isSubmitting = Var(false)
val isSubmitted = Var(false)
Expand All @@ -35,12 +40,7 @@ object SubmitPage:
)

// Use ZIO effect with proper error channel
val effect = surveyService.submitSurvey(submission)

// Run effect with callbacks using the temporary utility
// NOTE: This pattern should be simplified by tausi.zio utilities
SurveyService.runEffect(
effect,
surveyService.submitSurvey(submission).runWith(
onSuccess = _ => {
isSubmitting.set(false)
isSubmitted.set(true)
Expand Down
Loading