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
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# SPDX-License-Identifier: MIT AND Palimpsest-0.8
# SPDX-FileCopyrightText: 2024 Jonathan D.A. Jewell

name: CI

on:
push:
branches: [main, master]
pull_request:
branches: [main, master]

jobs:
build-and-test:
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [18.x, 20.x]

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Build
run: npm run build

- name: Run tests
run: npm test

lint:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Check formatting (ReScript)
run: npm run build -- -warn-error +A
continue-on-error: true # Warnings shouldn't fail CI for now

- name: Audit dependencies
run: npm audit --audit-level=high
continue-on-error: true # Don't fail on audit issues in dependencies
195 changes: 195 additions & 0 deletions examples/02_http/HttpExample.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: MIT AND Palimpsest-0.8
// SPDX-FileCopyrightText: 2024 Jonathan D.A. Jewell

@@ocaml.doc("
HTTP example demonstrating data fetching with TEA.
Fetches users from JSONPlaceholder API with loading and error states.
")

open Tea

// ============================================================================
// Types
// ============================================================================

type user = {
id: int,
name: string,
email: string,
username: string,
}

type remoteData<'a, 'e> =
| NotAsked
| Loading
| Success('a)
| Failure('e)

type model = {
users: remoteData<array<user>, string>,
}

type msg =
| FetchUsers
| GotUsers(result<array<user>, Http.httpError>)

// ============================================================================
// Decoders
// ============================================================================

let userDecoder: Json.decoder<user> = Json.map4(
(id, name, email, username) => {id, name, email, username},
Json.field("id", Json.int),
Json.field("name", Json.string),
Json.field("email", Json.string),
Json.field("username", Json.string),
)

let usersDecoder: Json.decoder<array<user>> = Json.array(userDecoder)

// ============================================================================
// Init
// ============================================================================

let init = _ => (
{users: NotAsked},
Cmd.none,
)

// ============================================================================
// Update
// ============================================================================

let update = (msg, model) => {
switch msg {
| FetchUsers => (
{...model, users: Loading},
Http.getJson(
"https://jsonplaceholder.typicode.com/users",
usersDecoder,
result => GotUsers(result),
),
)
| GotUsers(Ok(users)) => ({...model, users: Success(users)}, Cmd.none)
| GotUsers(Error(err)) => ({...model, users: Failure(Http.errorToString(err))}, Cmd.none)
}
}

// ============================================================================
// View
// ============================================================================

let viewUser = (user: user) => {
<div
key={Belt.Int.toString(user.id)}
style={ReactDOM.Style.make(
~padding="12px",
~marginBottom="8px",
~backgroundColor="#f5f5f5",
~borderRadius="4px",
(),
)}>
<div style={ReactDOM.Style.make(~fontWeight="bold", ~marginBottom="4px", ())}>
{React.string(user.name)}
</div>
<div style={ReactDOM.Style.make(~fontSize="14px", ~color="#666", ())}>
{React.string(`@${user.username}`)}
</div>
<div style={ReactDOM.Style.make(~fontSize="14px", ~color="#888", ())}>
{React.string(user.email)}
</div>
</div>
}

let view = (model, dispatch) => {
<div
style={ReactDOM.Style.make(
~maxWidth="600px",
~margin="0 auto",
~padding="20px",
~fontFamily="system-ui, sans-serif",
(),
)}>
<h1> {React.string("Users")} </h1>
<button
onClick={_ => dispatch(FetchUsers)}
disabled={model.users == Loading}
style={ReactDOM.Style.make(
~padding="10px 20px",
~fontSize="16px",
~cursor="pointer",
~marginBottom="20px",
(),
)}>
{React.string(
switch model.users {
| Loading => "Loading..."
| _ => "Fetch Users"
},
)}
</button>
{switch model.users {
| NotAsked =>
<p style={ReactDOM.Style.make(~color="#666", ())}>
{React.string("Click the button to fetch users")}
</p>
| Loading =>
<div style={ReactDOM.Style.make(~textAlign="center", ~padding="40px", ())}>
<div> {React.string("Loading...")} </div>
</div>
| Success(users) =>
<div>
<p style={ReactDOM.Style.make(~color="#666", ~marginBottom="16px", ())}>
{React.string(`Loaded ${Belt.Int.toString(Belt.Array.length(users))} users`)}
</p>
{users->Belt.Array.map(viewUser)->React.array}
</div>
| Failure(error) =>
<div
style={ReactDOM.Style.make(
~color="#d32f2f",
~padding="16px",
~backgroundColor="#ffebee",
~borderRadius="4px",
(),
)}>
<strong> {React.string("Error: ")} </strong>
{React.string(error)}
</div>
}}
</div>
}

// ============================================================================
// Subscriptions
// ============================================================================

let subscriptions = _model => Sub.none

// ============================================================================
// App
// ============================================================================

module App = MakeWithDispatch({
type model = model
type msg = msg
type flags = unit
let init = init
let update = update
let view = view
let subscriptions = subscriptions
})

// ============================================================================
// Mount
// ============================================================================

let mount = () => {
switch ReactDOM.querySelector("#root") {
| Some(root) => {
let rootElement = ReactDOM.Client.createRoot(root)
rootElement->ReactDOM.Client.Root.render(<App flags=() />)
}
| None => Js.Console.error("Could not find #root element")
}
}
25 changes: 25 additions & 0 deletions examples/02_http/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TEA HTTP Example</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
background-color: #fafafa;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module">
import { mount } from './HttpExample.bs.js';
mount();
</script>
</body>
</html>
1 change: 1 addition & 0 deletions src/Tea.res
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ module Cmd = Tea_Cmd
module Sub = Tea_Sub
module Html = Tea_Html
module Json = Tea_Json
module Http = Tea_Http

// ============================================================================
// Application types and functors
Expand Down
1 change: 1 addition & 0 deletions src/Tea.resi
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module Cmd = Tea_Cmd
module Sub = Tea_Sub
module Html = Tea_Html
module Json = Tea_Json
module Http = Tea_Http

// Application types
type app<'flags, 'model, 'msg> = Tea_App.app<'flags, 'model, 'msg>
Expand Down
5 changes: 5 additions & 0 deletions src/Tea_Cmd.res
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,8 @@ let rec execute = (cmd: t<'msg>, dispatch: 'msg => unit): unit => {
let message = (msg: 'msg): t<'msg> => {
Effect(dispatch => dispatch(msg))
}

@ocaml.doc("Create a command from an effect callback. The callback receives dispatch and can call it asynchronously.")
let effect = (callback: ('msg => unit) => unit): t<'msg> => {
Effect(callback)
}
3 changes: 3 additions & 0 deletions src/Tea_Cmd.resi
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ let execute: (t<'msg>, 'msg => unit) => unit

@ocaml.doc("Create a command that dispatches a message immediately")
let message: 'msg => t<'msg>

@ocaml.doc("Create a command from an effect callback. The callback receives dispatch and can call it asynchronously.")
let effect: (('msg => unit) => unit) => t<'msg>
Loading
Loading