diff --git a/.github/workflows/publish-examples.yml b/.github/workflows/publish-examples.yml
index e2b3052e..2aacb891 100644
--- a/.github/workflows/publish-examples.yml
+++ b/.github/workflows/publish-examples.yml
@@ -44,7 +44,7 @@ jobs:
- run: yarn install --frozen-lockfile
- - run: yarn shadow-cljs compile dev
+ - run: yarn shadow-cljs release dev
- name: Publish to GitHub Pages 馃殌
uses: JamesIves/github-pages-deploy-action@releases/v3
diff --git a/README.md b/README.md
index e265eba7..00abd0dd 100644
--- a/README.md
+++ b/README.md
@@ -149,6 +149,37 @@ todos
.map(todo => todo.get('name'))
```
+## Performance
+
+Homebase React tracks the attributes consumed in each component via the `entity.get` function and scopes those attributes to their respective `useEntity` or `useQuery` hook. Re-renders are only triggered when an attribute changes.
+
+The default caching reduces unnecessary re-renders and virtual DOM thrashing a lot. That said, it is still possible to trigger more re-renders than you might want.
+
+One top level `useQuery` + prop drilling the entities it returns will cause all children to re-render on any change to the parent or their siblings.
+
+To fix this we recommend passing ids to children, not whole entities. Instead get the entity in the child with `useEntity(id)`. This creates a new scope for each child so they are not affected by changes in the state of the parent or sibling components.
+
+```js
+const TodoList = () => {
+ const [todos] = useQuery({
+ $find: 'todo',
+ $where: { todo: { name: '$any' } }
+ })
+ return (todos.map(t => ))
+}
+
+// Good
+const Todo = React.memo(({ id }) => {
+ const [todo] = useEntity(id)
+ // ...
+})
+
+// Bad
+const Todo = React.memo(({ todo }) => {
+ // ...
+})
+```
+
## Docs
https://www.notion.so/Homebase-Alpha-Docs-0f0e22f3adcd4e9d87a13440ab0c7a0b
diff --git a/js/todo-example.jsx b/js/todo-example.jsx
index 5658de11..7e7c815c 100644
--- a/js/todo-example.jsx
+++ b/js/todo-example.jsx
@@ -29,8 +29,7 @@ const config = {
// identity is a special unique attribute for user generated ids
// E.g. todoFilters are settings that should be easy to lookup by their identity
identity: 'todoFilters',
- showCompleted: true,
- project: 0
+ showCompleted: true
}
}, {
user: {
@@ -110,57 +109,6 @@ const NewTodo = () => {
)
}
-const TodoFilters = () => {
- const [filters] = useEntity({ identity: 'todoFilters' })
- const [transact] = useTransact()
- return (
-
-
Show Completed?
-
transact([{ todoFilter: { id: filters.get('id'), showCompleted: e.target.checked }}])}
- />
- 路
-
transact([{ todoFilter: { id: filters.get('id'), project }}])}
- />
-
- )
-}
-
-const ProjectSelect = ({ value, onChange }) => {
- const [projects] = useQuery({
- $find: 'project',
- $where: { project: { name: '$any' } }
- })
- return (
- <>
-
- Project:
-
-
- onChange && onChange(Number(e.target.value))}
- >
-
- {projects.map(project => (
-
- {project.get('name')}
-
- ))}
-
- >
- )
-}
-
const TodoList = () => {
const [filters] = useEntity({ identity: 'todoFilters' })
const [todos] = useQuery({
@@ -169,36 +117,42 @@ const TodoList = () => {
})
return (
- {todos
- .filter(todo => {
+ {todos.filter(todo => {
if (!filters.get('showCompleted') && todo.get('isCompleted')) return false
if (filters.get('project') && todo.get('project', 'id') !== filters.get('project')) return false
+ if (filters.get('owner') && todo.get('owner', 'id') !== filters.get('owner')) return false
return true
- })
- .sort((a, b) => a.get('createdAt') > b.get('createdAt') ? -1 : 1)
- .map(todo => )}
+ }).sort((a, b) => a.get('createdAt') > b.get('createdAt') ? -1 : 1)
+ .map(todo => )}
)
}
-const Todo = ({ todo }) => (
-
-
-
-
-
+// PERFORMANCE: By accepting an `id` prop instead of a whole `todo` entity
+// this component stays disconnected from the useQuery in the parent TodoList.
+// useEntity creates a separate scope for every Todo so changes to TodoList
+// or sibling Todos don't trigger unnecessary re-renders.
+const Todo = React.memo(({ id }) => {
+ const [todo] = useEntity(id)
+ return (
-
- 路
-
- 路
-
+
+
+
+
+
+
+ 路
+
+ 路
+
+
+
+ {todo.get('createdAt').toLocaleString()}
+
-
- {todo.get('createdAt').toLocaleString()}
-
-
-)
+ )
+})
const TodoCheck = ({ todo }) => {
const [transact] = useTransact()
@@ -207,12 +161,7 @@ const TodoCheck = ({ todo }) => {
type="checkbox"
style={{ width: 20, height: 20, cursor: 'pointer' }}
checked={!!todo.get('isCompleted')}
- onChange={e => transact([{
- todo: {
- id: todo.get('id'),
- isCompleted: e.target.checked
- }
- }])}
+ onChange={e => transact([{ todo: { id: todo.get('id'), isCompleted: e.target.checked } }])}
/>
)
}
@@ -222,10 +171,10 @@ const TodoName = ({ todo }) => {
return (
transact([{ todo: { id: todo.get('id'), name: e.target.value }}])}
/>
)
@@ -234,41 +183,24 @@ const TodoName = ({ todo }) => {
const TodoProject = ({ todo }) => {
const [transact] = useTransact()
return (
- transact([{ todo: { id: todo.get('id'), 'project': projectId || null }}])}
+ transact([{ todo: { id: todo.get('id'), project }}])}
/>
)
}
const TodoOwner = ({ todo }) => {
const [transact] = useTransact()
- const [users] = useQuery({
- $find: 'user',
- $where: { user: { name: '$any' } }
- })
return (
- <>
-
- Owner:
-
-
- transact([{ todo: { id: todo.get('id'), owner: Number(e.target.value) || null }}])}
- >
-
- {users.map(user => (
-
- {user.get('name')}
-
- ))}
-
- >
+ transact([{ todo: { id: todo.get('id'), owner }}])}
+ />
)
}
@@ -279,4 +211,57 @@ const TodoDelete = ({ todo }) => {
Delete
)
-}
\ No newline at end of file
+}
+
+const TodoFilters = () => {
+ const [filters] = useEntity({ identity: 'todoFilters' })
+ const [transact] = useTransact()
+ return (
+
+ Show Completed?
+ transact([{ todoFilter: { id: filters.get('id'), showCompleted: e.target.checked }}])}
+ />
+
+ 路
+ transact([{ todoFilter: { id: filters.get('id'), project }}])}
+ />
+ 路
+ transact([{ todoFilter: { id: filters.get('id'), owner }}])}
+ />
+
+ )
+}
+
+const EntitySelect = React.memo(({ label, entityType, value, onChange }) => {
+ const [entities] = useQuery({
+ $find: entityType,
+ $where: { [entityType]: { name: '$any' } }
+ })
+ return (
+ {label}:
+ onChange && onChange(Number(e.target.value) || null)}
+ >
+
+ {entities.map(entity => (
+
+ {entity.get('name')}
+
+ ))}
+
+
+ )
+})
\ No newline at end of file
diff --git a/package.json b/package.json
index 8fba02e7..99d89571 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"test:js": "yarn build && jest js/tests",
"test:js:dev": "yarn build:dev && jest js/tests",
"test:cljs": "shadow-cljs compile test && node out/node-tests.js",
+ "test:cljs:watch": "shadow-cljs watch test-autorun",
"test": "yarn test:cljs && yarn test:js",
"test:dev": "yarn test:cljs && yarn test:js:dev",
"report": "rm -rf dist && shadow-cljs run shadow.cljs.build-report npm report.html",
diff --git a/shadow-cljs.edn b/shadow-cljs.edn
index ab1ee824..9bec04f7 100644
--- a/shadow-cljs.edn
+++ b/shadow-cljs.edn
@@ -23,6 +23,10 @@
:output-to "out/node-tests.js"
:ns-regexp "-test$"
:autorun false}
+ :test-autorun {:target :node-test
+ :output-to "out/node-tests.js"
+ :ns-regexp "-test$"
+ :autorun true}
:npm {:target :npm-module
:output-dir "dist/js"
:entries [homebase.react]
diff --git a/src/homebase/js.cljs b/src/homebase/js.cljs
index 3e5dbebb..a84d4948 100644
--- a/src/homebase/js.cljs
+++ b/src/homebase/js.cljs
@@ -26,13 +26,14 @@
(when-let [[_ verb _ key] (re-find bool-re string)]
(if (= "is" verb) (str key "?") (str verb "-" key "?"))))
-(defn js->key-not-memo [namespace string]
+(defn js->key-not-memo [namespace key]
(or
- (get js->db-attr-overrides string)
+ (get js->db-attr-overrides key)
(keyword (csk/->kebab-case namespace)
- (csk/->kebab-case
- (or (js->bool-key string)
- string)))))
+ (str (if (= (subs key 0 1) "_") "_" "")
+ (csk/->kebab-case
+ (or (js->bool-key key)
+ key))))))
(def js->key (memoize js->key-not-memo))
(comment
@@ -99,11 +100,6 @@
"unique" "identity"}
"owner" {"type" "ref"}}})))
-(defn q-entity-array [query conn & args]
- (->> (apply d/q query conn args)
- (map (fn id->entity [[id]] (d/entity conn id)))
- to-array))
-
(defn js->datalog [data]
(let [{find "$find" where "$where"} (js->clj data)]
{:find [(symbol (str "?" find))]
@@ -136,6 +132,13 @@
(object? query) (js->datalog query)
:else nil))
+(defn nil->retract [tx]
+ (if-let [id (:db/id tx)]
+ (map (fn [[k v]]
+ [(if (nil? v) :db/retract :db/add) id k v])
+ (dissoc tx :db/id))
+ [tx]))
+
(defn js-get [entity name]
(case name
"id" (:db/id entity)
@@ -143,64 +146,111 @@
"identity" (:db/ident entity)
(let [ks (remove #{:db/id :db/ident} (keys entity))
; This assumes that every entity only has keys of the same namespace once the :db keys are removed
- nms (namespace (first ks))
- k (js->key nms name)]
- (get entity k))))
+ ; E.g. :db/id 1, :todo/name "", :todo/email ""
+ ; Not: :db/id 1, :todo/name "", :email/address ""
+ k (when (first ks)
+ (js->key (namespace (first ks)) name))]
+ (when k (get entity k)))))
+
+(defn entity-in-db? [entity]
+ (not (nil? (first (d/datoms (.-db entity) :eavt (:db/id entity))))))
+
+(declare HBEntity)
+
+(defn Entity->HBEntity [v]
+ (if (= Entity (type v))
+ (HBEntity. v nil) v))
+
+(defn lookup-entity
+ ([entity attrs] (lookup-entity entity attrs false))
+ ([entity attrs nil-attrs-if-not-in-db?]
+ (Entity->HBEntity
+ (reduce
+ (fn [acc attr]
+ (if-not acc nil
+ (let [attr (keywordize attr)
+ f (if (keyword? attr) get js-get)]
+ (cond
+ (and nil-attrs-if-not-in-db?
+ (or (= :db/id attr) (= "id" attr))
+ (not (entity-in-db? acc))) nil
+ (set? acc) (f (first acc) attr)
+ acc (f acc attr)
+ :else nil))))
+ entity attrs))))
(extend-type Entity
Object
- (get [this & keys]
- (reduce
- (fn [acc key]
- (let [key (keywordize key)
- f (if (keyword? key) get js-get)]
- (cond
- (set? acc) (f (first acc) (keywordize key))
- acc (f acc (keywordize key))
- :else nil)))
- this keys)))
+ (get [entity & attrs] (lookup-entity entity attrs)))
-(defn nil->retract [tx]
- (if-let [id (:db/id tx)]
- (map (fn [[k v]]
- [(if (nil? v) :db/retract :db/add) id k v])
- (dissoc tx :db/id))
- [tx]))
+(deftype HBEntity [^datascript.impl.entity/Entity entity _meta]
+ IMeta
+ (-meta [_] _meta)
+ IWithMeta
+ (-with-meta [_ new-meta] (HBEntity. entity new-meta))
+ ILookup
+ (-lookup [_ attr] (lookup-entity entity [attr] true))
+ (-lookup [_ attr not-found] (or (lookup-entity entity [attr] true) not-found))
+ IAssociative
+ (-contains-key? [_ k] (not (nil? (lookup-entity entity [k] true))))
+ Object
+ (get [this & attrs]
+ (let [v (lookup-entity entity attrs true)]
+ (when-let [f (:HBEntity/get (meta this))]
+ (f [this attrs v]))
+ v)))
+(defn q-entity-array [query conn & args]
+ (->> (apply d/q query conn args)
+ (map (fn id->entity [[id]] (HBEntity. (d/entity conn id) nil)))
+ to-array))
+
+(declare humanize-transact-error humanize-entity-error humanize-q-error)
+
+(defn transact! [conn txs]
+ (try
+ (d/transact! conn (mapcat (comp nil->retract js->tx) txs))
+ (catch js/Error e
+ (throw (js/Error. (humanize-transact-error e))))))
+
+(defn entity [conn lookup]
+ (try
+ (HBEntity. (d/entity @conn (js->entity-lookup lookup)) nil)
+ (catch js/Error e
+ (throw (js/Error. (humanize-entity-error e))))))
+
+(defn q [query conn & args]
+ (try
+ (apply q-entity-array (js->query query) @conn (keywordize args))
+ (catch js/Error e
+ (throw (js/Error. (humanize-q-error e))))))
(defn humanize-transact-error [error]
(condp re-find (goog.object/get error "message")
- #"\[object Object\] is not ISeqable"
+ #"\[object Object\] is not ISeqable"
"Expected an array of transactions.
\nFor example: transact([
{todo: {name: 1}},
{todo: {name: 2}}
])
"
-
+
#"Unknown operation at \[nil nil nil nil\], expected"
"Expected 'retractEntity'.
\nFor example: transact([['retractEntity', id]])
"
-
+
#"Can't use tempid in '\[:db\.fn/retractEntity"
"Expected a numerical id.
\nFor example: transact([['retractEntity', 123]])
"
-
+
#"Expected number or lookup ref for entity id, got nil"
"Expected a numerical id.
\nFor example: transact([['retractEntity', 123]])
"
(goog.object/get error "message")))
-(defn transact! [conn txs]
- (try
- (d/transact! conn (mapcat (comp nil->retract js->tx) txs))
- (catch js/Error e
- (throw (js/Error. (humanize-transact-error e))))))
-
-
(defn humanize-entity-error [error]
(condp re-find (goog.object/get error "message")
#"Lookup ref attribute should be marked as :db/unique: \[:([\w-]+)/([\w-]+) ((?!\]).+)\]"
@@ -209,13 +259,6 @@
"\n\nAdd this to your config: { schema: { " nmspc ": { " attr ": { unique: 'identity' }}}\n"))
(goog.object/get error "message")))
-(defn entity [conn lookup]
- (try
- (d/entity @conn (js->entity-lookup lookup))
- (catch js/Error e
- (throw (js/Error. (humanize-entity-error e))))))
-
-
(defn example-js-query
([] (example-js-query "item"))
([nmsp] (str "\n
@@ -230,25 +273,19 @@ For example: query({
#"Query should be a vector or a map"
(str "Expected query to be in the form of an object or datalog string."
(example-js-query))
-
+
#"Query for unknown vars: \[\?\]"
(str "Expected query to have a $find and a $where clause."
(example-js-query))
-
+
; TODO: revist when datalog strings are better supported since this error is directed at JS object queries only.
#"Query for unknown vars: \[\?((?!\]).+)\]"
:>> (fn [[_ var]]
(str "Expected to see '" var "' in both the $find and $where clauses."
(example-js-query var)))
-
+
#"((?! is not ISeqable).+) is not ISeqable"
:>> (fn [[_ v]]
(str "Expected $where clause to be a nested object, not " v "."
(example-js-query)))
- (goog.object/get error "message")))
-
-(defn q [query conn & args]
- (try
- (apply q-entity-array (js->query query) @conn (keywordize args))
- (catch js/Error e
- (throw (js/Error. (humanize-q-error e))))))
\ No newline at end of file
+ (goog.object/get error "message")))
\ No newline at end of file
diff --git a/src/homebase/js_test.cljs b/src/homebase/js_test.cljs
index d189cbc5..d2cf9a17 100644
--- a/src/homebase/js_test.cljs
+++ b/src/homebase/js_test.cljs
@@ -4,32 +4,51 @@
[datascript.core :as d]
[homebase.js :as hbjs]))
-(deftest test-transact
- (testing "should succeed"
- (is (map? (hbjs/transact! (d/create-conn)
- (clj->js [{"wat" {"thing" 1}}
- ["retractEntity" 1]])))))
- (testing "should fail with humanized errors"
- (is (thrown-with-msg?
- js/Error
- #"(?s)Expected an array of transactions.*For example:"
- (hbjs/transact! (d/create-conn) (clj->js {}))))
- (is (thrown-with-msg?
- js/Error
- #"(?s)Expected 'retractEntity'.*For example:"
- (hbjs/transact! (d/create-conn) (clj->js [[]]))))
- (is (thrown-with-msg?
- js/Error
- #"(?s)Expected 'retractEntity'.*For example:"
- (hbjs/transact! (d/create-conn) (clj->js [["notAThing"]]))))
- (is (thrown-with-msg?
- js/Error
- #"(?s)Expected a numerical id.*For example:"
- (hbjs/transact! (d/create-conn) (clj->js [["retractEntity" "wat"]]))))
- (is (thrown-with-msg?
- js/Error
- #"(?s)Expected a numerical id.*For example:"
- (hbjs/transact! (d/create-conn) (clj->js [["retractEntity"]]))))))
+(def test-conn
+ (d/conn-from-db
+ (d/init-db
+ #{(d/datom 3 :todo/project 2)
+ (d/datom 2 :project/name "abc")}
+ {:todo/project {:db/valueType :db.type/ref
+ :db/cardinality :db.cardinality/one}})))
+
+(deftest test-entity-get
+ (testing "datascript entity get"
+ (is (thrown-with-msg?
+ js/Error
+ #"Cannot read property 'get' of null"
+ (.get (d/entity @(d/create-conn) nil) :db/id)))
+ (is (= 3 (:db/id (d/entity @(d/create-conn) 3))))
+ (is (= 3 (.get (d/entity @(d/create-conn) 3) :db/id)))
+ (is (nil? (.get (d/entity @(d/create-conn) 3) :todo/name)))
+ (is (nil? (get-in (d/entity @(d/create-conn) 3) [:todo/project :db/id])))
+ (is (= 2 (get-in (d/entity @test-conn 3) [:todo/project :db/id])))
+ (is (some? (get-in (d/entity @test-conn 2) [:todo/_project])))
+ (is (= 2 (.get (d/entity @test-conn 3) :todo/project :db/id)))
+ (is (= 3 (get (d/entity @test-conn 3) :db/id)))
+ (is (= 3 (.get (d/entity @test-conn 3) "id")))
+ (is (= 2 (.get (d/entity @test-conn 3) "project" "id")))
+ (is (thrown? js/Error (get (d/entity @test-conn 3) "id")))
+ (is (thrown? js/Error (get-in (d/entity @test-conn 3) ["project" "id"])))
+ (is (= "abc" (.get (d/entity @test-conn 3) "project" "name"))))
+ (testing "homebase entity get"
+ (is (some? (hbjs/entity (d/create-conn) 3)))
+ (is (= 3 (:db/id (hbjs/entity test-conn 3))))
+ (is (= 3 (get (hbjs/entity test-conn 3) "id")))
+ (is (= 3 (.get (hbjs/entity test-conn 3) "id")))
+ (is (nil? (.get (hbjs/entity (d/create-conn) 3) "name")))
+ (is (= "abc" (.get (hbjs/entity test-conn 2) "name")))
+ (is (nil? (get-in (hbjs/entity (d/create-conn) 3) ["project" "id"])))
+ (is (some? (.get (hbjs/entity test-conn 2) ":todo/_project")))
+ (is (= "abc" (.get (hbjs/entity test-conn 3) "project" "name")))
+ (is (nil? (:db/id (hbjs/entity (d/create-conn) 3))))
+ (is (nil? (.get (hbjs/entity (d/create-conn) 3) "id")))
+ (is (nil? (get (hbjs/entity (d/create-conn) 3) "id")))
+ (is (nil? (get-in (hbjs/entity (d/create-conn) 3) ["id"])))
+ (is (nil? (.get (hbjs/entity (d/create-conn) 3) "project" "id")))
+ (is (= 2 (get-in (hbjs/entity test-conn 3) ["project" "id"])))
+ (is (= "abc" (get-in (hbjs/entity test-conn 3) ["project" "name"])))))
+
(deftest test-transact
(testing "should succeed"
@@ -60,7 +79,7 @@
(deftest test-entity
(testing "should succeed"
- (is (= 1 (:db/id (hbjs/entity (d/create-conn) (clj->js 1))))))
+ (is (nil? (:db/id (hbjs/entity (d/create-conn) (clj->js 1))))))
(testing "should fail with humanized errors"
(is (thrown-with-msg?
js/Error
diff --git a/src/homebase/react.cljs b/src/homebase/react.cljs
index 15d6ca40..2cc51b7c 100644
--- a/src/homebase/react.cljs
+++ b/src/homebase/react.cljs
@@ -16,6 +16,20 @@
(second)
(clojure.string/trim))))))))
+(defn changed? [entities cached-entities]
+ (if (not= (count entities) (count cached-entities))
+ true
+ (reduce (fn [_ e]
+ (when (let [cached-e (get cached-entities (get e "id"))]
+ (if (nil? cached-e)
+ (reduced true)
+ (reduce (fn [_ [ks v]]
+ (when (not= v (get-in e ks))
+ (reduced true)))
+ nil cached-e)))
+ (reduced true)))
+ nil entities)))
+
(defonce ^:export homebase-context (react/createContext))
(def base-schema
@@ -34,25 +48,47 @@
(defn ^:export useEntity [lookup]
(let [conn (react/useContext homebase-context)
- run-lookup (fn [] (try-hook "useEntity" #(hbjs/entity conn lookup)))
- [result setResult] (react/useState (run-lookup))]
+ cached-entities (react/useMemo #(atom {}) #js [])
+ run-lookup (react/useCallback
+ (fn run-lookup []
+ (vary-meta (try-hook "useEntity" #(hbjs/entity conn lookup))
+ merge {:HBEntity/get (fn [[e ks v]] (swap! cached-entities assoc-in [(get e "id") ks] v))}))
+ #js [lookup])
+ [result setResult] (react/useState (run-lookup))
+ listener (react/useCallback
+ (fn entity-listener []
+ (let [result (run-lookup)]
+ (when (changed? #js [result] @cached-entities)
+ (setResult result))))
+ #js [run-lookup])]
(react/useEffect
(fn use-entity-effect []
(let [key (rand)]
- (d/listen! conn key #(setResult (run-lookup)))
- (fn unmount-use-entity-effect [] (d/unlisten! conn key))))
+ (d/listen! conn key listener)
+ #(d/unlisten! conn key)))
#js [lookup])
[result]))
(defn ^:export useQuery [query & args]
(let [conn (react/useContext homebase-context)
- run-query (fn [] (try-hook "useQuery" #(apply hbjs/q query conn args)))
- [result setResult] (react/useState (run-query))]
+ cached-entities (react/useMemo #(atom {}) #js [])
+ run-query (react/useCallback
+ (fn run-query []
+ (.map (try-hook "useQuery" #(apply hbjs/q query conn args))
+ (fn [e] (vary-meta e merge {:HBEntity/get (fn [[e ks v]] (swap! cached-entities assoc-in [(get e "id") ks] v))}))))
+ #js [query args])
+ [result setResult] (react/useState (run-query))
+ listener (react/useCallback
+ (fn query-listener []
+ (let [result (run-query)]
+ (when (changed? result @cached-entities)
+ (setResult result))))
+ #js [run-query])]
(react/useEffect
(fn use-query-effect []
(let [key (rand)]
- (d/listen! conn key #(setResult (run-query)))
- (fn unmount-use-query-effect [] (d/unlisten! conn key))))
+ (d/listen! conn key listener)
+ #(d/unlisten! conn key)))
#js [query args])
[result]))
diff --git a/src/js_gen/todo-example.js b/src/js_gen/todo-example.js
index 69150f00..5b7620b1 100644
--- a/src/js_gen/todo-example.js
+++ b/src/js_gen/todo-example.js
@@ -50,8 +50,7 @@ const config = {
// identity is a special unique attribute for user generated ids
// E.g. todoFilters are settings that should be easy to lookup by their identity
identity: 'todoFilters',
- showCompleted: true,
- project: 0
+ showCompleted: true
}
}, {
user: {
@@ -122,58 +121,6 @@ const NewTodo = () => {
}, "Create Todo"));
};
-const TodoFilters = () => {
- const [filters] = useEntity({
- identity: 'todoFilters'
- });
- const [transact] = useTransact();
- return /*#__PURE__*/_react.default.createElement("div", null, /*#__PURE__*/_react.default.createElement("label", {
- htmlFor: "show-completed"
- }, "Show Completed?"), /*#__PURE__*/_react.default.createElement("input", {
- type: "checkbox",
- id: "show-completed",
- checked: filters.get('showCompleted'),
- onChange: e => transact([{
- todoFilter: {
- id: filters.get('id'),
- showCompleted: e.target.checked
- }
- }])
- }), "\xA0\xB7\xA0", /*#__PURE__*/_react.default.createElement(ProjectSelect, {
- value: filters.get('project'),
- onChange: project => transact([{
- todoFilter: {
- id: filters.get('id'),
- project
- }
- }])
- }));
-};
-
-const ProjectSelect = ({
- value,
- onChange
-}) => {
- const [projects] = useQuery({
- $find: 'project',
- $where: {
- project: {
- name: '$any'
- }
- }
- });
- return /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, /*#__PURE__*/_react.default.createElement("label", null, "Project:"), "\xA0", /*#__PURE__*/_react.default.createElement("select", {
- name: "projects",
- value: value,
- onChange: e => onChange && onChange(Number(e.target.value))
- }, /*#__PURE__*/_react.default.createElement("option", {
- value: "0"
- }), projects.map(project => /*#__PURE__*/_react.default.createElement("option", {
- key: project.get('id'),
- value: project.get('id')
- }, project.get('name')))));
-};
-
const TodoList = () => {
const [filters] = useEntity({
identity: 'todoFilters'
@@ -189,37 +136,45 @@ const TodoList = () => {
return /*#__PURE__*/_react.default.createElement("div", null, todos.filter(todo => {
if (!filters.get('showCompleted') && todo.get('isCompleted')) return false;
if (filters.get('project') && todo.get('project', 'id') !== filters.get('project')) return false;
+ if (filters.get('owner') && todo.get('owner', 'id') !== filters.get('owner')) return false;
return true;
}).sort((a, b) => a.get('createdAt') > b.get('createdAt') ? -1 : 1).map(todo => /*#__PURE__*/_react.default.createElement(Todo, {
key: todo.get('id'),
- todo: todo
+ id: todo.get('id')
})));
-};
+}; // PERFORMANCE: By accepting an `id` prop instead of a whole `todo` entity
+// this component stays disconnected from the useQuery in the parent TodoList.
+// useEntity creates a separate scope for every Todo so changes to TodoList
+// or sibling Todos don't trigger unnecessary re-renders.
-const Todo = ({
- todo
-}) => /*#__PURE__*/_react.default.createElement("div", null, /*#__PURE__*/_react.default.createElement("div", {
- style: {
- display: 'flex',
- flexDirection: 'row',
- alignItems: 'flex-end',
- paddingTop: 20
- }
-}, /*#__PURE__*/_react.default.createElement(TodoCheck, {
- todo: todo
-}), /*#__PURE__*/_react.default.createElement(TodoName, {
- todo: todo
-})), /*#__PURE__*/_react.default.createElement("div", null, /*#__PURE__*/_react.default.createElement(TodoProject, {
- todo: todo
-}), "\xA0\xB7\xA0", /*#__PURE__*/_react.default.createElement(TodoOwner, {
- todo: todo
-}), "\xA0\xB7\xA0", /*#__PURE__*/_react.default.createElement(TodoDelete, {
- todo: todo
-})), /*#__PURE__*/_react.default.createElement("small", {
- style: {
- color: 'grey'
- }
-}, todo.get('createdAt').toLocaleString()));
+
+const Todo = _react.default.memo(({
+ id
+}) => {
+ const [todo] = useEntity(id);
+ return /*#__PURE__*/_react.default.createElement("div", null, /*#__PURE__*/_react.default.createElement("div", {
+ style: {
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'flex-end',
+ paddingTop: 20
+ }
+ }, /*#__PURE__*/_react.default.createElement(TodoCheck, {
+ todo: todo
+ }), /*#__PURE__*/_react.default.createElement(TodoName, {
+ todo: todo
+ })), /*#__PURE__*/_react.default.createElement("div", null, /*#__PURE__*/_react.default.createElement(TodoProject, {
+ todo: todo
+ }), "\xA0\xB7\xA0", /*#__PURE__*/_react.default.createElement(TodoOwner, {
+ todo: todo
+ }), "\xA0\xB7\xA0", /*#__PURE__*/_react.default.createElement(TodoDelete, {
+ todo: todo
+ })), /*#__PURE__*/_react.default.createElement("small", {
+ style: {
+ color: 'grey'
+ }
+ }, todo.get('createdAt').toLocaleString()));
+});
const TodoCheck = ({
todo
@@ -256,7 +211,7 @@ const TodoName = ({
textDecoration: 'line-through '
})
},
- value: todo.get('name'),
+ defaultValue: todo.get('name'),
onChange: e => transact([{
todo: {
id: todo.get('id'),
@@ -270,12 +225,14 @@ const TodoProject = ({
todo
}) => {
const [transact] = useTransact();
- return /*#__PURE__*/_react.default.createElement(ProjectSelect, {
- value: todo.get('project', 'id') || '',
- onChange: projectId => transact([{
+ return /*#__PURE__*/_react.default.createElement(EntitySelect, {
+ label: "Project",
+ entityType: "project",
+ value: todo.get('project', 'id'),
+ onChange: project => transact([{
todo: {
id: todo.get('id'),
- 'project': projectId || null
+ project
}
}])
});
@@ -285,29 +242,17 @@ const TodoOwner = ({
todo
}) => {
const [transact] = useTransact();
- const [users] = useQuery({
- $find: 'user',
- $where: {
- user: {
- name: '$any'
- }
- }
- });
- return /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, /*#__PURE__*/_react.default.createElement("label", null, "Owner:"), "\xA0", /*#__PURE__*/_react.default.createElement("select", {
- name: "users",
- value: todo.get('owner', 'id') || '',
- onChange: e => transact([{
+ return /*#__PURE__*/_react.default.createElement(EntitySelect, {
+ label: "Owner",
+ entityType: "user",
+ value: todo.get('owner', 'id'),
+ onChange: owner => transact([{
todo: {
id: todo.get('id'),
- owner: Number(e.target.value) || null
+ owner
}
}])
- }, /*#__PURE__*/_react.default.createElement("option", {
- value: ""
- }), users.map(user => /*#__PURE__*/_react.default.createElement("option", {
- key: user.get('id'),
- value: user.get('id')
- }, user.get('name')))));
+ });
};
const TodoDelete = ({
@@ -317,4 +262,68 @@ const TodoDelete = ({
return /*#__PURE__*/_react.default.createElement("button", {
onClick: () => transact([['retractEntity', todo.get('id')]])
}, "Delete");
-};
\ No newline at end of file
+};
+
+const TodoFilters = () => {
+ const [filters] = useEntity({
+ identity: 'todoFilters'
+ });
+ const [transact] = useTransact();
+ return /*#__PURE__*/_react.default.createElement("div", null, /*#__PURE__*/_react.default.createElement("label", null, "Show Completed?", /*#__PURE__*/_react.default.createElement("input", {
+ type: "checkbox",
+ checked: filters.get('showCompleted'),
+ onChange: e => transact([{
+ todoFilter: {
+ id: filters.get('id'),
+ showCompleted: e.target.checked
+ }
+ }])
+ })), "\xA0\xB7\xA0", /*#__PURE__*/_react.default.createElement(EntitySelect, {
+ label: "Project",
+ entityType: "project",
+ value: filters.get('project'),
+ onChange: project => transact([{
+ todoFilter: {
+ id: filters.get('id'),
+ project
+ }
+ }])
+ }), "\xA0\xB7\xA0", /*#__PURE__*/_react.default.createElement(EntitySelect, {
+ label: "Owner",
+ entityType: "user",
+ value: filters.get('owner'),
+ onChange: owner => transact([{
+ todoFilter: {
+ id: filters.get('id'),
+ owner
+ }
+ }])
+ }));
+};
+
+const EntitySelect = _react.default.memo(({
+ label,
+ entityType,
+ value,
+ onChange
+}) => {
+ const [entities] = useQuery({
+ $find: entityType,
+ $where: {
+ [entityType]: {
+ name: '$any'
+ }
+ }
+ });
+ return /*#__PURE__*/_react.default.createElement("label", null, label, ":\xA0", /*#__PURE__*/_react.default.createElement("select", {
+ name: entityType,
+ value: value || '',
+ onChange: e => onChange && onChange(Number(e.target.value) || null)
+ }, /*#__PURE__*/_react.default.createElement("option", {
+ key: "-",
+ value: ""
+ }), entities.map(entity => /*#__PURE__*/_react.default.createElement("option", {
+ key: entity.get('id'),
+ value: entity.get('id')
+ }, entity.get('name')))));
+});
\ No newline at end of file