diff --git a/.github/workflows/eval_in_wasm_build_test.yml b/.github/workflows/eval_in_wasm_build_test.yml index 93c3d91d..41f93c8d 100644 --- a/.github/workflows/eval_in_wasm_build_test.yml +++ b/.github/workflows/eval_in_wasm_build_test.yml @@ -54,6 +54,8 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install JS dependencies + env: + CXXFLAGS: "-std=c++20" run: pnpm install working-directory: . diff --git a/.github/workflows/hello_popcorn_build_test.yml b/.github/workflows/hello_popcorn_build_test.yml index 27827076..3635dab4 100644 --- a/.github/workflows/hello_popcorn_build_test.yml +++ b/.github/workflows/hello_popcorn_build_test.yml @@ -56,6 +56,8 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install JS dependencies + env: + CXXFLAGS: "-std=c++20" run: pnpm install working-directory: . diff --git a/.github/workflows/langtour_e2e_test.yml b/.github/workflows/langtour_e2e_test.yml index 8f6c5a4f..9c7bf5c5 100644 --- a/.github/workflows/langtour_e2e_test.yml +++ b/.github/workflows/langtour_e2e_test.yml @@ -64,6 +64,8 @@ jobs: working-directory: language-tour/elixir_tour - name: Install pnpm dependencies + env: + CXXFLAGS: "-std=c++20" run: pnpm install --frozen-lockfile - name: Build popcorn JS package diff --git a/.github/workflows/local_live_view_build.yml b/.github/workflows/local_live_view_build.yml index 7c1b7969..d94a729c 100644 --- a/.github/workflows/local_live_view_build.yml +++ b/.github/workflows/local_live_view_build.yml @@ -57,6 +57,8 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install JS dependencies + env: + CXXFLAGS: "-std=c++20" run: pnpm install working-directory: . diff --git a/.github/workflows/otp_js_e2e_test.yml b/.github/workflows/otp_js_e2e_test.yml index 975a5d9e..c6d2c878 100644 --- a/.github/workflows/otp_js_e2e_test.yml +++ b/.github/workflows/otp_js_e2e_test.yml @@ -65,6 +65,8 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install JS dependencies + env: + CXXFLAGS: "-std=c++20" run: pnpm install - name: Setup Playwright diff --git a/.github/workflows/popcorn_build_test.yml b/.github/workflows/popcorn_build_test.yml index 3db78ea1..e26120f8 100644 --- a/.github/workflows/popcorn_build_test.yml +++ b/.github/workflows/popcorn_build_test.yml @@ -70,6 +70,8 @@ jobs: - name: Install JS dependencies working-directory: . + env: + CXXFLAGS: "-std=c++20" run: pnpm install - name: Setup Playwright diff --git a/.github/workflows/popcorn_js_e2e_test.yml b/.github/workflows/popcorn_js_e2e_test.yml index 6cff0a56..c2c14457 100644 --- a/.github/workflows/popcorn_js_e2e_test.yml +++ b/.github/workflows/popcorn_js_e2e_test.yml @@ -45,6 +45,8 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install JS dependencies + env: + CXXFLAGS: "-std=c++20" run: pnpm install - name: Build popcorn JS package diff --git a/examples/local-lv-forms/assets/css/app.css b/examples/local-lv-forms/assets/css/app.css index ba5f5973..9402e3e5 100644 --- a/examples/local-lv-forms/assets/css/app.css +++ b/examples/local-lv-forms/assets/css/app.css @@ -124,7 +124,7 @@ font-size: 15px; } -.title { +h1 { padding: 30px 25px; font-size: 30px; font-weight: bold; diff --git a/examples/local-lv-forms/lib/form_demo_web/controllers/page_html/home.html.heex b/examples/local-lv-forms/lib/form_demo_web/controllers/page_html/home.html.heex index 46600b51..72391af1 100644 --- a/examples/local-lv-forms/lib/form_demo_web/controllers/page_html/home.html.heex +++ b/examples/local-lv-forms/lib/form_demo_web/controllers/page_html/home.html.heex @@ -1,5 +1,5 @@
-

LocalLiveView & Phoenix Integration: Forms Demo

+

LocalLiveView & Phoenix Integration: Forms Demo

Explore seamless form handling and LocalLiveView integration.

diff --git a/examples/local-lv-forms/lib/form_demo_web/live/form_demo_live.ex b/examples/local-lv-forms/lib/form_demo_web/live/form_demo_live.ex index 03c6c471..d0ba6697 100644 --- a/examples/local-lv-forms/lib/form_demo_web/live/form_demo_live.ex +++ b/examples/local-lv-forms/lib/form_demo_web/live/form_demo_live.ex @@ -6,7 +6,7 @@ defmodule FormDemoWeb.FormDemoLive do

<.local_live_view id={"form-demo-local-#{@socket.id}"} view="FormDemoLocal" />
-

[Server Runtime] User List:

+

[Server Runtime] User List:

    <%= for user <- @users do %>
  • Username: {user["username"]}, Email: {user["email"]}
  • diff --git a/examples/local-lv-forms/local/lib/form_demo_local.ex b/examples/local-lv-forms/local/lib/form_demo_local.ex index 3d06c2e8..7517bc00 100644 --- a/examples/local-lv-forms/local/lib/form_demo_local.ex +++ b/examples/local-lv-forms/local/lib/form_demo_local.ex @@ -5,25 +5,31 @@ defmodule FormDemoLocal do @impl true def render(assigns) do ~H""" +

    Add new user

    <.form for={@form} id="my-form" phx-change="validate" phx-submit="save"> - <.input type="text" field={@form[:username]} /> + <.input type="text" field={@form[:username]} placeholder="at least 4 characters" /> - <.input type="text" field={@form[:email]} /> -
    - + <.input type="text" field={@form[:email]} placeholder="name@domain.com" /> +
    + +
    -
    - -
    - <%= for error <- @errors do %> -

    {error}

    - <% end %> -
    -

    [Local Runtime] User List:

    +
    + <%= for error <- @errors do %> +

    {error}

    + <% end %> +
    +
    +

    [Local Runtime] User List:

    + <%= if @users == [] do %> +

    No users yet - save one above

    + <% end %>
      <%= for user <- @users do %>
    • Username: {user["username"]}, Email: {user["email"]}
    • diff --git a/examples/local-lv-forms/local/lib_landing/form_demo_local_presentation.ex b/examples/local-lv-forms/local/lib_landing/form_demo_local_presentation.ex new file mode 100644 index 00000000..6b36453a --- /dev/null +++ b/examples/local-lv-forms/local/lib_landing/form_demo_local_presentation.ex @@ -0,0 +1,89 @@ +defmodule FormDemoLocalPresentation do + use LocalLiveView + + defdelegate render(assigns), to: FormDemoLocal + + def mount(params, session, socket) do + result = FormDemoLocal.mount(params, session, socket) + {:ok, new_socket} = result + + Popcorn.Wasm.send_event("llv_presentation", %{ + block: nil, + event: "mount", + assigns: presentation_assigns(new_socket) + }) + + result + end + + def handle_event(event, params, socket) + when event in ["validate", "save", "generate_random"] do + effective_socket = + case socket.assigns[:_pending_cur] do + nil -> socket + pending -> assign(socket, Map.to_list(pending)) + end + + {:noreply, updated} = FormDemoLocal.handle_event(event, params, effective_socket) + + Popcorn.Wasm.send_event("llv_presentation", %{ + block: event, + event: event, + assigns: presentation_assigns(updated) + }) + + new_pending = %{ + form: updated.assigns.form, + errors: updated.assigns.errors, + disabled: updated.assigns.disabled, + users: updated.assigns.users + } + + {:noreply, + socket + |> assign(:_pending_prev, socket.assigns[:_pending_cur]) + |> assign(:_pending_cur, new_pending)} + end + + def handle_info({:js_push, "llv_ack", _}, socket) do + case {socket.assigns[:_pending_prev], socket.assigns[:_pending_cur]} do + {%{form: form, errors: errors, disabled: disabled, users: users}, _} -> + {:noreply, + socket + |> assign(:form, form) + |> assign(:errors, errors) + |> assign(:disabled, disabled) + |> assign(:users, users) + |> assign(:_pending_prev, nil)} + + {nil, %{form: form, errors: errors, disabled: disabled, users: users}} -> + {:noreply, + socket + |> assign(:form, form) + |> assign(:errors, errors) + |> assign(:disabled, disabled) + |> assign(:users, users) + |> assign(:_pending_cur, nil)} + + _ -> + {:noreply, socket} + end + end + + def handle_event(event, params, socket) do + FormDemoLocal.handle_event(event, params, socket) + end + + defp presentation_assigns(socket) do + assigns = socket.assigns + form_params = assigns.form.params + + %{ + username: Map.get(form_params, "username", ""), + email: Map.get(form_params, "email", ""), + errors: length(assigns.errors), + disabled: assigns.disabled, + users: length(assigns.users) + } + end +end diff --git a/examples/local-lv-forms/local/mix.exs b/examples/local-lv-forms/local/mix.exs index c01e086e..57b5d19f 100644 --- a/examples/local-lv-forms/local/mix.exs +++ b/examples/local-lv-forms/local/mix.exs @@ -7,6 +7,7 @@ defmodule Local.MixProject do version: "0.1.0", elixir: "~> 1.17", start_permanent: Mix.env() == :prod, + elixirc_paths: elixirc_paths(), deps: deps(), compilers: Mix.compilers(), aliases: aliases() @@ -17,6 +18,14 @@ defmodule Local.MixProject do [default_target: :wasm] end + defp elixirc_paths do + if System.get_env("LLV_LANDING_PATCH") == "true" do + ["lib", "lib_landing"] + else + ["lib"] + end + end + def application do [ extra_applications: [:logger], diff --git a/examples/local-lv-thermostat/local/lib/thermostat_live.ex b/examples/local-lv-thermostat/local/lib/thermostat_live.ex index 2616da43..c0b35b28 100644 --- a/examples/local-lv-thermostat/local/lib/thermostat_live.ex +++ b/examples/local-lv-thermostat/local/lib/thermostat_live.ex @@ -3,25 +3,20 @@ defmodule ThermostatLive do def render(assigns) do ~H""" -

      Current temperature: {@temperature}°C

      -
      - - +
      +

      Current temperature

      +

      {@temperature}°C

      +
      + + +
      +

      Country: {@country}

      -

      Country: {@country}

      """ end def mount(_params, _session, socket) do - temperature = 25 - country = "Poland" - - socket = - socket - |> assign(:temperature, temperature) - |> assign(:country, country) - - {:ok, socket} + {:ok, socket |> assign(:temperature, 25) |> assign(:country, "Poland")} end def handle_event("inc_temperature", _params, socket) do @@ -31,4 +26,5 @@ defmodule ThermostatLive do def handle_event("dec_temperature", _params, socket) do {:noreply, update(socket, :temperature, &(&1 - 1))} end + end diff --git a/examples/local-lv-thermostat/local/lib_landing/thermostat_live_presentation.ex b/examples/local-lv-thermostat/local/lib_landing/thermostat_live_presentation.ex new file mode 100644 index 00000000..af9c54d1 --- /dev/null +++ b/examples/local-lv-thermostat/local/lib_landing/thermostat_live_presentation.ex @@ -0,0 +1,78 @@ +defmodule ThermostatLivePresentation do + use LocalLiveView + + defdelegate render(assigns), to: ThermostatLive + + def mount(params, session, socket) do + result = ThermostatLive.mount(params, session, socket) + {:ok, new_socket} = result + + Popcorn.Wasm.send_event("llv_presentation", %{ + block: nil, + event: "mount", + assigns: presentation_assigns(new_socket) + }) + + result + end + + def handle_event(event, params, socket) + when event in ["inc_temperature", "dec_temperature"] do + + effective_socket = + case socket.assigns[:_pending_cur] do + nil -> socket + pending -> assign(socket, Map.to_list(pending)) + end + + {:noreply, updated} = ThermostatLive.handle_event(event, params, effective_socket) + + Popcorn.Wasm.send_event("llv_presentation", %{ + block: event, + event: event, + assigns: presentation_assigns(updated) + }) + + new_pending = %{ + temperature: updated.assigns.temperature, + country: updated.assigns.country + } + + {:noreply, + socket + |> assign(:_pending_prev, socket.assigns[:_pending_cur]) + |> assign(:_pending_cur, new_pending)} + end + + def handle_info({:js_push, "llv_ack", _}, socket) do + case {socket.assigns[:_pending_prev], socket.assigns[:_pending_cur]} do + {%{temperature: t, country: c}, _} -> + {:noreply, + socket + |> assign(:temperature, t) + |> assign(:country, c) + |> assign(:_pending_prev, nil)} + + {nil, %{temperature: t, country: c}} -> + {:noreply, + socket + |> assign(:temperature, t) + |> assign(:country, c) + |> assign(:_pending_cur, nil)} + + _ -> + {:noreply, socket} + end + end + + def handle_event(event, params, socket) do + ThermostatLive.handle_event(event, params, socket) + end + + defp presentation_assigns(socket) do + %{ + temperature: socket.assigns.temperature, + country: socket.assigns.country + } + end +end diff --git a/examples/local-lv-thermostat/local/mix.exs b/examples/local-lv-thermostat/local/mix.exs index 539d0e84..32501c5c 100644 --- a/examples/local-lv-thermostat/local/mix.exs +++ b/examples/local-lv-thermostat/local/mix.exs @@ -7,6 +7,7 @@ defmodule Local.MixProject do version: "0.1.0", elixir: "~> 1.17", start_permanent: Mix.env() == :prod, + elixirc_paths: elixirc_paths(), deps: deps(), compilers: Mix.compilers(), aliases: aliases() @@ -24,6 +25,14 @@ defmodule Local.MixProject do ] end + defp elixirc_paths do + if System.get_env("LLV_LANDING_PATCH") == "true" do + ["lib", "lib_landing"] + else + ["lib"] + end + end + defp deps do [ {:local_live_view, path: "../../../local-live-view"} diff --git a/landing-page/astro.config.mjs b/landing-page/astro.config.mjs index b6832cb6..432d5a02 100644 --- a/landing-page/astro.config.mjs +++ b/landing-page/astro.config.mjs @@ -54,12 +54,14 @@ export default defineConfig({ "../examples/local-lv-thermostat/priv/static/assets/js/wasm", dir: "../examples/local-lv-thermostat", newBundleName: "local_thermostat.avm", + extraEnv: { LLV_LANDING_PATCH: "true" }, }), buildBundle({ wasmSrcPathDefault: "../examples/local-lv-forms/priv/static/assets/js/wasm", dir: "../examples/local-lv-forms", newBundleName: "local_forms.avm", + extraEnv: { LLV_LANDING_PATCH: "true" }, }), ], markdown: { diff --git a/landing-page/build-wasm.js b/landing-page/build-wasm.js index 77ca2d71..9230c1f9 100644 --- a/landing-page/build-wasm.js +++ b/landing-page/build-wasm.js @@ -4,9 +4,9 @@ import { dirname, join } from "path"; import { fileURLToPath } from "url"; /** - * @param {{ dir: string, wasmSrcPathDefault?: string, newBundleName: string }} options + * @param {{ dir: string, wasmSrcPathDefault?: string, newBundleName: string, extraEnv?: Record }} options */ -export function buildBundle({ dir, wasmSrcPathDefault, newBundleName }) { +export function buildBundle({ dir, wasmSrcPathDefault, newBundleName, extraEnv = {} }) { return { name: "build-bundle", hooks: { @@ -15,7 +15,7 @@ export function buildBundle({ dir, wasmSrcPathDefault, newBundleName }) { const wasmSrcPath = wasmSrcPathDefault ?? join(dir, "dist", "wasm"); const wasmDestPath = wasmDir(config); - await run("mix", ["build"], { dir }); + await run("mix", ["build"], { dir, env: extraEnv }); const srcFiles = await readdir(wasmSrcPath); const [avm] = srcFiles.filter((path) => path.endsWith(".avm")); @@ -53,13 +53,14 @@ function wasmDir(config) { return join(publicPath, "wasm"); } -function run(cmd, args, { dir }) { +function run(cmd, args, { dir, env = {} }) { const strCmd = `${cmd} ${args.join(" ")}`; return new Promise((resolve, reject) => { const child = spawn(cmd, args, { cwd: dir, stdio: "inherit", + env: { ...process.env, ...env }, }); child.on("close", (code) => { diff --git a/landing-page/package.json b/landing-page/package.json index 8232c400..06e14630 100644 --- a/landing-page/package.json +++ b/landing-page/package.json @@ -13,18 +13,20 @@ "@fontsource-variable/handjet": "^5.2.6", "@fontsource-variable/inter": "^5.2.6", "@iconify-json/material-symbols-light": "^1.2.30", + "@nanostores/react": "^1.1.0", "@swmansion/popcorn": "workspace:*", "@tailwindcss/vite": "^4.1.11", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", - "ghostty-web": "^0.4.0", "astro": "^6.0.6", "astro-icon": "^1.1.5", "astro-mermaid": "^1.3.1", "chokidar": "^4.0.3", + "ghostty-web": "^0.4.0", "highlight.js": "^11.11.1", "local_live_view": "workspace:*", "mermaid": "^11.7.0", + "nanostores": "^1.3.0", "phoenix": "^1.8.0", "phoenix_live_view": "^1.1.0", "playwright": "^1.53.2", @@ -33,7 +35,9 @@ "rehype-mermaid": "^3.0.0", "sharp": "^0.34.5", "tailwindcss": "^4.1.11", - "three": "^0.178.0" + "three": "^0.178.0", + "tree-sitter": "^0.25.0", + "tree-sitter-elixir": "^0.3.5" }, "devDependencies": { "@types/phoenix": "^1.6.7", @@ -43,5 +47,12 @@ "prettier-plugin-tailwindcss": "^0.6.14", "vite": "catalog:", "vite-plugin-devtools-json": "^0.2.1" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "sharp", + "tree-sitter", + "tree-sitter-elixir" + ] } } diff --git a/landing-page/src/components/local-live-view/AssignsPanel.tsx b/landing-page/src/components/local-live-view/AssignsPanel.tsx new file mode 100644 index 00000000..19b72892 --- /dev/null +++ b/landing-page/src/components/local-live-view/AssignsPanel.tsx @@ -0,0 +1,32 @@ +import { useStore } from "@nanostores/react"; +import { $assigns, $flashKeys } from "../../scripts/local-live-view/store"; +import { PanelLayout } from "./PanelLayout"; + +export function AssignsPanel() { + const assigns = useStore($assigns); + const flashKeys = useStore($flashKeys); + const entries = Object.entries(assigns); + + return ( + +
      + %{ + {entries.map(([k, v], i) => ( +
      +    + {k}:{" "} + + {typeof v === "string" ? `"${v}"` : String(v)} + + {i < entries.length - 1 && ( + , + )} +
      + ))} + } +
      +
      + ); +} diff --git a/landing-page/src/components/local-live-view/EventLog.tsx b/landing-page/src/components/local-live-view/EventLog.tsx new file mode 100644 index 00000000..be811de2 --- /dev/null +++ b/landing-page/src/components/local-live-view/EventLog.tsx @@ -0,0 +1,25 @@ +import { useStore } from "@nanostores/react"; +import { $log } from "../../scripts/local-live-view/store"; +import "./llv-scrollbar.css"; +import { PanelLayout } from "./PanelLayout"; + +export function EventLog() { + const log = useStore($log); + if (log.length === 0) return null; + + return ( + +
      + {log.map((entry) => ( +
      + {entry.event} + {entry.result} +
      + ))} +
      +
      + ); +} diff --git a/landing-page/src/components/local-live-view/LifecycleStrip.tsx b/landing-page/src/components/local-live-view/LifecycleStrip.tsx new file mode 100644 index 00000000..a1368428 --- /dev/null +++ b/landing-page/src/components/local-live-view/LifecycleStrip.tsx @@ -0,0 +1,34 @@ +import { useStore } from "@nanostores/react"; +import { $step } from "../../scripts/local-live-view/store"; + +const STEPS = [ + "phx-event", + "handle_event", + "update assigns", + "re-render", +]; + +export function LifecycleStrip() { + const step = useStore($step); + + return ( +
      + {STEPS.map((s, i) => ( + + + {s} + + {i < STEPS.length - 1 && ( + + → + + )} + + ))} +
      + ); +} diff --git a/landing-page/src/components/local-live-view/LlvCodePanel.astro b/landing-page/src/components/local-live-view/LlvCodePanel.astro new file mode 100644 index 00000000..468284da --- /dev/null +++ b/landing-page/src/components/local-live-view/LlvCodePanel.astro @@ -0,0 +1,217 @@ +--- +import { Code } from "astro:components"; +import type { ShikiTransformer } from "shiki"; +import { createBlockTransformer } from "../../scripts/local-live-view/code-panel"; +import { popcornTheme } from "../../scripts/local-live-view/theme"; +import "./llv-scrollbar.css"; + +export interface CodeTab { + label: string; + id: string; + code: string; + lang: string; +} + +interface Props { + tabs: CodeTab[]; +} + +const { tabs } = Astro.props; + +const transformers: ShikiTransformer[] = tabs.map((tab) => + createBlockTransformer(tab.code), +); +--- + +
      +
      + { + tabs.map((tab, i) => ( + + )) + } +
      + { + tabs.map((tab, i) => ( +
      + +
      + )) + } +
      + + + + diff --git a/landing-page/src/components/local-live-view/LlvDemoLayout.astro b/landing-page/src/components/local-live-view/LlvDemoLayout.astro new file mode 100644 index 00000000..bb9539ac --- /dev/null +++ b/landing-page/src/components/local-live-view/LlvDemoLayout.astro @@ -0,0 +1,34 @@ +
      +
      + +
      + +
      + + diff --git a/landing-page/src/components/local-live-view/PanelLayout.tsx b/landing-page/src/components/local-live-view/PanelLayout.tsx new file mode 100644 index 00000000..8df0be1c --- /dev/null +++ b/landing-page/src/components/local-live-view/PanelLayout.tsx @@ -0,0 +1,19 @@ +import type { PropsWithChildren } from "react"; + +type PanelHeaderProps = { + title: string; +}; + +export function PanelLayout({ + children, + title, +}: PropsWithChildren) { + return ( +
      +

      + {title} +

      + {children} +
      + ); +} diff --git a/landing-page/src/components/local-live-view/llv-scrollbar.css b/landing-page/src/components/local-live-view/llv-scrollbar.css new file mode 100644 index 00000000..03198471 --- /dev/null +++ b/landing-page/src/components/local-live-view/llv-scrollbar.css @@ -0,0 +1,24 @@ +.llv-scroll, +.llv-scroll pre { + scrollbar-color: var(--color-llv-brown-50) var(--color-llv-gutter); + scrollbar-width: thin; +} +.llv-scroll::-webkit-scrollbar, +.llv-scroll pre::-webkit-scrollbar { + width: 6px; + height: 6px; +} +.llv-scroll::-webkit-scrollbar-track, +.llv-scroll pre::-webkit-scrollbar-track { + background: var(--color-llv-gutter); + border-radius: 3px; +} +.llv-scroll::-webkit-scrollbar-thumb, +.llv-scroll pre::-webkit-scrollbar-thumb { + background: var(--color-llv-brown-50); + border-radius: 3px; +} +.llv-scroll::-webkit-scrollbar-thumb:hover, +.llv-scroll pre::-webkit-scrollbar-thumb:hover { + background: #ef7c00; +} diff --git a/landing-page/src/pages/demos/local-forms.astro b/landing-page/src/pages/demos/local-forms.astro index f42b379c..202ee760 100644 --- a/landing-page/src/pages/demos/local-forms.astro +++ b/landing-page/src/pages/demos/local-forms.astro @@ -1,13 +1,36 @@ --- import Layout from "../../layouts/Layout.astro"; import Section from "../../components/Section.astro"; +import LlvDemoLayout from "../../components/local-live-view/LlvDemoLayout.astro"; +import { AssignsPanel } from "../../components/local-live-view/AssignsPanel"; +import { LifecycleStrip } from "../../components/local-live-view/LifecycleStrip"; +import { EventLog } from "../../components/local-live-view/EventLog"; +import LlvCodePanel from "../../components/local-live-view/LlvCodePanel.astro"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { CodeTab } from "../../components/local-live-view/LlvCodePanel.astro"; +import "../../styles/demos/local-forms.css"; + +const libDir = path.resolve( + fileURLToPath(import.meta.url), + "../../../../..", + "examples/local-lv-forms/local/lib", +); +const formCode = fs + .readFileSync(path.join(libDir, "form_demo_local.ex"), "utf8") + .trim(); + +const tabs: CodeTab[] = [ + { label: "FormDemoLocal", id: "form", code: formCode, lang: "elixir" }, +]; ---

      Local Forms

      @@ -21,245 +44,155 @@ import Section from "../../components/Section.astro"; correct format while typing in the form and on form submission.

      - -
      -
      -

      - Add New User -

      -
      -
      -
      -
      -
      -
      -      
      -defmodule FormDemoLocal do
      -  use LocalLiveView
      -  import Local.CoreComponents
      -
      -  @impl true
      -  def render(assigns) do
      -    ~H"""
      -    <div class="bordered">
      -      <.form for={@form} id="my-form" phx-change="validate" phx-submit="save">
      -        <label>USERNAME</label>
      -        <.input type="text" field={@form[:username]} />
      -        <label>EMAIL</label>
      -        <.input type="text" field={@form[:email]} />
      -        <div class="centered">
      -          <button class="ghost-button" disabled={@disabled}>SAVE</button>
      -        </div>
      -      </.form>
      -      <div class="centered">
      -        <button class="ghost-button" phx-click="generate_random">GENERATE RANDOM</button>
      -      </div>
      -    </div>
      -    <%= for error <- @errors do %>
      -      <p style="color:red;">{error}</p>
      -    <% end %>
      -    <div class="bordered">
      -      <h1>[Local Runtime] User List:</h1>
      -      <ul>
      -        <%= for user <- @users do %>
      -          <li>Username: {user["username"]}, Email: {user["email"]}</li>
      -        <% end %>
      -      </ul>
      -    </div>
      -    """
      -  end
      -
      -  @impl true
      -  def mount(_params, _session, socket) do
      -    send(self(), :sync)
      -    user = %{"email" => "", "username" => ""}
      -    {:ok, assign(socket, users: [], form: to_form(user), errors: [], disabled: true)}
      -  end
      -
      -  @impl true
      -  def handle_event("validate", params, socket) do
      -    errors = validate(params, socket.assigns.users)
      -    {:noreply, assign(socket, form: to_form(params), errors: errors, disabled: errors != [])}
      -  end
      -
      -  def handle_event("save", user_params, socket) do
      -    users = socket.assigns.users
      -
      -    case validate(user_params, users) do
      -      [] ->
      -        blank_user = %{"email" => "", "username" => ""}
      -        send_to_phoenix(%{"type" => "new_user", "user" => user_params})
      -
      -        {:noreply,
      -         assign(socket,
      -           form: to_form(blank_user),
      -           users: users ++ [user_params],
      -           errors: [],
      -           disabled: true
      -         )}
      -
      -      errors ->
      -        {:noreply, assign(socket, errors: errors, disabled: true)}
      -    end
      -  end
      -
      -  def handle_event(
      -        "llv_server_message",
      -        %{"type" => "synchronize", "users" => server_users},
      -        socket
      -      ) do
      -    filtered_users =
      -      Enum.filter(socket.assigns.users, fn user ->
      -        case validate_already_existing(user, server_users) do
      -          [] ->
      -            send_to_phoenix(%{"type" => "new_user", "user" => user})
      -            true
      -
      -          _ ->
      -            false
      -        end
      -      end)
       
      -    {:noreply, assign(socket, users: server_users ++ filtered_users)}
      -  end
      -
      -  def handle_event("generate_random", _params, socket) do
      -    users = socket.assigns.users
      -    user = generate_random_user(users)
      -    handle_event("save", user, socket)
      -  end
      -
      -  defp validate(user, existing_users) do
      -    (validate_correctness(user) ++ validate_already_existing(user, existing_users))
      -    |> Enum.filter(fn error -> error != "" end)
      -  end
      -
      -  defp validate_already_existing(user, existing_users) do
      -    user
      -    |> Enum.filter(fn {key, value} ->
      -      Enum.any?(existing_users, fn user -> Map.get(user, key) == value end)
      -    end)
      -    |> Enum.map(fn {key, _value} ->
      -      String.capitalize("#{key} already in use")
      -    end)
      -  end
      -
      -  defp validate_correctness(user) do
      -    Enum.map(user, fn {key, value} -> validate_correctness(key, value) end)
      -  end
      -
      -  defp validate_correctness("username", value) do
      -    cond do
      -      String.length(value) < 4 -> "Username length must be greater than 3 characters"
      -      true -> ""
      -    end
      -  end
      -
      -  defp validate_correctness("email", value) do
      -    with [name, server] <- String.split(value, "@"),
      -         true <- String.length(name) > 0 and String.contains?(server, ".") do
      -      ""
      -    else
      -      _err -> "Email must have an email format"
      -    end
      -  end
      -end
      -      
      -      
      -
      + + +
      +
      + + + +
      + + +
      - - -