diff --git a/deps.edn b/deps.edn index c2363e05..3280fcf7 100644 --- a/deps.edn +++ b/deps.edn @@ -5,9 +5,9 @@ ;; v2 datalevin/datalevin {:mvn/version "0.8.21"} - io.github.mhuebert/re-db {:git/sha "a01298e075889ab0ac5b9f2afe751ae5e8d20613"} + io.github.mhuebert/re-db {:git/sha "479a57401635f3a11ab128226976c6388b5f7f2c"} io.github.mhuebert/yawn {:git/sha "68285f6c132f26a2ff3cc2f7dffc3fe68c9856d9"} - io.github.mhuebert/inside-out {:git/sha "003ad6fbb282d5e49ccefa5a44cecae467e54183"} + io.github.mhuebert/inside-out {:git/sha "6f8a4f4413f344d59da2ed55ddb1a0e9090c30b7"} ;; Google Cloud com.google.firebase/firebase-admin {:mvn/version "6.13.0"} diff --git a/src/sb/app.cljc b/src/sb/app.cljc index 80f65511..062c5564 100644 --- a/src/sb/app.cljc +++ b/src/sb/app.cljc @@ -1,8 +1,10 @@ (ns sb.app - (:require [sb.app.account.ui] + (:require [inside-out.forms :as io] + [org.sparkboard.slack.schema] + [sb.app.account.ui] [sb.app.asset.ui] - [sb.app.board.ui] [sb.app.board.admin-ui] + [sb.app.board.ui] [sb.app.chat.ui] [sb.app.collection.ui] [sb.app.content.ui] @@ -14,34 +16,50 @@ [sb.app.form.ui :as form.ui] [sb.app.member.ui] [sb.app.notification.ui] + [sb.app.org.admin-ui] [sb.app.org.ui] - sb.app.org.admin-ui [sb.app.project.ui] [sb.app.social-feed.ui] [sb.app.vote.ui] - [org.sparkboard.slack.schema] + [sb.i18n :refer [t]] [sb.transit :as t] - [inside-out.forms :as io] - [sb.i18n :refer [t]])) + [sb.util :as u])) #?(: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" - :placeholder (t :tr/email)} - :validators [form.ui/email-validator]} - :account/password {:view field.ui/text-field - :props {:type "password" - :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} - :board/member-fields {:view field.admin-ui/fields-editor} - :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 + {:account/email {:view field.ui/text-field + :props {:type "email" + :placeholder (t :tr/email)} + :validators [form.ui/email-validator]} + :account/password {:view field.ui/text-field + :props {:type "password" + :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 + :props {:wrap u/prune}} + :board/member-fields {:view field.admin-ui/fields-editor + :make-field fields-editor-field + :props {:wrap u/prune}} + :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 diff --git a/src/sb/app/asset/data.cljc b/src/sb/app/asset/data.cljc index 5244114f..453d1f5e 100644 --- a/src/sb/app/asset/data.cljc +++ b/src/sb/app/asset/data.cljc @@ -53,6 +53,7 @@ :asset.variant/provider]} + :image/avatar (sch/ref :one :asset/as-map) :image/logo-large (sch/ref :one :asset/as-map) :image/footer (sch/ref :one :asset/as-map) diff --git a/src/sb/app/board/admin_ui.cljc b/src/sb/app/board/admin_ui.cljc index 9352d300..d17ecff8 100644 --- a/src/sb/app/board/admin_ui.cljc +++ b/src/sb/app/board/admin_ui.cljc @@ -1,11 +1,12 @@ (ns sb.app.board.admin-ui (:require [inside-out.forms :as io] [sb.app.board.data :as data] - [sb.app.entity.ui :as entity.ui :refer [use-persisted-attr view-field]] + [sb.app.entity.ui :as entity.ui :refer [use-persisted-field view-field]] [sb.app.views.header :as header] [sb.app.views.radix :as radix] [sb.app.views.ui :as ui] [sb.i18n :refer [t]] + [sb.util :as u] [yawn.hooks :as h])) ;; issue @@ -17,101 +18,60 @@ {:route "/b/:board-id/settings"} [{:as params :keys [board-id]}] (when-let [board (data/settings params)] - (prn (:board/member-fields board)) - (ui/with-form [?board - {:entity/title ?title - :entity/description ?description - :entity/domain-name ?domain-name - :image/avatar ?avatar - :board/member-fields (?member-fields :many - {: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?}) - :board/project-fields (?project-fields :many - {: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?}) - :board/registration-open? ?registration-open? - :board/registration-url-override ?registration-url-override - :board/registration-page-message ?registration-page-message - :board/invite-email-text ?invite-email-text} - :init board - :form/entity.id (:entity/id board)] - [:<> - (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} - - [:div.field-label (t :tr/basic-settings)] - [:div.flex-v.gap-4 - - (view-field ?title) - (view-field ?description) - (view-field ?domain-name) - (view-field ?avatar {:label (t :tr/image.logo)})] - - - [:div.field-label (t :tr/projects-and-members)] - [:div.flex-v.gap-4 - (view-field ?member-fields) - (view-field ?project-fields)] - - - [:div.field-label (t :tr/registration)] - [:div.flex-v.gap-4 - (view-field ?registration-open?) - (view-field ?registration-url-override) - (view-field ?registration-page-message) - (view-field ?invite-email-text)]] - - - - ;; TODO - ;; - :board/project-sharing-buttons - ;; - :board/member-tags - - ;; Registration - ;; - :board/registration-invitation-email-text - ;; - :board/registration-newsletter-field? - ;; - :board/registration-open? - ;; - :board/registration-message - ;; - :board/registration-url-override - ;; - :board/registration-codes - - ;; Theming - ;; - border radius - ;; - headline font - ;; - accent color - - ;; Sponsors - ;; - logo area with tiered sizes/visibility - - ;; Sticky Notes - ;; - schema: a new entity type (not a special kind of project) - ;; - modify migration based on ^new schema - ;; - color is picked per sticky note - ;; - sticky notes can include images/videos - - ]))) \ No newline at end of file + + [:<> + (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} + + [:div.field-label (t :tr/basic-settings)] + [:div.flex-v.gap-4 + + (use-persisted-field board :entity/title) + (use-persisted-field board :entity/description) + (use-persisted-field board :entity/domain-name) + (use-persisted-field board :image/avatar {:label (t :tr/image.logo)})] + + + [:div.field-label (t :tr/projects-and-members)] + [:div.flex-v.gap-4 + (use-persisted-field board :board/member-fields #_{:wrap u/prune}) + (use-persisted-field board :board/project-fields #_{:wrap u/prune})] + + + [:div.field-label (t :tr/registration)] + [:div.flex-v.gap-4 + (use-persisted-field board :board/registration-open?) + (use-persisted-field board :board/registration-url-override) + (use-persisted-field board :board/registration-page-message) + (use-persisted-field board :board/invite-email-text)]] + + + + ;; TODO + ;; - :board/project-sharing-buttons + ;; - :board/member-tags + + ;; Registration + ;; - :board/registration-invitation-email-text + ;; - :board/registration-newsletter-field? + ;; - :board/registration-open? + ;; - :board/registration-message + ;; - :board/registration-url-override + ;; - :board/registration-codes + + ;; Theming + ;; - border radius + ;; - headline font + ;; - accent color + + ;; Sponsors + ;; - logo area with tiered sizes/visibility + + ;; Sticky Notes + ;; - schema: a new entity type (not a special kind of project) + ;; - modify migration based on ^new schema + ;; - color is picked per sticky note + ;; - sticky notes can include images/videos + + ])) \ No newline at end of file diff --git a/src/sb/app/entity/ui.cljc b/src/sb/app/entity/ui.cljc index 314e9cb4..5d089354 100644 --- a/src/sb/app/entity/ui.cljc +++ b/src/sb/app/entity/ui.cljc @@ -1,70 +1,90 @@ (ns sb.app.entity.ui (:require [clojure.string :as str] [inside-out.forms :as io] + [re-db.api :as db] [sb.app.asset.ui :as asset.ui] + [sb.app.domain-name.ui :as domain.ui] [sb.app.entity.data :as data] [sb.app.field.ui :as field.ui] - [sb.routing :as routing] [sb.app.views.ui :as ui] [sb.icons :as icons] + [sb.routing :as routing] + [sb.schema :as sch] [sb.validate :as validate] [yawn.hooks :as h] - [yawn.view :as v] - [sb.schema :as sch] - [re-db.api :as db] - [sb.util :as u])) + [yawn.view :as v])) (defn infer-view [attribute] (when attribute - (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 - (cond (str/ends-with? (name attribute) "?") field.ui/checkbox-field))))) - + (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 field-path [?field] - (->> ?field - (iterate io/parent) - (take-while identity) - (keep #(or (:attribute %) (:entity/id %))) - reverse)) +(defn throw-no-persistence! [?field] + (throw (ex-info (str "No persistence for " (:sym ?field)) {:where (->> (iterate io/parent ?field) + (take-while identity) + (map :sym))}))) (defn persisted-value [?field] - (let [[e & path] (field-path ?field)] - (get-in (db/get e) path))) + (if-let [{:keys [entity attribute wrap]} (:field/persistence ?field)] + (wrap (get entity attribute)) + (:init ?field) + #_(throw-no-persistence! ?field))) + +(defn save-field [?field & {:as props}] + (if-let [{:keys [entity attribute wrap]} (io/closest ?field :field/persistence)] + (io/try-submit+ ?field + (data/save-attribute! nil + (sch/wrap-id entity) + attribute + (wrap @?field))) + (throw-no-persistence! ?field))) (defn view-field [?field & [props]] (let [view (or (:view props) (:view ?field) - (infer-view (:attribute ?field)) + (some-> (:attribute ?field) infer-view) (throw (ex-info (str "No view declared for field: " (:sym ?field) (:attribute ?field)) {:sym (:sym ?field) :attribute (:attribute ?field)}))) - [e a :as path] (field-path ?field)] - [:div - [view ?field (merge (dissoc props :view) - (when (and (= 2 (count path)) - (uuid? (first path))) - {:persisted-value (db/get e a) - :on-save #(io/try-submit+ ?field - (do (prn :saving-pruned (u/prune @?field)) - (data/save-attribute! nil e a (u/prune @?field))))}))]])) + {:keys [entity attribute]} ?field] + [view ?field (merge (:props ?field) + {:persisted-value (get entity attribute) + :on-save (partial save-field ?field props)} + (dissoc props :view))])) + +(defn add-meta! [?field m] + (swap! (io/!meta ?field) merge + (when-let [attr (:attribute m)] + (io/global-meta attr)) + m) + (when-some [init (:init m)] (reset! ?field init)) + ?field) -(defn use-persisted-attr [entity attribute & {:as props}] +(defn use-persisted-field [e a & {:as props}] #?(:cljs - (let [persisted-value (get entity attribute) - ?field (h/use-memo #(io/field :init persisted-value - :attribute attribute - props) - ;; create a new field when the persisted value changes - (h/use-deps persisted-value))] - (view-field ?field (merge {:on-save (fn save-attr [] - (io/try-submit+ ?field - (data/save-attribute! nil (:entity/id entity) attribute @?field))) - :persisted-value persisted-value} - props))))) + (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 + :entity e + :wrap (:wrap props identity) + :field/persistence {:attribute a + :entity e + :wrap (:wrap props identity)}})) + ;; create a new field when the persisted value changes + (h/use-deps persisted-value))] + (view-field ?field props)))) #?(:cljs (defn href [{:as e :entity/keys [kind id]} key] @@ -91,12 +111,11 @@ {:style {:background-image (asset.ui/css-url (asset.ui/asset-src avatar :avatar))}})))]) [:div.flex.items-center.px-3.leading-snug [:div.line-clamp-2 title]]]) -routing/entity-route (ui/defview settings-button [entity] (when-let [path (and (validate/editing-role? (:member/roles entity)) - (some-> (routing/entity-route entity 'admin-ui/settings) - routing/path-for))] + (some-> (routing/entity-route entity 'admin-ui/settings) + routing/path-for))] [:a.button {:href path} [icons/gear "icon-lg"]])) @@ -107,7 +126,7 @@ routing/entity-route :keys [entity/title image/avatar member/roles]}] [:div.flex.hover:bg-gray-100.rounded-lg [:a.flex.relative.gap-3.items-center.p-2.cursor-default.flex-auto - {:href (routing/entity-path entity :show)} + {:href (routing/entity-path entity 'ui/show)} [ui/avatar {:size 10} entity] [:div.line-clamp-2.leading-snug.flex-grow title]]]) diff --git a/src/sb/app/field/admin_ui.cljc b/src/sb/app/field/admin_ui.cljc index 68e400e6..fe2e61ef 100644 --- a/src/sb/app/field/admin_ui.cljc +++ b/src/sb/app/field/admin_ui.cljc @@ -1,7 +1,5 @@ (ns sb.app.field.admin-ui - (:require [clojure.pprint :refer [pprint]] - [applied-science.js-interop :as j] - [clojure.set :as set] + (:require [applied-science.js-interop :as j] [clojure.string :as str] [inside-out.forms :as io] [promesa.core :as p] @@ -140,6 +138,7 @@ (into [:div.flex-v] (map #(show-option % {:on-save on-save :persisted-value (entity.ui/persisted-value %)}) ?options)) + (let [?new (h/use-memo #(io/field :init ""))] [:form.flex.gap-2 {:on-submit (fn [^js e] (.preventDefault e) @@ -166,14 +165,13 @@ [: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 {:class "bg-white text-sm" - :multi-line true}) - (view-field ?hint {:class "bg-white text-sm" - :multi-line true + (view-field ?label {:multi-line true}) + (view-field ?hint {:multi-line true :placeholder "Further instructions"})] (when (= :field.type/select @?type) - (view-field ?options)) + [:div.col-span-2.text-sm + (view-field ?options)]) [:div.contents.labels-normal (view-field ?required?) @@ -181,13 +179,15 @@ (when (= :board/member-fields (:attribute (io/parent ?field))) (view-field ?show-at-registration?)) (view-field ?show-on-card?) - [:a.text-gray-500.hover:underline.cursor-pointer.flex.gap-2 - {:on-click #(radix/simple-alert! {:message "Are you sure you want to remove this?" - :confirm-text (t :tr/remove) - :confirm-fn (fn [] - (io/remove-many! ?field) - ((:on-save props)))})} - (t :tr/remove)]]])) + [:div + [:a.p-1.text-sm.cursor-pointer.inline-flex.gap-2.rounded.hover:bg-gray-200 + {:on-click #(radix/simple-alert! {:message "Are you sure you want to remove this?" + :confirm-text (t :tr/remove) + :confirm-fn (fn [] + (io/remove-many! ?field) + ((:on-save props)))})} + [icons/trash "text-destructive -ml-1"] + (t :tr/remove)]]]])) (ui/defview field-row {:key (fn [{:syms [?id]} _] @?id)} @@ -226,10 +226,11 @@ (field-row-detail ?field {:on-save on-save :persisted-value persisted-value}))])) -(ui/defview fields-editor [{:as ?fields :keys [label]} {:keys [on-save persisted-value]}] +(ui/defview fields-editor [{:as ?fields :keys [label]} props] (let [!new-field (h/use-state nil) !autofocus-ref (ui/use-autofocus-ref) - [expanded expand!] (h/use-state nil)] + [expanded expand!] (h/use-state nil) + on-save #(do (prn :will-save @?fields) (entity.ui/save-field ?fields props))] [:div.field-wrapper {:class "labels-semibold"} [:label.field-label {:class "flex items-center"} label diff --git a/src/sb/app/form/ui.cljc b/src/sb/app/form/ui.cljc index 6d4871f1..a0ba2b11 100644 --- a/src/sb/app/form/ui.cljc +++ b/src/sb/app/form/ui.cljc @@ -17,11 +17,14 @@ (str "field-" (:sym ?field)))) (defn maybe-save-field [?field props value] - (when-let [on-save (and (not= value (:persisted-value props)) + (if-let [on-save (and (not= value (:persisted-value props)) (:on-save props))] - (reset! ?field value) - (io/try-submit+ ?field - (on-save)))) + (do + (prn :saving value) + (reset! ?field value) + (io/try-submit+ ?field + (on-save))) + (prn :not-saving value))) (defn pass-props [props] (dissoc props :multi-line :postfix :wrapper-class @@ -60,7 +63,7 @@ (when save-on-change? (maybe-save-field ?field props new-value)))) :on-blur (fn [e] - (maybe-save-field ?field props (get-value e)) + (maybe-save-field ?field props (wrap (get-value e))) ((io/blur-handler ?field) e)) :on-focus (io/focus-handler ?field)} persisted-value diff --git a/src/sb/app/org/admin_ui.cljc b/src/sb/app/org/admin_ui.cljc index d861aef9..e571129e 100644 --- a/src/sb/app/org/admin_ui.cljc +++ b/src/sb/app/org/admin_ui.cljc @@ -1,12 +1,9 @@ (ns sb.app.org.admin-ui - (:require [sb.app.views.ui :as ui] - [sb.app.org.data :as data] + (:require [sb.app.entity.ui :as entity.ui] [sb.app.form.ui :as form.ui] - [sb.app.entity.ui :as entity.ui] - [sb.app.field.ui :as field.ui] - [sb.app.domain-name.ui :as domain-name.ui] + [sb.app.org.data :as data] [sb.app.views.header :as header] - [sb.i18n :refer [t]])) + [sb.app.views.ui :as ui])) (ui/defview settings {:route "/o/:org-id/settings"} @@ -15,10 +12,10 @@ [:<> (header/entity org (list (entity.ui/settings-button org))) [:div {:class form.ui/form-classes} - (entity.ui/use-persisted-attr org :entity/title field.ui/text-field) - (entity.ui/use-persisted-attr org :entity/description field.ui/prose-field) - (entity.ui/use-persisted-attr org :entity/domain-name domain-name.ui/domain-field) + (entity.ui/use-persisted-field org :entity/title ) + (entity.ui/use-persisted-field org :entity/description) + (entity.ui/use-persisted-field org :entity/domain-name) ;; TODO - uploading an image does not work - (entity.ui/use-persisted-attr org :image/avatar field.ui/image-field {:label (t :tr/image.logo)}) + (entity.ui/use-persisted-field org :image/avatar) ]])) \ No newline at end of file diff --git a/src/sb/app/views/radix.cljc b/src/sb/app/views/radix.cljc index 2b93a393..63a1e32f 100644 --- a/src/sb/app/views/radix.cljc +++ b/src/sb/app/views/radix.cljc @@ -196,15 +196,16 @@ ) (defn accordion [props & sections] - [:el.accordion-root accordion/Root (v/merge-props {:default-value #js["0"] - :type "multiple"} - props) - (->> (partition 2 sections) - (map-indexed - (fn [i [trigger content]] - [:el.accordion-item accordion/Item {:key i - :value (str i)} - [:el accordion/Header - [:el.accordion-trigger accordion/Trigger (v/x trigger) [icons/chevron-down]]] - [:el.accordion-content accordion/Content - (v/x content)]])))]) \ No newline at end of file + #?(:cljs + [:el.accordion-root accordion/Root (v/merge-props {:default-value #js["0"] + :type "multiple"} + props) + (->> (partition 2 sections) + (map-indexed + (fn [i [trigger content]] + [:el.accordion-item accordion/Item {:key i + :value (str i)} + [:el accordion/Header + [:el.accordion-trigger accordion/Trigger (v/x trigger) [icons/chevron-down]]] + [:el.accordion-content accordion/Content + (v/x content)]])))])) \ No newline at end of file