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
8 changes: 5 additions & 3 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ inThisBuild(
val libraries = new {
val `cats-effect` = Def.setting("org.typelevel" %%% "cats-effect" % "3.7.0-RC1")
val fs2 = Def.setting("co.fs2" %%% "fs2-core" % "3.13.0-M7")
val scalatags = Def.setting("com.lihaoyi" %%% "scalatags" % "0.13.1")
val laminar = Def.setting("com.raquo" %%% "laminar" % "17.2.1")
val waypoint = Def.setting("com.raquo" %%% "waypoint" % "9.0.0")
val zio = Def.setting("dev.zio" %%% "zio" % "2.1.22")
val `zio-streams` = Def.setting("dev.zio" %%% "zio-streams" % "2.1.22")
// Testing
Expand Down Expand Up @@ -90,15 +91,16 @@ val `tausi-sample` =
project
.in(file("modules/sample"))
.enablePlugins(ScalaJSPlugin)
.dependsOn(`tausi-cats`)
.dependsOn(`tausi-zio`)
.settings(libraryDependencies += libraries.scalatags.value)
.settings(libraryDependencies += libraries.laminar.value)
.settings(libraryDependencies += libraries.waypoint.value)
.settings(libraryDependencies += libraries.`scala-java-time`.value)
.settings(scalaJSUseMainModuleInitializer := true)
.settings(scalaJSLinkerConfig ~= { c =>
import org.scalajs.linker.interface.*
c.withModuleKind(ModuleKind.ESModule)
.withModuleSplitStyle(ModuleSplitStyle.SmallModulesFor(List("tausi.sample")))
.withSourceMap(false)
})

val `tausi-native` =
Expand Down
241 changes: 241 additions & 0 deletions modules/sample/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
# Tausi Sample: Customer Survey Application

A comprehensive 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:

- **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

## Architecture

### Package Structure

```
tausi.sample/
├── Main.scala # Application entry point
├── App.scala # Root Laminar component
├── commands/
│ └── SurveyCommands.scala # Tauri command definitions
├── components/
│ ├── Buttons.scala # Button components
│ ├── FormInputs.scala # Form input components
│ ├── Layout.scala # Layout components
│ └── ProgressIndicator.scala
├── config/
│ └── SurveyConfig.scala # Survey question configuration
├── model/
│ ├── Page.scala # Navigation state
│ └── SurveyData.scala # Data models
├── 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
```

## Tausi API Usage

### Command Invocation (ZIO)

```scala
import tausi.zio.*
import tausi.sample.commands.survey.{given, *}

// 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)
}
```

### Defining Custom Commands

```scala
import tausi.api.{Command, CommandId}
import tausi.api.codec.*

// Request type with Codec derivation
final case class SaveSurveyRequest(submission: SurveySubmission)

object SaveSurveyRequest:
given Codec[SaveSurveyRequest] = Codec.derived

// Command definition
given saveSurvey: Command[SaveSurveyRequest, Unit] =
new Command[SaveSurveyRequest, Unit]:
val id: CommandId = CommandId.unsafe("save_survey")
given encoder: Encoder[SaveSurveyRequest] = summon[Codec[SaveSurveyRequest]]
given decoder: Decoder[Unit] = summon[Codec[Unit]]
```

### Event System (ZIO)

```scala
import tausi.zio.events

// Listen for events
val handle: IO[TauriError, EventHandle] = events.listen[SurveyEvent](
"survey-event",
msg => handleEvent(msg.payload)
)

// Listen for single event
val once: IO[TauriError, EventHandle] = events.once[String](
"backend-ready",
msg => println(s"Backend ready: ${msg.payload}")
)

// Emit events
val emit: IO[TauriError, Unit] = events.emit("frontend-ready", ())
val emitWithPayload: IO[TauriError, Unit] = events.emit("survey-event", payload)
```

### 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

// Enums also supported
enum QuestionType:
case Rating, Text, MultiChoice

object QuestionType:
given Codec[QuestionType] = Codec.derived
```

## Laminar Patterns

### Reactive State with Var/Signal

```scala
final class AppState private (
val currentPage: Var[Page],
val contactDetails: Var[ContactDetails],
val 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))
```

### Controlled Inputs

```scala
input(
controlled(
value <-- valueSignal,
onInput.mapToValue --> { v => onValueChange(v) }
)
)
```

### Dynamic Children

```scala
div(
child <-- currentPage.signal.map { page =>
renderPage(page)
}
)

div(
children <-- answersSignal.map { answers =>
answers.map(renderAnswer)
}
)

div(
child.maybe <-- errorSignal.map {
case Some(err) => Some(errorComponent(err))
case None => None
}
)
```

## 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:

| Platform | Location |
|----------|----------|
| **Linux** | `~/.local/share/tausi.sample/surveys/` |
| **macOS** | `~/Library/Application Support/tausi.sample/surveys/` |
| **Windows** | `C:\Users\<User>\AppData\Roaming\tausi.sample\surveys\` |

Files are named using the pattern: `survey_<lastname>_<timestamp>.txt`

Example: `survey_smith_20251220_143052.txt`

To view saved surveys on Linux:
```bash
ls -la ~/.local/share/tausi.sample/surveys/
cat ~/.local/share/tausi.sample/surveys/survey_*.txt
```

## Running the Sample

```bash
# Development
cd modules/sample
npm install
npm run tauri dev

# Build
npm run tauri build
```
15 changes: 11 additions & 4 deletions modules/sample/index.html
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
<!doctype html>
<html lang="en">
<html lang="en" class="scrollbar-dark">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
<meta name="description" content="Customer Satisfaction Survey - Share your feedback"/>
<meta name="color-scheme" content="dark"/>
<meta name="theme-color" content="#0a0a0f"/>
<link rel="stylesheet" href="styles.css"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Tausi Sample</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600;14..32,700&display=swap" rel="stylesheet"/>
<title>Customer Satisfaction Survey</title>
<script type="module" src="/main.js" defer></script>
</head>

<body>
<body class="min-h-screen overflow-y-auto">
<div id="app" class="min-h-screen"></div>
</body>

</html>
Loading