🎉 Backport questions form integration.

Among other related that need to be ported.
This commit is contained in:
Andrey Antukh 2021-11-04 15:17:12 +01:00
parent a2d3616171
commit eb1bcfba83
45 changed files with 983 additions and 529 deletions

View file

@ -78,6 +78,7 @@
(def translations (obj/get global "penpotTranslations"))
(def themes (obj/get global "penpotThemes"))
(def sentry-dsn (obj/get global "penpotSentryDsn"))
(def onboarding-form-id (obj/get global "penpotOnboardingQuestionsFormId"))
(def flags (atom (parse-flags global)))
(def version (atom (parse-version global)))

View file

@ -16,6 +16,7 @@
[app.main.repo :as rp]
[app.util.i18n :as i18n :refer [tr]]
[app.util.router :as rt]
[app.util.timers :as tm]
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[potok.core :as ptk]))
@ -60,6 +61,7 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare fetch-projects)
(declare fetch-team-members)
(defn initialize
[{:keys [id] :as params}]
@ -84,6 +86,7 @@
(rx/merge
(ptk/watch (df/load-team-fonts id) state stream)
(ptk/watch (fetch-projects) state stream)
(ptk/watch (fetch-team-members) state stream)
(ptk/watch (du/fetch-teams) state stream)
(ptk/watch (du/fetch-users {:team-id id}) state stream)))))
@ -237,13 +240,14 @@
(update :dashboard-files d/merge files))))))
(defn fetch-recent-files
[]
(ptk/reify ::fetch-recent-files
ptk/WatchEvent
(watch [_ state _]
(let [team-id (:current-team-id state)]
(->> (rp/query :team-recent-files {:team-id team-id})
(rx/map recent-files-fetched))))))
([] (fetch-recent-files nil))
([team-id]
(ptk/reify ::fetch-recent-files
ptk/WatchEvent
(watch [_ state _]
(let [team-id (or team-id (:current-team-id state))]
(->> (rp/query :team-recent-files {:team-id team-id})
(rx/map recent-files-fetched)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Data Selection
@ -396,16 +400,13 @@
(let [{:keys [on-success on-error]
:or {on-success identity
on-error rx/throw}} (meta params)
team-id (:current-team-id state)]
(rx/concat
(when (uuid? reassign-to)
(->> (rp/mutation! :update-team-member-role {:team-id team-id
:role :owner
:member-id reassign-to})
(rx/ignore)))
(->> (rp/mutation! :leave-team {:id team-id})
(rx/tap on-success)
(rx/catch on-error)))))))
team-id (:current-team-id state)
params (cond-> {:id team-id}
(uuid? reassign-to)
(assoc :reassign-to reassign-to))]
(->> (rp/mutation! :leave-team params)
(rx/tap #(tm/schedule on-success))
(rx/catch on-error))))))
(defn invite-team-member
[{:keys [email role] :as params}]

View file

@ -7,12 +7,12 @@
(ns app.main.data.users
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.spec :as us]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.events :as ev]
[app.main.data.media :as di]
[app.main.data.modal :as modal]
[app.main.repo :as rp]
[app.util.i18n :as i18n]
[app.util.router :as rt]
@ -93,6 +93,8 @@
;; --- EVENT: fetch-profile
(declare logout)
(def profile-fetched?
(ptk/type? ::profile-fetched))
@ -105,18 +107,18 @@
ptk/UpdateEvent
(update [_ state]
(-> state
(assoc :profile-id id)
(assoc :profile profile)))
(cond-> state
(is-authenticated? profile)
(-> (assoc :profile-id id)
(assoc :profile profile))))
ptk/EffectEvent
(effect [_ state _]
(let [profile (:profile state)]
(when (not= uuid/zero (:id profile))
(swap! storage assoc :profile profile)
(i18n/set-locale! (:lang profile))
(some-> (:theme profile)
(theme/set-current-theme!)))))))
(when-let [profile (:profile state)]
(swap! storage assoc :profile profile)
(i18n/set-locale! (:lang profile))
(some-> (:theme profile)
(theme/set-current-theme!))))))
(defn fetch-profile
[]
@ -145,55 +147,84 @@
(rx/mapcat (fn [profile]
(if (= uuid/zero (:id profile))
(rx/empty)
(rx/of (fetch-teams))))))))))
(rx/of (fetch-teams)))))
(rx/observe-on :async))))))
;; --- EVENT: login
(defn- logged-in
"This is the main event that is executed once we have logged in
profile. The profile can proceed from standard login or from
accepting invitation, or third party auth signup or singin."
[profile]
(ptk/reify ::logged-in
IDeref
(-deref [_] profile)
(letfn [(get-redirect-event []
(let [team-id (:default-team-id profile)]
(rt/nav' :dashboard-projects {:team-id team-id})))]
ptk/WatchEvent
(watch [_ _ _]
(let [team-id (get-current-team-id profile)]
(->> (rx/concat
(rx/of (profile-fetched profile)
(fetch-teams))
(ptk/reify ::logged-in
IDeref
(-deref [_] profile)
(->> (rx/of (rt/nav' :dashboard-projects {:team-id team-id}))
(rx/delay 1000))
(when-not (get-in profile [:props :onboarding-viewed])
(->> (rx/of (modal/show {:type :onboarding}))
(rx/delay 1000))))
(rx/observe-on :async))))))
ptk/WatchEvent
(watch [_ _ _]
(when (is-authenticated? profile)
(->> (rx/of (profile-fetched profile)
(fetch-teams)
(get-redirect-event))
(rx/observe-on :async)))))))
(s/def ::login-params
(s/keys :req-un [::email ::password]))
(declare login-from-register)
(defn login
[{:keys [email password] :as data}]
(us/verify ::login-params data)
(ptk/reify ::login
ptk/WatchEvent
(watch [_ _ _]
(watch [_ _ stream]
(let [{:keys [on-error on-success]
:or {on-error rx/throw
on-success identity}} (meta data)
params {:email email
:password password
:scope "webapp"}]
(->> (rx/timer 100)
(rx/mapcat #(rp/mutation :login params))
(rx/tap on-success)
(rx/catch on-error)
(rx/map (fn [profile]
(with-meta profile
{::ev/source "login"})))
(rx/map logged-in))))))
;; NOTE: We can't take the profile value from login because
;; there are cases when login is successfull but the cookie is
;; not set properly (because of possible misconfiguration).
;; So, we proceed to make an additional call to fetch the
;; profile, and ensure that cookie is set correctly. If
;; profile fetch is successful, we mark the user logged in, if
;; the returned profile is an NOT authenticated profile, we
;; proceed to logout and show an error message.
(rx/merge
(->> (rp/mutation :login params)
(rx/map fetch-profile)
(rx/catch on-error))
(->> stream
(rx/filter profile-fetched?)
(rx/take 1)
(rx/map deref)
(rx/filter (complement is-authenticated?))
(rx/tap on-error)
(rx/map #(ex/raise :type :authentication))
(rx/observe-on :async))
(->> stream
(rx/filter profile-fetched?)
(rx/take 1)
(rx/map deref)
(rx/filter is-authenticated?)
(rx/map (fn [profile]
(with-meta profile
{::ev/source "login"})))
(rx/tap on-success)
(rx/map logged-in)
(rx/observe-on :async)))))))
(defn login-from-token
[{:keys [profile] :as tdata}]
@ -221,44 +252,46 @@
(rx/map (fn [profile]
(with-meta profile
{::ev/source "register"})))
(rx/map logged-in))))))
(rx/map logged-in)
(rx/observe-on :async))))))
;; --- EVENT: logout
(defn logged-out
[]
(ptk/reify ::logged-out
ptk/UpdateEvent
(update [_ state]
(select-keys state [:route :router :session-id :history]))
([] (logged-out {}))
([_params]
(ptk/reify ::logged-out
ptk/UpdateEvent
(update [_ state]
(select-keys state [:route :router :session-id :history]))
ptk/WatchEvent
(watch [_ _ _]
(rx/of (rt/nav :auth-login)))
ptk/WatchEvent
(watch [_ _ _]
;; NOTE: We need the `effect` of the current event to be
;; executed before the redirect.
(->> (rx/of (rt/nav :auth-login))
(rx/observe-on :async)))
ptk/EffectEvent
(effect [_ _ _]
(reset! storage {})
(i18n/reset-locale))))
ptk/EffectEvent
(effect [_ _ _]
(reset! storage {})
(i18n/reset-locale)))))
(defn logout
[]
(ptk/reify ::logout
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/mutation :logout)
(rx/delay-at-least 300)
(rx/catch (constantly (rx/of 1)))
(rx/map logged-out)))))
([] (logout {}))
([params]
(ptk/reify ::logout
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/mutation :logout)
(rx/delay-at-least 300)
(rx/catch (constantly (rx/of 1)))
(rx/map #(logged-out params)))))))
;; --- EVENT: register
;; TODO: remove
(s/def ::invitation-token ::us/not-empty-string)
(s/def ::register
(s/keys :req-un [::fullname ::password ::email]
:opt-un [::invitation-token]))
(s/keys :req-un [::fullname ::password ::email]))
(defn register
"Create a register event instance."
@ -347,20 +380,33 @@
(rx/empty)))
(rx/ignore))))))
(defn mark-onboarding-as-viewed
([] (mark-onboarding-as-viewed nil))
([{:keys [version]}]
(ptk/reify ::mark-oboarding-as-viewed
ptk/WatchEvent
(watch [_ state _]
(watch [_ _ _]
(let [version (or version (:main @cf/version))
props (-> (get-in state [:profile :props])
(assoc :onboarding-viewed true)
(assoc :release-notes-viewed version))]
props {:onboarding-viewed true
:release-notes-viewed version}]
(->> (rp/mutation :update-profile-props {:props props})
(rx/map (constantly (fetch-profile)))))))))
(defn mark-questions-as-answered
[]
(ptk/reify ::mark-questions-as-answered
ptk/UpdateEvent
(update [_ state]
(update-in state [:profile :props] assoc :onboarding-questions-answered true))
ptk/WatchEvent
(watch [_ _ _]
(let [props {:onboarding-questions-answered true}]
(->> (rp/mutation :update-profile-props {:props props})
(rx/map (constantly (fetch-profile))))))))
;; --- Update Photo
(defn update-photo

View file

@ -13,6 +13,7 @@
[app.main.data.users :as du]
[app.main.sentry :as sentry]
[app.main.store :as st]
[app.util.i18n :refer [tr]]
[app.util.router :as rt]
[app.util.timers :as ts]
[cljs.pprint :refer [pprint]]
@ -48,7 +49,9 @@
;; here and not in app.main.errors because of circular dependency.
(defmethod ptk/handle-error :authentication
[_]
(ts/schedule (st/emitf (du/logout))))
(let [msg (tr "errors.auth.unable-to-login")]
(st/emit! (du/logout {:capture-redirect true}))
(ts/schedule 500 (st/emitf (dm/warn msg)))))
;; That are special case server-errors that should be treated

View file

@ -6,6 +6,7 @@
(ns app.main.ui
(:require
[app.config :as cf]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.auth :refer [auth]]
@ -17,6 +18,8 @@
[app.main.ui.icons :as i]
[app.main.ui.messages :as msgs]
[app.main.ui.onboarding]
[app.main.ui.onboarding.questions]
[app.main.ui.releases]
[app.main.ui.render :as render]
[app.main.ui.settings :as settings]
[app.main.ui.static :as static]
@ -32,7 +35,7 @@
(mf/defc main-page
{::mf/wrap [#(mf/catch % {:fallback on-main-error})]}
[{:keys [route] :as props}]
[{:keys [route profile]}]
(let [{:keys [data params]} route]
[:& (mf/provider ctx/current-route) {:value route}
(case (:name data)
@ -70,13 +73,32 @@
:dashboard-font-providers
:dashboard-team-members
:dashboard-team-settings)
[:*
#_[:div.modal-wrapper
#_[:& app.main.ui.onboarding/onboarding-templates-modal]
[:& app.main.ui.onboarding/onboarding-modal]
#_[:& app.main.ui.onboarding/onboarding-modal]
#_[:& app.main.ui.onboarding/onboarding-team-modal]
]
[:& dashboard {:route route}]]
(when-let [props (some-> profile (get :props {}))]
(cond
(and cf/onboarding-form-id
(not (:onboarding-questions-answered props false))
(not (:onboarding-viewed props false)))
[:& app.main.ui.onboarding.questions/questions
{:profile profile
:form-id cf/onboarding-form-id}]
(not (:onboarding-viewed props))
[:& app.main.ui.onboarding/onboarding-modal {}]
(and (:onboarding-viewed props)
(not= (:release-notes-viewed props) (:main @cf/version))
(not= "0.0" (:main @cf/version)))
[:& app.main.ui.releases/release-notes-modal {}]))
[:& dashboard {:route route :profile profile}]]
:viewer
(let [{:keys [query-params path-params]} route
@ -124,12 +146,14 @@
(mf/defc app
[]
(let [route (mf/deref refs/route)
edata (mf/deref refs/exception)]
(let [route (mf/deref refs/route)
edata (mf/deref refs/exception)
profile (mf/deref refs/profile)]
[:& (mf/provider ctx/current-route) {:value route}
(if edata
[:& static/exception-page {:data edata}]
[:*
[:& msgs/notifications]
(when route
[:& main-page {:route route}])])]))
[:& (mf/provider ctx/current-profile) {:value profile}
(if edata
[:& static/exception-page {:data edata}]
[:*
[:& msgs/notifications]
(when route
[:& main-page {:route route :profile profile}])])]]))

View file

@ -30,8 +30,7 @@
(mf/use-callback
(fn [_ _]
(reset! submitted false)
(st/emit! (dm/info (tr "auth.notifications.recovery-token-sent"))
(rt/nav :auth-login))))
(st/emit! (dm/info (tr "auth.notifications.recovery-token-sent")))))
on-error
(mf/use-callback

View file

@ -15,8 +15,9 @@
;; for text shapes in the export process
(def text-plain-colors-ctx (mf/create-context false))
(def current-route (mf/create-context nil))
(def current-team-id (mf/create-context nil))
(def current-route (mf/create-context nil))
(def current-profile (mf/create-context nil))
(def current-team-id (mf/create-context nil))
(def current-project-id (mf/create-context nil))
(def current-page-id (mf/create-context nil))
(def current-file-id (mf/create-context nil))
(def current-page-id (mf/create-context nil))
(def current-file-id (mf/create-context nil))

View file

@ -7,9 +7,7 @@
(ns app.main.ui.dashboard
(:require
[app.common.spec :as us]
[app.config :as cf]
[app.main.data.dashboard :as dd]
[app.main.data.modal :as modal]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.context :as ctx]
@ -22,7 +20,6 @@
[app.main.ui.dashboard.search :refer [search-page]]
[app.main.ui.dashboard.sidebar :refer [sidebar]]
[app.main.ui.dashboard.team :refer [team-settings-page team-members-page]]
[app.util.timers :as tm]
[rumext.alpha :as mf]))
(defn ^boolean uuid-str?
@ -77,9 +74,8 @@
nil)])
(mf/defc dashboard
[{:keys [route] :as props}]
(let [profile (mf/deref refs/profile)
section (get-in route [:data :name])
[{:keys [route profile] :as props}]
(let [section (get-in route [:data :name])
params (parse-params route)
project-id (:project-id params)
@ -94,18 +90,8 @@
(mf/use-effect
(mf/deps team-id)
(st/emitf (dd/initialize {:id team-id})))
(mf/use-effect
(mf/deps)
(fn []
(let [props (:props profile)
version (:release-notes-viewed props)]
(when (and (:onboarding-viewed props)
(not= version (:main @cf/version))
(not= "0.0" (:main @cf/version)))
(tm/schedule 1000 #(st/emit! (modal/show {:type :release-notes
:version (:main @cf/version)})))))))
(st/emit! (dd/initialize {:id team-id}))))
[:& (mf/provider ctx/current-team-id) {:value team-id}
[:& (mf/provider ctx/current-project-id) {:value project-id}

View file

@ -115,7 +115,7 @@
(st/emit! (dm/success (tr "dashboard.success-move-file"))))
(if (or navigate? (not= team-id current-team-id))
(st/emit! (dd/go-to-files team-id project-id))
(st/emit! (dd/fetch-recent-files)
(st/emit! (dd/fetch-recent-files team-id)
(dd/clear-selected-files))))
on-move

View file

@ -327,8 +327,9 @@
on-finish-import
(mf/use-callback
(mf/deps (:id team))
(fn []
(st/emit! (dd/fetch-recent-files)
(st/emit! (dd/fetch-recent-files (:id team))
(dd/clear-selected-files))))
import-files (use-import-file project-id on-finish-import)
@ -366,7 +367,7 @@
on-drop-success
(fn []
(st/emit! (dm/success (tr "dashboard.success-move-file"))
(dd/fetch-recent-files)
(dd/fetch-recent-files (:id team))
(dd/clear-selected-files)))
on-drop

View file

@ -97,9 +97,10 @@
on-import
(mf/use-callback
(mf/deps (:id project) (:id team))
(fn []
(st/emit! (dd/fetch-files {:project-id (:id project)})
(dd/fetch-recent-files)
(dd/fetch-recent-files (:id team))
(dd/clear-selected-files))))]
[:div.dashboard-project-row {:class (when first? "first")}
@ -163,15 +164,15 @@
(mf/use-effect
(mf/deps team)
(fn []
(when team
(let [tname (if (:is-default team)
(tr "dashboard.your-penpot")
(:name team))]
(dom/set-html-title (tr "title.dashboard.projects" tname))))))
(let [tname (if (:is-default team)
(tr "dashboard.your-penpot")
(:name team))]
(dom/set-html-title (tr "title.dashboard.projects" tname)))))
(mf/use-effect
(mf/deps (:id team))
(fn []
(st/emit! (dd/fetch-recent-files)
(st/emit! (dd/fetch-recent-files (:id team))
(dd/clear-selected-files))))
(when (seq projects)

View file

@ -28,6 +28,7 @@
[app.util.i18n :as i18n :refer [tr]]
[app.util.object :as obj]
[app.util.router :as rt]
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[goog.functions :as f]
[rumext.alpha :as mf]))
@ -287,27 +288,39 @@
members-map (mf/deref refs/dashboard-team-members)
members (vals members-map)
on-rename-clicked
(st/emitf (modal/show :team-form {:team team}))
on-leaved-success
(fn []
(st/emit! (modal/hide)
(du/fetch-teams)))
leave-fn
(st/emitf (dd/leave-team (with-meta {} {:on-success on-leaved-success})))
leave-and-reassign-fn
(fn [member-id]
(let [params {:reassign-to member-id}]
(st/emit! (dd/go-to-projects (:default-team-id profile))
(dd/leave-team (with-meta params {:on-success on-leaved-success})))))
delete-fn
on-success
(fn []
(st/emit! (dd/go-to-projects (:default-team-id profile))
(dd/delete-team (with-meta team {:on-success on-leaved-success}))))
(modal/hide)
(du/fetch-teams)))
on-error
(fn [{:keys [code] :as error}]
(condp = code
:no-enough-members-for-leave
(rx/of (dm/error (tr "errors.team-leave.insufficient-members")))
:member-does-not-exist
(rx/of (dm/error (tr "errors.team-leave.member-does-not-exists")))
:owner-cant-leave-team
(rx/of (dm/error (tr "errors.team-leave.owner-cant-leave")))
(rx/throw error)))
leave-fn
(fn [member-id]
(let [params (cond-> {} (uuid? member-id) (assoc :reassign-to member-id))]
(st/emit! (dd/leave-team (with-meta params
{:on-success on-success
:on-error on-error})))))
delete-fn
(fn []
(st/emit! (dd/delete-team (with-meta team {:on-success on-success
:on-error on-error}))))
on-rename-clicked
(fn []
(st/emit! (modal/show :team-form {:team team})))
on-leave-clicked
(st/emitf (modal/show
@ -324,7 +337,7 @@
{:type ::leave-and-reassign
:profile profile
:team team
:accept leave-and-reassign-fn})))
:accept leave-fn})))
on-delete-clicked
(st/emitf
@ -501,7 +514,7 @@
[:li {:on-click (partial on-click :settings-password)}
[:span.icon i/lock]
[:span.text (tr "labels.password")]]
[:li {:on-click (partial on-click (du/logout))}
[:li {:on-click #(on-click (du/logout) %)}
[:span.icon i/exit]
[:span.text (tr "labels.logout")]]

View file

@ -6,32 +6,16 @@
(ns app.main.ui.onboarding
(:require
[app.common.spec :as us]
[app.config :as cf]
[app.main.data.dashboard :as dd]
[app.main.data.messages :as dm]
[app.main.data.modal :as modal]
[app.main.data.users :as du]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.forms :as fm]
[app.main.ui.icons :as i]
[app.main.ui.onboarding.questions]
[app.main.ui.onboarding.team-choice]
[app.main.ui.onboarding.templates]
[app.main.ui.releases.common :as rc]
[app.main.ui.releases.v1-10]
[app.main.ui.releases.v1-4]
[app.main.ui.releases.v1-5]
[app.main.ui.releases.v1-6]
[app.main.ui.releases.v1-7]
[app.main.ui.releases.v1-8]
[app.main.ui.releases.v1-9]
[app.util.dom :as dom]
[app.util.http :as http]
[app.util.i18n :as i18n :refer [tr]]
[app.util.object :as obj]
[app.util.router :as rt]
[app.util.timers :as tm]
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[rumext.alpha :as mf]))
;; --- ONBOARDING LIGHTBOX
@ -189,297 +173,3 @@
:slide @slide
:navigate navigate
:skip skip)))]]))
(s/def ::name ::us/not-empty-string)
(s/def ::team-form
(s/keys :req-un [::name]))
(mf/defc onboarding-choice-modal
{::mf/register modal/components
::mf/register-as :onboarding-choice}
[]
(let [;; When user choices the option of `fly solo`, we proceed to show
;; the onboarding templates modal.
on-fly-solo
(fn []
(tm/schedule 400 #(st/emit! (modal/show {:type :onboarding-templates}))))
;; When user choices the option of `team up`, we proceed to show
;; the team creation modal.
on-team-up
(fn []
(st/emit! (modal/show {:type :onboarding-team})))
]
[:div.modal-overlay
[:div.modal-container.onboarding.final.animated.fadeInUp
[:div.modal-top
[:h1 (tr "onboarding.choice.title")]
[:p (tr "onboarding.choice.desc")]]
[:div.modal-columns
[:div.modal-left
[:div.content-button {:on-click on-fly-solo}
[:h2 (tr "onboarding.choice.fly-solo")]
[:p (tr "onboarding.choice.fly-solo-desc")]]]
[:div.modal-right
[:div.content-button {:on-click on-team-up}
[:h2 (tr "onboarding.choice.team-up")]
[:p (tr "onboarding.choice.team-up-desc")]]]]
[:img.deco {:src "images/deco-left.png" :border "0"}]
[:img.deco.right {:src "images/deco-right.png" :border "0"}]]]))
(mf/defc onboarding-team-modal
{::mf/register modal/components
::mf/register-as :onboarding-team}
[]
(let [form (fm/use-form :spec ::team-form
:initial {})
on-submit
(mf/use-callback
(fn [form _]
(let [tname (get-in @form [:clean-data :name])]
(st/emit! (modal/show {:type :onboarding-team-invitations :name tname})))))]
[:div.modal-overlay
[:div.modal-container.onboarding-team
[:div.title
[:h2 (tr "onboarding.choice.team-up")]
[:p (tr "onboarding.choice.team-up-desc")]]
[:& fm/form {:form form
:on-submit on-submit}
[:div.team-row
[:& fm/input {:type "text"
:name :name
:label (tr "onboarding.team-input-placeholder")}]]
[:div.buttons
[:button.btn-secondary.btn-large
{:on-click #(st/emit! (modal/show {:type :onboarding-choice}))}
(tr "labels.cancel")]
[:& fm/submit-button
{:label (tr "labels.next")}]]]
[:img.deco {:src "images/deco-left.png" :border "0"}]
[:img.deco.right {:src "images/deco-right.png" :border "0"}]]]))
(defn get-available-roles
[]
[{:value "editor" :label (tr "labels.editor")}
{:value "admin" :label (tr "labels.admin")}])
(s/def ::email ::us/email)
(s/def ::role ::us/keyword)
(s/def ::invite-form
(s/keys :req-un [::role ::email]))
;; This is the final step of team creation, consists in provide a
;; shortcut for invite users.
(mf/defc onboarding-team-invitations-modal
{::mf/register modal/components
::mf/register-as :onboarding-team-invitations}
[{:keys [name] :as props}]
(let [initial (mf/use-memo (constantly
{:role "editor"
:name name}))
form (fm/use-form :spec ::invite-form
:initial initial)
roles (mf/use-memo #(get-available-roles))
on-success
(mf/use-callback
(fn [_form response]
(let [project-id (:default-project-id response)
team-id (:id response)]
(st/emit!
(modal/hide)
(rt/nav :dashboard-projects {:team-id team-id}))
(tm/schedule 400 #(st/emit!
(modal/show {:type :onboarding-templates
:project-id project-id}))))))
on-error
(mf/use-callback
(fn [_form _response]
(st/emit! (dm/error "Error on creating team."))))
;; The SKIP branch only creates the team, without invitations
on-skip
(mf/use-callback
(fn [_]
(let [mdata {:on-success (partial on-success form)
:on-error (partial on-error form)}
params {:name name}]
(st/emit! (dd/create-team (with-meta params mdata))))))
;; The SUBMIT branch creates the team with the invitations
on-submit
(mf/use-callback
(fn [form _]
(let [mdata {:on-success (partial on-success form)
:on-error (partial on-error form)}
params (:clean-data @form)]
(st/emit! (dd/create-team-with-invitations (with-meta params mdata))))))]
[:div.modal-overlay
[:div.modal-container.onboarding-team
[:div.title
[:h2 (tr "onboarding.choice.team-up")]
[:p (tr "onboarding.choice.team-up-desc")]]
[:& fm/form {:form form
:on-submit on-submit}
[:div.invite-row
[:& fm/input {:name :email
:label (tr "labels.email")}]
[:& fm/select {:name :role
:options roles}]]
[:div.buttons
[:button.btn-secondary.btn-large
{:on-click #(st/emit! (modal/show {:type :onboarding-choice}))}
(tr "labels.cancel")]
[:& fm/submit-button
{:label (tr "labels.create")}]]
[:div.skip-action
{:on-click on-skip}
[:div.action "Skip and invite later"]]]
[:img.deco {:src "images/deco-left.png" :border "0"}]
[:img.deco.right {:src "images/deco-right.png" :border "0"}]]]))
(mf/defc template-item
[{:keys [name path image project-id]}]
(let [downloading? (mf/use-state false)
link (str (assoc cf/public-uri :path path))
on-finish-import
(fn []
(st/emit! (dd/fetch-files {:project-id project-id})
(dd/fetch-recent-files)
(dd/clear-selected-files)))
open-import-modal
(fn [file]
(st/emit! (modal/show
{:type :import
:project-id project-id
:files [file]
:on-finish-import on-finish-import})))
on-click
(fn []
(reset! downloading? true)
(->> (http/send! {:method :get :uri link :response-type :blob :mode :no-cors})
(rx/subs (fn [{:keys [body] :as response}]
(open-import-modal {:name name :uri (dom/create-uri body)}))
(fn [error]
(js/console.log "error" error))
(fn []
(reset! downloading? false)))))
]
[:div.template-item
[:div.template-item-content
[:img {:src image}]]
[:div.template-item-title
[:div.label name]
(if @downloading?
[:div.action "Fetching..."]
[:div.action {:on-click on-click} "+ Add to drafts"])]]))
(mf/defc onboarding-templates-modal
{::mf/register modal/components
::mf/register-as :onboarding-templates}
;; NOTE: the project usually comes empty, it only comes fullfilled
;; when a user creates a new team just after signup.
[{:keys [project-id] :as props}]
(let [close-fn (mf/use-callback #(st/emit! (modal/hide)))
profile (mf/deref refs/profile)
project-id (or project-id (:default-project-id profile))]
[:div.modal-overlay
[:div.modal-container.onboarding-templates
[:div.modal-header
[:div.modal-close-button
{:on-click close-fn} i/close]]
[:div.modal-content
[:h3 (tr "onboarding.templates.title")]
[:p (tr "onboarding.templates.subtitle")]
[:div.templates
[:& template-item
{:path "/github/penpot-files/Penpot-Design-system.penpot"
:image "https://penpot.app/images/libraries/cover-ds-penpot.jpg"
:name "Penpot Design System"
:project-id project-id}]
[:& template-item
{:path "/github/penpot-files/Material-Design-Kit.penpot"
:image "https://penpot.app/images/libraries/cover-material.jpg"
:name "Material Design Kit"
:project-id project-id}]]]]]))
;;; --- RELEASE NOTES MODAL
(mf/defc release-notes
[{:keys [version] :as props}]
(let [slide (mf/use-state :start)
klass (mf/use-state "fadeInDown")
navigate
(mf/use-callback #(reset! slide %))
next
(mf/use-callback
(mf/deps slide)
(fn []
(if (= @slide :start)
(navigate 0)
(navigate (inc @slide)))))
finish
(mf/use-callback
(st/emitf (modal/hide)
(du/mark-onboarding-as-viewed {:version version})))
]
(mf/use-effect
(mf/deps)
(fn []
(st/emitf (du/mark-onboarding-as-viewed {:version version}))))
(mf/use-layout-effect
(mf/deps @slide)
(fn []
(when (not= :start @slide)
(reset! klass "fadeIn"))
(let [sem (tm/schedule 300 #(reset! klass nil))]
(fn []
(reset! klass nil)
(tm/dispose! sem)))))
(rc/render-release-notes
{:next next
:navigate navigate
:finish finish
:klass klass
:slide slide
:version version})))
(mf/defc release-notes-modal
{::mf/wrap-props false
::mf/register modal/components
::mf/register-as :release-notes}
[props]
(let [versions (methods rc/render-release-notes)
version (obj/get props "version")]
(when (contains? versions version)
[:div.relnotes
[:> release-notes props]])))
(defmethod rc/render-release-notes "0.0"
[params]
(rc/render-release-notes (assoc params :version "1.10")))

View file

@ -0,0 +1,48 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.main.ui.onboarding.questions
"External form for onboarding questions."
(:require
[app.main.data.users :as du]
[app.main.store :as st]
[app.util.dom :as dom]
[goog.events :as ev]
[promesa.core :as p]
[rumext.alpha :as mf]))
(defn load-arengu-sdk
[container-ref email form-id]
(letfn [(on-init []
(when-let [container (mf/ref-val container-ref)]
(-> (.embed js/ArenguForms form-id container)
(p/then (fn [form]
(.setHiddenField ^js form "email" email))))))
(on-submit-success [_event]
(st/emit! (du/mark-questions-as-answered)))
]
(let [script (dom/create-element "script")
head (unchecked-get js/document "head")
lkey1 (ev/listen js/document "af-submitForm-success" on-submit-success)]
(unchecked-set script "src" "https://sdk.arengu.com/forms.js")
(unchecked-set script "onload" on-init)
(dom/append-child! head script)
(fn []
(ev/unlistenByKey lkey1)))))
(mf/defc questions
[{:keys [profile form-id]}]
(let [container (mf/use-ref)]
(mf/use-effect (partial load-arengu-sdk container (:email profile) form-id))
[:div.modal-wrapper.questions-form
[:div.modal-overlay
[:div.modal-container {:ref container}]]]))

View file

@ -0,0 +1,181 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.main.ui.onboarding.team-choice
(:require
[app.common.spec :as us]
[app.main.data.dashboard :as dd]
[app.main.data.messages :as dm]
[app.main.data.modal :as modal]
[app.main.store :as st]
[app.main.ui.components.forms :as fm]
[app.util.i18n :as i18n :refer [tr]]
[app.util.router :as rt]
[app.util.timers :as tm]
[cljs.spec.alpha :as s]
[rumext.alpha :as mf]))
(s/def ::name ::us/not-empty-string)
(s/def ::team-form
(s/keys :req-un [::name]))
(mf/defc onboarding-choice-modal
{::mf/register modal/components
::mf/register-as :onboarding-choice}
[]
(let [;; When user choices the option of `fly solo`, we proceed to show
;; the onboarding templates modal.
on-fly-solo
(fn []
(tm/schedule 400 #(st/emit! (modal/show {:type :onboarding-templates}))))
;; When user choices the option of `team up`, we proceed to show
;; the team creation modal.
on-team-up
(fn []
(st/emit! (modal/show {:type :onboarding-team})))
]
[:div.modal-overlay
[:div.modal-container.onboarding.final.animated.fadeInUp
[:div.modal-top
[:h1 (tr "onboarding.welcome.title")]
[:p (tr "onboarding.welcome.desc3")]]
[:div.modal-columns
[:div.modal-left
[:div.content-button {:on-click on-fly-solo}
[:h2 (tr "onboarding.choice.fly-solo")]
[:p (tr "onboarding.choice.fly-solo-desc")]]]
[:div.modal-right
[:div.content-button {:on-click on-team-up}
[:h2 (tr "onboarding.choice.team-up")]
[:p (tr "onboarding.choice.team-up-desc")]]]]
[:img.deco {:src "images/deco-left.png" :border "0"}]
[:img.deco.right {:src "images/deco-right.png" :border "0"}]]]))
(mf/defc onboarding-team-modal
{::mf/register modal/components
::mf/register-as :onboarding-team}
[]
(let [form (fm/use-form :spec ::team-form
:initial {})
on-submit
(mf/use-callback
(fn [form _]
(let [tname (get-in @form [:clean-data :name])]
(st/emit! (modal/show {:type :onboarding-team-invitations :name tname})))))]
[:div.modal-overlay
[:div.modal-container.onboarding-team
[:div.title
[:h2 (tr "onboarding.choice.team-up")]
[:p (tr "onboarding.choice.team-up-desc")]]
[:& fm/form {:form form
:on-submit on-submit}
[:div.team-row
[:& fm/input {:type "text"
:name :name
:label (tr "onboarding.team-input-placeholder")}]]
[:div.buttons
[:button.btn-secondary.btn-large
{:on-click #(st/emit! (modal/show {:type :onboarding-choice}))}
(tr "labels.cancel")]
[:& fm/submit-button
{:label (tr "labels.next")}]]]
[:img.deco {:src "images/deco-left.png" :border "0"}]
[:img.deco.right {:src "images/deco-right.png" :border "0"}]]]))
(defn get-available-roles
[]
[{:value "editor" :label (tr "labels.editor")}
{:value "admin" :label (tr "labels.admin")}])
(s/def ::email ::us/email)
(s/def ::role ::us/keyword)
(s/def ::invite-form
(s/keys :req-un [::role ::email]))
;; This is the final step of team creation, consists in provide a
;; shortcut for invite users.
(mf/defc onboarding-team-invitations-modal
{::mf/register modal/components
::mf/register-as :onboarding-team-invitations}
[{:keys [name] :as props}]
(let [initial (mf/use-memo (constantly
{:role "editor"
:name name}))
form (fm/use-form :spec ::invite-form
:initial initial)
roles (mf/use-memo #(get-available-roles))
on-success
(mf/use-callback
(fn [_form response]
(let [project-id (:default-project-id response)
team-id (:id response)]
(st/emit!
(modal/hide)
(rt/nav :dashboard-projects {:team-id team-id}))
(tm/schedule 400 #(st/emit!
(modal/show {:type :onboarding-templates
:project-id project-id}))))))
on-error
(mf/use-callback
(fn [_form _response]
(st/emit! (dm/error "Error on creating team."))))
;; The SKIP branch only creates the team, without invitations
on-skip
(mf/use-callback
(fn [_]
(let [mdata {:on-success (partial on-success form)
:on-error (partial on-error form)}
params {:name name}]
(st/emit! (dd/create-team (with-meta params mdata))))))
;; The SUBMIT branch creates the team with the invitations
on-submit
(mf/use-callback
(fn [form _]
(let [mdata {:on-success (partial on-success form)
:on-error (partial on-error form)}
params (:clean-data @form)]
(st/emit! (dd/create-team-with-invitations (with-meta params mdata))))))]
[:div.modal-overlay
[:div.modal-container.onboarding-team
[:div.title
[:h2 (tr "onboarding.choice.team-up")]
[:p (tr "onboarding.choice.team-up-desc")]]
[:& fm/form {:form form
:on-submit on-submit}
[:div.invite-row
[:& fm/input {:name :email
:label (tr "labels.email")}]
[:& fm/select {:name :role
:options roles}]]
[:div.buttons
[:button.btn-secondary.btn-large
{:on-click #(st/emit! (modal/show {:type :onboarding-choice}))}
(tr "labels.cancel")]
[:& fm/submit-button
{:label (tr "labels.create")}]]
[:div.skip-action
{:on-click on-skip}
[:div.action "Skip and invite later"]]]
[:img.deco {:src "images/deco-left.png" :border "0"}]
[:img.deco.right {:src "images/deco-right.png" :border "0"}]]]))

View file

@ -0,0 +1,88 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.main.ui.onboarding.templates
(:require
[app.config :as cf]
[app.main.data.dashboard :as dd]
[app.main.data.modal :as modal]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.icons :as i]
[app.util.dom :as dom]
[app.util.http :as http]
[app.util.i18n :as i18n :refer [tr]]
[beicon.core :as rx]
[rumext.alpha :as mf]))
(mf/defc template-item
[{:keys [name path image project-id]}]
(let [downloading? (mf/use-state false)
link (str (assoc cf/public-uri :path path))
on-finish-import
(fn []
(st/emit! (dd/fetch-recent-files)))
open-import-modal
(fn [file]
(st/emit! (modal/show
{:type :import
:project-id project-id
:files [file]
:on-finish-import on-finish-import})))
on-click
(fn []
(reset! downloading? true)
(->> (http/send! {:method :get :uri link :response-type :blob :mode :no-cors})
(rx/subs (fn [{:keys [body] :as response}]
(open-import-modal {:name name :uri (dom/create-uri body)}))
(fn [error]
(js/console.log "error" error))
(fn []
(reset! downloading? false)))))
]
[:div.template-item
[:div.template-item-content
[:img {:src image}]]
[:div.template-item-title
[:div.label name]
(if @downloading?
[:div.action "Fetching..."]
[:div.action {:on-click on-click} "+ Add to drafts"])]]))
(mf/defc onboarding-templates-modal
{::mf/wrap-props false
::mf/register modal/components
::mf/register-as :onboarding-templates}
;; NOTE: the project usually comes empty, it only comes fullfilled
;; when a user creates a new team just after signup.
[{:keys [project-id] :as props}]
(let [close-fn (mf/use-callback #(st/emit! (modal/hide)))
profile (mf/deref refs/profile)
project-id (or project-id (:default-project-id profile))]
[:div.modal-overlay
[:div.modal-container.onboarding-templates
[:div.modal-header
[:div.modal-close-button
{:on-click close-fn} i/close]]
[:div.modal-content
[:h3 (tr "onboarding.templates.title")]
[:p (tr "onboarding.templates.subtitle")]
[:div.templates
[:& template-item
{:path "/github/penpot-files/Penpot-Design-system.penpot"
:image "https://penpot.app/images/libraries/cover-ds-penpot.jpg"
:name "Penpot Design System"
:project-id project-id}]
[:& template-item
{:path "/github/penpot-files/Material-Design-Kit.penpot"
:image "https://penpot.app/images/libraries/cover-material.jpg"
:name "Material Design Kit"
:project-id project-id}]]]]]))

View file

@ -0,0 +1,83 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.main.ui.releases
(:require
[app.main.data.modal :as modal]
[app.main.data.users :as du]
[app.main.store :as st]
[app.main.ui.releases.common :as rc]
[app.main.ui.releases.v1-4]
[app.main.ui.releases.v1-5]
[app.main.ui.releases.v1-6]
[app.main.ui.releases.v1-7]
[app.main.ui.releases.v1-8]
[app.main.ui.releases.v1-9]
[app.util.object :as obj]
[app.util.timers :as tm]
[rumext.alpha :as mf]))
;;; --- RELEASE NOTES MODAL
(mf/defc release-notes
[{:keys [version] :as props}]
(let [slide (mf/use-state :start)
klass (mf/use-state "fadeInDown")
navigate
(mf/use-callback #(reset! slide %))
next
(mf/use-callback
(mf/deps slide)
(fn []
(if (= @slide :start)
(navigate 0)
(navigate (inc @slide)))))
finish
(mf/use-callback
(st/emitf (modal/hide)
(du/mark-onboarding-as-viewed {:version version})))
]
(mf/use-effect
(mf/deps)
(fn []
(st/emitf (du/mark-onboarding-as-viewed {:version version}))))
(mf/use-layout-effect
(mf/deps @slide)
(fn []
(when (not= :start @slide)
(reset! klass "fadeIn"))
(let [sem (tm/schedule 300 #(reset! klass nil))]
(fn []
(reset! klass nil)
(tm/dispose! sem)))))
(rc/render-release-notes
{:next next
:navigate navigate
:finish finish
:klass klass
:slide slide
:version version})))
(mf/defc release-notes-modal
{::mf/wrap-props false
::mf/register modal/components
::mf/register-as :release-notes}
[props]
(let [versions (methods rc/render-release-notes)
version (obj/get props "version")]
(when (contains? versions version)
[:div.relnotes
[:> release-notes props]])))
(defmethod rc/render-release-notes "0.0"
[params]
(rc/render-release-notes (assoc params :version "1.10")))

View file

@ -6,10 +6,9 @@
(ns app.main.ui.static
(:require
[app.main.data.users :as du]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.icons :as i]
[app.util.globals :as globals]
[app.util.i18n :refer [tr]]
[app.util.object :as obj]
[app.util.router :as rt]
@ -19,14 +18,7 @@
{::mf/wrap-props false}
[props]
(let [children (obj/get props "children")
on-click (mf/use-callback
(fn []
(let [profile (deref refs/profile)]
(if (du/is-authenticated? profile)
(let [team-id (du/get-current-team-id profile)]
(st/emit! (rt/nav :dashboard-projects {:team-id team-id})))
(st/emit! (rt/nav :auth-login {}))))))]
on-click (mf/use-callback #(set! (.-href globals/location) ""))]
[:section.exception-layout
[:div.exception-header
{:on-click on-click}

View file

@ -166,7 +166,7 @@
(defn append-child!
[el child]
(.appendChild el child))
(.appendChild ^js el child))
(defn get-first-child
[el]

View file

@ -37,10 +37,16 @@
[& {:keys [initial] :as opts}]
(let [state (mf/useState 0)
render (aget state 1)
state-ref (mf/use-ref {:data (if (fn? initial) (initial) initial)
:errors {}
:touched {}})
form (mf/use-memo #(create-form-mutator state-ref render opts))]
get-state (mf/use-callback
(mf/deps initial)
(fn []
{:data (if (fn? initial) (initial) initial)
:errors {}
:touched {}}))
state-ref (mf/use-ref (get-state))
form (mf/use-memo (mf/deps initial) #(create-form-mutator state-ref render get-state opts))]
(mf/use-effect
(mf/deps initial)
@ -72,7 +78,7 @@
(not= cleaned ::s/invalid))))))
(defn- create-form-mutator
[state-ref render opts]
[state-ref render get-state opts]
(reify
IDeref
(-deref [_]
@ -80,7 +86,9 @@
IReset
(-reset! [it new-value]
(mf/set-ref-val! state-ref new-value)
(if (nil? new-value)
(mf/set-ref-val! state-ref (get-state))
(mf/set-ref-val! state-ref new-value))
(render inc))
ISwap

View file

@ -88,6 +88,7 @@
:credentials credentials
:referrerPolicy "no-referrer"
:signal signal}]
(-> (js/fetch (str uri) params)
(p/then (fn [response]
(vreset! abortable? false)

View file

@ -19,17 +19,16 @@
;; --- Router API
(defn map->Match
[data]
(r/map->Match data))
(defn resolve
([router id] (resolve router id {} {}))
([router id path-params] (resolve router id path-params {}))
([router id path-params query-params]
(when-let [match (r/match-by-name router id path-params)]
(if (empty? query-params)
(r/match->path match)
(let [query (u/map->query-string query-params)]
(-> (u/uri (r/match->path match))
(assoc :query query)
(str)))))))
(r/match->path match query-params))))
(defn create
[routes]
@ -161,7 +160,3 @@
(e/unlistenByKey key)))))
(rx/take-until stoper)
(rx/subs #(on-change router %)))))))