Writing UI Pages

This document covers the standard pattern for creating UI pages in the application. Pages are the main views that users navigate to, and they follow a consistent structure that separates concerns and makes testing easier.

Page Structure Overview

Every UI page consists of these components:

  1. Route definition in src/cljc/saas/routes.cljc
  2. Page file in src/cljc/saas/ui/pages/
  3. Page registration in src/cljc/saas/spa/pages.cljc

The Standard Page Pattern

Each page file should follow this structure:

(ns saas.ui.pages.my-page
  (:require
   [datascript.core :as ds]
   [saas.command :as command]
   [saas.routes :as routes]))

;; 1. get-state: Pure function that extracts props from context
(defn get-state [{:keys [db state]}]
  (let [entity (ds/entity db [:db/ident ::my-page-data])
        command {:command/kind :command/fetch-data}]
    {:data (:some/field entity)
     :loading? (command/issued? state command)
     :error? (command/error? state command)}))

;; 2. render-*: Pure function that renders hiccup from props
(defn render-my-page
  [{:keys [data loading? error?]}]
  (cond
    loading? [:div [:ui/loading]]
    error? [:div "Something went wrong"]
    :else [:div data]))

;; 3. render: Connects context to the render function
(defn render
  [ctx]
  (render-my-page (get-state ctx)))

;; 4. page: Registration map with route name and render function
(def page
  {:route/name ::routes/my-page
   :route/render #'render
   :route/on-load-actions []})

Why This Pattern?

  • get-state: A pure function that takes the context (db and state) and returns a plain map of props. This is easy to test without needing to set up the full application context.

  • render-*: A pure function that takes props and returns hiccup. This can be tested in isolation and displayed in Portfolio scenes with different prop combinations.

  • render: The glue that connects get-state to render-*. This is what gets called by the framework.

  • page: The registration map that connects the route to the render function.

Step-by-Step: Creating a New Page

1. Define the Route

Add your route to src/cljc/saas/routes.cljc:

(def spa-routes
  [;; ... existing routes ...

   ["/my-feature"
    {:name ::my-feature
     :seo/title "My Feature"
     :seo/description "Description for SEO"}]])

Route options:

  • :name - Required. The route identifier keyword (use the saas.routes namespace)
  • :seo/title - Page title for the browser tab
  • :seo/description - Meta description for SEO
  • :label - Human-readable label for breadcrumbs
  • :auth/required? - Set to true if the page requires authentication
  • :auth/roles - Set of membership roles allowed to access this route (e.g., #{:membership.role/owner :membership.role/admin}). Users without a matching role see a "Forbidden" page and the route is hidden from the sidebar. See Role-Based Access Control for full details.
  • :router/redirect - Redirect to another route (e.g., {:to ::other-route})

2. Create the Page File

Create src/cljc/saas/ui/pages/my_feature.cljc:

(ns saas.ui.pages.my-feature
  (:require
   [datascript.core :as ds]
   [saas.routes :as routes]))

(defn get-state [{:keys [db state]}]
  {:message "Hello, World!"})

(defn render-my-feature-page
  [{:keys [message]}]
  [:div.container.p-8
   [:h1.text-2xl.font-bold message]])

(defn render
  [ctx]
  (render-my-feature-page (get-state ctx)))

(def page
  {:route/name ::routes/my-feature
   :route/render #'render})

3. Register the Page

Add your page to src/cljc/saas/spa/pages.cljc:

(ns saas.spa.pages
  (:require
   ;; ... existing requires ...
   [saas.ui.pages.my-feature :as my-feature]))

(def pages
  [;; ... existing pages ...
   my-feature/page])

On-Load Actions

Pages can define actions that run when the page loads. This is useful for fetching data or initializing state.

(def actions
  {::fetch-data
   (fn [route-data]
     (let [{:keys [id]} (:route/query-params route-data)]
       [[:data/query {:query/kind :query/get-item
                      :query/data {:id id}}]]))})

(def page
  {:route/name ::routes/my-page
   :route/render #'render
   ;; the [:route/match-result] is resolved as a placeholder
   :route/on-load-actions [[::fetch-data [:route/match-result]]})

Accessing Route Data in On-Load Actions

When defining :route/on-load-actions, if the action depends on route data (path params, query params, etc.), use the placeholder [:route/match-result] that resolves the full route data. It contains:

  • :route/path - the current path
  • :route/name - the route name keyword
  • :route/path-params - path parameters (when present)
  • :route/query-params - query parameters (when present)
;; Correct: Use route-data from context
(def actions
  {::my-action
   (fn [route-data]
     (let [{:keys [id]} (:route/query-params route-data)]
       [[:data/command {:command/kind :command/fetch-item
                        :command/data {:id id}}]]))})

;; This action would be dispatched like this: (dispatch [[::my-action [:route/match-result]]])

(def page
  {:route/name ::routes/my-page
   :route/render #'render
   ;; if you just need the query params you can do [[::my-action [:route/match-result :route/query-params]]]
   :route/on-load-actions [[::my-action [:route/match-result]]]})

;; Incorrect: Querying DataScript (may return stale data)
(def actions
  {::my-action
   (fn [{:keys [db]} _]
     (let [route (ds/entity db [:route/id :current-route])
           id (get-in route [:route/query-params :id])]
       ;; This might not work! The transaction hasn't been applied yet.
       ...))})

Why? On-load-actions are dispatched in the same cycle as the transaction that stores the route in DataScript. Querying the db for route data may return stale results since the transaction hasn't been applied yet.

Testing Pages

Testing get-state

Use ds/db-with to create a test database with the required data:

(ns saas.ui.pages.my-page-test
  (:require
   [clojure.test :refer [deftest is testing]]
   [datascript.core :as ds]
   [lookup.core :as lookup]
   [saas.command :as command]
   [saas.ui-schema :as ui-schema]
   [saas.ui.pages.my-page :as my-page]))

;; Create an empty test database with the app schema
(def test-db (ds/empty-db ui-schema/schema))

(deftest get-state-test
  (testing "Basic state with minimal data"
    (let [ctx {:db test-db :state {}}
          result (my-page/get-state ctx)]
      (is (nil? (:data result)))
      (is (false? (:loading? result)))))

  (testing "State with data"
    (let [db (ds/db-with test-db [{:db/ident ::my-page/data
                                   :some/field "test value"}])
          ctx {:db db :state {}}
          result (my-page/get-state ctx)]
      (is (= "test value" (:data result)))
      (is (false? (:loading? result)))))

  (testing "State with issued command (loading state)"
    (let [command {:command/kind :command/fetch-data
                   :command/data {:id "123"}}
          state (command/issue-command {} #inst "2025-01-01T00:00:00.000-00:00" command)
          db (ds/db-with test-db [{:db/ident ::my-page/data
                                   :some/field "test value"}])
          ctx {:db db :state state}
          result (my-page/get-state ctx)]
      (is (true? (:loading? result)))))

  (testing "State with command error"
    (let [command {:command/kind :command/fetch-data
                   :command/data {:id "123"}}
          state (-> {}
                    (command/issue-command #inst "2025-01-01T00:00:00.000-00:00" command)
                    (command/receive-response #inst "2025-01-01T00:00:01.000-00:00" command
                                              {:error {:message "Not found"}}))
          ctx {:db test-db :state state}
          result (my-page/get-state ctx)]
      (is (true? (:error? result)))
      (is (= "Not found" (get-in result [:error :message]))))))

Testing render-* with lookup.core

(deftest render-my-page-test
  (testing "renders data when loaded"
    (let [rendered (my-page/render-my-page {:data "Hello"
                                            :loading? false
                                            :error? false})]
      (is (= "Hello" (lookup/text (lookup/select-one 'div rendered))))))

  (testing "shows loading state"
    (let [rendered (my-page/render-my-page {:loading? true})]
      (is (some? (lookup/select-one :ui/loading rendered)))))

  (testing "shows error state"
    (let [rendered (my-page/render-my-page {:loading? false
                                            :error? true
                                            :error {:message "Something went wrong"}})]
      (is (some? (lookup/select-one :ui/alert rendered))))))

Portfolio Scenes

Create scenes for your page in portfolio/saas/ui/pages/my_feature_scenes.cljs:

(ns saas.ui.pages.my-feature-scenes
  (:require
   [portfolio.replicant :as portfolio :refer-macros [defscene]]
   [saas.ui.pages.my-feature :as my-feature]))

(portfolio/configure-scenes
  {:title "My Feature"})

(defscene default-state
  "Page with data loaded"
  (my-feature/render-my-feature-page
    {:data "Hello, World!"
     :loading? false
     :error? false}))

(defscene loading-state
  "Page while loading"
  (my-feature/render-my-feature-page
    {:loading? true}))

(defscene error-state
  "Page with error"
  (my-feature/render-my-feature-page
    {:loading? false
     :error? true
     :error {:message "Something went wrong"}}))

Common Patterns

Pages with Forms

(def form-schema
  {:form/id :forms/my-form
   :form/schema [:map
                 [:field/name :string]
                 [:field/email :email]]
   :form/submit-actions [[:data/command {:command/kind :command/submit
                                          :command/data [:form/data]}]]})

(defn get-state [{:keys [db state]}]
  (let [form-values (ds/entity db [:db/ident ::form-values])
        form (ds/entity db [:form/id :forms/my-form])]
    {:form-data (into {} form-values)
     :form (into {} form)
     :submitting? (command/issued? state submit-command)}))

Pages with Command State

(defn get-state [{:keys [db state]}]
  (let [command {:command/kind :command/fetch-data}]
    {:loading? (command/issued? state command)
     :success? (command/success? state command)
     :error? (command/error? state command)
     :error (command/get-error state command)
     :result (command/get-result state command)}))

Pages with Layouts

(defn render [ctx]
  (layouts/dashboard
    ctx
    [:div.p-8
     (render-my-page-content (get-state ctx))]))

File Naming Convention

  • Route: ::routes/my-feature in saas.routes
  • Page file: src/cljc/saas/ui/pages/my_feature.cljc
  • Scenes file: portfolio/saas/ui/pages/my_feature_scenes.cljs
  • Test file: test/cljc/saas/ui/pages/my_feature_test.cljc

The page's :route/name should match the route defined in saas.routes.cljc. Use the ::routes/ alias to reference routes from the routes namespace.