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 ( -
- - 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 ( - <> - -   - - - ) -} - 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 ( - <> - -   - - + 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 ( +
+ +  路  + 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 ( + + ) +}) \ 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