add "class registry" example app
This commit is contained in:
parent
d5e421de52
commit
b8353aaafc
18
examples/class-registry/.gitignore
vendored
Normal file
18
examples/class-registry/.gitignore
vendored
Normal file
|
@ -0,0 +1,18 @@
|
|||
.DS_Store
|
||||
/target
|
||||
/classes
|
||||
/checkouts
|
||||
pom.xml
|
||||
pom.xml.asc
|
||||
*.jar
|
||||
*.class
|
||||
/.lein-*
|
||||
/.nrepl-port
|
||||
.settings/
|
||||
.project
|
||||
.classpath
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
/resources/public/cljs
|
57
examples/class-registry/README.md
Normal file
57
examples/class-registry/README.md
Normal file
|
@ -0,0 +1,57 @@
|
|||
# Reagent Data Views Example - Class Registry
|
||||
|
||||
This is a "Class Registry" application that has a lot of CRUD operations
|
||||
in it which allow users to manage students and professors, as well as
|
||||
classes and then assign the students/professors to those classes. The
|
||||
idea is _very_ loosely based off one of the Om tutorial applications
|
||||
(the data used is almost identical).
|
||||
|
||||
[1]: http://reagent-project.github.io/
|
||||
|
||||
## Running
|
||||
|
||||
### A quick note on the dependencies used
|
||||
|
||||
Since Reagent Data Views and the Views library it depends on are all
|
||||
currently in somewhat of an experimental / pre-beta state right now,
|
||||
you will need to first clone the following repositories and manually
|
||||
install the libraries via `lein install`:
|
||||
|
||||
* [views](https://github.com/gered/views)
|
||||
* [views-sql](https://github.com/gered/views-sql)
|
||||
* [reagent-data-views](https://github.com/gered/reagent-data-views)
|
||||
|
||||
As well, you can install [views-honeysql](https://github.com/gered/views-honeysql)
|
||||
if you want to try out using HoneySQL instead of SQL with views. But
|
||||
this example app does not use it so it's not required.
|
||||
|
||||
### Creating the Database
|
||||
|
||||
This example app uses a PostgreSQL database. The SQL script to create
|
||||
it is in `create_db.sql`. You can easily pipe it into `psql` at a
|
||||
command line to create it quickly, for example:
|
||||
|
||||
$ psql < create_db.sql
|
||||
|
||||
(Of course, add any username/host parameters you might need)
|
||||
|
||||
### Starting It Up
|
||||
|
||||
To build everything and run in one step:
|
||||
|
||||
$ lein rundemo
|
||||
|
||||
Then open up a web browser or two and head to http://localhost:8080/
|
||||
to see the web app in action.
|
||||
|
||||
If you want to run this application in a REPL, just be sure to build
|
||||
the ClojureScript:
|
||||
|
||||
$ lein cljsbuild once
|
||||
|
||||
And then in the REPL you can just run:
|
||||
|
||||
(-main)
|
||||
|
||||
to start the web app (you should be put in the correct namespace
|
||||
immediately).
|
53
examples/class-registry/create_db.sql
Normal file
53
examples/class-registry/create_db.sql
Normal file
|
@ -0,0 +1,53 @@
|
|||
-- For PostgreSQL
|
||||
-- run with psql. e.g. 'psql < create_db.sql'
|
||||
|
||||
CREATE ROLE class_registry LOGIN PASSWORD 's3cr3t';
|
||||
CREATE DATABASE class_registry OWNER class_registry;
|
||||
|
||||
-- assumes you're piping this script into psql ...
|
||||
\c class_registry;
|
||||
|
||||
CREATE TABLE classes
|
||||
(
|
||||
class_id SERIAL PRIMARY KEY NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
ALTER TABLE classes OWNER TO class_registry;
|
||||
|
||||
CREATE TABLE people
|
||||
(
|
||||
people_id SERIAL PRIMARY KEY NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
middle_name TEXT,
|
||||
last_name TEXT NOT NULL,
|
||||
email TEXT NOT NULL
|
||||
);
|
||||
ALTER TABLE people OWNER TO class_registry;
|
||||
|
||||
CREATE TABLE registry
|
||||
(
|
||||
registry_id SERIAL PRIMARY KEY NOT NULL,
|
||||
class_id INTEGER REFERENCES classes (class_id) ON DELETE CASCADE NOT NULL,
|
||||
people_id INTEGER REFERENCES people (people_id) ON DELETE CASCADE NOT NULL
|
||||
);
|
||||
ALTER TABLE registry OWNER TO class_registry;
|
||||
|
||||
INSERT INTO people (type, first_name, middle_name, last_name, email) VALUES ('student', 'Ben', NULL, 'Bitdiddle', 'benb@mit.edu');
|
||||
INSERT INTO people (type, first_name, middle_name, last_name, email) VALUES ('student', 'Alyssa', 'P', 'Hacker', 'aphacker@mit.edu');
|
||||
INSERT INTO people (type, first_name, middle_name, last_name, email) VALUES ('student', 'Eva', 'Lu', 'Ator', 'eval@mit.edu');
|
||||
INSERT INTO people (type, first_name, middle_name, last_name, email) VALUES ('student', 'Louis', NULL, 'Reasoner', 'prolog@mit.edu');
|
||||
INSERT INTO people (type, first_name, middle_name, last_name, email) VALUES ('professor', 'Gerald', 'Jay', 'Sussman', 'metacirc@mit.edu');
|
||||
INSERT INTO people (type, first_name, middle_name, last_name, email) VALUES ('professor', 'Hal', NULL, 'Abelson', 'evalapply@mit.edu');
|
||||
|
||||
INSERT INTO classes (code, name) VALUES ('6001', 'The Structure and Interpretation of Computer Programs');
|
||||
INSERT INTO classes (code, name) VALUES ('6946', 'The Structure and Interpretation of Classical Mechanics');
|
||||
INSERT INTO classes (code, name) VALUES ('1806', 'Linear Algebra');
|
||||
|
||||
INSERT INTO registry (class_id, people_id) VALUES ((SELECT class_id FROM classes WHERE code = '6001'),
|
||||
(SELECT people_id FROM people WHERE first_name = 'Gerald' AND middle_name = 'Jay' AND last_name = 'Sussman'));
|
||||
INSERT INTO registry (class_id, people_id) VALUES ((SELECT class_id FROM classes WHERE code = '6946'),
|
||||
(SELECT people_id FROM people WHERE first_name = 'Gerald' AND middle_name = 'Jay' AND last_name = 'Sussman'));
|
||||
INSERT INTO registry (class_id, people_id) VALUES ((SELECT class_id FROM classes WHERE code = '6001'),
|
||||
(SELECT people_id FROM people WHERE first_name = 'Hal' AND last_name = 'Abelson'));
|
56
examples/class-registry/project.clj
Normal file
56
examples/class-registry/project.clj
Normal file
|
@ -0,0 +1,56 @@
|
|||
(defproject class-registry "0.1.0-SNAPSHOT"
|
||||
:dependencies [[org.clojure/clojure "1.8.0"]
|
||||
[org.clojure/clojurescript "1.8.51"]
|
||||
[ring "1.4.0"]
|
||||
[ring/ring-defaults "0.2.0" :exclusions [javax.servlet/servlet-api]]
|
||||
[ring-middleware-format "0.7.0"]
|
||||
[compojure "1.4.0"]
|
||||
[org.immutant/web "2.1.4"]
|
||||
|
||||
[org.clojure/java.jdbc "0.6.1"]
|
||||
[org.postgresql/postgresql "9.4.1208.jre7"]
|
||||
[gered/clj-browserchannel "0.3.2"]
|
||||
[gered/clj-browserchannel-immutant-adapter "0.0.3"]
|
||||
[gered/views "1.5-SNAPSHOT"]
|
||||
[gered/views-sql "0.1.0-SNAPSHOT"]
|
||||
[reagent-data-views "0.2.0-SNAPSHOT"]
|
||||
[reagent-data-views-browserchannel "0.1.0-SNAPSHOT"]
|
||||
|
||||
[clj-pebble "0.2.0"]
|
||||
[reagent "0.6.0-alpha2"]
|
||||
[cljsjs/bootstrap "3.3.6-1"]
|
||||
[cljs-ajax "0.5.4"]
|
||||
|
||||
[environ "1.0.3"]]
|
||||
|
||||
:plugins [[lein-cljsbuild "1.1.3"]
|
||||
[lein-environ "1.0.3"]]
|
||||
|
||||
:main class-registry.server
|
||||
|
||||
:clean-targets ^{:protect false} [:target-path
|
||||
[:cljsbuild :builds :main :compiler :output-dir]
|
||||
[:cljsbuild :builds :main :compiler :output-to]]
|
||||
:cljsbuild {:builds {:main
|
||||
{:source-paths ["src"]
|
||||
:compiler {:output-to "resources/public/cljs/app.js"
|
||||
:output-dir "resources/public/cljs/target"
|
||||
:source-map true
|
||||
:optimizations :none
|
||||
:pretty-print true}}}}
|
||||
|
||||
:profiles {:dev {:env {:dev "true"}}
|
||||
|
||||
:uberjar {:env {}
|
||||
:aot :all
|
||||
:hooks [leiningen.cljsbuild]
|
||||
:cljsbuild {:jar true
|
||||
:builds {:main
|
||||
{:compiler ^:replace {:output-to "resources/public/cljs/app.js"
|
||||
:optimizations :advanced
|
||||
:pretty-print false}}}}}}
|
||||
|
||||
:aliases {"rundemo" ["do" ["clean"] ["cljsbuild" "once"] ["run"]]
|
||||
"uberjar" ["do" ["clean"] ["uberjar"]]}
|
||||
|
||||
)
|
25
examples/class-registry/resources/html/app.html
Normal file
25
examples/class-registry/resources/html/app.html
Normal file
|
@ -0,0 +1,25 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Class Registry</title>
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
|
||||
<!-- CSRF token that ring's anti-forgery middleware is expecting.
|
||||
we pick it up in class-registry.client and add it to the headers
|
||||
that cljs-ajax uses -->
|
||||
<meta name="anti-forgery-token" content="{{ csrfToken }}">
|
||||
</head>
|
||||
<body>
|
||||
<input type="hidden" id="anti-forgery-token" value="{{ csrfToken }}">
|
||||
<div id="app"></div>
|
||||
{% if dev %}<script type="text/javascript" src="cljs/target/goog/base.js"></script>{% endif %}
|
||||
<script type="text/javascript" src="cljs/app.js"></script>
|
||||
{% if dev %}<script type="text/javascript">goog.require('class_registry.client');</script>{% endif %}
|
||||
<script type="text/javascript">
|
||||
class_registry.client.run();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
30
examples/class-registry/resources/public/app.css
Normal file
30
examples/class-registry/resources/public/app.css
Normal file
|
@ -0,0 +1,30 @@
|
|||
body {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
#app-title {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.list div.row {
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.list div.row:hover {
|
||||
background-color: #d9edf7;
|
||||
}
|
||||
|
||||
.class-registry {
|
||||
margin-top: 3px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
div.actions button {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
div.row .value {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
382
examples/class-registry/src/class_registry/client.cljs
Normal file
382
examples/class-registry/src/class_registry/client.cljs
Normal file
|
@ -0,0 +1,382 @@
|
|||
(ns class-registry.client
|
||||
(:require
|
||||
[clojure.string :as string]
|
||||
[reagent.core :as r]
|
||||
[ajax.core :refer [POST default-interceptors to-interceptor]]
|
||||
[net.thegeez.browserchannel.client :as browserchannel]
|
||||
[reagent-data-views.client.component :refer [view-cursor] :refer-macros [defvc]]
|
||||
[reagent-data-views.browserchannel.client :as rdv-browserchannel]))
|
||||
|
||||
;; Class Registry - Reagent Data Views example app
|
||||
;;
|
||||
;; (This example app is (very) loosely based on one of the examples in the Om tutorial).
|
||||
;;
|
||||
;; This application is a little bit more complex for an example app, mostly caused by
|
||||
;; the UI layout (in place editing, etc). Even still, for anyone well versed in Reagent
|
||||
;; this code shouldn't be difficult to follow.
|
||||
;;
|
||||
;; In a real-world application we probably would not want to be quite this lazy when
|
||||
;; it comes to usage of view-cursor. However, for an example app I think this does
|
||||
;; serve to demonstrate how you can very quickly build UI's which reactively update to
|
||||
;; backend database operations and get a lot of "free" UI refreshes, simplifying your
|
||||
;; code.
|
||||
|
||||
|
||||
|
||||
;; AJAX actions
|
||||
|
||||
(defn add-person! [person] (POST "/people/add" {:params {:person person}}))
|
||||
(defn save-person! [person] (POST "/people/update" {:params {:person person}}))
|
||||
(defn delete-person! [id] (POST "/people/delete" {:params {:id id}}))
|
||||
(defn add-class! [class] (POST "/class/add" {:params {:class class}}))
|
||||
(defn save-class! [class] (POST "/class/update" {:params {:class class}}))
|
||||
(defn delete-class! [id] (POST "/class/delete" {:params {:id id}}))
|
||||
|
||||
(defn add-registration!
|
||||
[class-id people-id]
|
||||
(POST "/registry/add" {:params {:class-id class-id :people-id people-id}}))
|
||||
|
||||
(defn remove-registration!
|
||||
[id]
|
||||
(POST "/registry/remove" {:params {:id id}}))
|
||||
|
||||
|
||||
|
||||
;; helper/utility functions
|
||||
|
||||
(defn parse-person-name
|
||||
"returns a map with a person's first/last and optionally middle name in it
|
||||
when given a string of the format 'first-name middle-name last-name' or
|
||||
'first-name last-name'. the middle name can also just be an initial."
|
||||
[formatted-full-name]
|
||||
(let [[first middle last :as parts] (string/split formatted-full-name #"\s+")
|
||||
[first last middle] (if (nil? last) [first middle] [first last middle])
|
||||
middle (when middle (string/replace middle "." ""))]
|
||||
(if (>= (count parts) 2)
|
||||
{:first_name first
|
||||
:last_name last
|
||||
:middle_name middle})))
|
||||
|
||||
(defn format-editable-name
|
||||
"does the reverse of parse-person-name"
|
||||
[{:keys [first_name middle_name last_name] :as person}]
|
||||
(if middle_name
|
||||
(str first_name " " middle_name " " last_name)
|
||||
(str first_name " " last_name)))
|
||||
|
||||
(defn display-middle-name
|
||||
[{:keys [middle_name] :as person}]
|
||||
(if (= (count middle_name) 1)
|
||||
(str " " middle_name ".")
|
||||
(str " " middle_name)))
|
||||
|
||||
(defn display-name
|
||||
[{:keys [first_name last_name] :as person}]
|
||||
(str last_name ", " first_name (display-middle-name person)))
|
||||
|
||||
(defn validate-person
|
||||
[person]
|
||||
(cond
|
||||
(or (string/blank? (:first_name person))
|
||||
(string/blank? (:last_name person)))
|
||||
(js/alert "Invalid format for person's name. Format is:\n\n\"first_name [middle_name] last_name\"")
|
||||
|
||||
(string/blank? (:email person))
|
||||
(js/alert "Email address is required.")
|
||||
|
||||
:else person))
|
||||
|
||||
(defn validate-class
|
||||
[class]
|
||||
(cond
|
||||
(string/blank? (:code class))
|
||||
(js/alert "Class code is required.")
|
||||
|
||||
(string/blank? (:name class))
|
||||
(js/alert "Class name is required.")
|
||||
|
||||
:else class))
|
||||
|
||||
|
||||
|
||||
;; UI helpers
|
||||
|
||||
(defn text-edit
|
||||
"text editing component which maintains the text value state in a key within a provided atom"
|
||||
[& args]
|
||||
(let [[attrs value k] (if (= (count args) 2) (cons {} args) args)]
|
||||
[:input.form-control
|
||||
(merge
|
||||
attrs
|
||||
{:type "text"
|
||||
:value (get @value k)
|
||||
:on-change #(swap! value assoc k (-> % .-target .-value))})]))
|
||||
|
||||
(defn dropdown-list
|
||||
"dropdown list / select component which maintains selection state in a provided atom"
|
||||
[& args]
|
||||
(let [[attrs args] (if (= (count args) 2) args (cons {} args))
|
||||
{:keys [value data value-fn label-fn placeholder default-value]} args
|
||||
options (map
|
||||
(fn [item]
|
||||
^{:key (value-fn item)}
|
||||
[:option {:value (value-fn item)} (label-fn item)])
|
||||
data)
|
||||
options (if placeholder
|
||||
(cons ^{:key default-value}
|
||||
[:option {:value default-value} placeholder]
|
||||
options)
|
||||
options)]
|
||||
[:select.form-control
|
||||
(merge
|
||||
attrs
|
||||
{:value @value
|
||||
:on-change #(reset! value (-> % .-target .-value))})
|
||||
options]))
|
||||
|
||||
|
||||
|
||||
;; People UI
|
||||
|
||||
(defn person-info
|
||||
"row showing a single person and allowing editing/removal of it"
|
||||
[]
|
||||
(let [editing (r/atom nil)
|
||||
end-editing! #(reset! editing nil)
|
||||
start-editing! (fn [{:keys [people_id email] :as person}]
|
||||
(reset! editing
|
||||
{:id people_id
|
||||
:email email
|
||||
:name (format-editable-name person)}))
|
||||
save! (fn []
|
||||
(let [person (-> (:name @editing)
|
||||
(parse-person-name)
|
||||
(merge (select-keys @editing [:id :email])))]
|
||||
(when (validate-person person)
|
||||
(save-person! person)
|
||||
(end-editing!))))
|
||||
delete! (fn [{:keys [people_id] :as person}]
|
||||
(delete-person! people_id))]
|
||||
(fn [{:keys [email] :as person}]
|
||||
(if @editing
|
||||
[:div.row.bg-warning
|
||||
[:div.col-sm-5 [text-edit editing :name]]
|
||||
[:div.col-sm-5 [text-edit editing :email]]
|
||||
[:div.col-sm-2.actions
|
||||
[:button.btn.btn-sm.btn-success {:on-click save!} [:span.glyphicon.glyphicon-ok]]
|
||||
[:button.btn.btn-sm.btn-default {:on-click end-editing!} [:span.glyphicon.glyphicon-remove]]]]
|
||||
; not-editing display
|
||||
[:div.row
|
||||
[:div.col-sm-5 [:div.value (display-name person)]]
|
||||
[:div.col-sm-5 [:div.value email]]
|
||||
[:div.col-sm-2.actions
|
||||
[:button.btn.btn-sm.btn-default {:on-click #(start-editing! person)} [:span.glyphicon.glyphicon-pencil]]
|
||||
[:button.btn.btn-sm.btn-danger {:on-click #(delete! person)} [:span.glyphicon.glyphicon-remove]]]]))))
|
||||
|
||||
(defn new-person
|
||||
"row showing entry form for adding a new person"
|
||||
[type]
|
||||
(let [values (r/atom {})
|
||||
add! (fn []
|
||||
(let [person (merge {:type type
|
||||
:email (:email @values)}
|
||||
(parse-person-name (:name @values)))]
|
||||
(when (validate-person person)
|
||||
(add-person! person)
|
||||
(reset! values {}))))]
|
||||
[:div.row
|
||||
[:div.col-sm-5 [text-edit {:placeholder "Full name"} values :name]]
|
||||
[:div.col-sm-5 [text-edit {:placeholder "Email"} values :email]]
|
||||
[:div.col-sm-2.actions
|
||||
[:button.btn.btn-sm.btn-primary {:on-click add!} [:span.glyphicon.glyphicon-plus]]]]))
|
||||
|
||||
(defn people-list
|
||||
"sub-container for showing list of people of a certain type and allowing entry
|
||||
of new people of that same type"
|
||||
[people-type people]
|
||||
[:div.container-fluid.list
|
||||
(map
|
||||
(fn [{:keys [people_id] :as person}]
|
||||
^{:key people_id} [person-info person])
|
||||
people)
|
||||
[new-person people-type]])
|
||||
|
||||
(defvc people
|
||||
"main container for people information"
|
||||
[]
|
||||
(let [professors (view-cursor :people "professor")
|
||||
students (view-cursor :people "student")]
|
||||
[:div#people.container-fluid
|
||||
[:div.panel.panel-default
|
||||
[:div.panel-heading [:h3.panel-title "Professors"]]
|
||||
[:div.panel-body [people-list "professor" @professors]]]
|
||||
[:div.panel.panel-default
|
||||
[:div.panel-heading [:h3.panel-title "Students"]]
|
||||
[:div.panel-body [people-list "student" @students]]]]))
|
||||
|
||||
|
||||
|
||||
;; Class Registry UI
|
||||
|
||||
(defn registration-info
|
||||
"row showing a class registration and allowing removal of it"
|
||||
[{:keys [registry_id type] :as registration}]
|
||||
[:div.row
|
||||
[:div.col-sm-2.value (string/capitalize type)]
|
||||
[:div.col-sm-9.value (display-name registration)]
|
||||
[:div.col-sm-1.actions
|
||||
[:button.btn.btn-sm.btn-danger {:on-click #(remove-registration! registry_id)}
|
||||
[:span.glyphicon.glyphicon-remove]]]])
|
||||
|
||||
(defvc new-registration
|
||||
"row showing entry form for registering someone into a class"
|
||||
[]
|
||||
(let [value (r/atom "")]
|
||||
(fn [class-id]
|
||||
(let [people (view-cursor :people-registerable-for-class class-id)
|
||||
add! #(if-let [person-id @value]
|
||||
(when-not (= "" person-id)
|
||||
(add-registration! class-id (js/parseInt person-id))
|
||||
(reset! value "")))]
|
||||
[:div.row
|
||||
[:div.col-sm-11
|
||||
[dropdown-list
|
||||
{:data @people
|
||||
:value value
|
||||
:value-fn :people_id
|
||||
:label-fn #(str
|
||||
(if (= "professor" (:type %)) "(Professor) ")
|
||||
(display-name %))
|
||||
:placeholder "(Select a person to add!)"
|
||||
:default-value ""}]]
|
||||
[:div.col-sm-1.actions
|
||||
[:button.btn.btn-sm.btn-primary {:on-click add!} [:span.glyphicon.glyphicon-plus]]]]))))
|
||||
|
||||
(defvc class-registry-list
|
||||
"sub-container for showing list of people of a certain type and allowing entry
|
||||
of new people of that same type"
|
||||
[{:keys [class_id code name] :as class}]
|
||||
(let [registry (view-cursor :class-registry class_id)]
|
||||
[:div.col-sm-12.panel.panel-default.class-registry
|
||||
[:div.panel-body
|
||||
[:h4 "Class Registration"]
|
||||
(map
|
||||
(fn [{:keys [registry_id] :as registration}]
|
||||
^{:key registry_id} [registration-info registration])
|
||||
@registry)
|
||||
[new-registration class_id]]]))
|
||||
|
||||
|
||||
|
||||
;; Classes UI
|
||||
|
||||
(defn class-info
|
||||
"row showing a single class and allowing editing/removal of it. also has a
|
||||
toggle to show/hide the people registered in the class"
|
||||
[]
|
||||
(let [editing (r/atom nil)
|
||||
show-registry? (r/atom false)
|
||||
end-editing! #(reset! editing nil)
|
||||
start-editing! (fn [class]
|
||||
(reset! editing class)
|
||||
(reset! show-registry? false))
|
||||
save! #(let [class @editing]
|
||||
(when (validate-class class)
|
||||
(save-class! class)
|
||||
(end-editing!)))
|
||||
delete! #(delete-class! (:class_id %))
|
||||
toggle-registry! #(swap! show-registry? not)]
|
||||
(fn [{:keys [code name] :as class}]
|
||||
(if @editing
|
||||
[:div.row.bg-warning
|
||||
[:div.col-sm-2 [text-edit editing :code]]
|
||||
[:div.col-sm-7 [text-edit editing :name]]
|
||||
[:div.col-sm-3.actions
|
||||
[:button.btn.btn-sm.btn-success {:on-click save!} [:span.glyphicon.glyphicon-ok]]
|
||||
[:button.btn.btn-sm.btn-default {:on-click end-editing!} [:span.glyphicon.glyphicon-remove]]]]
|
||||
; not-editing display
|
||||
[:div
|
||||
{:class (str "row" (if @show-registry? " bg-success"))
|
||||
:on-double-click toggle-registry!}
|
||||
[:div.col-sm-2.value code]
|
||||
[:div.col-sm-7.value name]
|
||||
[:div.col-sm-3.actions
|
||||
[:button.btn.btn-sm.btn-default {:on-click #(start-editing! class)} [:span.glyphicon.glyphicon-pencil]]
|
||||
[:button.btn.btn-sm.btn-danger {:on-click #(delete! class)} [:span.glyphicon.glyphicon-remove]]
|
||||
[:button
|
||||
{:on-click toggle-registry!
|
||||
:class (str "btn btn-sm btn-info" (if @show-registry? " active"))}
|
||||
[:span.glyphicon.glyphicon-user]]]
|
||||
(if @show-registry?
|
||||
[class-registry-list class])]))))
|
||||
|
||||
(defn new-class
|
||||
"row showing entry form for adding a new class"
|
||||
[]
|
||||
(let [values (r/atom {})
|
||||
add! (fn []
|
||||
(let [class @values]
|
||||
(when (validate-class class)
|
||||
(add-class! class)
|
||||
(reset! values {}))))]
|
||||
(fn []
|
||||
[:div.row
|
||||
[:div.col-sm-2 [text-edit {:placeholder "Code"} values :code]]
|
||||
[:div.col-sm-7 [text-edit {:placeholder "Name"} values :name]]
|
||||
[:div.col-sm-3.actions
|
||||
[:button.btn.btn-sm.btn-primary {:on-click add!} [:span.glyphicon.glyphicon-plus]]]])))
|
||||
|
||||
(defn class-list
|
||||
"sub-container for showing list of classes and allowing entry of new classes"
|
||||
[classes]
|
||||
[:div.container-fluid.list
|
||||
(map
|
||||
(fn [{:keys [class_id] :as class}]
|
||||
^{:key class_id} [class-info class])
|
||||
classes)
|
||||
[new-class]])
|
||||
|
||||
(defvc classes
|
||||
"main container for class information"
|
||||
[]
|
||||
(let [classes (view-cursor :classes)]
|
||||
[:div#classes.container-fluid
|
||||
[:div.panel.panel-default
|
||||
[:div.panel-heading [:h3.panel-title "Classes"]]
|
||||
[:div.panel-body [class-list @classes]]]]))
|
||||
|
||||
(defn class-registry-app
|
||||
"main application container"
|
||||
[]
|
||||
[:div.container-fluid
|
||||
[:h1#app-title.page-header "Class Registry " [:small "Reagent Data Views Example"]]
|
||||
[:div.row
|
||||
[:div.col-sm-6 [people]]
|
||||
[:div.col-sm-6 [classes]]]])
|
||||
|
||||
|
||||
|
||||
;; AJAX CSRF stuff
|
||||
|
||||
(defn get-anti-forgery-token
|
||||
[]
|
||||
(if-let [tag (aget (.querySelectorAll js/document "meta[name='anti-forgery-token']") 0)]
|
||||
(.-content tag)))
|
||||
|
||||
(def csrf-interceptor
|
||||
(to-interceptor {:name "CSRF Interceptor"
|
||||
:request #(assoc-in % [:headers "X-CSRF-Token"] (get-anti-forgery-token))}))
|
||||
|
||||
(swap! default-interceptors (partial cons csrf-interceptor))
|
||||
|
||||
|
||||
|
||||
;; Page load
|
||||
|
||||
(defn ^:export run
|
||||
[]
|
||||
(enable-console-print!)
|
||||
(rdv-browserchannel/configure!)
|
||||
(browserchannel/connect! {} {:middleware [rdv-browserchannel/middleware]})
|
||||
|
||||
(r/render-component [class-registry-app] (.getElementById js/document "app")))
|
173
examples/class-registry/src/class_registry/server.clj
Normal file
173
examples/class-registry/src/class_registry/server.clj
Normal file
|
@ -0,0 +1,173 @@
|
|||
(ns class-registry.server
|
||||
(:gen-class)
|
||||
(:require
|
||||
[compojure.core :refer [routes GET POST]]
|
||||
[compojure.route :as route]
|
||||
[ring.middleware.defaults :refer [wrap-defaults site-defaults]]
|
||||
[ring.middleware.anti-forgery :refer [*anti-forgery-token*]]
|
||||
[ring.middleware.format :refer [wrap-restful-format]]
|
||||
[ring.util.response :refer [response]]
|
||||
[net.thegeez.browserchannel.server :refer [wrap-browserchannel]]
|
||||
[net.thegeez.browserchannel.immutant-async-adapter :refer [wrap-immutant-async-adapter]]
|
||||
[immutant.web :as immutant]
|
||||
[clj-pebble.core :as pebble]
|
||||
[environ.core :refer [env]]
|
||||
[clojure.java.jdbc :as jdbc]
|
||||
[views.sql.core :refer [vexec! with-view-transaction]]
|
||||
[views.sql.view :refer [view]]
|
||||
[reagent-data-views.browserchannel.server :as rdv-browserchannel]))
|
||||
|
||||
(def dev? (boolean (env :dev)))
|
||||
|
||||
(def db {:classname "org.postgresql.Driver"
|
||||
:subprotocol "postgresql"
|
||||
:subname "//10.0.0.20/class_registry"
|
||||
:user "class_registry"
|
||||
:password "s3cr3t"})
|
||||
|
||||
|
||||
|
||||
;; View functions.
|
||||
|
||||
(defn classes-list
|
||||
[]
|
||||
["SELECT class_id, code, name
|
||||
FROM classes
|
||||
ORDER BY code"])
|
||||
|
||||
(defn people-list
|
||||
[& [type]]
|
||||
(if type
|
||||
["SELECT people_id, type, first_name, middle_name, last_name, email
|
||||
FROM people
|
||||
WHERE type = ?
|
||||
ORDER BY last_name, first_name"
|
||||
type]
|
||||
["SELECT people_id, type, first_name, middle_name, last_name, email
|
||||
FROM people
|
||||
ORDER BY type, last_name, first_name"]))
|
||||
|
||||
(defn class-registry
|
||||
[class-id]
|
||||
["SELECT r.registry_id, p.type, p.first_name, p.middle_name, p.last_name
|
||||
FROM registry r
|
||||
JOIN people p on p.people_id = r.people_id
|
||||
WHERE r.class_id = ?
|
||||
ORDER BY p.type, p.last_name, p.first_name"
|
||||
class-id])
|
||||
|
||||
(defn people-registerable-for-class
|
||||
[class-id]
|
||||
["SELECT p.people_id, p.type, p.first_name, p.middle_name, p.last_name, p.email
|
||||
FROM people p
|
||||
WHERE p.people_id NOT IN (SELECT r.people_id
|
||||
FROM registry r
|
||||
WHERE r.class_id = ?)"
|
||||
class-id])
|
||||
|
||||
|
||||
|
||||
;; Views list.
|
||||
|
||||
(def views
|
||||
[(view :classes db #'classes-list)
|
||||
(view :people db #'people-list)
|
||||
(view :class-registry db #'class-registry)
|
||||
(view :people-registerable-for-class db #'people-registerable-for-class)])
|
||||
|
||||
|
||||
|
||||
;; SQL operations triggered by AJAX requests.
|
||||
|
||||
(defn add-person!
|
||||
[{:keys [type first_name middle_name last_name email] :as person}]
|
||||
(vexec! db ["INSERT INTO people (type, first_name, middle_name, last_name, email)
|
||||
VALUES (?, ?, ?, ?, ?)"
|
||||
type first_name middle_name last_name email])
|
||||
(response "ok"))
|
||||
|
||||
(defn update-person!
|
||||
[{:keys [id first_name middle_name last_name email] :as person}]
|
||||
(vexec! db ["UPDATE people SET
|
||||
first_name = ?, middle_name = ?, last_name = ?, email = ?
|
||||
WHERE people_id = ?"
|
||||
first_name middle_name last_name email id])
|
||||
(response "ok"))
|
||||
|
||||
(defn delete-person!
|
||||
[id]
|
||||
(vexec! db ["DELETE FROM people WHERE people_id = ?" id])
|
||||
(response "ok"))
|
||||
|
||||
(defn add-class!
|
||||
[{:keys [code name] :as class}]
|
||||
(vexec! db ["INSERT INTO classes (code, name) VALUES (?, ?)" code, name])
|
||||
(response "ok"))
|
||||
|
||||
(defn update-class!
|
||||
[{:keys [class_id code name] :as class}]
|
||||
(vexec! db ["UPDATE classes SET code = ?, name = ? WHERE class_id = ?" code name class_id])
|
||||
(response "ok"))
|
||||
|
||||
(defn delete-class!
|
||||
[id]
|
||||
(vexec! db ["DELETE FROM classes WHERE class_id = ?" id])
|
||||
(response "ok"))
|
||||
|
||||
(defn add-registration!
|
||||
[class-id people-id]
|
||||
(vexec! db ["INSERT INTO registry (class_id, people_id) VALUES (?, ?)" class-id people-id])
|
||||
(response "ok"))
|
||||
|
||||
(defn remove-registration!
|
||||
[registry-id]
|
||||
(vexec! db ["DELETE FROM registry WHERE registry_id = ?" registry-id])
|
||||
(response "ok"))
|
||||
|
||||
|
||||
|
||||
;; Compojure routes and Ring handler
|
||||
|
||||
(def app-routes
|
||||
(routes
|
||||
; ajax db action routes
|
||||
(POST "/people/add" [person] (add-person! person))
|
||||
(POST "/people/update" [person] (update-person! person))
|
||||
(POST "/people/delete" [id] (delete-person! id))
|
||||
|
||||
(POST "/class/add" [class] (add-class! class))
|
||||
(POST "/class/update" [class] (update-class! class))
|
||||
(POST "/class/delete" [id] (delete-class! id))
|
||||
|
||||
(POST "/registry/add" [class-id people-id] (add-registration! class-id people-id))
|
||||
(POST "/registry/remove" [id] (remove-registration! id))
|
||||
|
||||
; main page
|
||||
(GET "/" [] (pebble/render-resource
|
||||
"html/app.html"
|
||||
{:dev dev?
|
||||
:csrfToken *anti-forgery-token*}))
|
||||
|
||||
(route/resources "/")
|
||||
(route/not-found "not found")))
|
||||
|
||||
(def handler
|
||||
(-> app-routes
|
||||
(wrap-restful-format :formats [:transit-json])
|
||||
(wrap-defaults (assoc-in site-defaults [:security :anti-forgery] (not dev?)))
|
||||
(wrap-browserchannel {} {:middleware [rdv-browserchannel/middleware]})
|
||||
(wrap-immutant-async-adapter)))
|
||||
|
||||
|
||||
|
||||
;; Web server startup & main
|
||||
|
||||
(defn run-server []
|
||||
(pebble/set-options! :cache (not dev?))
|
||||
|
||||
(rdv-browserchannel/init-views! views)
|
||||
|
||||
(immutant/run handler {:port 8080}))
|
||||
|
||||
(defn -main [& args]
|
||||
(run-server))
|
Loading…
Reference in a new issue