diff --git a/bb.edn b/bb.edn index b56863ff..d633ce16 100644 --- a/bb.edn +++ b/bb.edn @@ -40,7 +40,7 @@ migrate:fetch (yarn shadow-cljs clj-run sb.migration.core/tx!) migrate:tx (do (shell "rm -rf .db/datalevin") - (shell "yarn shadow-cljs clj-run sb.migration.core/tx!")) + (shell "yarn shadow-cljs -A:dev:nio:local clj-run sb.migration.core/tx!")) ;; this step is run manually after verifying a staging build. staging:promote (let [{:strs [Registry Repository Tag]} (-> (shell {:out :string} diff --git a/deps.edn b/deps.edn index 3280fcf7..8f7cc57f 100644 --- a/deps.edn +++ b/deps.edn @@ -5,7 +5,7 @@ ;; v2 datalevin/datalevin {:mvn/version "0.8.21"} - io.github.mhuebert/re-db {:git/sha "479a57401635f3a11ab128226976c6388b5f7f2c"} + io.github.mhuebert/re-db {:git/sha "db43109983156bf12adfdac38134b91f782179cd"} io.github.mhuebert/yawn {:git/sha "68285f6c132f26a2ff3cc2f7dffc3fe68c9856d9"} io.github.mhuebert/inside-out {:git/sha "6f8a4f4413f344d59da2ed55ddb1a0e9090c30b7"} diff --git a/src/sb/app.cljc b/src/sb/app.cljc index ba3a4847..0740c837 100644 --- a/src/sb/app.cljc +++ b/src/sb/app.cljc @@ -22,28 +22,21 @@ [sb.app.social-feed.ui] [sb.app.vote.ui] [sb.i18n :refer [t]] - [sb.transit :as t] - [sb.util :as u])) + [sb.transit :as t])) #?(:cljs (def client-endpoints (t/read (shadow.resource/inline "public/js/sparkboard.views.transit.json")))) -(defn fields-editor-field [] - (io/field :many (u/prune {:field/id ?id - :field/type ?type - :field/label ?label - :field/hint ?hint - :field/options (?options :many {:field-option/label ?label - :field-option/value ?value - :field-option/color ?color}) - :field/required? ?required? - :field/show-as-filter? ?show-as-filter? - :field/show-at-registration? ?show-at-registration? - :field/show-on-card? ?show-on-card?}))) - (def global-field-meta - {:account/email {:view field.ui/text-field - :props {:type "email" + {:string {:view field.ui/text-field} + :http/url {:view field.ui/text-field} + :boolean {:view field.ui/checkbox-field} + :prose/as-map {:view field.ui/prose-field + :make-field (fn [init _props] + (io/form {:prose/format prose/?format + :prose/string prose/?string} + :init init))} + :account/email {:props {:type "email" :placeholder (t :tr/email)} :validators [form.ui/email-validator]} :account/password {:view field.ui/text-field @@ -51,13 +44,11 @@ :placeholder (t :tr/password)} :validators [(io/min-length 8)]} :entity/title {:validators [(io/min-length 3)]} - :board/project-fields {:view field.admin-ui/fields-editor - :make-field fields-editor-field} - :board/member-fields {:view field.admin-ui/fields-editor - :make-field fields-editor-field} - :field/label {:view field.ui/text-field} - :field/hint {:view field.ui/text-field} :field/options {:view field.admin-ui/options-editor} :entity/domain-name {:view domain.ui/domain-field - :validators (domain.ui/validators)} - :image/avatar {:view field.ui/image-field}}) \ No newline at end of file + :make-field domain.ui/make-domain-field} + :entity/fields {:view field.admin-ui/fields-editor + :make-field field.admin-ui/make-field:fields} + :entity/field-entries {:view field.ui/entries-field + :make-field field.ui/make-field:entries} + :asset/as-map {:view field.ui/image-field}}) \ No newline at end of file diff --git a/src/sb/app/asset/ui.cljc b/src/sb/app/asset/ui.cljc index 2b3b690f..706a0c33 100644 --- a/src/sb/app/asset/ui.cljc +++ b/src/sb/app/asset/ui.cljc @@ -1,13 +1,14 @@ (ns sb.app.asset.ui (:require [sb.query-params :as query-params] - [sb.app.asset.data])) + [sb.app.asset.data] + [sb.schema :as sch])) (def variants {:avatar {:op "bound" :width 200 :height 200} :card {:op "bound" :width 600} :page {:op "bound" :width 1200}}) (defn asset-src [asset variant] - (when-let [id (:entity/id asset)] + (when-let [id (sch/unwrap-id asset)] (str "/assets/" id (some-> (variants variant) query-params/query-string)))) diff --git a/src/sb/app/board/admin_ui.cljc b/src/sb/app/board/admin_ui.cljc index 09612bfa..c033fb57 100644 --- a/src/sb/app/board/admin_ui.cljc +++ b/src/sb/app/board/admin_ui.cljc @@ -21,30 +21,32 @@ [:<> (header/entity board (list (entity.ui/settings-button board))) + [radix/accordion {:class "max-w-[600px] mx-auto my-6 flex-v gap-6" - :multiple true} + :multiple true} - [:div.field-label (t :tr/basic-settings)] - [:div.flex-v.gap-4 + [:div.field-label (t :tr/basic-settings)] + [:div.flex-v.gap-4 - (use-persisted-attr board :entity/title) - (use-persisted-attr board :entity/description) - (use-persisted-attr board :entity/domain-name) - (use-persisted-attr board :image/avatar {:label (t :tr/image.logo)})] + (use-persisted-attr board :entity/title) + (use-persisted-attr board :entity/description) + (use-persisted-attr board :entity/domain-name) + (use-persisted-attr board :image/avatar {:field/label (t :tr/image.logo)})] - [:div.field-label (t :tr/projects-and-members)] - [:div.flex-v.gap-4 - (use-persisted-attr board :board/member-fields) - (use-persisted-attr board :board/project-fields)] + [:div.field-label (t :tr/projects-and-members)] + [:div.flex-v.gap-4 + (use-persisted-attr board :board/member-fields) + (use-persisted-attr board :board/project-fields)] - [:div.field-label (t :tr/registration)] - [:div.flex-v.gap-4 - (use-persisted-attr board :board/registration-open?) - (use-persisted-attr board :board/registration-url-override) - (use-persisted-attr board :board/registration-page-message) - (use-persisted-attr board :board/invite-email-text)]] + [:div.field-label (t :tr/registration)] + [:div.flex-v.gap-4 + (use-persisted-attr board :board/registration-open?) + (use-persisted-attr board :board/registration-url-override) + (use-persisted-attr board :board/registration-page-message) + (use-persisted-attr board :board/invite-email-text)] + ] diff --git a/src/sb/app/board/data.cljc b/src/sb/app/board/data.cljc index f6f21f62..f7f0572d 100644 --- a/src/sb/app/board/data.cljc +++ b/src/sb/app/board/data.cljc @@ -29,8 +29,8 @@ :board/sticky-color {:doc "Deprecate - sticky notes can pick their own colors" s- :html/color} :board/member-tags {s- [:sequential :tag/as-map]} - :board/project-fields {s- [:sequential :field/as-map]} - :board/member-fields {s- [:sequential :field/as-map]} + :board/project-fields {s- :entity/fields} + :board/member-fields {s- :entity/fields} :board/invite-email-text {:hint "Text of email sent when inviting a user to a board." s- :string}, :board/registration-newsletter-field? {:hint "During registration, request permission to send the user an email newsletter" diff --git a/src/sb/app/board/ui.cljc b/src/sb/app/board/ui.cljc index 8e275f41..ea03b2c1 100644 --- a/src/sb/app/board/ui.cljc +++ b/src/sb/app/board/ui.cljc @@ -44,7 +44,7 @@ (.preventDefault e) (ui/with-submission [result (data/new! {:board @!board}) :form !board] - (routing/nav! `show {:board-id (:entity/id result)}))) + (routing/nav! `show {:board-id (:entity/id result)}))) :ref (ui/use-autofocus-ref)} [:h2.text-2xl (t :tr/new-board)] @@ -53,14 +53,14 @@ [:label.field-label {} (t :tr/owner)] (radix/select-menu {:value @?owner :on-value-change (partial reset! ?owner) - :options + :field/options (->> owners (map (fn [{:keys [entity/id entity/title image/avatar]}] {:value (str id) :text title :icon [:img.w-5.h-5.rounded-sm {:src (asset.ui/asset-src avatar :avatar)}]})))})]) - [field.ui/text-field ?title {:label (t :tr/title)}] + [field.ui/text-field ?title {:field/label (t :tr/title)}] (domain.ui/domain-field ?domain nil) [form.ui/submit-form !board (t :tr/create)]]))) diff --git a/src/sb/app/domain_name/ui.cljc b/src/sb/app/domain_name/ui.cljc index 671cad30..38b27ec4 100644 --- a/src/sb/app/domain_name/ui.cljc +++ b/src/sb/app/domain_name/ui.cljc @@ -1,46 +1,51 @@ (ns sb.app.domain-name.ui - (:require [clojure.string :as str] - [inside-out.forms :as forms] + (:require [inside-out.forms :as io] [promesa.core :as p] [sb.app.domain-name.data :as data] [sb.app.field.ui :as field.ui] [sb.app.form.ui :as form.ui] [sb.app.views.ui :as ui] - [sb.i18n :refer [t]])) + [sb.i18n :refer [t]] + [sb.util :as u])) #?(:cljs (defn availability-validator [] - (-> (fn [v {:keys [field]}] - (when (not= (:domain-name/name (:init field)) - (:domain-name/name v)) - (when-let [v (:domain-name/name v)] - (when (>= (count v) 3) - (p/let [res (data/check-availability {:domain v})] - (if (:available? res) - (forms/message :info - [:span.text-green-500.font-bold (t :tr/available)]) - (forms/message :invalid - (t :tr/not-available) - {:visibility :always}))))))) - (forms/debounce 300)))) + (-> (fn [v {:as what :keys [field]}] + (when (and v + (not= v (:init field)) + (>= (count v) 3)) + (p/let [res (data/check-availability {:domain v})] + (prn :res res) + (if (:available? res) + (io/message :info + [:span.text-green-500.font-bold (t :tr/available)]) + (io/message :invalid + (t :tr/not-available) + {:visibility :always}))))) + (io/debounce 300)))) -(ui/defview domain-field [?domain props] +(defn make-domain-field [init _props] + (io/form {:domain-name/name + (some-> domain-name/?name + u/some-str + data/normalize-domain + data/qualify-domain)} + :meta {domain-name/?name {:init (or (some-> init + :domain-name/name + data/unqualify-domain) + "") + :validators [data/domain-valid-string + #?(:cljs (availability-validator))]}} + )) + +(ui/defview domain-field [{:as ?field :syms [domain-name/?name]} props] [:div.field-wrapper - [form.ui/show-label ?domain] + [form.ui/show-label ?field (:field/label props)] [:div.flex.gap-2.items-stretch - (field.ui/text-field ?domain (merge props - {:wrap (fn [v] - (when-not (str/blank? v) - {:domain-name/name (data/qualify-domain (data/normalize-domain v))})) - :unwrap (fn [v] - (or (some-> v :domain-name/name data/unqualify-domain) "")) - :auto-complete "off" - :spell-check false - :wrapper-class "flex-auto" - :label false})) - [:div.flex.items-center.text-sm.text-gray-500.h-10 ".sparkboard.com"]]]) - -(defn validators [] - [data/domain-valid-string - #?(:cljs (availability-validator))]) \ No newline at end of file + (field.ui/text-field ?name (merge props + {:auto-complete "off" + :spell-check false + :field/wrapper-class "flex-auto" + :field/label false})) + [:div.flex.items-center.text-sm.text-gray-500.h-10 ".sparkboard.com"]]]) \ No newline at end of file diff --git a/src/sb/app/entity/data.cljc b/src/sb/app/entity/data.cljc index 705ef6fb..0d37eac4 100644 --- a/src/sb/app/entity/data.cljc +++ b/src/sb/app/entity/data.cljc @@ -52,6 +52,7 @@ s- :prose/as-map #_#_:db/fulltext true} :entity/field-entries {s- [:map-of :uuid :field-entry/as-map]} + :entity/fields {s- [:sequential :field/as-map]} :entity/video {:doc "Primary video for project (distinct from fields)" s- :video/url} :entity/public? {:doc "Contents of this entity can be accessed without authentication (eg. and indexed by search engines)" diff --git a/src/sb/app/entity/ui.cljc b/src/sb/app/entity/ui.cljc index 7b854307..2d967991 100644 --- a/src/sb/app/entity/ui.cljc +++ b/src/sb/app/entity/ui.cljc @@ -14,19 +14,8 @@ [yawn.hooks :as h] [yawn.view :as v])) -(defn infer-view [attribute] - (when attribute - (case attribute - :entity/domain-name domain.ui/domain-field - :image/avatar field.ui/image-field - (let [{:keys [malli/schema]} (get @sch/!schema attribute)] - (case schema - :string field.ui/text-field - :http/url field.ui/text-field - :boolean field.ui/checkbox-field - :prose/as-map field.ui/prose-field - [:sequential :field/as-map] @(resolve 'sb.app.field.admin-ui/fields-editor) - (cond (str/ends-with? (name attribute) "?") field.ui/checkbox-field)))))) +(defn malli-schema [a] + (some-> (get @sch/!schema a) :malli/schema)) (defn throw-no-persistence! [?field] (throw (ex-info (str "No persistence for " (:sym ?field)) {:where (->> (iterate io/parent ?field) @@ -38,7 +27,7 @@ (defn view-field [?field & [props]] (let [view (or (:view props) (:view ?field) - (some-> (:attribute ?field) infer-view) + (some-> (:attribute ?field) io/global-meta :view) (throw (ex-info (str "No view declared for field: " (:sym ?field) (:attribute ?field)) {:sym (:sym ?field) :attribute (:attribute ?field)})))] [view ?field (merge (:props ?field) @@ -57,10 +46,9 @@ (let [persisted-value (get e a) make-field (or (:make-field props) (:make-field (io/global-meta a)) - #(io/field)) - ?field (h/use-memo #(doto (make-field) - (add-meta! {:init persisted-value - :attribute a + (fn [init _props] (io/field :init init))) + ?field (h/use-memo #(doto (make-field persisted-value props) + (add-meta! {:attribute a :db/id (sch/wrap-id e) :field/persisted? true})) ;; create a new field when the persisted value changes diff --git a/src/sb/app/field/admin_ui.cljc b/src/sb/app/field/admin_ui.cljc index 159ba3e2..d705438c 100644 --- a/src/sb/app/field/admin_ui.cljc +++ b/src/sb/app/field/admin_ui.cljc @@ -3,6 +3,7 @@ [clojure.string :as str] [inside-out.forms :as io] [promesa.core :as p] + [re-db.reactive :as r] [sb.app.entity.ui :as entity.ui :refer [view-field]] [sb.app.entity.data :as entity.data] [sb.app.field.ui :as field.ui] @@ -109,10 +110,10 @@ "opacity-0 group-hover:opacity-100" "cursor-drag"]}) [icons/drag-dots]]] - [field.ui/text-field ?label {:label false - :wrapper-class "flex-auto" - :class "rounded-sm relative focus:z-2" - :style {:background-color @?color + [field.ui/text-field ?label {:field/label false + :field/wrapper-class "flex-auto" + :class "rounded-sm relative focus:z-2" + :style {:background-color @?color :color (color/contrasting-text-color @?color)}}] [:div.relative.w-10.focus-within-ring.rounded.overflow-hidden.self-stretch [field.ui/color-field ?color {:style {:top -10 @@ -149,7 +150,7 @@ (p/let [result (entity.data/save-field ?options)] (reset! ?new (:init ?new)) result)))} - [field.ui/text-field ?new {:placeholder "Option label" :wrapper-class "flex-auto"}] + [field.ui/text-field ?new {:placeholder "Option label" :field/wrapper-class "flex-auto"}] [:div.btn.bg-white.px-3.py-1.shadow "Add Option"]]) #_[ui/pprinted @?options]]) @@ -166,9 +167,9 @@ [:div.bg-gray-100.gap-3.grid.grid-cols-2.pl-12.pr-7.pt-4.pb-6 [:div.col-span-2.flex-v.gap-3 - (view-field ?label {:multi-line true}) - (view-field ?hint {:multi-line true - :placeholder "Further instructions"})] + (view-field ?label {:field/multi-line? true}) + (view-field ?hint {:field/multi-line? true + :placeholder "Further instructions"})] (when (= :field.type/select @?type) [:div.col-span-2.text-sm @@ -224,7 +225,21 @@ (when expanded? (field-row-detail ?field))])) -(ui/defview fields-editor [{:as ?fields :keys [label]} props] +(defn make-field:fields [init props] + (io/field :many (u/prune {:field/id ?id + :field/type ?type + :field/label ?label + :field/hint ?hint + :field/options (?options :many {:field-option/label ?label + :field-option/value ?value + :field-option/color ?color}) + :field/required? ?required? + :field/show-as-filter? ?show-as-filter? + :field/show-at-registration? ?show-at-registration? + :field/show-on-card? ?show-on-card?}) + :init init)) + +(ui/defview fields-editor [{:as ?fields :keys [field/label]} props] (let [!new-field (h/use-state nil) !autofocus-ref (ui/use-autofocus-ref) [expanded expand!] (h/use-state nil)] @@ -236,13 +251,14 @@ (radix/dropdown-menu {:id :add-field :trigger [:div.text-sm.text-gray-500.font-normal.hover:underline.cursor-pointer.place-self-center "Add Field"] - :children (for [[type {:keys [icon label]}] data/field-types] - [{:on-select #(reset! !new-field - (io/form {:field/id (random-uuid) - :field/type ?type - :field/label ?label} - :init {:field/type type} - :required [?label]))} + :children (for [[type {:keys [icon field/label]}] data/field-types] + [{:on-select #(let [id (random-uuid)] + (reset! !new-field + (io/form {:field/id id + :field/type ?type + :field/label ?label} + :init {:field/type type} + :required [?label])))} [:div.flex.gap-4.items-center.cursor-default [icon "text-gray-600"] label]])}))]] [:div.flex-v.border.rounded.labels-sm (->> ?fields @@ -258,15 +274,17 @@ [:form.flex.gap-2.items-start.relative {:on-submit (ui/prevent-default (fn [e] + (prn [:client1 @?new-field]) (io/add-many! ?fields @?new-field) + (prn [:client2 (last @?fields)]) (expand! (:field/id @?new-field)) (reset! !new-field nil) (entity.data/save-field ?fields)))} [:div.h-10.flex.items-center [(:icon (data/field-types @?type)) "icon-lg text-gray-700 mx-2"]] - [field.ui/text-field ?label {:label false - :ref !autofocus-ref - :placeholder (:label ?label) - :wrapper-class "flex-auto"}] + [field.ui/text-field ?label {:field/label false + :ref !autofocus-ref + :placeholder (:field/label ?label) + :field/wrapper-class "flex-auto"}] [:button.btn.btn-white.h-10 {:type "submit"} (t :tr/add)] [:div.flex.items-center.justify-center.icon-light-gray.h-10.w-7 diff --git a/src/sb/app/field/data.cljc b/src/sb/app/field/data.cljc index 536b0411..35a45f45 100644 --- a/src/sb/app/field/data.cljc +++ b/src/sb/app/field/data.cljc @@ -110,16 +110,16 @@ :field/show-on-card? :field/type]) -(def field-types {:field.type/prose {:icon icons/text - :label (t :tr/text)} - :field.type/select {:icon icons/dropdown-menu - :label (t :tr/menu)} - :field.type/video {:icon icons/video - :label (t :tr/video)} - :field.type/link-list {:icon icons/link-2 - :label (t :tr/links)} - :field.type/image-list {:icon icons/photo - :label (t :tr/image)} +(def field-types {:field.type/prose {:icon icons/text + :field/label (t :tr/text)} + :field.type/select {:icon icons/dropdown-menu + :field/label (t :tr/menu)} + :field.type/video {:icon icons/video + :field/label (t :tr/video)} + :field.type/link-list {:icon icons/link-2 + :field/label (t :tr/links)} + :field.type/image-list {:icon icons/photo + :field/label (t :tr/image)} }) (defn blank? [color] @@ -220,27 +220,27 @@ vec)]]) {})) -(defmulti entry-value (fn [field entry] (:field/type field))) +(defmulti entry-value (comp :field/type :field-entry/field)) -(defmethod entry-value nil [_ _] nil) +(defmethod entry-value nil [_] nil) -(defmethod entry-value :field.type/image-list [_field entry] +(defmethod entry-value :field.type/image-list [entry] (when-let [images (u/guard (:image-list/images entry) seq)] {:image-list/images images})) -(defmethod entry-value :field.type/video [_field entry] +(defmethod entry-value :field.type/video [entry] (when-let [value (u/guard (:video/url entry) (complement str/blank?))] {:video/url value})) -(defmethod entry-value :field.type/select [_field entry] +(defmethod entry-value :field.type/select [entry] (when-let [value (u/guard (:select/value entry) (complement str/blank?))] {:select/value value})) -(defmethod entry-value :field.type/link-list [_field entry] +(defmethod entry-value :field.type/link-list [entry] (when-let [value (u/guard (:link-list/links entry) seq)] {:link-list/links value})) -(defmethod entry-value :field.type/prose [_field entry] +(defmethod entry-value :field.type/prose [entry] (when-let [value (u/guard (:prose/string entry) (complement str/blank?))] {:prose/string value :prose/format (:prose/format entry)})) diff --git a/src/sb/app/field/ui.cljc b/src/sb/app/field/ui.cljc index 36eb0961..e1fdc5c6 100644 --- a/src/sb/app/field/ui.cljc +++ b/src/sb/app/field/ui.cljc @@ -14,6 +14,8 @@ [sb.client.sanitize :as sanitize] [sb.icons :as icons] [sb.routing :as routing] + [sb.schema :as sch] + [sb.util :as u] [yawn.hooks :as h] [yawn.view :as v])) @@ -61,7 +63,7 @@ (let [messages (forms/visible-messages ?field) loading? (:loading? ?field) props (-> (v/merge-props (form.ui/?field-props ?field - (merge {:event->value (comp boolean (j/get-in [:target :checked]))} + (merge {:field/event->value (comp boolean (j/get-in [:target :checked]))} props)) {:type "checkbox" :on-blur (forms/blur-handler ?field) @@ -84,7 +86,7 @@ [:input.h-5.w-5.rounded.border-gray-300.text-primary (form.ui/pass-props props)] [:div.flex-v.gap-1.ml-2 - (when-let [label (form.ui/get-label (:label props) ?field)] + (when-let [label (form.ui/get-label ?field (:field/label props))] [:div.flex.items-center.h-5 label]) (when (seq messages) (into [:div.text-gray-500] (map form.ui/view-message) messages))]]])) @@ -92,58 +94,46 @@ (ui/defview text-field "A text-input element that reads metadata from a ?field to display appropriately" [?field props] - (let [{:as props - :keys [inline? - multi-line - multi-paragraph - wrap - unwrap - wrapper-class] - :or {wrap identity - unwrap identity}} (merge props (:props (meta ?field))) + (let [{:as props + :field/keys [multi-line? + wrap + unwrap + wrapper-class] + :or {wrap identity + unwrap identity}} (merge props (:props (meta ?field))) blur! (fn [e] (j/call-in e [:target :blur])) - cancel! (fn [e] + cancel! (fn [^js e] + (.preventDefault e) + (.stopPropagation e) (reset! ?field (entity.data/persisted-value ?field)) - (blur! e)) + (js/setTimeout #(blur! e) 0)) props (v/merge-props props (form.ui/?field-props ?field - (merge {:event->value (j/get-in [:target :value]) - :wrap #(when-not (str/blank? %) %) - :unwrap #(or % "")} + (merge {:field/event->value (j/get-in [:target :value]) + :field/wrap #(when-not (str/blank? %) %) + :field/unwrap #(or % "")} props)) - {:class ["pr-8 rounded" - (if inline? - "form-inline" - "default-ring") + {:class ["pr-8 rounded default-ring" (when (:invalid (forms/types (forms/visible-messages ?field))) "outline-invalid")] - :placeholder (or (:placeholder props) - (when inline? (or (:label props) (:label ?field)))) - :on-key-down - (ui/keydown-handler {(if multi-paragraph - :Meta-Enter - :Enter) #(when (io/ancestor-by ?field :field/persisted?) - (j/call % :preventDefault) - (entity.data/maybe-save-field ?field)) - :Escape blur! - :Meta-. cancel!})})] + :placeholder (:placeholder props) + :on-key-down (let [save #(when (io/ancestor-by ?field :field/persisted?) + (j/call % :preventDefault) + (entity.data/maybe-save-field ?field))] + (ui/keydown-handler (merge {:Meta-Enter save + :Escape cancel! + :Meta-. cancel!} + (when-not multi-line? + {:Enter save}))))})] (v/x [:div.field-wrapper {:class wrapper-class} - (when-not inline? (form.ui/show-label ?field (:label props))) + (form.ui/show-label ?field (:field/label props)) [:div.flex-v.relative - (if multi-line - [auto-size (v/merge-props {:class "form-text w-full"} (form.ui/pass-props props))] - [:input.form-text (form.ui/pass-props props)]) - - (when (= "Label" (form.ui/get-label nil ?field)) - (prn (form.ui/get-label nil ?field) {:before (entity.data/persisted-value ?field) - :after (:value props) - :changed? (some-> (entity.data/persisted-value ?field) - (not= (:value props)))})) - (when-let [postfix (or (:postfix props) - (:postfix (meta ?field)) + [auto-size (v/merge-props {:class "form-text w-full"} (form.ui/pass-props props))] + (when-let [postfix (or (:field/postfix props) + (:field/postfix (meta ?field)) (and (some-> (entity.data/persisted-value ?field) (not= (:value props))) [icons/pencil-outline "w-4 h-4 text-txt/40"]))] @@ -160,25 +150,23 @@ (def unwrap-prose :prose/string) -(ui/defview prose-field [?field props] +(ui/defview prose-field [{:as ?prose-field :prose/syms [?format ?string]} props] ;; TODO ;; multi-line markdown editor with formatting - (text-field ?field (merge {:wrap wrap-prose - :unwrap unwrap-prose - :multi-line true} - props))) + (text-field ?string (merge {:field/multi-line? true} + props))) -(ui/defview show-select [?field {:field/keys [label options]} entry] +(ui/defview show-select [?field {:field/keys [label options can-edit?]} entry] [:div.flex-v.gap-2 [:label.field-label label] - [radix/select-menu {:value (:select/value @?field) - :id (str (:entity/id entry)) - :read-only? (:can-edit? ?field) - :options (->> options - (map (fn [{:field-option/keys [label value color]}] - {:text label - :value value})) - doall)}]]) + [radix/select-menu {:value (or @?field "") + :id (str (:entity/id entry)) + :field/can-edit? can-edit? + :field/options (->> options + (map (fn [{:field-option/keys [label value color]}] + {:text label + :value (or value "")})) + doall)}]]) (comment @@ -201,12 +189,12 @@ (ui/defview video-field {:key (fn [?field] #?(:cljs (goog/getUid ?field)))} - [?field {:as props :keys [can-edit?]}] + [?field {:as props :keys [field/can-edit?]}] (let [!editing? (h/use-state (nil? @?field))] [:div.field-wrapper ;; preview shows persisted value? [:div.flex.items-center - [:div.flex-auto (form.ui/show-label ?field (:label props))] + [:div.flex-auto (form.ui/show-label ?field (:field/label props))] #_(when can-edit? [:div.place-self-end [:a {:on-click #(swap! !editing? not)} [(if @!editing? icons/chevron-up icons/chevron-down) "icon-gray"]]])] @@ -214,32 +202,34 @@ [show-video url]) (when can-edit? (text-field ?field (merge props - {:label false - :placeholder "YouTube or Vimeo url" - :wrap (partial hash-map :video/url) - :unwrap :video/url})))])) + {:field/label false + :placeholder "YouTube or Vimeo url" + :field/wrap (partial hash-map :video/url) + :field/unwrap :video/url})))])) -(ui/defview select-field [?field {:as props :keys [label options]}] +(ui/defview select-field [?field {:as props :field/keys [label options]}] [:div.field-wrapper (form.ui/show-label ?field label) - [radix/select-menu (-> (form.ui/?field-props ?field props) + [radix/select-menu (-> (form.ui/?field-props ?field (merge {:field/event->value identity} + props)) (set/rename-keys {:on-change :on-value-change}) - (assoc :can-edit? (:can-edit? props) - :event->value identity - :save-on-change? true - :options (->> options - (map (fn [{:field-option/keys [label value color]}] - {:text label - :value value})) - doall)))] + (assoc :on-value-change (fn [v] + (reset! ?field v) + (entity.data/maybe-save-field ?field)) + :field/can-edit? (:field/can-edit? props) + :field/options (->> options + (map (fn [{:field-option/keys [label value color]}] + {:text label + :value value})) + doall)))] (when (:loading? ?field) [:div.loading-bar.absolute.bottom-0.left-0.right-0 {:class "h-[3px]"}])]) (ui/defview color-field [?field props] [:input.default-ring.default-ring-hover.rounded (-> (form.ui/?field-props ?field - (merge props {:event->value (j/get-in [:target :value]) - :save-on-change? true})) + (merge props {:field/event->value (j/get-in [:target :value]) + :save-on-change? true})) (v/merge-props props) (assoc :type "color") (update :value #(or % "#ffffff")) @@ -254,16 +244,17 @@ on-file (fn [file] (forms/touch! ?field) (reset! !selected-blob (js/URL.createObjectURL file)) - (ui/with-submission [asset (routing/POST `asset.data/upload! (doto (js/FormData.) - (.append "files" file))) + (ui/with-submission [id (routing/POST `asset.data/upload! + (doto (js/FormData.) + (.append "files" file))) :form ?field] - (reset! ?field asset) + (reset! ?field (sch/wrap-id id)) (entity.data/maybe-save-field ?field))) !input (h/use-ref)] ;; TODO handle on-save [:label.gap-2.flex-v.relative {:for (form.ui/field-id ?field)} - (form.ui/show-label ?field (:label props)) + (form.ui/show-label ?field (:field/label props)) [:button.flex-v.items-center.justify-center.p-3.gap-3.relative.default-ring.default-ring-hover {:on-click #(j/call @!input :click) :class ["rounded-lg" @@ -296,34 +287,66 @@ :on-change #(some-> (j/get-in % [:target :files 0]) on-file)}]] (form.ui/show-field-messages ?field)]])) -(ui/defview images-field [?field {:as props :keys [label]}] - (let [images (->> (:images/order @?field) - (map (fn [id] - {:url (asset.ui/asset-src {:entity/id id} :card) - :entity/id id})))] - (for [{:keys [entity/id url]} images] - ;; TODO - ;; upload image, - ;; re-order images - [:div.relative {:key url} - [:div.inset-0.bg-black.absolute.opacity-10] - [:img {:src url}]]))) +(ui/defview images-field [?images {:field/keys [label can-edit?]}] + (for [{:syms [?id]} ?images + :let [url (asset.ui/asset-src @?id :card)]] + ;; TODO + ;; upload image, + ;; re-order images + [:div.relative {:key url} + [form.ui/show-label ?images label] + [:div.inset-0.bg-black.absolute.opacity-10] + [:img {:src url}]])) + +(ui/defview link-list-field [?links {:field/keys [label]}] + [:div.field-wrapper + (form.ui/show-label ?links label) + (for [{:syms [link/?text link/?url]} ?links] + [:a {:href @?url} (or @?text @?url)]) + + ]) (ui/defview show-entry - {:key (comp :entity/id :field)} - [{:keys [field entry can-edit?]}] - (let [value (data/entry-value field entry) - ?field (h/use-memo #(forms/field :init (data/entry-value field entry) :label (:field/label field)) - [(str value)]) - props {:label (:label field) - :can-edit? can-edit?}] + {:key (fn [?entry props] + (-> @?entry :field-entry/field :field/id))} + [?entry props] + (let [field (:field-entry/field @?entry) + props (merge (select-keys field [:field/label :field/options]) + (select-keys props [:field/can-edit?]))] (case (:field/type field) - :field.type/video [video-field ?field props] - :field.type/select [select-field ?field (merge props - {:wrap (fn [x] {:select/value x}) - :unwrap :select/value - :options (:field/options field)})] - :field.type/link-list [ui/pprinted value props] - :field.type/image-list [images-field ?field props] - :field.type/prose [prose-field ?field props] - (str "no match" field)))) \ No newline at end of file + :field.type/video [video-field + ('video/?url ?entry) + props] + :field.type/select [select-field ('select/?value ?entry) props] + :field.type/link-list [link-list-field ('link-list/?links ?entry) props] + :field.type/image-list [images-field ('image-list/?images ?entry) props] + :field.type/prose [prose-field ?entry props] + (str "no match" field)))) + +(defn make-field:entries [init {:keys [entity/fields]}] + (let [init (for [field fields] + (merge #:field-entry{:field field} (get init (:field/id field))))] + (io/form + (->> (?entries :many + {:field-entry/field field-entry/?field + :image-list/images (image-list/?images :many {:entity/id (sch/unwrap-id ?id)}) + :video/url video/?url + :select/value select/?value + :link-list/links (link-list/?links :many {:text link/?text + :url link/?url}) + :prose/format prose/?format + :prose/string prose/?string} + :init init) + (into {} + (map (fn [{:as entry :keys [field-entry/field]}] + [(:field/id field) (dissoc entry :field-entry/field)]))) + u/prune)))) + +(ui/defview entries-field [{:syms [?entries]} + {:as props + :keys [field/can-edit?]}] + + (doall (for [?entry (seq ?entries) + :when (or can-edit? + (data/entry-value @?entry))] + (show-entry ?entry props)))) \ No newline at end of file diff --git a/src/sb/app/form/ui.cljc b/src/sb/app/form/ui.cljc index 387a12f7..5b584837 100644 --- a/src/sb/app/form/ui.cljc +++ b/src/sb/app/form/ui.cljc @@ -17,47 +17,47 @@ :clj (str "field-" (:sym ?field)))) -(defn pass-props [props] (dissoc props - :multi-line :postfix :wrapper-class - :event->value - :on-change-value - :wrap :unwrap - :inline? - :can-edit? - :label)) -(defn get-label [label ?field] - (u/some-or label (:label ?field))) +(defn pass-props [props] + (reduce (fn [m k] + (cond-> m + (qualified-keyword? k) + (dissoc k))) + props + (keys props))) + +(defn get-label [?field & [label]] + (u/some-or label + (:field/label ?field) + (when-let [a (:attribute ?field)] + (sb.i18n/tr* (keyword "tr" (name a)))))) (defn show-label [?field & [label]] - (when-let [label (get-label label ?field)] + (when-let [label (get-label ?field label)] [:label.field-label {:for (field-id ?field)} label])) (defn ?field-props [?field - {:keys [event->value - wrap - unwrap - on-change-value + {:keys [field/event->value + field/wrap + field/unwrap on-change save-on-change?] - :or {wrap identity + :or {wrap identity unwrap identity}}] - {:id (field-id ?field) - :value (unwrap @?field) - :on-change (fn [e] - (let [new-value (wrap (event->value e))] - (reset! ?field new-value) - (when on-change-value - (pass-props (on-change-value new-value))) - (when on-change - (on-change e)) - (when save-on-change? - (entity.data/maybe-save-field ?field)))) - :on-blur (fn [e] - (reset! ?field (wrap (event->value e))) - (entity.data/maybe-save-field ?field) - ((io/blur-handler ?field) e)) - :on-focus (io/focus-handler ?field)}) + {:id (field-id ?field) + :value (unwrap @?field) + :on-change (fn [e] + (let [new-value (wrap (event->value e))] + (reset! ?field new-value) + (when on-change + (on-change e)) + (when save-on-change? + (entity.data/maybe-save-field ?field)))) + :on-blur (fn [e] + (reset! ?field (wrap (event->value e))) + (entity.data/maybe-save-field ?field) + ((io/blur-handler ?field) e)) + :on-focus (io/focus-handler ?field)}) (def email-validator (fn [v _] (when v diff --git a/src/sb/app/org/ui.cljc b/src/sb/app/org/ui.cljc index d391afbe..b71e94c4 100644 --- a/src/sb/app/org/ui.cljc +++ b/src/sb/app/org/ui.cljc @@ -74,6 +74,6 @@ :form !org] (routes/nav! [`show {:org-id (:entity/id result)}])))} [:h2.text-2xl (t :tr/new-org)] - [field.ui/text-field ?title {:label (t :tr/title)}] + [field.ui/text-field ?title {:field/label (t :tr/title)}] (domain.ui/domain-field ?domain nil) [form.ui/submit-form !org (t :tr/create)]])) \ No newline at end of file diff --git a/src/sb/app/project/ui.cljc b/src/sb/app/project/ui.cljc index 1fa100a9..0dd5609c 100644 --- a/src/sb/app/project/ui.cljc +++ b/src/sb/app/project/ui.cljc @@ -1,14 +1,17 @@ (ns sb.app.project.ui (:require [inside-out.forms :as forms] [re-db.api :as db] + [sb.app.entity.ui :as entity.ui] [sb.app.field.data :as field.data] [sb.app.field.ui :as field.ui] [sb.app.project.data :as data] + [sb.authorize :as az] [sb.i18n :refer [t]] [sb.routing :as routing] [sb.app.views.ui :as ui] [sb.icons :as icons] [sb.app.views.radix :as radix] + [sb.schema :as sch] [sb.validate :as validate] [yawn.hooks :as h] [yawn.view :as v])) @@ -24,13 +27,13 @@ (let [action-picker (fn [props] [radix/select-menu (v/merge-props props - {:id :project-action - :can-edit? true - :placeholder [:span.text-gray-500 (t :tr/choose-action)] - :options [{:text (t :tr/copy-link) - :value "LINK"} - {:text (t :tr/start-chat) - :value "CHAT"}]})]) + {:id :project-action + :placeholder [:span.text-gray-500 (t :tr/choose-action)] + :field/can-edit? true + :field/options [{:text (t :tr/copy-link) + :value "LINK"} + {:text (t :tr/start-chat) + :value "CHAT"}]})]) add-btn (fn [props] (v/x [:button.p-3.text-gray-500.items-center.inline-flex.btn-darken.flex-none.rounded props [icons/plus "icon-sm scale-125"]])) @@ -96,8 +99,8 @@ [:div.flex-auto.text-sm [ui/pprinted (:member/roles entity)]] [radix/select-menu {:value @!dev-edit? :on-value-change (partial reset! !dev-edit?) - :can-edit? true - :options [{:value nil :text "Current User"} + :field/can-edit? true + :field/options [{:value nil :text "Current User"} {:value true :text "Editor"} {:value false :text "Viewer"}]}]])]))) @@ -117,9 +120,7 @@ field-entries] :keys [project/badges member/roles]} (data/show params) - [can-edit? dev-panel] (use-dev-panel project) - fields (->> project :entity/parent :board/project-fields) - entries (->> project :entity/field-entries)] + [can-edit? dev-panel] (use-dev-panel project)] [:<> dev-panel [:div.flex-v.gap-6.pb-6 @@ -135,7 +136,7 @@ [:div.flex.self-start.ml-auto.px-1.rounded-bl-lg.border-b.border-l.relative [radix/tooltip "Back to board" [:a {:class title-icon-classes - :href (routing/entity-path (:entity/parent project) :show)} + :href (routing/entity-path (:entity/parent project) 'ui/show)} [icons/arrow-left]]] [radix/tooltip "Link to project" [:a {:class title-icon-classes @@ -150,13 +151,11 @@ (into [:ul] (map (fn [bdg] [:li.rounded.bg-badge.text-badge-txt.py-1.px-2.text-sm.inline-flex (:badge/label bdg)])) badges)]) - (for [field fields - :let [entry (get entries (:field/id field))] - :when (or can-edit? - (field.data/entry-value field entry))] - (field.ui/show-entry {:can-edit? can-edit? - :field field - :entry entry})) + (entity.ui/use-persisted-attr project + :entity/field-entries + {:entity/fields (->> project :entity/parent :board/project-fields) + :member/roles roles + :field/can-edit? can-edit?}) [:section.flex-v.gap-2.items-start [manage-community-actions project (:project/community-actions project)]] (when video diff --git a/src/sb/app/views/radix.cljc b/src/sb/app/views/radix.cljc index 63a1e32f..9c6ee2fd 100644 --- a/src/sb/app/views/radix.cljc +++ b/src/sb/app/views/radix.cljc @@ -11,6 +11,7 @@ [yawn.util] [sb.i18n :refer [t]] [re-db.reactive :as r] + [sb.app.form.ui :as form.ui] [yawn.hooks :as h])) @@ -79,13 +80,15 @@ [:el sel/ItemText [:div.flex.gap-2.py-2 icon text]] [:el sel/ItemIndicator]])) -(defn select-menu [{:as props :keys [placeholder id - can-edit?] - options :options - :or {id :radix-select}}] +(defn select-menu [{:as props :keys [id + placeholder + field/options + field/can-edit?] + :or {id :radix-select}}] (v/x - [:el sel/Root (cond-> (dissoc props :trigger :placeholder :options :read-only?) - (not can-edit?) (assoc :disabled true)) + [:el sel/Root (cond-> (form.ui/pass-props (dissoc props :trigger :placeholder)) + (not can-edit?) + (assoc :disabled true)) [:el.btn.bg-white.flex.items-center.rounded.whitespace-nowrap.gap-1.group.default-ring.default-ring-hover.px-3 sel/Trigger {:class [(if can-edit? "disabled:text-gray-400" diff --git a/src/sb/client/core.cljs b/src/sb/client/core.cljs index 27f264d2..f61b41f0 100644 --- a/src/sb/client/core.cljs +++ b/src/sb/client/core.cljs @@ -65,10 +65,10 @@ (cond-> (k field-meta) validator (update :validators conj validator)))) - (forms/set-global-meta! (fn [k] - (when k - (merge {:label (sb.i18n/tr* (keyword "tr" (name k)))} - (app/global-field-meta k))))) + (forms/set-global-meta! (fn [a] + (merge (some-> (get @sch/!schema a) (#(or (:malli/ref-schema %) + (:malli/schema %))) (app/global-field-meta)) + (app/global-field-meta a)))) ) (defn ^:dev/after-load init-endpoints! [] diff --git a/src/sb/migration/core.clj b/src/sb/migration/core.clj index e0ea4862..104901ae 100644 --- a/src/sb/migration/core.clj +++ b/src/sb/migration/core.clj @@ -20,4 +20,8 @@ (-> (db/transact! (one-time/all-entities)) :tx-data count - (str " datoms")))) \ No newline at end of file + (str " datoms")))) + +(comment + (tx!) + ) \ No newline at end of file diff --git a/src/sb/migration/one_time.clj b/src/sb/migration/one_time.clj index 27e25654..b5173a83 100644 --- a/src/sb/migration/one_time.clj +++ b/src/sb/migration/one_time.clj @@ -421,7 +421,7 @@ (->> (coll-entities :board/as-map) (mapcat (juxt :board/member-fields :board/project-fields)) (mapcat identity) - (map (juxt :entity/id identity)) + (map (juxt :field/id identity)) (into {})))) (defn prose [s] @@ -469,9 +469,7 @@ })))))] (-> (dissoc m k) (cond-> entry-value - (assoc-in - [to-k field-id] - (assoc entry-value :field-entry/type field-type)))))) + (assoc-in [to-k field-id] entry-value))))) m field-ks) (catch Exception e diff --git a/src/sb/schema.cljc b/src/sb/schema.cljc index 8591abb4..b3c6dd66 100644 --- a/src/sb/schema.cljc +++ b/src/sb/schema.cljc @@ -44,11 +44,13 @@ {:pre [(keyword? nesting-schema)]} (case cardinality :one (merge rs/ref rs/one - {s- (conj db-id nesting-schema)}) + {s- (conj db-id nesting-schema) + :malli/ref-schema nesting-schema}) :many (merge rs/ref rs/many {s- [:sequential - (conj db-id nesting-schema)]})))) + (conj db-id nesting-schema)] + :malli/ref-schema nesting-schema})))) (def unique-id-str (merge rs/unique-id rs/string @@ -58,7 +60,6 @@ rs/uuid {s- :uuid})) - (def unique-value rs/unique-value) (def unique-id rs/unique-id) (def component rs/component) diff --git a/src/sb/util.cljc b/src/sb/util.cljc index 5cd8f3fa..2fa6b7f9 100644 --- a/src/sb/util.cljc +++ b/src/sb/util.cljc @@ -48,15 +48,19 @@ (defn prune "Removes nil values from a map recursively" - [m] - (reduce-kv (fn [m k v] - (if (nil? v) - m - (if (map? v) - (assoc-seq m k (prune v)) - (if (sequential? v) - (assoc-seq m k (map prune v)) - (assoc m k v))))) {} m)) + [x] + (if (sequential? x) + (map prune x) + (reduce-kv (fn [m k v] + (if (nil? v) + m + (if (map? v) + (assoc-seq m k (prune v)) + (if (sequential? v) + (assoc-seq m k (map prune v)) + (assoc m k v))))) + {} + x))) (defn keep-changes "Removes nil values from a map, not recursive"