diff --git a/.github/workflows/flybot-site-deploy.yml b/.github/workflows/flybot-site-deploy.yml index 183ab06..4b03344 100644 --- a/.github/workflows/flybot-site-deploy.yml +++ b/.github/workflows/flybot-site-deploy.yml @@ -6,7 +6,7 @@ on: - 'flybot-site-v*' env: - AWS_REGION: ap-southeast-1 + AWS_REGION: ${{ vars.AWS_REGION }} ECR_REPOSITORY: flybot-site jobs: diff --git a/.github/workflows/pull-playground-deploy.yml b/.github/workflows/pull-playground-deploy.yml new file mode 100644 index 0000000..6c2574a --- /dev/null +++ b/.github/workflows/pull-playground-deploy.yml @@ -0,0 +1,76 @@ +name: Deploy Pull Playground + +on: + push: + tags: + - 'pull-playground-v*' + +env: + AWS_REGION: ${{ vars.AWS_REGION }} + S3_BUCKET: ${{ vars.PLAYGROUND_S3_BUCKET }} + CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.PLAYGROUND_CF_DISTRIBUTION_ID }} + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Setup Clojure + uses: DeLaGuardo/setup-clojure@12.5 + with: + cli: latest + bb: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Cache deps + uses: actions/cache@v4 + with: + path: | + ~/.m2/repository + ~/.gitlibs + ~/.npm + key: ${{ runner.os }}-deps-${{ hashFiles('**/deps.edn', '**/package-lock.json') }} + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Build SPA + working-directory: examples/pull-playground + run: | + npm install + clojure -M:cljs -m shadow.cljs.devtools.cli release app + + - name: Deploy to S3 + run: | + aws s3 sync examples/pull-playground/resources/public s3://$S3_BUCKET --delete + + - name: Invalidate CloudFront cache + run: | + aws cloudfront create-invalidation \ + --distribution-id $CLOUDFRONT_DISTRIBUTION_ID \ + --paths "/*" + + - name: Summary + run: | + echo "### Deployment Complete :rocket:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Tag:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY + echo "- **URL:** https://pattern.flybot.sg" >> $GITHUB_STEP_SUMMARY diff --git a/examples/pull-playground/CLAUDE.md b/examples/pull-playground/CLAUDE.md new file mode 100644 index 0000000..9603609 --- /dev/null +++ b/examples/pull-playground/CLAUDE.md @@ -0,0 +1,126 @@ +# Pull Playground + +Interactive SPA for learning pull patterns. Sandbox mode runs entirely in-browser (SCI); Remote mode talks to a demo server. + +## Quick Start + +```bash +# Single terminal — nREPL with CLJ + CLJS support +bb dev examples/pull-playground +``` + +```clojure +;; In REPL: start shadow-cljs (same JVM) +(shadow.cljs.devtools.server/start!) +(shadow.cljs.devtools.api/watch :app) ; dev server on 3001 + +;; Backend server (needed for Remote mode only) +(start!) ; server on 8081 +(stop!) +(restart!) +``` + +### Ports + +| Port | Purpose | +|------|---------| +| 3001 | shadow-cljs dev server (hot reload) | +| 8081 | Backend API (Remote mode) | + +## Architecture + +### dispatch-of + pull-api (effects-as-data) + +The app uses a custom dispatch pattern — NOT Replicant's built-in action system. Components close over `dispatch!` and call it directly with effect maps: + +```clojure +;; Views dispatch effect maps +(dispatch! {:db state/set-loading ; pure state update + :pull (:pattern-text db)}) ; execute pattern + +(dispatch! {:db #(state/set-mode % :remote) + :nav :remote ; pushState URL + :pull :init}) ; fetch remote data +``` + +### Effect types + +| Effect | Value | What happens | +|--------|-------|--------------| +| `:db` | `(fn [db] db')` | `swap! app-db update root-key f` | +| `:pull` | `"pattern string"` | Execute via current mode's `:execute` | +| `:pull` | `:keyword` | Named action (`:init`, `:reset`, `:schema`) | +| `:nav` | `:sandbox` / `:remote` | `pushState` URL navigation | +| `:batch` | `(fn [db dispatcher] [...])` | Composed effects | + +### pull-api — noun-based API + +Data structure you can read to understand each mode's API surface: + +```clojure +(def pull-api + {:sandbox + {:execute (fn [dispatch! _db pattern] ...) + :reset (fn [dispatch! _db] ...)} + :remote + {:execute (fn [dispatch! db pattern] ...) + :init (fn [dispatch! db] ...) + :schema (fn [dispatch! db] ...)}}) +``` + +Leaf functions receive `dispatch!` (created by dispatch-of) and call it back with result effects. This enables recursive async flows without a separate callback system. + +### State layer + +Pure `db → db` updater functions in `state.cljc`. Testable on JVM (no browser needed). All state lives under `:app/playground` in the app-db atom. + +## Testing + +Uses Rich Comment Tests (RCT). State and views are `.cljc` — tests run on JVM: + +```bash +bb test examples/pull-playground +``` + +### What to test + +| Layer | File | Testable on JVM? | +|-------|------|-----------------| +| State updaters | `state.cljc` | Yes — pure functions | +| View structure | `views.cljc` | Yes — hiccup data | +| pull-api / dispatch | `core.cljs` | No — requires browser (SCI, fetch) | +| Sandbox eval | `sandbox.cljc` | Yes — SCI runs on JVM | + +### RCT conventions + +```clojure +^:rct/test +(comment + (set-mode {:mode :sandbox :result {:data 1}} :remote) + ;=> contains {:mode :remote :result nil} + ) +``` + +Every new state updater or view helper needs an RCT test. + +## Key Files + +``` +src/sg/flybot/playground/ +├── common/data.cljc # Default sample data + schema +├── server/main.clj # Demo backend (http-kit + remote handler) +└── ui/core/ + ├── core.cljs # Entry point, dispatch-of, pull-api, Transit client + ├── state.cljc # Pure state updaters (db → db) + ├── sandbox.cljc # SCI-based pattern execution + └── views.cljc # Replicant hiccup (dispatch! closures) +``` + +## deps.edn aliases + +| Alias | Purpose | +|-------|---------| +| `:dev` | nREPL + cider + RCT | +| `:cljs` | shadow-cljs + SCI + replicant + transit-cljs | +| `:rct` | RCT test runner | +| `:run` | Start backend standalone | diff --git a/examples/pull-playground/bb.edn b/examples/pull-playground/bb.edn index 9ae4f88..3f22110 100644 --- a/examples/pull-playground/bb.edn +++ b/examples/pull-playground/bb.edn @@ -1,4 +1,25 @@ {:tasks - {test + {dev + {:doc "Start nREPL with CLJS support. Then in REPL: + (shadow.cljs.devtools.server/start!) + (shadow.cljs.devtools.api/watch :app) + For backend server: (start!) for server on 8081." + :task (clojure "-M:dev:cljs -m nrepl.cmdline --middleware \"[cider.nrepl/cider-middleware]\"")} + + watch + {:doc "Start shadow-cljs watch + dev server on port 3002 (standalone, no REPL)" + :task (shell "npx shadow-cljs -A:dev:cljs watch app")} + + test {:doc "Run RCT (no kaocha needed)" - :task (clojure "-X:dev:rct")}}} + :task (clojure "-X:dev:rct")} + + deploy + {:doc "Build SPA and deploy to S3. Set S3_BUCKET in .env or environment." + :task (let [bucket (System/getenv "S3_BUCKET")] + (when-not bucket + (println "Error: S3_BUCKET not set. Add it to .env or export it.") + (System/exit 1)) + (shell "npm install") + (clojure "-M:cljs -m shadow.cljs.devtools.cli release app") + (shell "aws s3 sync resources/public" (str "s3://" bucket) "--delete"))}}} diff --git a/examples/pull-playground/deps.edn b/examples/pull-playground/deps.edn index 8254802..5d7dfef 100644 --- a/examples/pull-playground/deps.edn +++ b/examples/pull-playground/deps.edn @@ -1,24 +1,23 @@ {:paths ["src" "resources"] - :deps {org.clojure/clojure {:mvn/version "1.12.0"} + :deps {org.clojure/clojure {:mvn/version "1.12.4"} local/pattern {:local/root "../../pattern"} local/collection {:local/root "../../collection"} local/remote {:local/root "../../remote"} - http-kit/http-kit {:mvn/version "2.8.0"} - ring/ring-core {:mvn/version "1.12.2"} - ring-cors/ring-cors {:mvn/version "0.1.13"} - com.cognitect/transit-clj {:mvn/version "1.0.333"} + http-kit/http-kit {:mvn/version "2.8.1"} + ring/ring-core {:mvn/version "1.15.3"} + com.cognitect/transit-clj {:mvn/version "1.1.347"} parinferish/parinferish {:mvn/version "0.8.0"} - metosin/malli {:mvn/version "0.16.4"}} + metosin/malli {:mvn/version "0.20.0"}} :aliases {:dev {:extra-paths ["dev"] :extra-deps {io.github.robertluo/rich-comment-tests {:mvn/version "1.1.78"} - nrepl/nrepl {:mvn/version "1.3.0"} - cider/cider-nrepl {:mvn/version "0.50.2"}}} + nrepl/nrepl {:mvn/version "1.5.2"} + cider/cider-nrepl {:mvn/version "0.58.0"}}} :rct {:exec-fn com.mjdowney.rich-comment-tests.test-runner/run-tests-in-file-tree! :exec-args {:dirs #{"src"}}} - :run {:main-opts ["-m" "sg.flybot.playground.server"]} - :cljs {:extra-deps {thheller/shadow-cljs {:mvn/version "2.28.22"} - no.cjohansen/replicant {:mvn/version "2025.06.21"} + :run {:main-opts ["-m" "sg.flybot.playground.server.main"]} + :cljs {:extra-deps {thheller/shadow-cljs {:mvn/version "3.3.5"} + no.cjohansen/replicant {:mvn/version "2025.12.1"} com.cognitect/transit-cljs {:mvn/version "0.8.280"} - org.babashka/sci {:mvn/version "0.8.43"} + org.babashka/sci {:mvn/version "0.12.51"} parinferish/parinferish {:mvn/version "0.8.0"}}}}} diff --git a/examples/pull-playground/dev/user.clj b/examples/pull-playground/dev/user.clj index 9fa5c4f..dd60e26 100644 --- a/examples/pull-playground/dev/user.clj +++ b/examples/pull-playground/dev/user.clj @@ -1,47 +1,32 @@ -(ns user - "REPL helpers for playground backend development. - - Start the demo server: - (start!) - - Stop: - (stop!)" - (:require [sg.flybot.playground.server :as server])) +;; SCI + shadow-cljs workaround (babashka/sci#832): +;; Pre-load CLJS analyzer API so SCI's copy-var macro doesn't NPE. +(try (requiring-resolve 'cljs.analyzer.api/ns-resolve) (catch Exception _ nil)) -(defn start! - "Start the playground demo server on port 8081." - [] - (server/start!)) +(ns user + "REPL helpers for playground development. -(defn stop! - "Stop the server." - [] - (server/stop!)) + Start: (start!) + Stop: (stop!) + Restart: (restart!)" + (:require [sg.flybot.playground.server.main :as server])) -(defn restart! - "Restart the server." - [] - (stop!) - (start!)) +(defn start! [] (server/start!)) +(defn stop! [] (server/stop!)) +(defn restart! [] (stop!) (start!)) (comment - ;; Start the demo server (start!) - - ;; Stop the server (stop!) - ;; Test pattern matching locally + ;; Test pattern matching against running server data (require '[sg.flybot.pullable.impl :as impl]) (let [pattern '{:users ?users} - matcher (impl/compile-pattern pattern) - result (matcher (impl/vmr server/sample-data))] - {:val (:val result) - :vars (:vars result)}) + data (:data @server/system) + result ((impl/compile-pattern pattern) (impl/vmr data))] + (:vars result)) - ;; Test with indexed lookup (let [pattern '{:users {{:id 2} ?user}} - matcher (impl/compile-pattern pattern) - result (matcher (impl/vmr server/sample-data))] + data (:data @server/system) + result ((impl/compile-pattern pattern) (impl/vmr data))] (:vars result))) diff --git a/examples/pull-playground/resources/public/css/style.css b/examples/pull-playground/resources/public/css/style.css index 41725a1..85f8215 100644 --- a/examples/pull-playground/resources/public/css/style.css +++ b/examples/pull-playground/resources/public/css/style.css @@ -6,12 +6,27 @@ --text: #1a1a2e; --text-muted: #666; --border: #e0e0e0; - --accent: #0066cc; - --accent-hover: #0052a3; + --accent: #2891b8; + --accent-hover: #237a9c; + --accent-gold: #b59135; --success: #28a745; --error: #dc3545; --warning: #ffc107; --code-bg: #f4f4f4; + + /* Typography */ + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-mono: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; + + /* Transitions */ + --transition-fast: 0.15s ease; + --transition-normal: 0.2s ease; + + /* Border radius */ + --radius-sm: 3px; + --radius-md: 4px; + --radius-lg: 6px; + --radius-xl: 8px; } [data-theme="dark"] { @@ -21,8 +36,9 @@ --text: #c9d1d9; --text-muted: #8b949e; --border: #30363d; - --accent: #58a6ff; - --accent-hover: #79b8ff; + --accent: #4db8db; + --accent-hover: #3da8cb; + --accent-gold: #d4a940; --success: #3fb950; --error: #f85149; --warning: #d29922; @@ -33,7 +49,7 @@ * { box-sizing: border-box; } body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-family: var(--font-sans); line-height: 1.6; margin: 0; background: var(--bg); @@ -61,6 +77,9 @@ body { margin: 0; font-size: 1.5rem; font-weight: 600; + padding-bottom: 0.25rem; + border-bottom: 2px solid transparent; + border-image: linear-gradient(90deg, var(--accent), var(--accent-gold)) 1; } .header-right { @@ -74,7 +93,7 @@ body { display: flex; gap: 0; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-lg); overflow: hidden; } @@ -85,7 +104,7 @@ body { cursor: pointer; color: var(--text-muted); font-size: 0.875rem; - transition: all 0.2s; + transition: all var(--transition-normal); } .mode-toggle button:hover { @@ -101,7 +120,7 @@ body { .theme-toggle { background: var(--bg); border: 1px solid var(--border); - border-radius: 4px; + border-radius: var(--radius-md); padding: 0.25rem 0.5rem; color: var(--text-muted); cursor: pointer; @@ -116,7 +135,7 @@ body { .sidebar-toggle { background: var(--bg); border: 1px solid var(--border); - border-radius: 4px; + border-radius: var(--radius-md); padding: 0.25rem 0.5rem; color: var(--text-muted); cursor: pointer; @@ -201,6 +220,7 @@ body { flex: 1; display: flex; flex-direction: column; + gap: 0.75rem; } .data-panel .editor-section { @@ -210,10 +230,9 @@ body { } .data-panel .remote-sections { - flex: 1; display: flex; flex-direction: column; - gap: 1rem; + gap: 0.75rem; } .data-panel .url-section { @@ -283,7 +302,7 @@ body { .pattern-results-panel .results-content { flex: 1; overflow: auto; - font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; + font-family: var(--font-mono); font-size: 0.875rem; } @@ -322,10 +341,10 @@ body { min-height: 120px; padding: 0.75rem; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-lg); background: var(--bg-editor); color: var(--text); - font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; + font-family: var(--font-mono); font-size: 0.875rem; line-height: 1.5; resize: none; @@ -339,10 +358,10 @@ body { .editor-section input[type="text"] { padding: 0.75rem; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-lg); background: var(--bg-editor); color: var(--text); - font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; + font-family: var(--font-mono); font-size: 0.875rem; } @@ -357,11 +376,11 @@ body { color: white; border: none; padding: 0.75rem 1.5rem; - border-radius: 6px; + border-radius: var(--radius-lg); font-size: 0.875rem; font-weight: 600; cursor: pointer; - transition: background 0.2s; + transition: background var(--transition-normal); margin-top: 0.5rem; } @@ -376,7 +395,7 @@ body { /* Results Panel */ .results-panel .panel-content { - font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; + font-family: var(--font-mono); font-size: 0.875rem; } @@ -396,7 +415,7 @@ body { .result-value { background: var(--code-bg); padding: 0.75rem; - border-radius: 6px; + border-radius: var(--radius-lg); white-space: pre-wrap; word-break: break-word; overflow-x: auto; @@ -435,10 +454,10 @@ body { .example-list li { padding: 0.5rem 0.75rem; - border-radius: 4px; + border-radius: var(--radius-md); cursor: pointer; font-size: 0.875rem; - transition: background 0.2s; + transition: background var(--transition-normal); } .example-list li:hover { @@ -467,7 +486,7 @@ body { } .syntax-list { - font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; + font-family: var(--font-mono); font-size: 0.75rem; line-height: 1.8; } @@ -490,9 +509,54 @@ body { .remote-sections { display: flex; flex-direction: column; - gap: 1rem; - flex: 1; - min-height: 0; + gap: 0.75rem; +} + +/* Info hint icon with tooltip */ +.info-hint { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + margin-left: 0.375rem; + border-radius: 50%; + border: 1.5px solid var(--warning); + color: var(--warning); + font-size: 0.625rem; + font-weight: 600; + font-style: normal; + font-family: var(--font-sans); + text-transform: none; + cursor: help; + position: relative; + vertical-align: middle; +} + +.info-hint:hover::after { + content: attr(data-tooltip); + position: absolute; + top: calc(100% + 0.5rem); + left: 0; + width: 260px; + padding: 0.5rem 0.75rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + color: var(--text); + font-size: 0.8125rem; + font-weight: 400; + font-style: normal; + line-height: 1.5; + text-transform: none; + letter-spacing: normal; + white-space: normal; + z-index: 100; +} + +[data-theme="dark"] .info-hint:hover::after { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); } /* Schema Section (Remote mode) */ @@ -521,12 +585,12 @@ body { .fetch-schema-btn { background: var(--bg); border: 1px solid var(--border); - border-radius: 4px; + border-radius: var(--radius-md); padding: 0.25rem 0.5rem; font-size: 0.75rem; color: var(--text-muted); cursor: pointer; - transition: all 0.2s; + transition: all var(--transition-normal); } .fetch-schema-btn:hover { @@ -545,7 +609,7 @@ body { max-height: 200px; overflow: auto; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-lg); background: var(--bg-editor); } @@ -557,7 +621,7 @@ body { .schema-value { margin: 0; padding: 0.75rem; - font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; + font-family: var(--font-mono); font-size: 0.75rem; line-height: 1.5; white-space: pre-wrap; @@ -594,7 +658,7 @@ body { flex: 1; min-height: 120px; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-lg); background: var(--bg-editor); overflow: hidden; } @@ -618,7 +682,7 @@ body { color: transparent; -webkit-text-fill-color: transparent; caret-color: var(--text); - font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; + font-family: var(--font-mono); font-size: 0.875rem; line-height: 1.5; resize: none; @@ -654,7 +718,7 @@ body { pointer-events: auto; cursor: help; border-radius: 2px; - transition: background-color 0.15s; + transition: background-color var(--transition-fast); } .edn-editor-overlay.hoverable .token-keyword:hover { @@ -664,7 +728,7 @@ body { .edn-editor-code { margin: 0; - font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; + font-family: var(--font-mono); font-size: 0.875rem; line-height: 1.5; white-space: pre-wrap; @@ -769,7 +833,7 @@ body { flex: 1; min-height: 100px; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-lg); background: var(--bg-editor); overflow: auto; } @@ -777,7 +841,7 @@ body { .schema-viewer .schema-code { margin: 0; padding: 0.75rem; - font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; + font-family: var(--font-mono); font-size: 0.75rem; line-height: 1.5; white-space: pre-wrap; @@ -805,7 +869,7 @@ body { .schema-viewer .token-keyword { cursor: help; border-radius: 2px; - transition: background-color 0.15s; + transition: background-color var(--transition-fast); } .schema-viewer .token-keyword:hover { @@ -828,7 +892,7 @@ body { padding: 0.75rem; background: var(--bg-card); border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-xl); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); font-size: 0.8125rem; line-height: 1.5; @@ -839,14 +903,14 @@ body { } .tooltip-header { - font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; + font-family: var(--font-mono); font-weight: 600; color: var(--accent); margin-bottom: 0.25rem; } .tooltip-type { - font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; + font-family: var(--font-mono); font-size: 0.75rem; color: var(--text-muted); margin-bottom: 0.5rem; @@ -866,8 +930,8 @@ body { .tooltip-example code { background: var(--code-bg); padding: 0.125rem 0.25rem; - border-radius: 3px; - font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; + border-radius: var(--radius-sm); + font-family: var(--font-mono); } .tooltip-deprecated { @@ -876,7 +940,7 @@ body { margin-top: 0.5rem; padding: 0.25rem 0.5rem; background: rgba(255, 193, 7, 0.1); - border-radius: 4px; + border-radius: var(--radius-md); } .tooltip-since { @@ -901,9 +965,9 @@ body { overflow-y: auto; background: var(--bg-card); border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-lg); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; + font-family: var(--font-mono); font-size: 0.8125rem; } @@ -915,7 +979,7 @@ body { padding: 0.5rem 0.75rem; cursor: pointer; color: var(--accent); - transition: background-color 0.1s; + transition: background-color var(--transition-fast); } .autocomplete-item:hover, @@ -934,7 +998,7 @@ body { .edn-display { border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-lg); background: var(--code-bg); overflow: auto; } @@ -942,7 +1006,7 @@ body { .edn-display .edn-code { margin: 0; padding: 0.75rem; - font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace; + font-family: var(--font-mono); font-size: 0.875rem; line-height: 1.5; white-space: pre-wrap; @@ -957,7 +1021,7 @@ body { display: flex; gap: 0; border: 1px solid var(--border); - border-radius: 4px; + border-radius: var(--radius-md); overflow: hidden; margin-bottom: 0.5rem; width: fit-content; @@ -973,7 +1037,7 @@ body { font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; - transition: all 0.15s; + transition: all var(--transition-fast); } .schema-view-toggle button:hover { @@ -985,3 +1049,71 @@ body { color: white; } +/*============================================================================= + * Sandbox Mode + *============================================================================*/ + +.sandbox-data-section { + display: flex; + flex-direction: column; + min-height: 0; +} + +.sandbox-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + flex-shrink: 0; +} + +.data-view-toggle { + display: flex; + gap: 0; + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; +} + +.data-view-toggle button { + background: var(--bg); + border: none; + padding: 0.25rem 0.5rem; + cursor: pointer; + color: var(--text-muted); + font-size: 0.6875rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; + transition: all var(--transition-fast); +} + +.data-view-toggle button:hover { + background: var(--bg-card); +} + +.data-view-toggle button.active { + background: var(--accent); + color: white; +} + +.reset-btn { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + color: var(--text-muted); + cursor: pointer; + transition: all var(--transition-normal); +} + +.reset-btn:hover { + border-color: var(--error); + color: var(--error); +} + +.sandbox-content { + overflow: auto; +} + diff --git a/examples/pull-playground/resources/public/index.html b/examples/pull-playground/resources/public/index.html index fe2cf40..11521a0 100644 --- a/examples/pull-playground/resources/public/index.html +++ b/examples/pull-playground/resources/public/index.html @@ -5,6 +5,9 @@ Pull Pattern Playground + + + diff --git a/examples/pull-playground/shadow-cljs.edn b/examples/pull-playground/shadow-cljs.edn index f47972b..82bbd16 100644 --- a/examples/pull-playground/shadow-cljs.edn +++ b/examples/pull-playground/shadow-cljs.edn @@ -1,11 +1,10 @@ {:deps true :deps-aliases [:cljs] - :dev-http {3002 {:root "resources/public" - :proxy-url "http://localhost:8081"}} + :dev-http {3001 "resources/public"} :nrepl {:port 0} :builds {:app {:target :browser :output-dir "resources/public/js" :asset-path "/js" - :modules {:main {:entries [sg.flybot.playground.core]}} - :devtools {:after-load sg.flybot.playground.core/render!}}}} + :modules {:main {:entries [sg.flybot.playground.ui.core]}} + :devtools {:after-load sg.flybot.playground.ui.core/render!}}}} diff --git a/examples/pull-playground/src/sg/flybot/playground/api.cljs b/examples/pull-playground/src/sg/flybot/playground/api.cljs deleted file mode 100644 index 9249e21..0000000 --- a/examples/pull-playground/src/sg/flybot/playground/api.cljs +++ /dev/null @@ -1,75 +0,0 @@ -(ns sg.flybot.playground.api - "Remote API client using Transit." - (:require [cognitect.transit :as t] - [cljs.reader :as reader])) - -(defn- transit-reader [] - (t/reader :json)) - -(defn- transit-writer [] - (t/writer :json)) - -(defn- encode [data] - (t/write (transit-writer) data)) - -(defn- decode [s] - (t/read (transit-reader) s)) - -(defn- format-error - "Format an error response to a user-friendly string." - [errors] - (let [{:keys [code reason path]} (first errors)] - (str (name code) ": " reason - (when (seq path) - (str " at " (pr-str path)))))) - -(defn pull! - "Execute a pull query against a remote server. - - url - Server API endpoint - pattern-str - Pattern as EDN string - on-success - fn of bindings map (symbol -> value) - on-error - fn of error string" - [url pattern-str on-success on-error] - (try - (let [pattern (reader/read-string pattern-str)] - (-> (js/fetch url - #js {:method "POST" - :headers #js {"Content-Type" "application/transit+json" - "Accept" "application/transit+json"} - :body (encode {:pattern pattern})}) - (.then (fn [resp] - (if (.-ok resp) - (.text resp) - (throw (js/Error. (str "HTTP " (.-status resp))))))) - (.then (fn [text] - (let [response (decode text)] - ;; Check for error response - (if (:errors response) - (on-error (format-error (:errors response))) - ;; Success: response is directly the bindings map - (on-success response))))) - (.catch (fn [err] - (on-error (.-message err)))))) - (catch :default e - (on-error (str "Parse error: " (.-message e)))))) - -(defn fetch-schema! - "Fetch schema from remote server. - - url - Server API endpoint (schema is at url/_schema) - on-success - fn of {:schema ... :sample ...} map - on-error - fn of error string" - [url on-success on-error] - (let [schema-url (str url "/_schema")] - (-> (js/fetch schema-url - #js {:method "GET" - :headers #js {"Accept" "application/transit+json"}}) - (.then (fn [resp] - (if (.-ok resp) - (.text resp) - (throw (js/Error. (str "HTTP " (.-status resp))))))) - (.then (fn [text] - (on-success (decode text)))) - (.catch (fn [err] - (on-error (.-message err))))))) diff --git a/examples/pull-playground/src/sg/flybot/playground/common/data.cljc b/examples/pull-playground/src/sg/flybot/playground/common/data.cljc new file mode 100644 index 0000000..dab1a30 --- /dev/null +++ b/examples/pull-playground/src/sg/flybot/playground/common/data.cljc @@ -0,0 +1,39 @@ +(ns sg.flybot.playground.common.data + "Sample data and schema shared between sandbox and remote modes.") + +(def default-data + {:users [{:id 1 :name "Alice" :email "alice@example.com" :role :admin} + {:id 2 :name "Bob" :email "bob@example.com" :role :user} + {:id 3 :name "Carol" :email "carol@example.com" :role :user}] + :posts [{:id 1 :title "Hello World" :author "Alice" :tags ["intro" "welcome"]} + {:id 2 :title "Pattern Matching" :author "Bob" :tags ["tutorial" "patterns"]} + {:id 3 :title "Advanced Topics" :author "Alice" :tags ["advanced"]}] + :config {:version "1.0.0" + :features {:dark-mode true :notifications false}}}) + +(def default-schema + "Malli hiccup form. Server wraps in m/schema, sandbox uses raw." + [:map {:version "1.0.0" + :doc "Sample API for Pull Pattern Playground"} + [:users {:doc "User accounts"} + [:vector {:ilookup true} + [:map + [:id {:doc "Unique identifier" :example 1} :int] + [:name {:doc "Display name" :example "Alice"} :string] + [:email {:doc "Email address" :example "alice@example.com"} :string] + [:role {:doc "User role" :example :admin} :keyword]]]] + [:posts {:doc "Blog posts"} + [:vector {:ilookup true} + [:map + [:id {:doc "Post identifier" :example 1} :int] + [:title {:doc "Post title" :example "Hello World"} :string] + [:author {:doc "Author name" :example "Alice"} :string] + [:tags {:doc "Post tags" :example ["intro" "welcome"]} [:vector :string]]]]] + [:config {:doc "Application configuration"} + [:map + [:version {:doc "App version"} :string] + [:features {:doc "Feature flags"} + [:map + [:dark-mode {:doc "Dark mode enabled"} :boolean] + [:notifications {:doc "Notifications enabled"} :boolean]]] + [:debug {:optional true :doc "Debug mode"} :boolean]]]]) diff --git a/examples/pull-playground/src/sg/flybot/playground/core.cljs b/examples/pull-playground/src/sg/flybot/playground/core.cljs deleted file mode 100644 index e478110..0000000 --- a/examples/pull-playground/src/sg/flybot/playground/core.cljs +++ /dev/null @@ -1,131 +0,0 @@ -(ns sg.flybot.playground.core - "Playground SPA entry point - dispatch and effect execution." - (:require [replicant.dom :as r] - [sg.flybot.playground.state :as state] - [sg.flybot.playground.views :as views] - [sg.flybot.playground.local :as local] - [sg.flybot.playground.api :as api])) - -(defonce app-state (atom state/initial-state)) - -;;============================================================================= -;; Event Dispatch -;;============================================================================= - -(declare dispatch!) - -(def ^:private handlers - "Maps event keywords to state functions." - {:set-mode (fn [s mode] (state/set-mode s mode)) - :update-pattern (fn [s text] (state/update-pattern s text)) - :update-data (fn [s text] (state/update-data s text)) - :update-server-url (fn [s url] (state/update-server-url s url)) - :select-example (fn [s example] (state/select-example s example)) - :set-selected-example (fn [s idx] (state/set-selected-example s idx)) - :execute (fn [s _] (state/execute s)) - :execution-success (fn [s result] (state/execution-success s result)) - :execution-error (fn [s error] (state/execution-error s error)) - :clear-result (fn [s _] (state/clear-result s)) - :fetch-schema (fn [s _] (state/fetch-schema s)) - :fetch-schema-success (fn [s schema] (state/fetch-schema-success s schema)) - :fetch-schema-error (fn [s error] (state/fetch-schema-error s error)) - :set-sample-data (fn [s data] (state/set-sample-data s data)) - :set-schema-view-mode (fn [s mode] (state/set-schema-view-mode s mode)) - :toggle-sidebar (fn [s _] (state/toggle-sidebar s)) - ;; Autocomplete - :show-autocomplete (fn [s data] (state/show-autocomplete s data)) - :hide-autocomplete (fn [s _] (state/hide-autocomplete s)) - :select-autocomplete (fn [s idx] (state/select-autocomplete s idx)) - :move-autocomplete (fn [s dir] (state/move-autocomplete-selection s dir))}) - -(defn- apply-handler [state event] - (let [[event-type & args] (if (vector? event) event [event]) - handler (get handlers event-type)] - (if handler - (apply handler state args) - (do (js/console.warn "Unknown event:" event-type) - {:state state})))) - -;;============================================================================= -;; Effect Execution -;;============================================================================= - -(defn- execute-local! [{:keys [pattern data]}] - (let [result (local/execute pattern data)] - (if (:error result) - (dispatch! [:execution-error (:error result)]) - (dispatch! [:execution-success result])))) - -(defn- execute-remote! [{:keys [pattern url]}] - (api/pull! url pattern - (fn [result] (dispatch! [:execution-success result])) - (fn [error] (dispatch! [:execution-error error])))) - -(defn- fetch-schema! [{:keys [url]}] - (api/fetch-schema! url - (fn [{:keys [schema sample]}] - (dispatch! [:fetch-schema-success schema]) - ;; Use server-provided sample data (handles ILookup entities correctly) - (js/setTimeout - #(dispatch! [:set-sample-data sample]) - 0)) - (fn [error] (dispatch! [:fetch-schema-error error])))) - -(defn- execute-effects! [{:keys [local-exec remote-exec fetch-schema]}] - (when local-exec (execute-local! local-exec)) - (when remote-exec (execute-remote! remote-exec)) - (when fetch-schema (fetch-schema! fetch-schema))) - -;;============================================================================= -;; Dispatch -;;============================================================================= - -(defn dispatch! - "Dispatch an event: :keyword or [:keyword arg1 arg2 ...]" - [event] - (let [old-state @app-state - {:keys [state fx]} (apply-handler old-state event)] - (reset! app-state state) - (when fx (execute-effects! fx)))) - -;;============================================================================= -;; Rendering -;;============================================================================= - -(defonce root-el (atom nil)) - -(defn render! [] - (when @root-el - (r/render @root-el (views/app-view @app-state dispatch!)))) - -(add-watch app-state :render (fn [_ _ _ _] (render!))) - -;;============================================================================= -;; Theme -;;============================================================================= - -(defn ^:export toggle-theme! [] - (let [body (.-body js/document) - current (.getAttribute body "data-theme") - new-theme (if (= current "dark") "light" "dark")] - (.setAttribute body "data-theme" new-theme) - (js/localStorage.setItem "theme" new-theme) - (render!))) - -(defn- init-theme! [] - (let [saved (js/localStorage.getItem "theme")] - (when (= saved "dark") - (.setAttribute (.-body js/document) "data-theme" "dark")))) - -;;============================================================================= -;; Initialization -;;============================================================================= - -(defn ^:export init! [] - (js/console.log "Pull Pattern Playground initializing...") - (reset! root-el (js/document.getElementById "app")) - (init-theme!) - (render!) - (js/console.log "Pull Pattern Playground initialized")) - -(init!) diff --git a/examples/pull-playground/src/sg/flybot/playground/examples.cljc b/examples/pull-playground/src/sg/flybot/playground/examples.cljc deleted file mode 100644 index 711850d..0000000 --- a/examples/pull-playground/src/sg/flybot/playground/examples.cljc +++ /dev/null @@ -1,54 +0,0 @@ -(ns sg.flybot.playground.examples - "Pre-built example patterns for the playground.") - -(def examples - [{:name "Basic Binding" - :description "Extract a value into a variable" - :pattern "{:name ?name}" - :data "{:name \"Alice\" :age 30}"} - - {:name "Multiple Bindings" - :description "Extract multiple values at once" - :pattern "{:name ?name :age ?age}" - :data "{:name \"Bob\" :age 25 :city \"NYC\"}"} - - {:name "Nested Maps" - :description "Match patterns in nested structures" - :pattern "{:user {:name ?name :email ?email}}" - :data "{:user {:name \"Carol\" :email \"carol@example.com\" :id 123}}"} - - {:name "Wildcard" - :description "Match any value without binding" - :pattern "{:status ?_ :data ?data}" - :data "{:status \"ok\" :data {:items [1 2 3]}}"} - - {:name "Sequence Binding" - :description "Extract elements from sequences" - :pattern "[?first ?second ?rest*]" - :data "[1 2 3 4 5]"} - - {:name "Predicate Constraint" - :description "Match only if predicate succeeds" - :pattern "{:age (?age :when pos?)}" - :data "{:name \"Dave\" :age 42}"} - - {:name "Default Value" - :description "Provide fallback for missing keys" - :pattern "{:name ?name :nickname (?nick :default \"N/A\")}" - :data "{:name \"Eve\"}"} - - {:name "Indexed Lookup" - :description "Look up by key pattern (ILookup)" - :pattern "{:users {{:id 2} ?user}}" - :data "{:users {{:id 1} {:name \"A\"} {:id 2} {:name \"B\"} {:id 3} {:name \"C\"}}}"}]) - -(def syntax-reference - [{:syntax "?x" :description "Bind value to x"} - {:syntax "?_" :description "Wildcard (match any)"} - {:syntax "?x?" :description "Optional (0-1)"} - {:syntax "?x*" :description "Zero or more"} - {:syntax "?x+" :description "One or more"} - {:syntax "{}" :description "Map pattern"} - {:syntax "[]" :description "Sequence pattern"} - {:syntax ":when" :description "Predicate constraint"} - {:syntax ":default" :description "Fallback value"}]) diff --git a/examples/pull-playground/src/sg/flybot/playground/local.cljs b/examples/pull-playground/src/sg/flybot/playground/local.cljs deleted file mode 100644 index 4fe3d69..0000000 --- a/examples/pull-playground/src/sg/flybot/playground/local.cljs +++ /dev/null @@ -1,80 +0,0 @@ -(ns sg.flybot.playground.local - "Local pattern execution using the pattern library with SCI." - (:require [cljs.reader :as reader] - [sci.core :as sci] - [sg.flybot.pullable.impl :as impl])) - -;; SCI context for sandboxed evaluation -(def ^:private sci-ctx - (sci/init {:namespaces {'clojure.core {'pos? pos? - 'neg? neg? - 'zero? zero? - 'even? even? - 'odd? odd? - 'string? string? - 'number? number? - 'keyword? keyword? - 'symbol? symbol? - 'map? map? - 'vector? vector? - 'seq? seq? - 'set? set? - 'nil? nil? - 'some? some? - 'empty? empty? - 'count count - '= = - 'not= not= - '< < - '> > - '<= <= - '>= >= - 'identity identity}}})) - -(defn- sci-resolve [sym] - (sci/eval-form sci-ctx sym)) - -(defn- sci-eval [form] - (sci/eval-form sci-ctx form)) - -(defn execute - "Execute a pattern against data locally. - - pattern-str - EDN string of the pattern - data-str - EDN string of the data - - Returns bindings map (symbol -> value) on success, - or {:error \"message\"} on failure." - [pattern-str data-str] - (try - (let [pattern (reader/read-string pattern-str) - data (reader/read-string data-str) - matcher (impl/compile-pattern pattern {:resolve sci-resolve - :eval sci-eval}) - result (matcher (impl/vmr data))] - (if (impl/failure? result) - {:error (str "Match failed: " (:reason result) - (when (seq (:path result)) - (str " at path " (:path result))))} - ;; Return vars bindings directly (spec-compliant format) - (:vars result))) - (catch :default e - {:error (str "Error: " (.-message e))}))) - -^:rct/test -(comment - ;; Basic binding works - returns vars directly - (execute "{:name ?n}" "{:name \"Alice\"}") - ;=>> {'n "Alice"} - - ;; Multiple bindings - returns vars directly - (execute "{:a ?a :b ?b}" "{:a 1 :b 2 :c 3}") - ;=>> {'a 1 'b 2} - - ;; Parse error returns error - (:error (execute "{:name ?n" "{:name \"Alice\"}")) - ;=>> string? - - ;; Match failure returns error - (:error (execute "{:name ?n :age ?a}" "{:name \"Alice\"}"))) - ;=>> string?) diff --git a/examples/pull-playground/src/sg/flybot/playground/server.clj b/examples/pull-playground/src/sg/flybot/playground/server.clj deleted file mode 100644 index f1de1e2..0000000 --- a/examples/pull-playground/src/sg/flybot/playground/server.clj +++ /dev/null @@ -1,124 +0,0 @@ -(ns sg.flybot.playground.server - "Demo server with sample data for testing remote mode. - Uses standard Remote Pull Protocol v0.2 handler." - (:require [org.httpkit.server :as http] - [ring.middleware.cors :refer [wrap-cors]] - [ring.middleware.params :refer [wrap-params]] - [sg.flybot.pullable.remote :as remote] - [sg.flybot.pullable.collection :as coll] - [sg.flybot.pullable.sample :as sample] - [sg.flybot.pullable.malli] - [malli.core :as m])) - -;;============================================================================= -;; Sample Data -;;============================================================================= - -(def users-source - (coll/atom-source - {:initial [{:id 1 :name "Alice" :email "alice@example.com" :role :admin} - {:id 2 :name "Bob" :email "bob@example.com" :role :user} - {:id 3 :name "Carol" :email "carol@example.com" :role :user}]})) - -(def posts-source - (coll/atom-source - {:initial [{:id 1 :title "Hello World" :author "Alice" :tags ["intro" "welcome"]} - {:id 2 :title "Pattern Matching" :author "Bob" :tags ["tutorial" "patterns"]} - {:id 3 :title "Advanced Topics" :author "Alice" :tags ["advanced"]}]})) - -(def sample-data - {:users (coll/collection users-source) - :posts (coll/collection posts-source) - :config {:version "1.0.0" - :features {:dark-mode true :notifications false}}}) - -(def sample-schema - "Schema describing the sample data structure with inline Malli documentation. - Uses Malli's hiccup syntax with properties on each field entry." - (m/schema - [:map {:version "1.0.0" - :doc "Sample API for Pull Pattern Playground"} - [:users {:doc "User accounts"} - [:vector {:ilookup true} - [:map - [:id {:doc "Unique identifier" :example 1} :int] - [:name {:doc "Display name" :example "Alice"} :string] - [:email {:doc "Email address" :example "alice@example.com"} :string] - [:role {:doc "User role" :example :admin} :keyword]]]] - [:posts {:doc "Blog posts" - :operations {:list "Returns all posts" - :get "Lookup by {:id n}"}} - [:vector {:ilookup true} - [:map - [:id {:doc "Post identifier" :example 1} :int] - [:title {:doc "Post title" :example "Hello World"} :string] - [:author {:doc "Author name" :example "Alice"} :string] - [:tags {:doc "Post tags" :example ["intro" "welcome"]} [:vector :string]]]]] - [:config {:doc "Application configuration"} - [:map - [:version {:doc "App version"} :string] - [:features {:doc "Feature flags"} - [:map - [:dark-mode {:doc "Dark mode enabled"} :boolean] - [:notifications {:doc "Notifications enabled"} :boolean]]]]]])) - -;;============================================================================= -;; API Function -;;============================================================================= - -(defn api-fn - "API function for Remote Pull Protocol. - Returns data and schema for pattern matching." - [_ring-request] - {:data sample-data - :schema sample-schema - :sample (sample/generate sample-schema {:size 10 :seed 42 :min 5})}) - -(defn- health-handler - "Health check endpoint." - [request] - (when (and (= :get (:request-method request)) - (= "/health" (:uri request))) - {:status 200 - :headers {"Content-Type" "text/plain"} - :body "OK"})) - -;;============================================================================= -;; Middleware -;;============================================================================= - -(def app - (let [pull-handler (remote/make-handler api-fn)] - (-> (fn [request] - (or (health-handler request) - (pull-handler request))) - wrap-params - (wrap-cors :access-control-allow-origin [#".*"] - :access-control-allow-methods [:get :post :options] - :access-control-allow-headers ["Content-Type" "Accept"])))) - -;;============================================================================= -;; Server -;;============================================================================= - -(defonce server (atom nil)) - -(defn start! [& [{:keys [port] :or {port 8081}}]] - (println (str "Starting demo server on port " port "...")) - (println "Sample data available:") - (println " :users - List of users") - (println " :posts - List of posts") - (println " :config - App configuration") - (reset! server (http/run-server app {:port port})) - (println (str "Server running at http://localhost:" port))) - -(defn stop! [] - (when-let [s @server] - (s :timeout 100) - (reset! server nil) - (println "Server stopped"))) - -(defn -main [& args] - (let [port (if (seq args) (Integer/parseInt (first args)) 8081)] - (start! {:port port}) - @(promise))) ; Keep running diff --git a/examples/pull-playground/src/sg/flybot/playground/server/main.clj b/examples/pull-playground/src/sg/flybot/playground/server/main.clj new file mode 100644 index 0000000..29b9ae9 --- /dev/null +++ b/examples/pull-playground/src/sg/flybot/playground/server/main.clj @@ -0,0 +1,82 @@ +(ns sg.flybot.playground.server.main + "Demo server for testing remote mode. + Uses standard Remote Pull Protocol v0.2 handler." + (:require [org.httpkit.server :as http] + [ring.middleware.params :refer [wrap-params]] + [sg.flybot.playground.common.data :as data] + [sg.flybot.pullable.remote :as remote] + [sg.flybot.pullable.collection :as coll] + [sg.flybot.pullable.sample :as sample] + [sg.flybot.pullable.malli] + [malli.core :as m])) + +;;============================================================================= +;; Middleware +;;============================================================================= + +(defn- wrap-cors + "Add CORS headers for cross-origin requests." + [handler] + (fn [request] + (let [origin (get-in request [:headers "origin"] "*")] + (if (= :options (:request-method request)) + {:status 204 + :headers {"Access-Control-Allow-Origin" origin + "Access-Control-Allow-Methods" "GET, POST, OPTIONS" + "Access-Control-Allow-Headers" "Content-Type, Accept" + "Access-Control-Max-Age" "86400"}} + (-> (handler request) + (assoc-in [:headers "Access-Control-Allow-Origin"] origin)))))) + +;;============================================================================= +;; Handlers +;;============================================================================= + +(defn- health-handler + "Health check endpoint." + [request] + (when (and (= :get (:request-method request)) + (= "/health" (:uri request))) + {:status 200 + :headers {"Content-Type" "text/plain"} + :body "OK"})) + +(defn- make-app [{:keys [data schema sample]}] + (let [api-fn (fn [_ring-request] + {:data data :schema schema :sample sample}) + pull-handler (remote/make-handler api-fn)] + (-> (fn [request] + (or (health-handler request) + (pull-handler request))) + wrap-params + wrap-cors))) + +;;============================================================================= +;; System Lifecycle +;;============================================================================= + +(defonce system (atom nil)) + +(defn start! [& [{:keys [port] :or {port 8081}}]] + (let [sources {:users (coll/atom-source {:initial (:users data/default-data)}) + :posts (coll/atom-source {:initial (:posts data/default-data)})} + colls {:users (coll/collection (:users sources)) + :posts (coll/collection (:posts sources)) + :config (:config data/default-data)} + schema (m/schema data/default-schema) + sample (sample/generate schema {:size 10 :seed 42 :min 5}) + app (make-app {:data colls :schema schema :sample sample}) + stop-fn (http/run-server app {:port port})] + (reset! system {:stop-fn stop-fn :data colls :schema schema}) + (println (str "Server running at http://localhost:" port)))) + +(defn stop! [] + (when-let [{:keys [stop-fn]} @system] + (stop-fn :timeout 100) + (reset! system nil) + (println "Server stopped"))) + +(defn -main [& args] + (let [port (if (seq args) (Integer/parseInt (first args)) 8081)] + (start! {:port port}) + @(promise))) diff --git a/examples/pull-playground/src/sg/flybot/playground/state.cljc b/examples/pull-playground/src/sg/flybot/playground/state.cljc deleted file mode 100644 index b780a43..0000000 --- a/examples/pull-playground/src/sg/flybot/playground/state.cljc +++ /dev/null @@ -1,184 +0,0 @@ -(ns sg.flybot.playground.state - "Application state - pure functions returning {:state ... :fx ...}. - - Each function returns a map with: - - :state - the new state - - :fx - optional effects map {:local-exec ... :remote-exec ...}") - -;;============================================================================= -;; State Shape -;;============================================================================= - -(def initial-state - {:mode :local ; :local | :remote - :pattern-text "" ; Pattern editor content - :data-text "" ; Data editor content (local mode) - :server-url "http://localhost:8081/api" ; Server URL (remote mode) - :result nil ; bindings map (symbol -> value) or nil - :error nil ; Error message string or nil - :loading? false - :selected-example nil ; Index of selected example - :schema nil ; Remote server schema (remote mode only) - :schema-loading? false - :schema-error nil - :sample-data nil ; Generated sample data from schema (remote mode) - :schema-view-mode :schema ; :schema | :sample - toggle in schema viewer - :sidebar-collapsed? false ; Hide examples panel (remote mode) - ;; Autocomplete state - :autocomplete nil}) ; {:completions [...] :selected 0 :prefix ":" :x :y} - -;;============================================================================= -;; State Transitions (pure functions) -;;============================================================================= - -(defn set-mode [state mode] - {:state (-> state - (assoc :mode mode) - (assoc :result nil :error nil) - (assoc :schema nil :schema-error nil :sample-data nil) - (assoc :schema-view-mode :schema))}) - -(defn update-pattern [state text] - {:state (assoc state :pattern-text text)}) - -(defn update-data [state text] - {:state (assoc state :data-text text)}) - -(defn update-server-url [state url] - ;; Just update URL - debounced fetch happens via :fetch-schema event - {:state (assoc state :server-url url)}) - -(defn fetch-schema [state] - ;; Trigger schema fetch for current URL (also sets loading state) - {:state (assoc state :schema nil :schema-error nil :schema-loading? true) - :fx {:fetch-schema {:url (:server-url state)}}}) - -(defn select-example [state {:keys [pattern data]}] - {:state (-> state - (assoc :pattern-text pattern) - (assoc :data-text data) - (assoc :result nil :error nil))}) - -(defn execute [state] - {:state (assoc state :loading? true :error nil :result nil) - :fx (if (= :local (:mode state)) - {:local-exec {:pattern (:pattern-text state) - :data (:data-text state)}} - {:remote-exec {:pattern (:pattern-text state) - :url (:server-url state)}})}) - -(defn execution-success [state result] - {:state (assoc state :loading? false :result result :error nil)}) - -(defn execution-error [state error] - {:state (assoc state :loading? false :error error :result nil)}) - -(defn clear-result [state] - {:state (assoc state :result nil :error nil)}) - -(defn set-selected-example [state idx] - {:state (assoc state :selected-example idx)}) - -(defn fetch-schema-success [state schema] - {:state (assoc state :schema-loading? false :schema schema :schema-error nil :sample-data nil)}) - -(defn fetch-schema-error [state error] - {:state (assoc state :schema-loading? false :schema nil :schema-error error :sample-data nil)}) - -(defn set-sample-data [state sample-data] - {:state (assoc state :sample-data sample-data)}) - -(defn set-schema-view-mode [state mode] - {:state (assoc state :schema-view-mode mode)}) - -(defn toggle-sidebar [state] - {:state (update state :sidebar-collapsed? not)}) - -;;============================================================================= -;; Autocomplete -;;============================================================================= - -(defn show-autocomplete [state autocomplete-data] - {:state (assoc state :autocomplete autocomplete-data)}) - -(defn hide-autocomplete [state] - {:state (assoc state :autocomplete nil)}) - -(defn select-autocomplete [state idx] - {:state (assoc-in state [:autocomplete :selected] idx)}) - -(defn move-autocomplete-selection [state direction] - (let [{:keys [completions selected]} (:autocomplete state) - n (count completions) - new-idx (mod (+ selected direction) n)] - {:state (assoc-in state [:autocomplete :selected] new-idx)})) - -;;============================================================================= -;; Tests -;;============================================================================= - -^:rct/test -(comment - ;; set-mode changes mode and clears results - (let [{:keys [state]} (set-mode {:mode :local :result {:data 1}} :remote)] - [(:mode state) (:result state)]) - ;=> [:remote nil] - - ;; update-pattern updates pattern text - (let [{:keys [state]} (update-pattern initial-state "{:name ?n}")] - (:pattern-text state)) - ;=> "{:name ?n}" - - ;; execute returns local-exec effect in local mode - (let [{:keys [fx]} (execute {:mode :local :pattern-text "p" :data-text "d"})] - (:local-exec fx)) - ;=> {:pattern "p" :data "d"} - - ;; execute returns remote-exec effect in remote mode - (let [{:keys [fx]} (execute {:mode :remote :pattern-text "p" :server-url "http://x"})] - (:remote-exec fx)) - ;=> {:pattern "p" :url "http://x"} - - ;; execution-success stores result (bindings map) - (let [{:keys [state]} (execution-success {:loading? true} {'x 1})] - [(:loading? state) (:result state)]) - ;=> [false {'x 1}] - - ;; execution-error stores error - (let [{:keys [state]} (execution-error {:loading? true} "Parse error")] - [(:loading? state) (:error state)]) - ;=> [false "Parse error"] - - ;; fetch-schema returns fetch-schema effect - (let [{:keys [fx]} (fetch-schema {:server-url "http://test/api"})] - (:fetch-schema fx)) - ;=> {:url "http://test/api"} - - ;; fetch-schema-success stores schema and clears sample-data - (let [{:keys [state]} (fetch-schema-success {:schema-loading? true :sample-data {:old "data"}} {:users [:name :email]})] - [(:schema-loading? state) (:schema state) (:sample-data state)]) - ;=> [false {:users [:name :email]} nil] - - ;; fetch-schema-error stores error and clears sample-data - (let [{:keys [state]} (fetch-schema-error {:schema-loading? true :sample-data {:old "data"}} "Network error")] - [(:schema-loading? state) (:schema-error state) (:sample-data state)]) - ;=> [false "Network error" nil] - - ;; set-sample-data stores generated data - (let [{:keys [state]} (set-sample-data {} {:users [{:name "Alice"}]})] - (:sample-data state)) - ;=> {:users [{:name "Alice"}]} - - ;; set-schema-view-mode changes view mode - (let [{:keys [state]} (set-schema-view-mode {:schema-view-mode :schema} :sample)] - (:schema-view-mode state)) - ;=> :sample - - ;; toggle-sidebar toggles collapsed state - (let [{:keys [state]} (toggle-sidebar {:sidebar-collapsed? false})] - (:sidebar-collapsed? state)) - ;=> true - - (let [{:keys [state]} (toggle-sidebar {:sidebar-collapsed? true})] - (:sidebar-collapsed? state))) - ;=> false) diff --git a/examples/pull-playground/src/sg/flybot/playground/ui/core.cljs b/examples/pull-playground/src/sg/flybot/playground/ui/core.cljs new file mode 100644 index 0000000..ee2b67a --- /dev/null +++ b/examples/pull-playground/src/sg/flybot/playground/ui/core.cljs @@ -0,0 +1,225 @@ +(ns sg.flybot.playground.ui.core + "Playground SPA entry point. + + Uses a dispatch-of effect pattern (like hibou): + - :db — pure state updater (swap! app-db update root-key f) + - :pull — pull pattern API (string or keyword, routed via pull-api) + - :nav — URL navigation (pushState) + - :batch — composed effects" + (:require [replicant.dom :as r] + [cognitect.transit :as t] + [cljs.reader :as reader] + [sg.flybot.playground.common.data :as data] + [sg.flybot.playground.ui.core.state :as state] + [sg.flybot.playground.ui.core.views :as views] + [sg.flybot.playground.ui.core.sandbox :as sandbox] + [sg.flybot.pullable.remote :as remote])) + +(defonce app-db (atom {:app/playground state/initial-state})) + +(def ^:private root-key :app/playground) + +(defonce ^:private popstate-listener (atom nil)) + +;;============================================================================= +;; Remote API Client (Transit) +;;============================================================================= + +(defn- transit-reader [] + (t/reader :json)) + +(defn- transit-writer [] + (t/writer :json)) + +(defn- encode [data] + (t/write (transit-writer) data)) + +(defn- decode [s] + (t/read (transit-reader) s)) + +(defn- format-error + "Format an error response to a user-friendly string." + [errors] + (let [{:keys [code reason path]} (first errors)] + (str (name code) ": " reason + (when (seq path) + (str " at " (pr-str path)))))) + +(defn- pull! + "Execute a pull query against a remote server. + Always reads body — errors are transit-encoded in the response." + [url pattern-str on-success on-error] + (try + (let [pattern (reader/read-string pattern-str)] + (-> (js/fetch url + #js {:method "POST" + :headers #js {"Content-Type" "application/transit+json" + "Accept" "application/transit+json"} + :body (encode {:pattern pattern})}) + (.then (fn [resp] (.text resp))) + (.then (fn [text] + (let [response (decode text)] + (if (:errors response) + (on-error (format-error (:errors response))) + (on-success response))))) + (.catch (fn [err] + (on-error (.-message err)))))) + (catch :default e + (on-error (str "Parse error: " (.-message e)))))) + +(defn- fetch-schema! + "Fetch schema from remote server. + Checks resp.ok — schema endpoint returns plain HTTP errors, not transit." + [url on-success on-error] + (let [schema-url (str url "/_schema")] + (-> (js/fetch schema-url + #js {:method "GET" + :headers #js {"Accept" "application/transit+json"}}) + (.then (fn [resp] + (if (.-ok resp) + (.text resp) + (throw (js/Error. (str "HTTP " (.-status resp))))))) + (.then (fn [text] + (on-success (decode text)))) + (.catch (fn [err] + (on-error (.-message err))))))) + +;;============================================================================= +;; Pull API +;;============================================================================= + +(def ^:private read-all-pattern + (pr-str (into {} (map (fn [k] [k (symbol (str "?" (name k)))])) (keys data/default-data)))) + +(defn- pull-result->data + "Convert pull bindings (transit symbol keys) to keyword-keyed data map." + [result] + (into {} (map (fn [[k v]] [(keyword (name k)) v])) result)) + +(def ^:private pull-api + {:sandbox + {:execute + (fn [dispatch! _db pattern] + (let [{:keys [result error snapshot]} (sandbox/execute! pattern)] + (if error + (dispatch! {:db #(state/set-error % error)}) + (dispatch! {:db #(-> % (state/set-result result) + (state/set-sandbox-data snapshot))})))) + :reset + (fn [dispatch! _db] + (let [snap (sandbox/reset-data!)] + (dispatch! {:db #(-> % (state/set-sandbox-data snap) state/clear-result)})))} + + :remote + {:execute + (fn [dispatch! db pattern] + (let [url (:server-url db) + mutation? (try (some? (remote/parse-mutation (reader/read-string pattern))) + (catch :default _ false))] + (pull! url pattern + (fn [result] + (dispatch! {:db #(state/set-result % result)}) + (when mutation? + (pull! url read-all-pattern + (fn [data] + (dispatch! {:db #(state/set-remote-data % (pull-result->data data))})) + (fn [_] nil)))) + (fn [error] + (dispatch! {:db #(state/set-error % error)}))))) + :init + (fn [dispatch! db] + (pull! (:server-url db) read-all-pattern + (fn [result] + (dispatch! {:db #(state/set-remote-data % (pull-result->data result))})) + (fn [_] nil))) + :schema + (fn [dispatch! db] + (fetch-schema! (:server-url db) + (fn [{:keys [schema sample]}] + (dispatch! {:db #(-> % (state/set-schema schema) (assoc :sample-data sample))})) + (fn [error] + (dispatch! {:db #(state/set-schema-error % error)}))))}}) + +;;============================================================================= +;; Dispatch +;;============================================================================= + +(defn dispatch-of [app-db root-key] + (fn [effects] + (doseq [[type effect-def] effects] + (case type + :db (swap! app-db update root-key effect-def) + :pull (let [db (get @app-db root-key) + api (get pull-api (:mode db)) + dispatch! ((partial dispatch-of app-db) root-key)] + (if (string? effect-def) + ((:execute api) dispatch! db effect-def) + (when-let [f (get api effect-def)] + (f dispatch! db)))) + :nav (let [path (str "/" (name effect-def))] + (when-not (= path (.-pathname js/location)) + (.pushState js/history nil "" path))) + :batch (doseq [[dispatch! eff] (effect-def @app-db (partial dispatch-of app-db))] + (dispatch! eff)) + (js/console.warn "Unknown effect type:" type))))) + +;;============================================================================= +;; Rendering +;;============================================================================= + +(defonce root-el (atom nil)) + +(defn render! [] + (when-let [el @root-el] + (let [db (get @app-db root-key) + dispatch! (dispatch-of app-db root-key)] + (r/render el (views/app-view {::views/db db ::views/dispatch! dispatch!}))))) + +(add-watch app-db :render (fn [_ _ _ _] (render!))) + +;;============================================================================= +;; Theme +;;============================================================================= + +(defn ^:export toggle-theme! [] + (let [body (.-body js/document) + new-theme (if (= (.getAttribute body "data-theme") "dark") "light" "dark")] + (.setAttribute body "data-theme" new-theme) + (js/localStorage.setItem "theme" new-theme))) + +(defn- init-theme! [] + (when (= (js/localStorage.getItem "theme") "dark") + (.setAttribute (.-body js/document) "data-theme" "dark"))) + +;;============================================================================= +;; Initialization +;;============================================================================= + +(defn- path->mode [] + (if (= (.-pathname js/location) "/remote") + :remote + :sandbox)) + +(defn ^:export init! [] + (reset! root-el (js/document.getElementById "app")) + (init-theme!) + (when (= (.-pathname js/location) "/") + (.replaceState js/history nil "" "/sandbox")) + (let [snap (sandbox/init!) + mode (path->mode) + dispatch! (dispatch-of app-db root-key)] + (dispatch! {:db (fn [db] + (-> db + (state/set-sandbox-data snap) + (state/set-sandbox-schema data/default-schema) + (state/set-mode mode)))}) + (when (= mode :remote) + (dispatch! {:pull :init})) + (when-let [prev @popstate-listener] + (.removeEventListener js/window "popstate" prev)) + (let [listener (fn [_] (dispatch! {:db #(state/set-mode % (path->mode))}))] + (.addEventListener js/window "popstate" listener) + (reset! popstate-listener listener))) + (render!)) + +(init!) diff --git a/examples/pull-playground/src/sg/flybot/playground/ui/core/sandbox.cljc b/examples/pull-playground/src/sg/flybot/playground/ui/core/sandbox.cljc new file mode 100644 index 0000000..20c0de3 --- /dev/null +++ b/examples/pull-playground/src/sg/flybot/playground/ui/core/sandbox.cljc @@ -0,0 +1,151 @@ +(ns sg.flybot.playground.ui.core.sandbox + "Sandbox mode — browser-side atom-source collections with CRUD. + + Detects mutations from pattern structure (nil key = create, + nil value = delete) and executes patterns against atom-sources." + (:require #?(:clj [clojure.edn :as edn] + :cljs [cljs.reader :as reader]) + [sg.flybot.playground.common.data :as data] + [sg.flybot.pullable.collection :as coll] + [sg.flybot.pullable.impl :as impl] + [sg.flybot.pullable.remote :as remote] + [sg.flybot.pullable.malli] + [malli.core :as m] + #?(:cljs [sci.core :as sci]))) + +;;============================================================================= +;; SCI Context (ClojureScript only) +;;============================================================================= + +#?(:cljs + (def ^:private sci-ctx + (sci/init {:namespaces {'clojure.core {'pos? pos? + 'neg? neg? + 'zero? zero? + 'even? even? + 'odd? odd? + 'string? string? + 'number? number? + 'keyword? keyword? + 'symbol? symbol? + 'map? map? + 'vector? vector? + 'seq? seq? + 'set? set? + 'nil? nil? + 'some? some? + 'empty? empty? + 'count count + '= = + 'not= not= + '< < + '> > + '<= <= + '>= >= + 'identity identity}}}))) + +#?(:cljs + (defn- sci-resolve [sym] + (sci/eval-form sci-ctx sym))) + +#?(:cljs + (defn- sci-eval [form] + (sci/eval-form sci-ctx form))) + +;;============================================================================= +;; Sources +;;============================================================================= + +(defonce sources (atom nil)) +(defonce compiled-schema (atom nil)) + +(defn- make-sources [data] + {:users (coll/atom-source {:initial (:users data)}) + :posts (coll/atom-source {:initial (:posts data)})}) + +(defn- make-collections [srcs] + {:users (coll/collection (:users srcs)) + :posts (coll/collection (:posts srcs))}) + +(defn snapshot [] + (when-let [srcs @sources] + (let [colls (make-collections srcs)] + {:users (vec (seq (:users colls))) + :posts (vec (seq (:posts colls))) + :config (:config data/default-data)}))) + +(defn init! [] + (reset! sources (make-sources data/default-data)) + (reset! compiled-schema (m/schema data/default-schema)) + (snapshot)) + +(defn reset-data! [] + (init!)) + +;;============================================================================= +;; Execution +;;============================================================================= + +(defn- collection? [v] + (instance? #?(:clj sg.flybot.pullable.collection.Collection + :cljs coll/Collection) v)) + +(defn- realize + "Convert Collection objects to plain vectors." + [v] + (cond + (collection? v) (vec (seq v)) + (map? v) (into {} (map (fn [[k v]] [k (realize v)])) v) + (vector? v) (mapv realize v) + (seq? v) (map realize v) + :else v)) + +(defn- read-pattern [s] + #?(:clj (edn/read-string s) + :cljs (reader/read-string s))) + +(defn execute! + "Execute a pattern string against sandbox collections. + Auto-detects read vs mutation from pattern structure. + Returns {:result bindings :snapshot data} or {:error message}." + [pattern-str] + (try + (let [pattern (read-pattern pattern-str)] + (if-let [{:keys [path query value]} (remote/parse-mutation pattern)] + (let [coll-key (first path) + coll (get (make-collections @sources) coll-key)] + (if (and coll (satisfies? coll/Mutable coll)) + {:result {coll-key (coll/mutate! coll query value)} + :snapshot (snapshot)} + {:error (str "Collection " coll-key " not found or not mutable")})) + (let [colls (make-collections @sources) + data (assoc colls :config (:config data/default-data)) + opts #?(:clj {:schema @compiled-schema} + :cljs {:schema @compiled-schema + :resolve sci-resolve :eval-fn sci-eval}) + matcher (impl/compile-pattern pattern opts) + result (matcher (impl/vmr data))] + (if (impl/failure? result) + {:error (str "Match failed: " (:reason result) + (when (seq (:path result)) + (str " at path " (:path result))))} + {:result (realize (:vars result)) :snapshot (snapshot)})))) + (catch #?(:clj Exception :cljs :default) e + {:error (str "Error: " #?(:clj (.getMessage e) :cljs (.-message e)))}))) + +;;============================================================================= +;; Tests +;;============================================================================= + +^:rct/test +(comment + (do (init!) (:result (execute! "{:config ?cfg}"))) + ;=>> {'cfg map?} + + (do (init!) (get-in (execute! "{:users ?all}") [:result 'all])) + ;=>> vector? + + (do (init!) + (-> (execute! "{:users {nil {:id 99 :name \"Dave\" :email \"d@e\" :role :user}}}") + :snapshot :users count))) + ;=> 4) diff --git a/examples/pull-playground/src/sg/flybot/playground/ui/core/state.cljc b/examples/pull-playground/src/sg/flybot/playground/ui/core/state.cljc new file mode 100644 index 0000000..77e987a --- /dev/null +++ b/examples/pull-playground/src/sg/flybot/playground/ui/core/state.cljc @@ -0,0 +1,145 @@ +(ns sg.flybot.playground.ui.core.state + "Application state — pure db→db updater functions. + + State lives under :app/playground in the app-db atom. + Each updater takes a state map and returns a new state map.") + +;;============================================================================= +;; State Shape +;;============================================================================= + +(def initial-state + {:mode :sandbox ; :sandbox | :remote (path-based: /sandbox, /remote) + :pattern-text "" ; Pattern editor content + :result nil ; Bindings map (symbol → value) + :error nil ; Error message string + :loading? false + :selected-example nil ; Index of selected example + ;; Sandbox mode + :sandbox-data nil ; Snapshot: {:users [...] :posts [...] :config {...}} + :sandbox-schema nil ; Malli hiccup form for autocomplete/tooltips + :data-view :data ; :data | :schema toggle + ;; Remote mode + :remote-data nil ; Server snapshot: {:users [...] :posts [...] :config {...}} + :server-url "http://localhost:8081/api" + :schema nil ; Remote server schema + :schema-loading? false + :schema-error nil + :sample-data nil ; Generated sample data from schema + :schema-view-mode :schema ; :schema | :sample toggle + ;; Autocomplete + :autocomplete nil}) ; {:completions [...] :selected 0 :prefix ":" :x :y} + +;;============================================================================= +;; Pure Updaters (db → db) +;;============================================================================= + +(defn clear-result [db] + (assoc db :result nil :error nil)) + +(defn set-loading [db] + (assoc db :loading? true :error nil :result nil)) + +(defn set-mode [db mode] + (-> db + (assoc :mode mode) + (assoc :result nil :error nil :selected-example nil) + (assoc :schema nil :schema-error nil :sample-data nil) + (assoc :schema-view-mode :schema))) + +(defn set-result [db result] + (assoc db :loading? false :result result :error nil)) + +(defn set-error [db error] + (assoc db :loading? false :error error :result nil)) + +(defn set-sandbox-data [db snapshot] + (assoc db :sandbox-data snapshot)) + +(defn set-sandbox-schema [db schema] + (assoc db :sandbox-schema schema)) + +(defn set-remote-data [db data] + (assoc db :remote-data data)) + +;;============================================================================= +;; Remote mode updaters +;;============================================================================= + +(defn set-schema-loading [db] + (assoc db :schema nil :schema-error nil :schema-loading? true)) + +(defn set-schema [db schema] + (assoc db :schema-loading? false :schema schema :schema-error nil :sample-data nil)) + +(defn set-schema-error [db error] + (assoc db :schema-loading? false :schema nil :schema-error error :sample-data nil)) + +;;============================================================================= +;; Autocomplete updaters +;;============================================================================= + +(defn show-autocomplete [db data] + (assoc db :autocomplete data)) + +(defn hide-autocomplete [db] + (assoc db :autocomplete nil)) + +(defn select-autocomplete [db idx] + (assoc-in db [:autocomplete :selected] idx)) + +(defn move-autocomplete-selection [db direction] + (let [{:keys [completions selected]} (:autocomplete db) + n (count completions)] + (assoc-in db [:autocomplete :selected] (mod (+ selected direction) n)))) + +;;============================================================================= +;; Tests +;;============================================================================= + +^:rct/test +(comment + ;; set-mode changes mode and clears results + (let [db (set-mode {:mode :sandbox :result {:data 1}} :remote)] + [(:mode db) (:result db)]) + ;=> [:remote nil] + + ;; clear-result nils out result and error + (let [db (clear-result {:result {:a 1} :error "oops"})] + [(:result db) (:error db)]) + ;=> [nil nil] + + ;; set-loading sets loading and clears result/error + (let [db (set-loading {:loading? false :result {:a 1} :error "x"})] + [(:loading? db) (:result db) (:error db)]) + ;=> [true nil nil] + + ;; set-result stores bindings + (let [db (set-result {:loading? true} {'x 1})] + [(:loading? db) (:result db)]) + ;=> [false {'x 1}] + + ;; set-error stores error + (let [db (set-error {:loading? true} "Parse error")] + [(:loading? db) (:error db)]) + ;=> [false "Parse error"] + + ;; set-sandbox-data stores snapshot + (let [db (set-sandbox-data {} {:users [{:id 1}]})] + (:sandbox-data db)) + ;=> {:users [{:id 1}]} + + ;; set-remote-data stores remote server snapshot + (let [db (set-remote-data {} {:users [{:id 1}]})] + (:remote-data db)) + ;=> {:users [{:id 1}]} + + ;; set-schema stores schema and clears sample-data + (let [db (set-schema {:schema-loading? true :sample-data {:old "data"}} [:map [:name :string]])] + [(:schema-loading? db) (:schema db) (:sample-data db)]) + ;=> [false [:map [:name :string]] nil] + + ;; move-autocomplete-selection wraps around + (let [db {:autocomplete {:completions [:a :b :c] :selected 2}}] + (:selected (:autocomplete (move-autocomplete-selection db 1))))) + ;=> 0) diff --git a/examples/pull-playground/src/sg/flybot/playground/ui/core/views.cljc b/examples/pull-playground/src/sg/flybot/playground/ui/core/views.cljc new file mode 100644 index 0000000..9b69b01 --- /dev/null +++ b/examples/pull-playground/src/sg/flybot/playground/ui/core/views.cljc @@ -0,0 +1,287 @@ +(ns sg.flybot.playground.ui.core.views + "UI views — defalias components with namespaced props. + + Components receive data via ::keys and dispatch effects directly." + (:require [sg.flybot.playground.ui.core.views.examples :as examples] + [sg.flybot.playground.ui.core.views.edn-editor-interactive.editor :as edn] + [sg.flybot.playground.ui.core.state :as state] + [clojure.string :as str] + #?(:cljs [sg.flybot.playground.ui.core.views.edn-editor-interactive :as edn-i]) + #?(:cljs [replicant.alias :refer [defalias]]))) + +;;============================================================================= +;; Helpers +;;============================================================================= + +(defn- format-result [v] + (if (nil? v) "nil" (pr-str v))) + +(defn- format-result-pretty [v] + (str/trim (edn/pretty-str v))) + +;;============================================================================= +;; Icons +;;============================================================================= + +(defn- sun-icon [] + [:svg {:width "16" :height "16" :viewBox "0 0 24 24" :fill "none" :stroke "currentColor" :stroke-width "2"} + [:circle {:cx "12" :cy "12" :r "5"}] + [:line {:x1 "12" :y1 "1" :x2 "12" :y2 "3"}] + [:line {:x1 "12" :y1 "21" :x2 "12" :y2 "23"}] + [:line {:x1 "4.22" :y1 "4.22" :x2 "5.64" :y2 "5.64"}] + [:line {:x1 "18.36" :y1 "18.36" :x2 "19.78" :y2 "19.78"}] + [:line {:x1 "1" :y1 "12" :x2 "3" :y2 "12"}] + [:line {:x1 "21" :y1 "12" :x2 "23" :y2 "12"}] + [:line {:x1 "4.22" :y1 "19.78" :x2 "5.64" :y2 "18.36"}] + [:line {:x1 "18.36" :y1 "5.64" :x2 "19.78" :y2 "4.22"}]]) + +(defn- moon-icon [] + [:svg {:width "16" :height "16" :viewBox "0 0 24 24" :fill "none" :stroke "currentColor" :stroke-width "2"} + [:path {:d "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"}]]) + +;;============================================================================= +;; Components +;;============================================================================= + +#?(:cljs + (defalias site-header + [{::keys [db dispatch!]}] + (let [{:keys [mode]} db] + [:header.site-header + [:h1 "Pull Pattern Playground"] + [:div.header-right + [:div.mode-toggle + [:button {:class (when (= mode :sandbox) "active") + :on {:click #(dispatch! {:db (fn [db] (state/set-mode db :sandbox)) + :nav :sandbox})}} + "Sandbox"] + [:button {:class (when (= mode :remote) "active") + :on {:click #(dispatch! {:db (fn [db] (state/set-mode db :remote)) + :nav :remote + :pull :init})}} + "Remote"]] + [:button.theme-toggle {:title "Toggle theme" + :on {:click #(js/sg.flybot.playground.ui.core.toggle_theme_BANG_)}} + [:span.show-light (moon-icon)] + [:span.show-dark (sun-icon)]]]]))) + +#?(:clj + (defn site-header [{}] + [:header.site-header + [:h1 "Pull Pattern Playground"]])) + +;;----------------------------------------------------------------------------- +;; Data Panel +;;----------------------------------------------------------------------------- + +(defn- data-display + "Read-only display of sample data with :data/:schema toggle and optional reset." + [{::keys [displayed-data displayed-schema data-view mode dispatch!]}] + [:div.sandbox-data-section + [:div.sandbox-header + [:div.data-view-toggle + [:button {:class (when (= data-view :data) "active") + :on {:click #(dispatch! {:db (fn [db] (assoc db :data-view :data))})}} + "Data"] + [:button {:class (when (= data-view :schema) "active") + :on {:click #(dispatch! {:db (fn [db] (assoc db :data-view :schema))})}} + "Schema"]] + (when (= mode :sandbox) + [:button.reset-btn {:on {:click #(dispatch! {:pull :reset})}} + "Reset"])] + [:div.sandbox-content + (let [text (format-result-pretty (if (= data-view :schema) displayed-schema displayed-data))] + (edn/edn-display {:value text :placeholder "No data"}))]]) + +#?(:cljs + (defalias data-panel + [{::keys [db dispatch!]}] + (let [{:keys [mode server-url sandbox-data sandbox-schema remote-data data-view]} db] + [:div.panel.data-panel + [:div.panel-header + [:h2 "Data"]] + [:div.panel-content + (when (= mode :remote) + [:div.remote-sections + [:div.editor-section.url-section + [:label "Server URL" + [:span.info-hint {:data-tooltip "Remote mode connects to a running Pull Pattern server. Clone the repository and run the demo server locally."} "i"]] + [:input {:type "text" + :value server-url + :placeholder "http://localhost:8081/api" + :on {:input #(dispatch! {:db (fn [db] (assoc db :server-url (.. % -target -value)))})}}]]]) + (data-display + {::displayed-data (if (= mode :remote) remote-data sandbox-data) + ::displayed-schema sandbox-schema + ::data-view data-view + ::mode mode + ::dispatch! dispatch!})]])) + + :clj + (defn data-panel [{}] + [:div.panel.data-panel + [:div.panel-header + [:h2 "Data"]] + [:div.panel-content + [:div "Sample data"]]])) + +;;----------------------------------------------------------------------------- +;; Pattern + Results Panel +;;----------------------------------------------------------------------------- + +(def ^:private pattern-editor-id "pattern-editor") + +#?(:cljs + (defalias pattern-results-panel + [{::keys [db dispatch!]}] + (let [{:keys [mode pattern-text loading? schema sandbox-schema autocomplete result error]} db + active-schema (case mode + :remote schema + :sandbox sandbox-schema + nil)] + [:div.panel.pattern-results-panel + ;; Pattern section + [:div.pattern-section + [:div.section-header + [:label "Pattern"] + [:button.execute-btn + {:on {:click #(dispatch! {:db state/set-loading + :pull (:pattern-text db)})} + :disabled loading?} + (if loading? "Executing..." "Execute")]] + [:div.pattern-editor + (edn-i/edn-editor + {:value pattern-text + :placeholder "Enter a pull pattern, e.g. {:name ?n}" + :on-change #(dispatch! {:db (fn [db] (assoc db :pattern-text %))}) + :parinfer-mode :indent + :editor-id pattern-editor-id + :schema (when (#{:remote :sandbox} mode) active-schema) + :hover-tooltips? (and (#{:remote :sandbox} mode) (some? active-schema)) + :autocomplete (when (#{:remote :sandbox} mode) autocomplete) + :on-autocomplete #(dispatch! {:db (fn [db] (state/show-autocomplete db %))}) + :on-autocomplete-hide #(dispatch! {:db state/hide-autocomplete}) + :on-autocomplete-select #(dispatch! {:db (fn [db] (state/select-autocomplete db %))}) + :on-autocomplete-move #(dispatch! {:db (fn [db] (state/move-autocomplete-selection db %))})})] + (when (and (#{:remote :sandbox} mode) autocomplete) + (edn-i/editor-autocomplete + {:editor-id pattern-editor-id + :autocomplete autocomplete + :on-change #(dispatch! {:db (fn [db] (assoc db :pattern-text %))}) + :on-autocomplete-hide #(dispatch! {:db state/hide-autocomplete}) + :on-autocomplete-select #(dispatch! {:db (fn [db] (state/select-autocomplete db %))})}))] + ;; Results section + [:div.results-section + [:div.section-header + [:label "Results"]] + [:div.results-content + (cond + loading? + [:div.loading "Executing pattern..."] + + error + [:div.result-value.error error] + + result + [:div.result-value.success + (edn/highlight-edn (format-result-pretty result))] + + :else + [:div.result-value.empty "Enter a pattern and data, then click Execute"])]]])) + + :clj + (defn pattern-results-panel [{::keys [db dispatch!]}] + (let [{:keys [pattern-text result error]} db] + [:div.panel.pattern-results-panel + [:div.pattern-section + [:div.section-header + [:label "Pattern"] + [:button.execute-btn "Execute"]] + [:div.pattern-editor + [:textarea {:value pattern-text + :placeholder "Enter a pull pattern, e.g. {:name ?n}"}]]] + [:div.results-section + [:div.section-header [:label "Results"]] + [:div.results-content + (cond + error [:div.result-value.error error] + result [:div.result-value.success (format-result-pretty result)] + :else [:div.result-value.empty "Enter a pattern and data, then click Execute"])]]]))) + +;;----------------------------------------------------------------------------- +;; Examples Panel +;;----------------------------------------------------------------------------- + +#?(:cljs + (defalias examples-panel + [{::keys [selected-example dispatch!]}] + [:div.panel.examples-panel + [:div.panel-header + [:h2 "Examples"]] + [:div.panel-content + [:ul.example-list + (for [[idx example] (map-indexed vector examples/examples)] + [:li {:replicant/key idx + :class (when (= idx selected-example) "active") + :title (:description example) + :on {:click #(dispatch! + {:db (fn [db] + (-> db + (assoc :pattern-text (:pattern example) + :selected-example idx) + state/clear-result))})}} + (:name example)])] + [:div.syntax-reference + [:h3 "Syntax Reference"] + [:div.syntax-list + (for [{:keys [syntax description]} examples/syntax-reference] + [:div.syntax-item {:replicant/key syntax} + [:code syntax] + [:span description]])]]]]) + + :clj + (defn examples-panel [{::keys [selected-example dispatch!]}] + [:div.panel.examples-panel + [:div.panel-header [:h2 "Examples"]] + [:div.panel-content + [:ul.example-list + (for [[idx example] (map-indexed vector examples/examples)] + [:li {:replicant/key idx + :class (when (= idx selected-example) "active")} + (:name example)])]]])) + +;;============================================================================= +;; App Root +;;============================================================================= + +(defn app-view [{::keys [db dispatch!]}] + (let [{:keys [selected-example]} db] + [:div.app-container + #?(:cljs [::site-header {::db db ::dispatch! dispatch!}] + :clj (site-header {::db db ::dispatch! dispatch!})) + [:div.main-content.with-sidebar + #?(:cljs [::data-panel {::db db ::dispatch! dispatch!}] + :clj (data-panel {::db db ::dispatch! dispatch!})) + #?(:cljs [::pattern-results-panel {::db db ::dispatch! dispatch!}] + :clj (pattern-results-panel {::db db ::dispatch! dispatch!})) + #?(:cljs [::examples-panel {::selected-example selected-example + ::dispatch! dispatch!}] + :clj (examples-panel {::selected-example selected-example + ::dispatch! dispatch!}))]])) + +;;============================================================================= +;; Tests +;;============================================================================= + +^:rct/test +(comment + ;; format-result handles nil + (format-result nil) ;=> "nil" + + ;; format-result handles maps + (format-result {:a 1}) ;=> "{:a 1}" + + ;; app-view returns container + (first (app-view {::db {:mode :sandbox :pattern-text ""} + ::dispatch! identity}))) + ;=> :div.app-container) diff --git a/examples/pull-playground/src/sg/flybot/playground/edn_editor_interactive.cljs b/examples/pull-playground/src/sg/flybot/playground/ui/core/views/edn_editor_interactive.cljs similarity index 99% rename from examples/pull-playground/src/sg/flybot/playground/edn_editor_interactive.cljs rename to examples/pull-playground/src/sg/flybot/playground/ui/core/views/edn_editor_interactive.cljs index c59db15..b57491e 100644 --- a/examples/pull-playground/src/sg/flybot/playground/edn_editor_interactive.cljs +++ b/examples/pull-playground/src/sg/flybot/playground/ui/core/views/edn_editor_interactive.cljs @@ -1,11 +1,11 @@ -(ns sg.flybot.playground.edn-editor-interactive +(ns sg.flybot.playground.ui.core.views.edn-editor-interactive "Interactive EDN editor with parinfer support. This ClojureScript-only module handles the interactive editing behavior: - Textarea for input - Overlay with syntax highlighting - Parinfer integration for auto-balancing" - (:require [sg.flybot.playground.edn-editor :as edn] + (:require [sg.flybot.playground.ui.core.views.edn-editor-interactive.editor :as edn] [parinferish.core :as paren] [clojure.string :as str] [malli.core :as m])) diff --git a/examples/pull-playground/src/sg/flybot/playground/edn_editor.cljc b/examples/pull-playground/src/sg/flybot/playground/ui/core/views/edn_editor_interactive/editor.cljc similarity index 99% rename from examples/pull-playground/src/sg/flybot/playground/edn_editor.cljc rename to examples/pull-playground/src/sg/flybot/playground/ui/core/views/edn_editor_interactive/editor.cljc index 22d62ac..53446b8 100644 --- a/examples/pull-playground/src/sg/flybot/playground/edn_editor.cljc +++ b/examples/pull-playground/src/sg/flybot/playground/ui/core/views/edn_editor_interactive/editor.cljc @@ -1,4 +1,4 @@ -(ns sg.flybot.playground.edn-editor +(ns sg.flybot.playground.ui.core.views.edn-editor-interactive.editor "EDN editor component with syntax highlighting and rainbow parens. Uses parinferish to parse EDN/Clojure code and render it with diff --git a/examples/pull-playground/src/sg/flybot/playground/ui/core/views/examples.cljc b/examples/pull-playground/src/sg/flybot/playground/ui/core/views/examples.cljc new file mode 100644 index 0000000..4dfa40c --- /dev/null +++ b/examples/pull-playground/src/sg/flybot/playground/ui/core/views/examples.cljc @@ -0,0 +1,65 @@ +(ns sg.flybot.playground.ui.core.views.examples + "Pre-built example patterns for the playground.") + +(def examples + [{:name "List all users" + :description "Read all users from the collection" + :pattern "{:users ?all}"} + + {:name "List all posts" + :description "Read all posts from the collection" + :pattern "{:posts ?all}"} + + {:name "Multiple bindings" + :description "Extract multiple top-level keys at once" + :pattern "{:users ?u :posts ?p}"} + + {:name "Lookup user by ID" + :description "Find a specific user using indexed lookup" + :pattern "{:users {{:id 2} ?user}}"} + + {:name "Read config" + :description "Read the application configuration" + :pattern "{:config ?cfg}"} + + {:name "Nested map" + :description "Match nested structure inside config" + :pattern "{:config {:features ?features}}"} + + {:name "Default value" + :description "Provide fallback for missing keys" + :pattern "{:config {:debug (?d :default false)}}"} + + {:name "Constrained lookup" + :description "Find a post and apply a predicate constraint" + :pattern "{:posts {{:id 1} {:title (?t :when string?)}}}"} + + {:name "Create user" + :description "Create with nil key — auto-detected as mutation" + :pattern "{:users {nil {:name \"Dave\" :email \"dave@example.com\" :role :user}}}"} + + {:name "Update user" + :description "Update with map value — auto-detected as mutation" + :pattern "{:users {{:id 1} {:name \"Alice Updated\"}}}"} + + {:name "Delete user" + :description "Delete with nil value — auto-detected as mutation" + :pattern "{:users {{:id 2} nil}}"}]) + +(def syntax-reference + [{:syntax "?x" :description "Bind value to x"} + {:syntax "?_" :description "Wildcard (match any)"} + {:syntax "?x?" :description "Optional (0-1)"} + {:syntax "?x*" :description "Zero or more"} + {:syntax "?x+" :description "One or more"} + {:syntax "{}" :description "Map pattern"} + {:syntax "[]" :description "Sequence pattern"} + {:syntax ":when" :description "Predicate constraint"} + {:syntax ":default" :description "Fallback value"} + {:syntax "{nil data}" :description "Create (sandbox)"} + {:syntax "{{:id 1} nil}" :description "Delete (sandbox)"}]) + +^:rct/test +(comment + (count examples) ;=> 11 + (:name (first examples))) ;=> "List all users") diff --git a/examples/pull-playground/src/sg/flybot/playground/views.cljc b/examples/pull-playground/src/sg/flybot/playground/views.cljc deleted file mode 100644 index e190d41..0000000 --- a/examples/pull-playground/src/sg/flybot/playground/views.cljc +++ /dev/null @@ -1,218 +0,0 @@ -(ns sg.flybot.playground.views - "UI views - pure functions returning hiccup. - - Views emit events by calling (dispatch! :event) or (dispatch! [:event arg])." - (:require [sg.flybot.playground.examples :as examples] - [sg.flybot.playground.edn-editor :as edn] - [clojure.string :as str] - #?(:cljs [sg.flybot.playground.edn-editor-interactive :as edn-i]))) - -;;============================================================================= -;; Helpers -;;============================================================================= - -(defn- format-result - "Format a Clojure value for display (single line)" - [v] - (if (nil? v) - "nil" - (pr-str v))) - -(defn- format-result-pretty - "Format a Clojure value with pretty-printing" - [v] - (str/trim (edn/pretty-str v))) - -;;============================================================================= -;; Components -;;============================================================================= - -(defn- sun-icon [] - [:svg {:width "16" :height "16" :viewBox "0 0 24 24" :fill "none" :stroke "currentColor" :stroke-width "2"} - [:circle {:cx "12" :cy "12" :r "5"}] - [:line {:x1 "12" :y1 "1" :x2 "12" :y2 "3"}] - [:line {:x1 "12" :y1 "21" :x2 "12" :y2 "23"}] - [:line {:x1 "4.22" :y1 "4.22" :x2 "5.64" :y2 "5.64"}] - [:line {:x1 "18.36" :y1 "18.36" :x2 "19.78" :y2 "19.78"}] - [:line {:x1 "1" :y1 "12" :x2 "3" :y2 "12"}] - [:line {:x1 "21" :y1 "12" :x2 "23" :y2 "12"}] - [:line {:x1 "4.22" :y1 "19.78" :x2 "5.64" :y2 "18.36"}] - [:line {:x1 "18.36" :y1 "5.64" :x2 "19.78" :y2 "4.22"}]]) - -(defn- moon-icon [] - [:svg {:width "16" :height "16" :viewBox "0 0 24 24" :fill "none" :stroke "currentColor" :stroke-width "2"} - [:path {:d "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"}]]) - -(defn site-header [state dispatch!] - (let [{:keys [mode]} state] - [:header.site-header - [:h1 "Pull Pattern Playground"] - [:div.header-right - [:div.mode-toggle - [:button {:class (when (= mode :local) "active") - :on {:click #(dispatch! [:set-mode :local])}} - "Local"] - [:button {:class (when (= mode :remote) "active") - :on {:click #(dispatch! [:set-mode :remote])}} - "Remote"]] - [:button.theme-toggle {:title "Toggle theme" - :on {:click #?(:clj identity - :cljs #(js/sg.flybot.playground.core.toggle_theme_BANG_))}} - [:span.show-light (moon-icon)] - [:span.show-dark (sun-icon)]]]])) - -(defn- schema-display [state dispatch!] - (let [{:keys [schema schema-loading? schema-error sample-data schema-view-mode]} state] - [:div.schema-section - [:div.schema-header - [:label "Server Schema"] - [:button.fetch-schema-btn {:on {:click #(dispatch! :fetch-schema)} - :disabled schema-loading?} - (if schema-loading? "Loading..." "Fetch")]] - #?(:cljs (edn-i/schema-viewer - {:schema schema - :sample-data sample-data - :view-mode schema-view-mode - :on-mode-change #(dispatch! [:set-schema-view-mode %]) - :loading? schema-loading? - :error schema-error - :placeholder "Click Fetch to load schema"}) - :clj [:div.schema-content - (cond - schema-error [:div.schema-error schema-error] - schema [:pre.schema-value (format-result-pretty schema)] - :else [:div.schema-empty "Click Fetch to load schema"])])])) - -(defn data-panel - "Left panel: Data (local) or Schema (remote)" - [state dispatch!] - (let [{:keys [mode data-text server-url]} state] - [:div.panel.data-panel - [:div.panel-header - [:h2 (if (= mode :local) "Data" "Schema")]] - [:div.panel-content - (if (= mode :local) - [:div.editor-section - #?(:cljs (edn-i/edn-editor - {:value data-text - :placeholder "Enter EDN data to match against" - :on-change #(dispatch! [:update-data %]) - :parinfer-mode :indent}) - :clj [:textarea {:value data-text - :placeholder "Enter EDN data to match against"}])] - [:div.remote-sections - [:div.editor-section.url-section - [:label "Server URL"] - [:input {:type "text" - :value server-url - :placeholder "http://localhost:8081/api" - :on {:input #(dispatch! [:update-server-url (.. % -target -value)])}}]] - (schema-display state dispatch!)])]])) - -(def ^:private pattern-editor-id "pattern-editor") - -(defn pattern-results-panel - "Right panel: Pattern editor + Results stacked vertically" - [state dispatch!] - (let [{:keys [mode pattern-text loading? schema autocomplete result error]} state] - [:div.panel.pattern-results-panel - ;; Pattern section - [:div.pattern-section - [:div.section-header - [:label "Pattern"] - [:button.execute-btn {:on {:click #(dispatch! :execute)} - :disabled loading?} - (if loading? "Executing..." "Execute")]] - [:div.pattern-editor - #?(:cljs (edn-i/edn-editor - {:value pattern-text - :placeholder "Enter a pull pattern, e.g. {:name ?n}" - :on-change #(dispatch! [:update-pattern %]) - :parinfer-mode :indent - :editor-id pattern-editor-id - ;; Pass schema for autocomplete and hover tooltips in remote mode - :schema (when (= mode :remote) schema) - ;; Enable hover tooltips in remote mode - :hover-tooltips? (and (= mode :remote) (some? schema)) - ;; Autocomplete state and callbacks (dropdown rendered separately below) - :autocomplete (when (= mode :remote) autocomplete) - :on-autocomplete #(dispatch! [:show-autocomplete %]) - :on-autocomplete-hide #(dispatch! :hide-autocomplete) - :on-autocomplete-select #(dispatch! [:select-autocomplete %]) - :on-autocomplete-move #(dispatch! [:move-autocomplete %])}) - :clj [:textarea {:value pattern-text - :placeholder "Enter a pull pattern, e.g. {:name ?n}"}])] - ;; Autocomplete dropdown - rendered outside editor to avoid overflow clipping - #?(:cljs (when (and (= mode :remote) autocomplete) - (edn-i/editor-autocomplete - {:editor-id pattern-editor-id - :autocomplete autocomplete - :on-change #(dispatch! [:update-pattern %]) - :on-autocomplete-hide #(dispatch! :hide-autocomplete) - :on-autocomplete-select #(dispatch! [:select-autocomplete %])})) - :clj nil)] - ;; Results section - [:div.results-section - [:div.section-header - [:label "Results"]] - [:div.results-content - (cond - loading? - [:div.loading "Executing pattern..."] - - error - [:div.result-value.error error] - - result - [:div.result-value.success - (edn/highlight-edn (format-result-pretty result))] - - :else - [:div.result-value.empty "Enter a pattern and data, then click Execute"])]]])) - -(defn examples-panel [state dispatch!] - (let [selected (:selected-example state)] - [:div.panel.examples-panel - [:div.panel-header - [:h2 "Examples"]] - [:div.panel-content - [:ul.example-list - (for [[idx example] (map-indexed vector examples/examples)] - [:li {:replicant/key idx - :class (when (= idx selected) "active") - :title (:description example) - :on {:click #(do (dispatch! [:select-example example]) - (dispatch! [:set-selected-example idx]))}} - (:name example)])] - [:div.syntax-reference - [:h3 "Syntax Reference"] - [:div.syntax-list - (for [{:keys [syntax description]} examples/syntax-reference] - [:div.syntax-item {:replicant/key syntax} - [:code syntax] - [:span description]])]]]])) - -(defn app-view [state dispatch!] - (let [{:keys [mode]} state] - [:div.app-container - (site-header state dispatch!) - [:div.main-content {:class (when (= mode :local) "with-sidebar")} - (data-panel state dispatch!) - (pattern-results-panel state dispatch!) - (when (= mode :local) - (examples-panel state dispatch!))]])) - -;;============================================================================= -;; Tests -;;============================================================================= - -^:rct/test -(comment - ;; format-result handles nil - (format-result nil) ;=> "nil" - - ;; format-result handles maps - (format-result {:a 1}) ;=> "{:a 1}" - - ;; app-view returns container - (first (app-view {:mode :local :pattern-text "" :data-text ""} identity))) ;=> :div.app-container) diff --git a/pattern/src/sg/flybot/pullable/impl.cljc b/pattern/src/sg/flybot/pullable/impl.cljc index c6a00f5..c7b5e3c 100644 --- a/pattern/src/sg/flybot/pullable/impl.cljc +++ b/pattern/src/sg/flybot/pullable/impl.cljc @@ -10,7 +10,7 @@ [clojure.walk :as walk] [clojure.zip :as zip] [sg.flybot.pullable.schema :as schema] - [sg.flybot.pullable.util :refer [vars->]]) + [sg.flybot.pullable.util :refer [vars-> variable?]]) #?(:cljs (:require-macros [sg.flybot.pullable.impl]))) ;;============================================================================= @@ -1203,7 +1203,7 @@ (and (seq? x) (let [fst (first x)] (or ;; Original form: (?x :opt ...) - (and (symbol? fst) (= \? (first (name fst)))) + (variable? fst) ;; After matching-var-rewrite: ((? :var ...) :opt ...) (and (seq? fst) (= '? (first fst)) (= :var (second fst))) ;; Rewritten wildcard: ((? :any) :skip ...) @@ -1385,9 +1385,7 @@ [fst] (cond ;; Quantified var: ?x*, ?x+, ?x?, ?x*!, ?x+! - (and (symbol? fst) - (= \? (first (name fst))) - (parse-matching-var fst)) + (and (variable? fst) (parse-matching-var fst)) (let [{:keys [sym quantifier greedy?]} (parse-matching-var fst) var-sym (when sym (symbol (subs (name sym) 1)))] (case quantifier @@ -1397,8 +1395,7 @@ nil)) ;; Simple var: ?x, ?_ (bare ? is not a var - it's the core pattern marker) - (and (symbol? fst) - (= \? (first (name fst))) + (and (variable? fst) (> (count (name fst)) 1) (not (re-find forbidden-var-chars (subs (name fst) 1)))) (let [var-name (subs (name fst) 1)] @@ -1818,7 +1815,7 @@ [template vars] (walk/postwalk (fn [x] - (if (and (symbol? x) (= \? (first (name x)))) + (if (variable? x) (let [nm (name x) ;; Strip ? prefix and any quantifier suffix base (if-let [[_ b] (re-matches #"\?([^\s\?\+\*\!]+)[\?\+\*]?\!?" nm)] @@ -1922,7 +1919,7 @@ vars (atom #{}) _ (walk/postwalk (fn [x] - (when (and (symbol? x) (= \? (first (name x)))) + (when (variable? x) (let [nm (name x) ;; Strip prefix ? and any suffix quantifiers base (if-let [[_ b] (re-matches #"\?([^\s\?\+\*\!]+)[\?\+\*]?\!?" nm)] @@ -2110,6 +2107,17 @@ (compile-pattern '?x {:schema :map}) ;=>> fn? (compile-pattern '?x {:schema :string}) ;=>> fn? + ;; Valid: :default and :when chain patterns on scalar schema + (compile-pattern '{:name (?n :default "anon")} {:schema {:name :string}}) ;=>> fn? + (compile-pattern '{:age (?a :when pos?)} {:schema {:age :number}}) ;=>> fn? + + ;; Invalid: key not in schema throws even with :default + (try + (compile-pattern '{:debug (?d :default false)} {:schema {:version :string}}) + (catch clojure.lang.ExceptionInfo e + (:key (ex-data e)))) + ;=> :debug + ;; Invalid: seq pattern on :map schema - throws (try (compile-pattern '[?x] {:schema :map}) diff --git a/pattern/src/sg/flybot/pullable/schema.cljc b/pattern/src/sg/flybot/pullable/schema.cljc index e5f58c3..a8f5a68 100644 --- a/pattern/src/sg/flybot/pullable/schema.cljc +++ b/pattern/src/sg/flybot/pullable/schema.cljc @@ -62,7 +62,7 @@ :seq (or (#{:seq :var :any} ptn-type) (and (= ptn-type :map) indexed-lookup?)) (:number :string :keyword :symbol :boolean) - (#{:var :val :pred :any :regex} ptn-type) + (#{:var :val :pred :any :regex :-> :sub} ptn-type) true))] (when-not type-ok? (throw (ex-info (str "Schema violation: pattern :" ptn-type " vs schema :" type diff --git a/pattern/src/sg/flybot/pullable/util.cljc b/pattern/src/sg/flybot/pullable/util.cljc index cf7c6b2..7d2c86c 100644 --- a/pattern/src/sg/flybot/pullable/util.cljc +++ b/pattern/src/sg/flybot/pullable/util.cljc @@ -2,6 +2,20 @@ "Pure utility functions and macros with no domain knowledge." #?(:cljs (:require-macros [sg.flybot.pullable.util]))) +(defn variable? + "Check if x is a ?-prefixed pattern variable symbol." + [x] + (and (symbol? x) (= \? (first (name x))))) + +^:rct/test +(comment + (variable? '?x) ;=> true + (variable? '?_) ;=> true + (variable? '?x*) ;=> true + (variable? 'x) ;=> false + (variable? :foo) ;=> false + (variable? "?x")) ;=> false) + (defmacro vars-> "Create a fn that destructures vars map. (vars-> [sym child] (mvar sym child)) diff --git a/remote/src/sg/flybot/pullable/remote.cljc b/remote/src/sg/flybot/pullable/remote.cljc index 8cc1a48..15d0e59 100644 --- a/remote/src/sg/flybot/pullable/remote.cljc +++ b/remote/src/sg/flybot/pullable/remote.cljc @@ -102,6 +102,13 @@ (pull-handler request) (next-handler request)))))) +(def parse-mutation + "Detect if pattern is a mutation. Returns {:path :query :value} or nil. + + Mutations use nil (create) or map (update/delete) as query keys. + Read patterns (keyword query keys or ?-variable values) return nil." + http/parse-mutation) + ;; For client implementations (def encode "Encode Clojure data to bytes. Format: :transit-json, :transit-msgpack, :edn." @@ -115,6 +122,7 @@ (comment make-handler ;=>> fn? wrap-api ;=>> fn? + parse-mutation ;=>> fn? encode ;=>> fn? decode ;=>> fn? ) diff --git a/remote/src/sg/flybot/pullable/remote/http.cljc b/remote/src/sg/flybot/pullable/remote/http.cljc index 073a9d6..6fd8868 100644 --- a/remote/src/sg/flybot/pullable/remote/http.cljc +++ b/remote/src/sg/flybot/pullable/remote/http.cljc @@ -6,6 +6,7 @@ [clojure.string :as str] [clojure.walk] [sg.flybot.pullable.impl :as pattern] + [sg.flybot.pullable.util :refer [variable?]] [sg.flybot.pullable.collection :as coll] #?(:clj [cognitect.transit :as transit]) #?(:clj [clojure.edn :as edn]) @@ -282,9 +283,19 @@ (parse-mutation '{:posts ?all}) ;=> nil - (parse-mutation '{:member {:me ?user}})) + (parse-mutation '{:member {:me ?user}}) ;=> nil + ;; parse-mutation: keyword query keys are reads, not mutations + (parse-mutation '{:config {:debug (?d :default false)}}) + ;=> nil + + (parse-mutation '{:users {:name "Alice"}}) + ;=> nil + + (parse-mutation '{:role {:config {:debug true}}})) + ;=> nil) + ;;============================================================================= ;; Response Helpers ;;============================================================================= @@ -374,12 +385,7 @@ {:body (encode (prepare-for-wire response) format) :content-type (get content-types format)}) -(defn- variable? - "Check if value is a ?-prefixed symbol (pattern variable)." - [v] - (and (symbol? v) (= \? (first (name v))))) - -(defn- parse-mutation +(defn parse-mutation "Detect if pattern is a mutation. Returns {:path :query :value} or nil. Flat mutation patterns: @@ -405,13 +411,15 @@ (= 1 (count (val (first v1))))) (let [[k2 v2] (first v1) [query value] (first v2)] - (when-not (variable? value) + (when (and (not (variable? value)) + (or (nil? query) (map? query))) {:path [k1 k2] :query query :value value})) ;; Flat: {:posts {query value}} (and (map? v1) (= 1 (count v1))) (let [[query value] (first v1)] - (when-not (variable? value) + (when (and (not (variable? value)) + (or (nil? query) (map? query))) {:path [k1] :query query :value value})) :else nil)))))