🎉 Add full teams administration.

This commit is contained in:
Andrey Antukh 2020-10-05 18:20:39 +02:00 committed by Hirunatan
parent f6830b4b85
commit 142036891a
62 changed files with 3175 additions and 1606 deletions

View file

@ -8,7 +8,9 @@
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.config
(:require [app.util.object :as obj]))
(:require
[app.util.object :as obj]
[cuerdas.core :as str]))
(this-as global
(def default-language "en")
@ -24,4 +26,7 @@
(defn resolve-media-path
[path]
(str media-uri "/" path))
(when path
(if (str/starts-with? path "data:")
path
(str media-uri "/" path))))

View file

@ -9,31 +9,24 @@
(ns app.main
(:require
[hashp.core :include-macros true]
[cljs.spec.alpha :as s]
[beicon.core :as rx]
[rumext.alpha :as mf]
[app.common.uuid :as uuid]
[app.main.data.auth :refer [logout]]
[app.main.data.users :as udu]
[app.main.store :as st]
[app.main.ui :as ui]
[app.main.ui.confirm]
[app.main.ui.modal :refer [modal]]
[app.main.worker]
[app.util.dom :as dom]
[app.util.i18n :as i18n]
[app.util.theme :as theme]
[app.util.router :as rt]
[app.util.object :as obj]
[app.util.router :as rt]
[app.util.storage :refer [storage]]
[app.util.theme :as theme]
[app.util.timers :as ts]
;; MODALS
[app.main.ui.settings.delete-account]
[app.main.ui.settings.change-email]
[app.main.ui.confirm]
[app.main.ui.workspace.colorpicker]
[app.main.ui.workspace.libraries]))
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[rumext.alpha :as mf]))
(declare reinit)

View file

@ -151,19 +151,17 @@
;; --- Request Account Deletion
(def request-account-deletion
(letfn [(on-error [{:keys [code] :as error}]
(if (= :app.services.mutations.profile/owner-teams-with-people code)
(let [msg (tr "settings.notifications.profile-deletion-not-allowed")]
(rx/of (dm/error msg)))
(rx/empty)))]
(ptk/reify ::request-account-deletion
ptk/WatchEvent
(watch [_ state stream]
(rx/concat
(->> (rp/mutation :delete-profile {})
(rx/map #(rt/nav :auth-goodbye))
(rx/catch on-error)))))))
(defn request-account-deletion
[params]
(ptk/reify ::request-account-deletion
ptk/WatchEvent
(watch [_ state stream]
(let [{:keys [on-error on-success]
:or {on-error identity
on-success identity}} (meta params)]
(->> (rp/mutation :delete-profile {})
(rx/tap on-success)
(rx/catch on-error))))))
;; --- Recovery Request

View file

@ -164,7 +164,7 @@
(if shift?
(change-stroke ids color nil nil)
(change-fill ids color nil nil))
(md/hide-modal))))]
(md/hide))))]
(ptk/reify ::start-picker
ptk/UpdateEvent
(update [_ state]

View file

@ -14,6 +14,9 @@
[app.util.router :as rt]
[app.util.time :as dt]
[app.util.timers :as ts]
[app.util.avatars :as avatars]
[app.main.data.media :as di]
[app.main.data.messages :as dm]
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
@ -29,10 +32,12 @@
(s/def ::created-at ::us/inst)
(s/def ::modified-at ::us/inst)
(s/def ::is-pinned ::us/boolean)
(s/def ::photo ::us/string)
(s/def ::team
(s/keys :req-un [::id
::name
::photo
::created-at
::modified-at]))
@ -59,6 +64,13 @@
;; --- Fetch Team
(defn assoc-team-avatar
[{:keys [photo name] :as team}]
(us/assert ::team team)
(cond-> team
(or (nil? photo) (empty? photo))
(assoc :photo (avatars/generate {:name name}))))
(defn fetch-team
[{:keys [id] :as params}]
(letfn [(fetched [team state]
@ -66,9 +78,21 @@
(ptk/reify ::fetch-team
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/query :team params)
(rx/map #(partial fetched %)))))))
(let [profile (:profile state)]
(->> (rp/query :team params)
(rx/map assoc-team-avatar)
(rx/map #(partial fetched %))))))))
(defn fetch-team-members
[{:keys [id] :as params}]
(us/assert ::us/uuid id)
(letfn [(fetched [members state]
(assoc-in state [:team-members id] (d/index-by :id members)))]
(ptk/reify ::fetch-team-members
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/query :team-members {:team-id id})
(rx/map #(partial fetched %)))))))
;; --- Fetch Projects
@ -83,6 +107,31 @@
(->> (rp/query :projects {:team-id team-id})
(rx/map #(partial fetched %)))))))
(defn fetch-bundle
[{:keys [id] :as params}]
(us/assert ::us/uuid id)
(ptk/reify ::fetch-team
ptk/WatchEvent
(watch [_ state stream]
(let [profile (:profile state)]
(->> (rx/merge (ptk/watch (fetch-team params) state stream)
(ptk/watch (fetch-projects {:team-id id}) state stream))
(rx/catch (fn [{:keys [type code] :as error}]
(cond
(and (= :not-found type)
(not= id (:default-team-id profile)))
(rx/of (rt/nav :dashboard-projects {:team-id (:default-team-id profile)})
(dm/error "Team does not found"))
(and (= :validation type)
(= :not-authorized code)
(not= id (:default-team-id profile)))
(rx/of (rt/nav :dashboard-projects {:team-id (:default-team-id profile)})
(dm/error "Team does not found"))
:else
(rx/throw error)))))))))
;; --- Search Files
@ -181,6 +230,114 @@
(rx/tap on-success)
(rx/catch on-error))))))
(defn update-team
[{:keys [id name] :as params}]
(us/assert ::team params)
(ptk/reify ::update-team
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:teams id :name] name))
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/mutation! :update-team params)
(rx/ignore)))))
(defn update-team-photo
[{:keys [file team-id] :as params}]
(us/assert ::di/js-file file)
(us/assert ::us/uuid team-id)
(ptk/reify ::update-team-photo
ptk/WatchEvent
(watch [_ state stream]
(let [on-success di/notify-finished-loading
on-error #(do (di/notify-finished-loading)
(di/process-error %))
prepare #(hash-map :file % :team-id team-id)]
(di/notify-start-loading)
(->> (rx/of file)
(rx/map di/validate-file)
(rx/map prepare)
(rx/mapcat #(rp/mutation :update-team-photo %))
(rx/do on-success)
(rx/map #(fetch-team %))
(rx/catch on-error))))))
(defn update-team-member-role
[{:keys [team-id role member-id] :as params}]
(us/assert ::us/uuid team-id)
(us/assert ::us/uuid member-id)
(us/assert ::us/keyword role)
(ptk/reify ::update-team-member-role
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/mutation! :update-team-member-role params)
(rx/mapcat #(rx/of (fetch-team-members {:id team-id})
(fetch-team {:id team-id})))))))
(defn delete-team-member
[{:keys [team-id member-id] :as params}]
(us/assert ::us/uuid team-id)
(us/assert ::us/uuid member-id)
(ptk/reify ::delete-team-member
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/mutation! :delete-team-member params)
(rx/mapcat #(rx/of (fetch-team-members {:id team-id})
(fetch-team {:id team-id})))))))
(defn leave-team
[{:keys [id reassign-to] :as params}]
(us/assert ::team params)
(us/assert (s/nilable ::us/uuid) reassign-to)
(ptk/reify ::leave-team
ptk/WatchEvent
(watch [_ state stream]
(let [{:keys [on-success on-error]
:or {on-success identity
on-error identity}} (meta params)]
(rx/concat
(when (uuid? reassign-to)
(->> (rp/mutation! :update-team-member-role {:team-id id
:role :owner
:member-id reassign-to})
(rx/ignore)))
(->> (rp/mutation! :leave-team {:id id})
(rx/tap on-success)
(rx/catch on-error)))))))
(defn invite-team-member
[{:keys [team-id email role] :as params}]
(us/assert ::us/uuid team-id)
(us/assert ::us/email email)
(us/assert ::us/keyword role)
(ptk/reify ::invite-team-member
ptk/WatchEvent
(watch [_ state stream]
(let [{:keys [on-success on-error]
:or {on-success identity
on-error identity}} (meta params)]
(->> (rp/mutation! :invite-team-member params)
(rx/tap on-success)
(rx/catch on-error))))))
(defn delete-team
[{:keys [id] :as params}]
(us/assert ::team params)
(ptk/reify ::delete-team
ptk/WatchEvent
(watch [_ state stream]
(let [{:keys [on-success on-error]
:or {on-success identity
on-error identity}} (meta params)]
(->> (rp/mutation! :delete-team {:id id})
(rx/tap on-success)
(rx/catch on-error))))))
(defn create-project
[{:keys [team-id] :as params}]
(us/assert ::us/uuid team-id)
@ -289,12 +446,12 @@
;; --- Set File shared
(defn set-file-shared
[id is-shared]
{:pre [(uuid? id) (boolean? is-shared)]}
[{:keys [id project-id is-shared] :as params}]
(us/assert ::file params)
(ptk/reify ::set-file-shared
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:files id :is-shared] is-shared))
(assoc-in state [:files project-id id :is-shared] is-shared))
ptk/WatchEvent
(watch [_ state stream]

View file

@ -8,30 +8,54 @@
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.main.data.modal
(:refer-clojure :exclude [update])
(:require
[potok.core :as ptk]))
[potok.core :as ptk]
[app.main.store :as st]
[app.common.uuid :as uuid]
[cljs.core :as c]))
(defn show-modal [id type props]
(ptk/reify ::show-modal
ptk/UpdateEvent
(update [_ state]
(-> state
(assoc ::modal {:id id
:type type
:props props
:allow-click-outside false})))))
(defonce components (atom {}))
(defn hide-modal []
(defn show
([props]
(show (uuid/next) (:type props) props))
([type props] (show (uuid/next) type props))
([id type props]
(ptk/reify ::show-modal
ptk/UpdateEvent
(update [_ state]
(assoc state ::modal {:id id
:type type
:props props
:allow-click-outside false})))))
(defn hide
[]
(ptk/reify ::hide-modal
ptk/UpdateEvent
(update [_ state]
(-> state
(dissoc ::modal)))))
(dissoc state ::modal))))
(defn update-modal [options]
(defn update
[options]
(ptk/reify ::update-modal
ptk/UpdateEvent
(update [_ state]
(-> state
(update ::modal merge options)))))
(c/update state ::modal merge options))))
(defn show!
[type props]
(st/emit! (show type props)))
(defn allow-click-outside!
[]
(st/emit! (update {:allow-click-outside true})))
(defn disallow-click-outside!
[]
(st/emit! (update {:allow-click-outside false})))
(defn hide!
[]
(st/emit! (hide)))

View file

@ -106,6 +106,7 @@
(defn request-email-change
[{:keys [email] :as data}]
(us/assert ::us/email email)
(ptk/reify ::request-email-change
ptk/WatchEvent
(watch [_ state stream]

View file

@ -98,6 +98,14 @@
(seq params))
(send-mutation! id form)))
(defmethod mutation :update-team-photo
[id params]
(let [form (js/FormData.)]
(run! (fn [[key val]]
(.append form (name key) val))
(seq params))
(send-mutation! id form)))
(defmethod mutation :login
[id params]
(let [uri (str cfg/public-uri "/api/login")]

View file

@ -0,0 +1,13 @@
;; 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) 2020 UXBOX Labs SL
(ns app.main.store)
(defmacro emitf
[& events]
`(fn []
(app.main.store/emit! ~@events)))

View file

@ -5,6 +5,7 @@
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.main.store
(:require-macros [app.main.store])
(:require
[beicon.core :as rx]
[okulary.core :as l]

View file

@ -9,11 +9,6 @@
(ns app.main.ui
(:require
[expound.alpha :as expound]
[beicon.core :as rx]
[cuerdas.core :as str]
[potok.core :as ptk]
[rumext.alpha :as mf]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.uuid :as uuid]
@ -21,18 +16,22 @@
[app.main.data.messages :as dm]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.auth :refer [auth verify-token]]
[app.main.ui.auth :refer [auth]]
[app.main.ui.auth.verify-token :refer [verify-token]]
[app.main.ui.cursors :as c]
[app.main.ui.dashboard :refer [dashboard]]
[app.main.ui.icons :as i]
[app.main.ui.cursors :as c]
[app.main.ui.messages :as msgs]
[app.main.ui.render :as render]
[app.main.ui.settings :as settings]
[app.main.ui.static :refer [not-found-page not-authorized-page]]
[app.main.ui.viewer :refer [viewer-page]]
[app.main.ui.render :as render]
[app.main.ui.workspace :as workspace]
[app.util.i18n :as i18n :refer [tr t]]
[app.util.timers :as ts]))
[app.util.timers :as ts]
[expound.alpha :as expound]
[potok.core :as ptk]
[rumext.alpha :as mf]))
;; --- Routes
@ -60,12 +59,13 @@
;; Used for export
["/render-object/:file-id/:page-id/:object-id" :render-object]
["/dashboard"
["/team/:team-id"
["/projects" :dashboard-projects]
["/search" :dashboard-search]
["/libraries" :dashboard-libraries]
["/projects/:project-id" :dashboard-files]]]
["/dashboard/team/:team-id"
["/members" :dashboard-team-members]
["/settings" :dashboard-team-settings]
["/projects" :dashboard-projects]
["/search" :dashboard-search]
["/libraries" :dashboard-libraries]
["/projects/:project-id" :dashboard-files]]
["/workspace/:project-id/:file-id" :workspace]])
@ -109,7 +109,9 @@
(:dashboard-search
:dashboard-projects
:dashboard-files
:dashboard-libraries)
:dashboard-libraries
:dashboard-team-members
:dashboard-team-settings)
[:& dashboard {:route route}]
:viewer
@ -186,3 +188,9 @@
(ts/schedule 100 #(st/emit! (dm/show {:content "Something wrong has happened."
:type :error
:timeout 5000}))))))
;; (defonce foo
;; (do
;; (prn "attach listener")
;; (.addEventListener js/window "error" (fn [err] (ptk/handle-error (unchecked-get err "error"))))
;; 1))

View file

@ -9,6 +9,7 @@
(ns app.main.ui.auth
(:require
[app.common.uuid :as uuid]
[app.main.data.auth :as da]
[app.main.data.messages :as dm]
[app.main.data.users :as du]
@ -20,6 +21,7 @@
[app.main.ui.auth.register :refer [register-page]]
[app.main.ui.icons :as i]
[app.util.forms :as fm]
[app.util.storage :refer [cache]]
[app.util.i18n :as i18n :refer [tr t]]
[app.util.router :as rt]
[app.util.timers :as ts]
@ -35,7 +37,9 @@
(mf/defc auth
[{:keys [route] :as props}]
(let [section (get-in route [:data :name])
locale (mf/deref i18n/locale)]
locale (mf/deref i18n/locale)
params (:query-params route)]
[:div.auth
[:section.auth-sidebar
[:a.logo {:href "/#/"} i/logo]
@ -43,61 +47,9 @@
[:section.auth-content
(case section
:auth-register [:& register-page {:locale locale}]
:auth-login [:& login-page {:locale locale}]
:auth-register [:& register-page {:locale locale :params params}]
:auth-login [:& login-page {:locale locale :params params}]
:auth-goodbye [:& goodbye-page {:locale locale}]
:auth-recovery-request [:& recovery-request-page {:locale locale}]
:auth-recovery [:& recovery-page {:locale locale
:params (:query-params route)}])]]))
(defmulti handle-token (fn [token] (:iss token)))
(defmethod handle-token :verify-email
[data]
(let [msg (tr "settings.notifications.email-verified-successfully")]
(ts/schedule 100 #(st/emit! (dm/success msg)))
(st/emit! (rt/nav :auth-login))))
(defmethod handle-token :change-email
[data]
(let [msg (tr "settings.notifications.email-changed-successfully")]
(ts/schedule 100 #(st/emit! (dm/success msg)))
(st/emit! (rt/nav :settings-profile)
du/fetch-profile)))
(defmethod handle-token :auth
[tdata]
(st/emit! (da/login-from-token tdata)))
(defmethod handle-token :default
[tdata]
(js/console.log "Unhandled token:" (pr-str tdata))
(st/emit! (rt/nav :auth-login)))
(mf/defc verify-token
[{:keys [route] :as props}]
(let [token (get-in route [:query-params :token])]
(mf/use-effect
(fn []
(->> (rp/mutation :verify-profile-token {:token token})
(rx/subs
(fn [tdata]
(handle-token tdata))
(fn [error]
(case (:code error)
:email-already-exists
(let [msg (tr "errors.email-already-exists")]
(ts/schedule 100 #(st/emit! (dm/error msg)))
(st/emit! (rt/nav :auth-login)))
:email-already-validated
(let [msg (tr "errors.email-already-validated")]
(ts/schedule 100 #(st/emit! (dm/warn msg)))
(st/emit! (rt/nav :auth-login)))
(let [msg (tr "errors.generic")]
(ts/schedule 100 #(st/emit! (dm/error msg)))
(st/emit! (rt/nav :auth-login)))))))))
[:div.verify-token
i/loader-pencil]))

View file

@ -20,10 +20,9 @@
[app.main.store :as st]
[app.main.ui.messages :as msgs]
[app.main.data.messages :as dm]
[app.main.ui.components.forms :refer [input submit-button form]]
[app.main.ui.components.forms :as fm]
[app.util.object :as obj]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr t]]
[app.util.router :as rt]))
@ -50,18 +49,31 @@
(mf/defc login-form
[{:keys [locale] :as props}]
(let [error? (mf/use-state false)
submit-event (mf/use-var da/login)
form (fm/use-form :spec ::login-form
:inital {})
on-error
(fn [form event]
(js/console.log error?)
(reset! error? true))
on-submit
(fn [form event]
(reset! error? false)
(let [params (with-meta (:clean-data form)
{:on-error on-error})]
(st/emit! (@submit-event params))))]
(mf/use-callback
(mf/deps form)
(fn [event]
(reset! error? false)
(let [params (with-meta (:clean-data @form)
{:on-error on-error})]
(st/emit! (da/login params)))))
on-submit-ldap
(mf/use-callback
(mf/deps form)
(fn [event]
(reset! error? false)
(let [params (with-meta (:clean-data @form)
{:on-error on-error})]
(st/emit! (da/login-with-ldap params)))))]
[:*
(when @error?
@ -70,28 +82,28 @@
:content (t locale "errors.auth.unauthorized")
:on-close #(reset! error? false)}])
[:& form {:on-submit on-submit
:spec ::login-form
:initial {}}
[:& input
{:name :email
:type "text"
:tab-index "2"
:help-icon i/at
:label (t locale "auth.email-label")}]
[:& input
{:type "password"
:name :password
:tab-index "3"
:help-icon i/eye
:label (t locale "auth.password-label")}]
[:& submit-button
[:& fm/form {:on-submit on-submit :form form}
[:div.fields-row
[:& fm/input
{:name :email
:type "text"
:tab-index "2"
:help-icon i/at
:label (t locale "auth.email-label")}]]
[:div.fields-row
[:& fm/input
{:type "password"
:name :password
:tab-index "3"
:help-icon i/eye
:label (t locale "auth.password-label")}]]
[:& fm/submit-button
{:label (t locale "auth.login-submit-label")
:on-click #(reset! submit-event da/login)}]
:on-click on-submit}]
(when cfg/login-with-ldap
[:& submit-button
[:& fm/submit-button
{:label (t locale "auth.login-with-ldap-submit-label")
:on-click #(reset! submit-event da/login-with-ldap)}])]]))
:on-click on-submit}])]]))
(mf/defc login-page
[{:keys [locale] :as props}]

View file

@ -17,15 +17,14 @@
[app.main.data.auth :as uda]
[app.main.data.messages :as dm]
[app.main.store :as st]
[app.main.ui.components.forms :refer [input submit-button form]]
[app.main.ui.components.forms :as fm]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [t tr]]
[app.util.router :as rt]))
(s/def ::password-1 ::fm/not-empty-string)
(s/def ::password-2 ::fm/not-empty-string)
(s/def ::token ::fm/not-empty-string)
(s/def ::password-1 ::us/not-empty-string)
(s/def ::password-2 ::us/not-empty-string)
(s/def ::token ::us/not-empty-string)
(s/def ::recovery-form
(s/keys :req-un [::password-1
@ -54,29 +53,31 @@
(defn- on-submit
[form event]
(let [params (with-meta {:token (get-in form [:clean-data :token])
:password (get-in form [:clean-data :password-2])}
{:on-error (partial on-error form)
:on-success (partial on-success form)})]
(st/emit! (uda/recover-profile params))))
(let [mdata {:on-error on-error
:on-success on-success}
params {:token (get-in @form [:clean-data :token])
:password (get-in @form [:clean-data :password-2])}]
(st/emit! (uda/recover-profile (with-meta params mdata)))))
(mf/defc recovery-form
[{:keys [locale params] :as props}]
[:& form {:on-submit on-submit
:spec ::recovery-form
:validators [password-equality]
:initial params}
(let [form (fm/use-form :spec ::recovery-form
:validators [password-equality]
:initial params)]
[:& fm/form {:on-submit on-submit
:form form}
[:div.fields-row
[:& fm/input {:type "password"
:name :password-1
:label (t locale "auth.new-password-label")}]]
[:& input {:type "password"
:name :password-1
:label (t locale "auth.new-password-label")}]
[:div.fields-row
[:& fm/input {:type "password"
:name :password-2
:label (t locale "auth.confirm-password-label")}]]
[:& input {:type "password"
:name :password-2
:label (t locale "auth.confirm-password-label")}]
[:& submit-button
{:label (t locale "auth.recovery-submit-label")}]])
[:& fm/submit-button
{:label (t locale "auth.recovery-submit-label")}]]))
;; --- Recovery Request Page
@ -86,7 +87,6 @@
[:div.form-container
[:h1 "Forgot your password?"]
[:div.subtitle "Please enter your new password"]
[:& recovery-form {:locale locale :params params}]
[:div.links

View file

@ -9,45 +9,48 @@
(ns app.main.ui.auth.recovery-request
(:require
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[rumext.alpha :as mf]
[app.common.spec :as us]
[app.main.data.auth :as uda]
[app.main.data.messages :as dm]
[app.main.store :as st]
[app.main.ui.components.forms :refer [input submit-button form]]
[app.main.ui.components.forms :as fm]
[app.main.ui.icons :as i]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [tr t]]
[app.util.router :as rt]))
[app.util.router :as rt]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[rumext.alpha :as mf]))
(s/def ::email ::us/email)
(s/def ::recovery-request-form (s/keys :req-un [::email]))
(defn- on-success
[]
(st/emit! (dm/info (tr "auth.notifications.recovery-token-sent"))
(rt/nav :auth-login)))
(defn- on-submit
[form event]
(let [on-success #(st/emit!
(dm/info (tr "auth.notifications.recovery-token-sent"))
(rt/nav :auth-login))
params (with-meta (:clean-data form)
{:on-success on-success})]
(let [params (with-meta (:clean-data @form)
{:on-success on-success})]
(st/emit! (uda/request-profile-recovery params))))
(mf/defc recovery-form
[{:keys [locale] :as props}]
[:& form {:on-submit on-submit
:spec ::recovery-request-form
:initial {}}
(let [form (fm/use-form :spec ::recovery-request-form
:initial {})]
[:& fm/form {:on-submit on-submit
:form form}
[:div.fields-row
[:& fm/input {:name :email
:label (t locale "auth.email-label")
:help-icon i/at
:type "text"}]]
[:& input {:name :email
:label (t locale "auth.email-label")
:help-icon i/at
:type "text"}]
[:& fm/submit-button
{:label (t locale "auth.recovery-request-submit-label")}]]))
[:& submit-button
{:label (t locale "auth.recovery-request-submit-label")}]])
;; --- Recovery Request Page
@ -57,7 +60,6 @@
[:div.form-container
[:h1 (t locale "auth.recovery-request-title")]
[:div.subtitle (t locale "auth.recovery-request-subtitle")]
[:& recovery-form {:locale locale}]
[:div.links

View file

@ -9,16 +9,16 @@
(ns app.main.ui.auth.register
(:require
[app.common.spec :as us]
[app.config :as cfg]
[app.main.data.auth :as da]
[app.main.data.auth :as uda]
[app.main.data.users :as du]
[app.main.data.messages :as dm]
[app.main.store :as st]
[app.main.ui.components.forms :refer [input submit-button form]]
[app.main.ui.components.forms :as fm]
[app.main.ui.icons :as i]
[app.main.ui.messages :as msgs]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr t]]
[app.util.router :as rt]
[app.util.timers :as tm]
@ -32,15 +32,6 @@
{:type :warning
:content (tr "auth.demo-warning")}])
(s/def ::fullname ::fm/not-empty-string)
(s/def ::password ::fm/not-empty-string)
(s/def ::email ::fm/email)
(s/def ::register-form
(s/keys :req-un [::password
::fullname
::email]))
(defn- on-error
[form error]
(case (:code error)
@ -55,9 +46,14 @@
(defn- on-success
[form data]
(let [msg (tr "auth.notifications.validation-email-sent" (:email data))]
(st/emit! (rt/nav :auth-login)
(dm/success msg))))
(if (and (:is-active data) (:claims data))
(let [message (tr "auth.notifications.team-invitation-accepted")]
(st/emit! (rt/nav :dashboard-projects {:team-id (get-in data [:claims :team-id])})
du/fetch-profile
(dm/success message)))
(let [message (tr "auth.notifications.validation-email-sent" (:email data))]
(st/emit! (rt/nav :auth-login)
(dm/success message)))))
(defn- validate
[data]
@ -67,57 +63,74 @@
(defn- on-submit
[form event]
(let [data (with-meta (:clean-data form)
(let [data (with-meta (:clean-data @form)
{:on-error (partial on-error form)
:on-success (partial on-success form)})]
(st/emit! (uda/register data))))
(st/emit! (da/register data))))
(s/def ::fullname ::us/not-empty-string)
(s/def ::password ::us/not-empty-string)
(s/def ::email ::us/email)
(s/def ::token ::us/not-empty-string)
(s/def ::register-form
(s/keys :req-un [::password
::fullname
::email]
:opt-un [::token]))
(mf/defc register-form
[{:keys [locale] :as props}]
[:& form {:on-submit on-submit
:spec ::register-form
:validators [validate]
:initial {}}
[:& input {:name :fullname
:tab-index "1"
:label (t locale "auth.fullname-label")
:type "text"}]
[:& input {:type "email"
:name :email
:tab-index "2"
:help-icon i/at
:label (t locale "auth.email-label")}]
[:& input {:name :password
:tab-index "3"
:hint (t locale "auth.password-length-hint")
:label (t locale "auth.password-label")
:type "password"}]
[{:keys [locale params] :as props}]
(let [initial (mf/use-memo (mf/deps params) (constantly params))
form (fm/use-form :spec ::register-form
:validators [validate]
:initial initial)]
[:& submit-button
{:label (t locale "auth.register-submit-label")}]])
[:& fm/form {:on-submit on-submit
:form form}
[:div.fields-row
[:& fm/input {:name :fullname
:tab-index "1"
:label (t locale "auth.fullname-label")
:type "text"}]]
[:div.fields-row
[:& fm/input {:type "email"
:name :email
:tab-index "2"
:help-icon i/at
:label (t locale "auth.email-label")}]]
[:div.fields-row
[:& fm/input {:name :password
:tab-index "3"
:hint (t locale "auth.password-length-hint")
:label (t locale "auth.password-label")
:type "password"}]]
[:& fm/submit-button
{:label (t locale "auth.register-submit-label")}]]))
;; --- Register Page
(mf/defc register-page
[{:keys [locale] :as props}]
[:section.generic-form
[:div.form-container
[:h1 (t locale "auth.register-title")]
[:div.subtitle (t locale "auth.register-subtitle")]
(when cfg/demo-warning
[:& demo-warning])
[{:keys [locale params] :as props}]
[:div.form-container
[:h1 (t locale "auth.register-title")]
[:div.subtitle (t locale "auth.register-subtitle")]
(when cfg/demo-warning
[:& demo-warning])
[:& register-form {:locale locale}]
[:& register-form {:locale locale
:params params}]
[:div.links
[:div.link-entry
[:span (t locale "auth.already-have-account") " "]
[:a {:on-click #(st/emit! (rt/nav :auth-login))
:tab-index "4"}
(t locale "auth.login-here")]]
[:div.links
[:div.link-entry
[:span (t locale "auth.already-have-account") " "]
[:a {:on-click #(st/emit! (rt/nav :auth-login))
:tab-index "4"}
(t locale "auth.login-here")]]
[:div.link-entry
[:span (t locale "auth.create-demo-profile-label") " "]
[:a {:on-click #(st/emit! da/create-demo-profile)
:tab-index "5"}
(t locale "auth.create-demo-profile")]]]]])
[:div.link-entry
[:span (t locale "auth.create-demo-profile-label") " "]
[:a {:on-click #(st/emit! da/create-demo-profile)
:tab-index "5"}
(t locale "auth.create-demo-profile")]]]])

View file

@ -0,0 +1,94 @@
;; 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/.
;;
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.main.ui.auth.verify-token
(:require
[app.common.uuid :as uuid]
[app.main.data.auth :as da]
[app.main.data.messages :as dm]
[app.main.data.users :as du]
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.ui.auth.login :refer [login-page]]
[app.main.ui.auth.recovery :refer [recovery-page]]
[app.main.ui.auth.recovery-request :refer [recovery-request-page]]
[app.main.ui.auth.register :refer [register-page]]
[app.main.ui.icons :as i]
[app.util.forms :as fm]
[app.util.storage :refer [cache]]
[app.util.i18n :as i18n :refer [tr t]]
[app.util.router :as rt]
[app.util.timers :as ts]
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[rumext.alpha :as mf]))
(defmulti handle-token (fn [token] (:iss token)))
(defmethod handle-token :verify-email
[data]
(let [msg (tr "dashboard.notifications.email-verified-successfully")]
(ts/schedule 100 #(st/emit! (dm/success msg)))
(st/emit! (rt/nav :auth-login))))
(defmethod handle-token :change-email
[data]
(let [msg (tr "dashboard.notifications.email-changed-successfully")]
(ts/schedule 100 #(st/emit! (dm/success msg)))
(st/emit! (rt/nav :settings-profile)
du/fetch-profile)))
(defmethod handle-token :auth
[tdata]
(st/emit! (da/login-from-token tdata)))
(defmethod handle-token :team-invitation
[tdata]
(case (:state tdata)
:created
(let [message (tr "auth.notifications.team-invitation-accepted")]
(st/emit! du/fetch-profile
(rt/nav :dashboard-projects {:team-id (:team-id tdata)})
(dm/success message)))
:pending
(st/emit! (rt/nav :auth-register {} {:token (:token tdata)}))))
(defmethod handle-token :default
[tdata]
(js/console.log "Unhandled token:" (pr-str tdata))
(st/emit! (rt/nav :auth-login)))
(mf/defc verify-token
[{:keys [route] :as props}]
(let [token (get-in route [:query-params :token])]
(mf/use-effect
(fn []
(->> (rp/mutation :verify-token {:token token})
(rx/subs
(fn [tdata]
(handle-token tdata))
(fn [error]
(case (:code error)
:email-already-exists
(let [msg (tr "errors.email-already-exists")]
(ts/schedule 100 #(st/emit! (dm/error msg)))
(st/emit! (rt/nav :auth-login)))
:email-already-validated
(let [msg (tr "errors.email-already-validated")]
(ts/schedule 100 #(st/emit! (dm/warn msg)))
(st/emit! (rt/nav :auth-login)))
(let [msg (tr "errors.generic")]
(ts/schedule 100 #(st/emit! (dm/error msg)))
(st/emit! (rt/nav :auth-login)))))))))
[:div.verify-token
i/loader-pencil]))

View file

@ -20,21 +20,22 @@
[app.util.dom :as dom]))
(def form-ctx (mf/create-context nil))
(def use-form fm/use-form)
(mf/defc input
[{:keys [type label help-icon disabled name form hint trim] :as props}]
(let [form (mf/use-ctx form-ctx)
(let [form (or form (mf/use-ctx form-ctx))
type' (mf/use-state type)
focus? (mf/use-state false)
locale (mf/deref i18n/locale)
touched? (get-in form [:touched name])
error (get-in form [:errors name])
touched? (get-in @form [:touched name])
error (get-in @form [:errors name])
value (get-in form [:data name] "")
value (get-in @form [:data name] "")
help-icon' (cond
help-icon' (cond
(and (= type "password")
(= @type' "password"))
i/eye
@ -67,7 +68,7 @@
on-blur
(fn [event]
(reset! focus? false)
(when-not (get-in form [:touched name])
(when-not (get-in @form [:touched name])
(swap! form assoc-in [:touched name] true)))
props (-> props
@ -80,33 +81,33 @@
:type @type')
(obj/clj->props))]
[:div.field.custom-input
{:class klass}
[:*
[:label label]
[:> :input props]
(when help-icon'
[:div.help-icon
{:style {:cursor "pointer"}
:on-click (when (= "password" type)
swap-text-password)}
help-icon'])
(cond
(and touched? (:message error))
[:span.error (t locale (:message error))]
[:div.custom-input
{:class klass}
[:*
[:label label]
[:> :input props]
(when help-icon'
[:div.help-icon
{:style {:cursor "pointer"}
:on-click (when (= "password" type)
swap-text-password)}
help-icon'])
(cond
(and touched? (:message error))
[:span.error (t locale (:message error))]
(string? hint)
[:span.hint hint])]]))
(string? hint)
[:span.hint hint])]]))
(mf/defc select
[{:keys [options label name form default]
:or {default ""}}]
(let [form (mf/use-ctx form-ctx)
value (get-in form [:data name] default)
(let [form (or form (mf/use-ctx form-ctx))
value (get-in @form [:data name] default)
cvalue (d/seek #(= value (:value %)) options)
on-change (fm/on-input-change form name)]
[:div.field.custom-select
[:div.custom-select
[:select {:value value
:on-change on-change}
(for [item options]
@ -122,34 +123,21 @@
(mf/defc submit-button
[{:keys [label form on-click] :as props}]
(let [form (mf/use-ctx form-ctx)]
(let [form (or form (mf/use-ctx form-ctx))]
[:input.btn-primary.btn-large
{:name "submit"
:class (when-not (:valid form) "btn-disabled")
:disabled (not (:valid form))
:class (when-not (:valid @form) "btn-disabled")
:disabled (not (:valid @form))
:on-click on-click
:value label
:type "submit"}]))
(mf/defc form
[{:keys [on-submit spec validators initial children class] :as props}]
(let [frm (fm/use-form :spec spec
:validators validators
:initial initial)]
(mf/use-effect
(mf/deps initial)
(fn []
(if (fn? initial)
(swap! frm update :data merge (initial))
(swap! frm update :data merge initial))))
[:& (mf/provider form-ctx) {:value frm}
[{:keys [on-submit form children class] :as props}]
(let [on-submit (or on-submit (constantly nil))]
[:& (mf/provider form-ctx) {:value form}
[:form {:class class
:on-submit (fn [event]
(dom/prevent-default event)
(on-submit frm event))}
(on-submit form event))}
children]]))

View file

@ -2,53 +2,69 @@
;; 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) 2016 Andrey Antukh <niwi@niwi.nz>
;; Copyright (c) 2016 Juan de la Cruz <delacruzgarciajuan@gmail.com>
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.main.ui.confirm
(:require
[app.main.data.modal :as modal]
[app.main.store :as st]
[app.main.ui.icons :as i]
[rumext.alpha :as mf]
[app.main.ui.modal :as modal]
[app.util.i18n :refer (tr)]
[app.util.data :refer [classnames]]
[app.util.dom :as dom]))
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr t]]
[rumext.alpha :as mf]))
(mf/defc confirm-dialog
{::mf/register modal/components
::mf/register-as :confirm-dialog}
[{:keys [message on-accept on-cancel hint cancel-text accept-text not-danger?] :as ctx}]
(let [message (or message (tr "ds.confirm-title"))
cancel-text (or cancel-text (tr "ds.confirm-cancel"))
accept-text (or accept-text (tr "ds.confirm-ok"))
::mf/register-as :confirm}
[{:keys [message title on-accept on-cancel hint cancel-label accept-label] :as props}]
(let [locale (mf/deref i18n/locale)
accept
(fn [event]
(dom/prevent-default event)
(modal/hide!)
(on-accept (dissoc ctx :on-accept :on-cancel)))
on-accept (or on-accept identity)
on-cancel (or on-cancel identity)
message (or message (t locale "ds.confirm-title"))
cancel-label (or cancel-label (tr "ds.confirm-cancel"))
accept-label (or accept-label (tr "ds.confirm-ok"))
title (or title (t locale "ds.confirm-title"))
accept-fn
(mf/use-callback
(fn [event]
(dom/prevent-default event)
(st/emit! (modal/hide))
(on-accept props)))
cancel-fn
(mf/use-callback
(fn [event]
(dom/prevent-default event)
(st/emit! (modal/hide))
(on-cancel props)))]
cancel
(fn [event]
(dom/prevent-default event)
(modal/hide!)
(when on-cancel
(on-cancel (dissoc ctx :on-accept :on-cancel))))]
[:div.modal-overlay
[:div.modal.confirm-dialog
[:a.close {:on-click cancel} i/close]
[:div.modal-container.confirm-dialog
[:div.modal-header
[:div.modal-header-title
[:h2 title]]
[:div.modal-close-button
{:on-click cancel-fn} i/close]]
[:div.modal-content
[:h3.dialog-title message]
(if hint [:span hint])
[:div.dialog-buttons
[:input.dialog-cancel-button
{:type "button"
:value cancel-text
:on-click cancel}]
[:h3 message]
(when (string? hint)
[:p hint])]
[:input.dialog-accept-button
[:div.modal-footer
[:div.action-buttons
[:input.cancel-button
{:type "button"
:class (classnames :not-danger not-danger?)
:value accept-text
:on-click accept}]]]]]))
:value cancel-label
:on-click cancel-fn}]
[:input.accept-button
{:type "button"
:value accept-label
:on-click accept-fn}]]]]]))

View file

@ -20,6 +20,7 @@
[app.main.ui.dashboard.projects :refer [projects-section]]
[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.main.ui.icons :as i]
[app.util.i18n :as i18n :refer [t]]
[app.util.router :as rt]
@ -56,7 +57,7 @@
(l/derived (l/in [:projects team-id]) st/state))
(mf/defc dashboard-content
[{:keys [team projects project section search-term] :as props}]
[{:keys [team projects project section search-term profile] :as props}]
[:div.dashboard-content
(case section
:dashboard-projects
@ -75,6 +76,12 @@
:dashboard-libraries
[:& libraries-page {:team team}]
:dashboard-team-members
[:& team-members-page {:team team :profile profile}]
:dashboard-team-settings
[:& team-settings-page {:team team :profile profile}]
nil)])
(mf/defc dashboard
@ -96,18 +103,18 @@
(mf/use-effect
(mf/deps team-id)
(fn []
(st/emit! (dd/fetch-team {:id team-id})
(dd/fetch-projects {:team-id team-id}))))
(st/emitf (dd/fetch-bundle {:id team-id})))
[:section.dashboard-layout
[:& sidebar {:team team
:projects projects
:project project
:profile profile
:section section
:search-term search-term}]
(when team
[:& dashboard-content {:projects projects
:profile profile
:project project
:section section
:search-term search-term

View file

@ -10,12 +10,13 @@
(ns app.main.ui.dashboard.files
(:require
[app.main.data.dashboard :as dd]
[app.main.data.modal :as modal]
[app.main.store :as st]
[app.main.ui.components.context-menu :refer [context-menu]]
[app.main.ui.dashboard.grid :refer [grid]]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.main.ui.modal :as modal]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t]]
[app.util.router :as rt]
@ -24,9 +25,9 @@
(mf/defc header
[{:keys [team project] :as props}]
(let [local (mf/use-state {:menu-open false
:edition false})
locale (mf/deref i18n/locale)
(let [local (mf/use-state {:menu-open false
:edition false})
locale (mf/deref i18n/locale)
project-id (:id project)
team-id (:id team)
@ -39,21 +40,6 @@
on-edit
(mf/use-callback #(swap! local assoc :edition true :menu-open false))
on-blur
(mf/use-callback
(mf/deps project)
(fn [event]
(let [name (-> event dom/get-target dom/get-value)]
#_(st/emit! (dd/rename-project (:id project) name))
(swap! local assoc :edition false))))
on-key-down
(mf/use-callback
(mf/deps project)
(fn [event]
(cond
(kbd/enter? event) (on-blur event)
(kbd/esc? event) (swap! local assoc :edition false))))
delete-fn
(mf/use-callback
@ -65,7 +51,12 @@
on-delete
(mf/use-callback
(mf/deps project)
(fn [] (modal/show! :confirm-dialog {:on-accept delete-fn})))
(st/emitf (modal/show
{:type :confirm
:title "Deleting project"
:message "Are you sure you wan't to delete this project?"
:accept-label "Delete project"
:on-accept delete-fn})))
on-create-clicked
(mf/use-callback
@ -77,26 +68,21 @@
[:header.dashboard-header
(if (:is-default project)
[:h1.dashboard-title (t locale "dashboard.header.draft")]
[:*
[:h1.dashboard-title (t locale "dashboard.header.project" (:name project))]
[:div.icon {:on-click on-menu-click} i/actions]
[:& context-menu {:on-close on-menu-close
:show (:menu-open @local)
:options [[(t locale "dashboard.grid.rename") on-edit]
[(t locale "dashboard.grid.delete") on-delete]]}]
(if (:edition @local)
[:input.element-name {:type "text"
:auto-focus true
:on-key-down on-key-down
:on-blur on-blur
:default-value (:name project)}])])
#_[:ul.main-nav
[:li.current
[:a "PROJECTS"]]
[:li
[:a "MEMBERS"]]]
[:div.dashboard-title
[:h1 (t locale "dashboard.header.draft")]]
(if (:edition @local)
[:& inline-edition {:content (:name project)
:on-end (fn [name]
(st/emit! (dd/rename-project (assoc project :name name)))
(swap! local assoc :edition false))}]
[:div.dashboard-title
[:h1 (:name project)]
[:div.icon {:on-click on-menu-click} i/actions]
[:& context-menu {:on-close on-menu-close
:show (:menu-open @local)
:options [[(t locale "dashboard.grid.rename") on-edit]
[(t locale "dashboard.grid.delete") on-delete]]}]]))
[:a.btn-secondary.btn-small {:on-click on-create-clicked}
(t locale "dashboard.new-file")]]))
@ -119,7 +105,7 @@
[:*
[:& header {:team team :project project}]
[:section.dashboard-grid-container
[:section.dashboard-container
[:& grid {:id (:id project)
:files files}]]]))

View file

@ -16,9 +16,10 @@
[app.main.fonts :as fonts]
[app.main.store :as st]
[app.main.ui.components.context-menu :refer [context-menu]]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.main.ui.modal :as modal]
[app.main.data.modal :as modal]
[app.main.worker :as wrk]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t tr]]
@ -60,21 +61,25 @@
(mf/defc grid-item
{:wrap [mf/memo]}
[{:keys [id file] :as props}]
(let [local (mf/use-state {:menu-open false :edition false})
locale (mf/deref i18n/locale)
(let [local (mf/use-state {:menu-open false :edition false})
locale (mf/deref i18n/locale)
on-close (mf/use-callback #(swap! local assoc :menu-open false))
delete (mf/use-callback (mf/deps id) #(st/emit! (dd/delete-file file)))
add-shared (mf/use-callback (mf/deps id) #(st/emit! (dd/set-file-shared id true)))
del-shared (mf/use-callback (mf/deps id) #(st/emit! (dd/set-file-shared id false)))
on-close (mf/use-callback #(swap! local assoc :menu-open false))
delete-fn
(mf/use-callback
(mf/deps file)
(st/emitf (dd/delete-file file)))
on-delete
(mf/use-callback
(mf/deps id)
(mf/deps file)
(fn [event]
(dom/stop-propagation event)
(modal/show! :confirm-dialog {:on-accept delete})))
(st/emit! (modal/show {:type :confirm
:title "Deleting file"
:message "Are you sure you want to delete this file?"
:on-accept delete-fn
:accept-label "Delete file"}))))
on-navigate
(mf/use-callback
(mf/deps id)
@ -84,73 +89,75 @@
qparams {:page-id (first (get-in file [:data :pages]))}]
(st/emit! (rt/nav :workspace pparams qparams)))))
add-shared
(mf/use-callback
(mf/deps file)
(st/emitf (dd/set-file-shared (assoc file :is-shared true))))
del-shared
(mf/use-callback
(mf/deps file)
(st/emitf (dd/set-file-shared (assoc file :is-shared false))))
on-add-shared
(mf/use-callback
(mf/deps id)
(mf/deps file)
(fn [event]
(dom/stop-propagation event)
(modal/show! :confirm-dialog
{:message (t locale "dashboard.grid.add-shared-message" (:name file))
:hint (t locale "dashboard.grid.add-shared-hint")
:accept-text (t locale "dashboard.grid.add-shared-accept")
:not-danger? true
:on-accept add-shared})))
on-edit
(mf/use-callback
(mf/deps id)
(fn [event]
(dom/stop-propagation event)
(swap! local assoc :edition true)))
(st/emit! (modal/show
{:type :confirm
:message (t locale "dashboard.grid.add-shared-message" (:name file))
:title "Adding as shared library"
:hint (t locale "dashboard.grid.add-shared-hint")
:accept-label (t locale "dashboard.grid.add-shared-accept")
:on-accept add-shared}))))
on-del-shared
(mf/use-callback
(mf/deps id)
(mf/deps file)
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(modal/show! :confirm-dialog
{:message (t locale "dashboard.grid.remove-shared-message" (:name file))
(modal/show! :confirm
{:title "Unsharing file"
:message (t locale "dashboard.grid.remove-shared-message" (:name file))
:hint (t locale "dashboard.grid.remove-shared-hint")
:accept-text (t locale "dashboard.grid.remove-shared-accept")
:not-danger? false
:accept-label (t locale "dashboard.grid.remove-shared-accept")
:on-accept del-shared})))
on-menu-click
(mf/use-callback
(mf/deps id)
(mf/deps file)
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(swap! local assoc :menu-open true)))
on-blur
edit
(mf/use-callback
(mf/deps id)
(fn [event]
(let [name (-> event dom/get-target dom/get-value)
file (assoc file :name name)]
(st/emit! (dd/rename-file file))
(swap! local assoc :edition false))))
(mf/deps file)
(fn [name]
(st/emit! (dd/rename-file (assoc file :name name)))
(swap! local assoc :edition false)))
on-key-down
on-edit
(mf/use-callback
#(cond
(kbd/enter? %) (on-blur %)
(kbd/esc? %) (swap! local assoc :edition false)))
(mf/deps file)
(fn [event]
(dom/stop-propagation event)
(swap! local assoc :edition true)))
]
[:div.grid-item.project-th {:on-click on-navigate}
[:div.overlay]
[:& grid-item-thumbnail {:file file}]
(when (:is-shared file)
[:div.item-badge
i/library])
[:div.item-badge i/library])
[:div.item-info
(if (:edition @local)
[:input.element-name {:type "text"
:auto-focus true
:on-key-down on-key-down
:on-blur on-blur
:default-value (:name file)}]
[:& inline-edition {:content (:name file)
:on-end edit}]
[:h3 (:name file)])
[:& grid-item-metadata {:modified-at (:modified-at file)}]]
[:div.project-th-actions {:class (dom/classnames
@ -188,11 +195,12 @@
[:& empty-placeholder])]))
(mf/defc line-grid-row
[{:keys [locale files] :as props}]
[{:keys [locale files on-load-more] :as props}]
(let [rowref (mf/use-ref)
width (mf/use-state 900)
limit (mf/use-state 1)
itemsize 290]
(mf/use-layout-effect
@ -229,17 +237,18 @@
:file item
:key (:id item)}])
(when (> (count files) @limit)
[:div.grid-item.placeholder
[:div.grid-item.placeholder {:on-click on-load-more}
[:div.placeholder-icon i/arrow-down]
[:div.placeholder-label "Show all files"]])]))
[:div.placeholder-label
(t locale "dashboard.grid.show-all-files")]])]))
(mf/defc line-grid
[{:keys [project-id opts files] :as props}]
(let [locale (mf/deref i18n/locale)
click #(st/emit! (dd/create-file project-id))]
[{:keys [project-id opts files on-load-more] :as props}]
(let [locale (mf/deref i18n/locale)]
[:section.dashboard-grid
(if (pos? (count files))
[:& line-grid-row {:files files
:on-load-more on-load-more
:locale locale}]
[:& empty-placeholder])]))

View file

@ -0,0 +1,66 @@
;; 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/.
;;
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.main.ui.dashboard.inline-edition
(:require
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.util.dom :as dom]
[rumext.alpha :as mf]))
(mf/defc inline-edition
[{:keys [content on-end] :as props}]
(let [name (mf/use-state content)
input-ref (mf/use-ref)
on-input
(mf/use-callback
(fn [event]
(->> (dom/get-target-val event)
(reset! name))))
on-cancel
(mf/use-callback
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(on-end @name)))
on-click
(mf/use-callback
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)))
on-keyup
(mf/use-callback
(fn [event]
(cond
(kbd/esc? event)
(on-cancel)
(kbd/enter? event)
(let [name (dom/get-target-val event)]
(on-end name)))))]
(mf/use-effect
(fn []
(let [node (mf/ref-val input-ref)]
(dom/focus! node)
(dom/select-text! node))))
[:div.edit-wrapper
[:input.element-title {:value @name
:ref input-ref
:on-click on-click
:on-change on-input
:on-key-down on-keyup}]
[:span.close {:on-click on-cancel} i/close]]))

View file

@ -24,14 +24,13 @@
[app.util.router :as rt]
[app.util.time :as dt]))
;; --- Component: Recent files
(mf/defc header
{::mf/wrap [mf/memo]}
[{:keys [profile locale team] :as props}]
[{:keys [locale team] :as props}]
(let [create #(st/emit! (dd/create-project {:team-id (:id team)}))]
[:header.dashboard-header
[:h1.dashboard-title "Projects"]
[:div.dashboard-title
[:h1 "Projects"]]
[:a.btn-secondary.btn-small {:on-click create}
(t locale "dashboard.header.new-project")]]))
@ -63,14 +62,12 @@
on-nav
(mf/use-callback
(mf/deps project)
(fn []
(st/emit! (rt/nav :dashboard-files {:team-id (:team-id project)
:project-id (:id project)}))))
(st/emitf (rt/nav :dashboard-files {:team-id (:team-id project)
:project-id (:id project)})))
toggle-pin
(mf/use-callback
(mf/deps project)
(fn []
(st/emit! (dd/toggle-project-pin project))))
(st/emitf (dd/toggle-project-pin project)))
on-file-created
(mf/use-callback
@ -111,6 +108,7 @@
[:& line-grid
{:project-id (:id project)
:on-load-more on-nav
:files files}]]))
(mf/defc projects-section
@ -129,7 +127,7 @@
[:*
[:& header {:locale locale
:team team}]
[:section.dashboard-grid-container
[:section.dashboard-container
(for [project projects]
[:& project-item {:project project
:locale locale

View file

@ -32,7 +32,7 @@
(st/emitf (dd/search-files {:team-id (:id team)
:search-term search-term})))
[:section.dashboard-grid-container.search
[:section.dashboard-container.search
(cond
(empty? search-term)
[:div.grid-empty-placeholder

View file

@ -11,7 +11,7 @@
(:require
[app.common.data :as d]
[app.common.spec :as us]
[app.main.constants :as c]
[app.config :as cfg]
[app.main.data.auth :as da]
[app.main.data.dashboard :as dd]
[app.main.data.messages :as dm]
@ -19,10 +19,12 @@
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.components.forms :refer [input submit-button form]]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.components.forms :as fm]
[app.main.ui.dashboard.team-form]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.main.ui.modal :as modal]
[app.main.data.modal :as modal]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t tr]]
[app.util.object :as obj]
@ -35,55 +37,6 @@
[okulary.core :as l]
[rumext.alpha :as mf]))
(mf/defc sidebar-project-edition
[{:keys [item on-end] :as props}]
(let [name (mf/use-state (:name item))
input-ref (mf/use-ref)
on-input
(mf/use-callback
(fn [event]
(->> event
(dom/get-target)
(dom/get-value)
(reset! name))))
on-cancel
(mf/use-callback
(fn []
(st/emit! dd/clear-project-for-edit)
(on-end)))
on-keyup
(mf/use-callback
(fn [event]
(cond
(kbd/esc? event)
(on-cancel)
(kbd/enter? event)
(let [name (-> event
dom/get-target
dom/get-value)]
(st/emit! dd/clear-project-for-edit
(dd/rename-project (assoc item :name name)))
(on-end)))))]
(mf/use-effect
(fn []
(let [node (mf/ref-val input-ref)]
(dom/focus! node)
(dom/select-text! node))))
[:div.edit-wrapper
[:input.element-title {:value @name
:ref input-ref
:on-change on-input
:on-key-down on-keyup}]
[:span.close {:on-click on-cancel} i/close]]))
(mf/defc sidebar-project
[{:keys [item selected?] :as props}]
(let [dstate (mf/deref refs/dashboard-local)
@ -97,23 +50,29 @@
(fn []
(st/emit! (rt/nav :dashboard-files {:team-id (:team-id item)
:project-id (:id item)}))))
on-dbl-click
(mf/use-callback #(reset! edition? true))]
(mf/use-callback #(reset! edition? true))
on-edit
(mf/use-callback
(mf/deps item)
(fn [name]
(st/emit! (dd/rename-project (assoc item :name name)))
(reset! edition? false)))]
[:li {:on-click on-click
:on-double-click on-dbl-click
:class (when selected? "current")}
(if @edition?
[:& sidebar-project-edition {:item item
:on-end #(reset! edition? false)}]
[:& inline-edition {:content (:name item)
:on-end on-edit}]
[:span.element-title (:name item)])]))
(mf/defc sidebar-search
[{:keys [search-term team-id locale] :as props}]
(let [search-term (or search-term "")
emit! (mf/use-memo #(f/debounce st/emit! 500))
emit! (mf/use-memo #(f/debounce st/emit! 500))
on-search-focus
(mf/use-callback
@ -158,36 +117,223 @@
{:on-click on-clear-click}
i/close]]))
(mf/defc sidebar-team-switch
[{:keys [team profile] :as props}]
(mf/defc teams-selector-dropdown
[{:keys [team profile locale] :as props}]
(let [show-dropdown? (mf/use-state false)
teams (mf/use-state [])
show-team-opts-ddwn? (mf/use-state false)
show-teams-ddwn? (mf/use-state false)
teams (mf/use-state [])
on-create-clicked
(mf/use-callback
(st/emitf (modal/show :team-form {})))
on-nav
go-projects
(mf/use-callback #(st/emit! (rt/nav :dashboard-projects {:team-id %})))]
(mf/use-layout-effect
(mf/deps (:id team))
(fn []
(->> (rp/query! :teams)
(rx/map #(mapv dd/assoc-team-avatar %))
(rx/subs #(reset! teams %)))))
[:ul.dropdown.teams-dropdown
[:li.title (t locale "dashboard.sidebar.switch-team")]
[:hr]
[:li.team-name {:on-click (partial go-projects (:default-team-id profile))}
[:span.team-icon i/logo-icon]
[:span.team-text "Your penpot"]]
(for [team (remove :is-default @teams)]
[:* {:key (:id team)}
[:li.team-name {:on-click (partial go-projects (:id team))}
[:span.team-icon
[:img {:src (cfg/resolve-media-path (:photo team))}]]
[:span.team-text {:title (:name team)} (:name team)]]])
[:hr]
[:li.action {:on-click on-create-clicked}
(t locale "dashboard.sidebar.create-team")]]))
(s/def ::member-id ::us/uuid)
(s/def ::leave-modal-form
(s/keys :req-un [::member-id]))
(mf/defc leave-and-reassign-modal
{::mf/register modal/components
::mf/register-as ::leave-and-reassign
::mf/props-spec ::kaka-de-vaca}
[{:keys [members profile team accept]}]
(let [form (fm/use-form :spec ::leave-modal-form :initial {})
options (into [{:value "" :label "Select a member to promote"}]
(map #(hash-map :name (:name %) :value (str (:id %))) members))
on-cancel
(mf/use-callback (st/emitf (modal/hide)))
on-accept
(mf/use-callback
(mf/deps form)
(fn [event]
(let [member-id (get-in @form [:clean-data :member-id])]
(accept member-id))))]
[:div.modal-overlay
[:div.modal-container.confirm-dialog
[:div.modal-header
[:div.modal-header-title
[:h2 "Before you leave"]]
[:div.modal-close-button
{:on-click on-cancel} i/close]]
[:div.modal-content.generic-form
[:p "You are " (:name team) " owner."]
[:p "Select an other member to promote before leave."]
[:& fm/form {:form form}
[:& fm/select {:name :member-id
:options options}]]]
[:div.modal-footer
[:div.action-buttons
[:input.cancel-button
{:type "button"
:value "Cancel"
:on-click on-cancel}]
[:input.accept-button
{:type "button"
:class (when-not (:valid @form) "btn-disabled")
:disabled (not (:valid @form))
:value "Promoto and Leave"
:on-click on-accept}]]]]]))
(mf/defc team-options-dropdown
[{:keys [team locale profile] :as props}]
(let [members (mf/use-state [])
go-members
(mf/use-callback
(mf/deps team)
(st/emitf (rt/nav :dashboard-team-members {:team-id (:id team)})))
go-settings
(mf/use-callback
(mf/deps team)
(st/emitf (rt/nav :dashboard-team-settings {:team-id (:id team)})))
go-projects
(mf/use-callback #(st/emit! (rt/nav :dashboard-projects {:team-id %})))
on-create-clicked
(mf/use-callback #(modal/show! :team-form {}))]
(mf/use-callback
(st/emitf (modal/show :team-form {})))
(mf/use-effect
(mf/deps (:id teams))
on-rename-clicked
(mf/use-callback
(mf/deps team)
(st/emitf (modal/show :team-form {:team team})))
on-leaved-success
(mf/use-callback
(mf/deps team profile)
(st/emitf (rt/nav :dashboard-projects {:team-id (:default-team-id profile)})))
leave-fn
(mf/use-callback
(mf/deps team)
(st/emitf (dd/leave-team (with-meta team {:on-success on-leaved-success}))))
leave-and-reassign-fn
(mf/use-callback
(mf/deps team)
(fn [member-id]
(let [team (assoc team :reassign-to member-id)]
(st/emit! (dd/leave-team (with-meta team {:on-success on-leaved-success}))))))
on-leave-clicked
(mf/use-callback
(mf/deps team)
(st/emitf (modal/show
{:type :confirm
:title "Leaving team"
:message "Are you sure you want to leave this team?"
:accept-label "Leave team"
:on-accept leave-fn})))
on-leave-as-owner-clicked
(mf/use-callback
(mf/deps team @members)
(st/emitf (modal/show
{:type ::leave-and-reassign
:profile profile
:team team
:accept leave-and-reassign-fn
:members @members})))
delete-fn
(mf/use-callback
(mf/deps team)
(st/emitf (dd/delete-team (with-meta team {:on-success on-leaved-success}))))
on-delete-clicked
(mf/use-callback
(mf/deps team)
(st/emitf (modal/show
{:type :confirm
:title "Deleting team"
:message (str "Are you sure you want to delete this team?\n"
"All projects and files associated with team will be permanently deleted.")
:accept-label "Delete team"
:on-accept delete-fn})))]
(mf/use-layout-effect
(mf/deps (:id team))
(fn []
(->> (rp/query! :teams)
(rx/subs #(reset! teams %)))))
(->> (rp/query! :team-members {:team-id (:id team)})
(rx/subs #(reset! members %)))))
[:ul.dropdown.options-dropdown
[:li {:on-click go-members} (t locale "dashboard.sidebar.team-members")]
[:li {:on-click go-settings} (t locale "dashboard.sidebar.settings")]
[:hr]
[:li {:on-click on-rename-clicked} (t locale "dashboard.sidebar.rename-team")]
(cond
(:is-owner team)
[:li {:on-click on-leave-as-owner-clicked} (t locale "dashboard.sidebar.leave-team")]
(> (count @members) 1)
[:li {:on-click on-leave-clicked} (t locale "dashboard.sidebar.leave-team")])
(when (:is-owner team)
[:li {:on-click on-delete-clicked} (t locale "dashboard.sidebar.delete-team")])]))
(mf/defc sidebar-team-switch
[{:keys [team profile locale] :as props}]
(let [show-dropdown? (mf/use-state false)
show-team-opts-ddwn? (mf/use-state false)
show-teams-ddwn? (mf/use-state false)]
[:div.sidebar-team-switch
[:div.switch-content
[:div.current-team
[:div.team-name
[:span.team-icon i/logo-icon]
(if (:is-default team)
[:span.team-text "Your penpot"]
[:span.team-text (:name team)])]
(if (:is-default team)
[:div.team-name
[:span.team-icon i/logo-icon]
[:span.team-text (t locale "dashboard.sidebar.default-team-name")]]
[:div.team-name
[:span.team-icon
[:img {:src (cfg/resolve-media-path (:photo team))}]]
[:span.team-text {:title (:name team)} (:name team)]])
[:span.switch-icon {:on-click #(reset! show-teams-ddwn? true)}
i/arrow-down]]
(when-not (:is-default team)
[:div.switch-options {:on-click #(reset! show-team-opts-ddwn? true)}
i/actions])]
@ -195,86 +341,15 @@
;; Teams Dropdown
[:& dropdown {:show @show-teams-ddwn?
:on-close #(reset! show-teams-ddwn? false)}
[:ul.dropdown.teams-dropdown
[:li.title "Switch Team"]
[:hr]
[:li.team-item {:on-click (partial on-nav (:default-team-id profile))}
[:span.icon i/logo-icon]
[:span.text "Your penpot"]]
(for [team (remove :is-default @teams)]
[:* {:key (:id team)}
[:hr]
[:li.team-item {:on-click (partial on-nav (:id team))}
[:span.icon i/logo-icon]
[:span.text (:name team)]]])
[:hr]
[:li.action {:on-click on-create-clicked}
"+ Create new team"]]]
[:& teams-selector-dropdown {:team team
:profile profile
:locale locale}]]
[:& dropdown {:show @show-team-opts-ddwn?
:on-close #(reset! show-team-opts-ddwn? false)}
[:ul.dropdown.options-dropdown
[:li "Members"]
[:li "Settings"]
[:hr]
[:li "Rename"]
[:li "Leave team"]
[:li "Delete team"]]]
]))
(s/def ::name ::us/not-empty-string)
(s/def ::team-form
(s/keys :req-un [::name]))
(mf/defc team-form-modal
{::mf/register modal/components
::mf/register-as :team-form}
[props]
(let [locale (mf/deref i18n/locale)
on-success
(mf/use-callback
(fn [form response]
(modal/hide!)
(let [msg "Team created successfuly"]
(st/emit!
(dm/success msg)
(rt/nav :dashboard-projects {:team-id (:id response)})))))
on-error
(mf/use-callback
(fn [form response]
(let [msg "Error on creating team."]
(st/emit! (dm/error msg)))))
on-submit
(mf/use-callback
(fn [form]
(let [mdata {:on-success (partial on-success form)
:on-error (partial on-error form)}
params {:name (get-in form [:clean-data :name])}]
(st/emit! (dd/create-team (with-meta params mdata))))))]
[:div.modal-overlay
[:div.generic-modal.team-form-modal
[:span.close {:on-click #(modal/hide!)} i/close]
[:section.modal-content.generic-form
[:h2 "CREATE NEW TEAM"]
[:& form {:on-submit on-submit
:spec ::team-form
:initial {}}
[:& input {:type "text"
:name :name
:label "Enter new team name:"}]
[:div.buttons-row
[:& submit-button
{:label "Create team"}]]]]]]))
[:& team-options-dropdown {:team team
:profile profile
:locale locale}]]]))
(mf/defc sidebar-content
[{:keys [locale projects profile section team project search-term] :as props}]
@ -283,15 +358,27 @@
(d/seek :is-default)
(:id))
team-id (:id team)
projects? (= section :dashboard-projects)
libs? (= section :dashboard-libraries)
drafts? (and (= section :dashboard-files)
(= (:id project) default-project-id))
go-projects #(st/emit! (rt/nav :dashboard-projects {:team-id (:id team)}))
go-default #(st/emit! (rt/nav :dashboard-files {:team-id (:id team) :project-id default-project-id}))
go-libs #(st/emit! (rt/nav :dashboard-libraries {:team-id (:id team)}))
go-projects
(mf/use-callback
(mf/deps team)
(st/emitf (rt/nav :dashboard-projects {:team-id (:id team)})))
go-drafts
(mf/use-callback
(mf/deps team default-project-id)
(fn []
(st/emit! (rt/nav :dashboard-files
{:team-id (:id team)
:project-id default-project-id}))))
go-libs
(mf/use-callback
(mf/deps team)
(st/emitf (rt/nav :dashboard-libraries {:team-id (:id team)})))
pinned-projects
(->> (vals projects)
@ -299,8 +386,7 @@
(filter :is-pinned))]
[:div.sidebar-content
[:& sidebar-team-switch {:team team :profile profile}]
[:& sidebar-team-switch {:team team :profile profile :locale locale}]
[:hr]
[:& sidebar-search {:search-term search-term
:team-id (:id team)
@ -313,7 +399,7 @@
i/recent
[:span.element-title (t locale "dashboard.sidebar.projects")]]
[:li {:on-click go-default
[:li {:on-click go-drafts
:class-name (when drafts? "current")}
i/file-html
[:span.element-title (t locale "dashboard.sidebar.drafts")]]
@ -337,7 +423,7 @@
:selected? (= (:id item) (:id project))}])]
[:div.sidebar-empty-placeholder
[:span.icon i/pin]
[:span.text "Pinned projects will appear here"]])]]))
[:span.text (t locale "dashboard.sidebar.no-projects-placeholder")]])]]))
(mf/defc profile-section
@ -365,30 +451,25 @@
[:ul.dropdown
[:li {:on-click (partial on-click :settings-profile)}
[:span.icon i/user]
[:span.text (t locale "dashboard.header.profile-menu.profile")]]
[:span.text (t locale "dashboard.sidebar.profile")]]
[:hr]
[:li {:on-click (partial on-click :settings-password)}
[:span.icon i/lock]
[:span.text (t locale "dashboard.header.profile-menu.password")]]
[:span.text (t locale "dashboard.sidebar.password")]]
[:hr]
[:li {:on-click (partial on-click da/logout)}
[:span.icon i/exit]
[:span.text (t locale "dashboard.header.profile-menu.logout")]]]]]))
[:span.text (t locale "dashboard.logout")]]]]]))
(mf/defc sidebar
{::mf/wrap-props false
::mf/wrap [mf/memo]}
[props]
(let [locale (mf/deref i18n/locale)
profile (mf/deref refs/profile)
profile (obj/get props "profile")
props (-> (obj/clone props)
(obj/set! "locale" locale)
(obj/set! "profile" profile))]
(obj/set! "locale" locale))]
[:div.dashboard-sidebar
[:div.sidebar-inside
[:> sidebar-content props]
[:& profile-section {:profile profile
:locale locale}]]]))
[:& profile-section {:profile profile :locale locale}]]]))

View file

@ -0,0 +1,301 @@
;; 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/.
;;
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.main.ui.dashboard.team
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.spec :as us]
[app.config :as cfg]
[app.main.constants :as c]
[app.main.data.dashboard :as dd]
[app.main.data.messages :as dm]
[app.main.data.modal :as modal]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.components.file-uploader :refer [file-uploader]]
[app.main.ui.components.forms :as fm]
[app.main.ui.dashboard.team-form]
[app.main.ui.icons :as i]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t tr]]
[app.util.router :as rt]
[app.util.time :as dt]
[cljs.spec.alpha :as s]
[okulary.core :as l]
[rumext.alpha :as mf]))
(mf/defc header
{::mf/wrap [mf/memo]}
[{:keys [section locale team] :as props}]
(let [go-members
(mf/use-callback
(mf/deps team)
(st/emitf (rt/nav :dashboard-team-members {:team-id (:id team)})))
go-settings
(mf/use-callback
(mf/deps team)
(st/emitf (rt/nav :dashboard-team-settings {:team-id (:id team)})))
invite-member
(mf/use-callback
(mf/deps team)
(st/emitf (modal/show {:type ::invite-member
:team team})))
members-section? (= section :dashboard-team-members)
settings-section? (= section :dashboard-team-settings)]
[:header.dashboard-header
[:div.dashboard-title
[:h1 "Projects"]]
[:nav
[:ul
[:li {:class (when members-section? "active")}
[:a {:on-click go-members} "MEMBERS"]]
[:li {:class (when settings-section? "active")}
[:a {:on-click go-settings} "SETTINGS"]]]]
(if members-section?
[:a.btn-secondary.btn-small {:on-click invite-member}
(t locale "dashboard.header.invite-profile")]
[:div])]))
(s/def ::email ::us/email)
(s/def ::role ::us/keyword)
(s/def ::invite-member-form
(s/keys :req-un [::role ::email]))
(mf/defc invite-member-modal
{::mf/register modal/components
::mf/register-as ::invite-member}
[{:keys [team] :as props}]
(let [roles [{:value "" :label "Role"}
{:value "admin" :label "Admin"}
{:value "editor" :label "Editor"}
{:value "viewer" :label "Viewer"}]
initial (mf/use-memo (mf/deps team) (constantly {:team-id (:id team)}))
form (fm/use-form :spec ::invite-member-form
:initial initial)
on-success
(mf/use-callback
(mf/deps team)
(st/emitf (dm/success "Invitation sent successfully")))
on-submit
(mf/use-callback
(mf/deps team)
(fn [form]
(let [params (:clean-data @form)
mdata {:on-success (partial on-success form)}]
(st/emit! (dd/invite-team-member (with-meta params mdata))))))]
(prn "invite-member-modal" @form)
[:div.modal.dashboard-invite-modal.form-container
[:& fm/form {:on-submit on-submit :form form}
[:div.title
[:span.text "Invite a new team member"]]
[:div.form-row
[:& fm/input {:name :email
:label "Introduce an email"}]
[:& fm/select {:name :role
:options roles}]]
[:div.action-buttons
[:& fm/submit-button {:label "Send invitation"}]]]]))
(mf/defc team-member
[{:keys [team member profile] :as props}]
(let [show? (mf/use-state false)
set-role
#(st/emit! (dd/update-team-member-role {:team-id (:id team)
:member-id (:id member)
:role %}))
set-owner-fn
(partial set-role :owner)
set-admin
(mf/use-callback (mf/deps team member) (partial set-role :admin))
set-editor
(mf/use-callback (mf/deps team member) (partial set-role :editor))
set-viewer
(mf/use-callback (mf/deps team member) (partial set-role :viewer))
set-owner
(mf/use-callback
(mf/deps team member)
(st/emitf (modal/show
{:type :confirm
:title "Promoto to owner"
:message "Are you sure you wan't to promote this user to owner?"
:accept-label "Promote"
:on-accept set-owner-fn})))
delete-fn
(st/emitf (dd/delete-team-member {:team-id (:id team) :member-id (:id member)}))
delete
(mf/use-callback
(mf/deps team member)
(st/emitf (modal/show
{:type :confirm
:title "Delete team member"
:message "Are you sure wan't to delete this user from team?"
:accept-label "Delete"
:on-accept delete-fn})))]
[:div.table-row
[:div.table-field.name (:name member)]
[:div.table-field.email (:email member)]
[:div.table-field.permissions
[:*
(cond
(:is-owner member)
[:span.label "Owner"]
(:is-admin member)
[:span.label "Admin"]
(:can-edit member)
[:span.label "Editor"]
:else
[:span.label "Viewer"])
(when (and (not (:is-owner member))
(or (:is-admin team)
(:is-owner team)))
[:span.icon {:on-click #(reset! show? true)} i/arrow-down])]
[:& dropdown {:show @show?
:on-close #(reset! show? false)}
[:ul.dropdown.options-dropdown
[:li {:on-click set-admin} "Admin"]
[:li {:on-click set-editor} "Editor"]
[:li {:on-click set-viewer} "Viewer"]
(when (:is-owner team)
[:*
[:hr]
[:li {:on-click set-owner} "Promote to owner"]])
[:hr]
(when (and (or (:is-owner team)
(:is-admin team))
(not= (:id profile)
(:id member)))
[:li {:on-click delete} "Remove"])]]]]))
(mf/defc team-members
[{:keys [members-map team profile] :as props}]
(let [members (->> (vals members-map)
(sort-by :created-at)
(remove :is-owner))
owner (->> (vals members-map)
(d/seek :is-owner))]
[:div.dashboard-table
[:div.table-header
[:div.table-field.name "Name"]
[:div.table-field.email "Email"]
[:div.table-field.permissions "Permissions"]]
[:div.table-rows
[:& team-member {:member owner :team team :profile profile}]
(for [item members]
[:& team-member {:member item :team team :profile profile :key (:id item)}])]]))
(defn- members-ref
[team-id]
(l/derived (l/in [:team-members team-id]) st/state))
(mf/defc team-members-page
[{:keys [team profile] :as props}]
(let [locale (mf/deref i18n/locale)
members-ref (mf/use-memo (mf/deps team) #(members-ref (:id team)))
members-map (mf/deref members-ref)]
(mf/use-effect
(mf/deps team)
(st/emitf (dd/fetch-team-members team)))
[:*
[:& header {:locale locale
:section :dashboard-team-members
:team team}]
[:section.dashboard-container.dashboard-team-members
[:& team-members {:locale locale
:profile profile
:team team
:members-map members-map}]]]))
(mf/defc team-settings-page
[{:keys [team profile] :as props}]
(let [locale (mf/deref i18n/locale)
finput (mf/use-ref)
members-ref (mf/use-memo (mf/deps team) #(members-ref (:id team)))
members-map (mf/deref members-ref)
on-image-click
(mf/use-callback #(dom/click (mf/ref-val finput)))
on-file-selected
(mf/use-callback
(mf/deps team)
(fn [file]
(st/emit! (dd/update-team-photo {:file file
:team-id (:id team)}))))]
(mf/use-effect
(mf/deps team)
(st/emitf (dd/fetch-team-members team)))
[:*
[:& header {:locale locale
:section :dashboard-team-settings
:team team}]
[:section.dashboard-container.dashboard-team-settings
[:div.team-settings
[:div.horizontal-blocks
[:div.block.info-block
[:div.label "Team info"]
[:div.name (:name team)]
[:div.icon
[:span.update-overlay {:on-click on-image-click} i/exit]
[:img {:src (cfg/resolve-media-path (:photo team))}]
[:& file-uploader {:accept "image/jpeg,image/png"
:multi false
:input-ref finput
:on-selected on-file-selected}]]]
[:div.block.owner-block
[:div.label "Team members"]
[:div.owner
[:span.icon [:img {:src (cfg/resolve-media-path (:photo-uri profile))}]]
[:span.text (:fullname profile)]]
[:div.summary
[:span.icon i/user]
[:span.text (t locale "dashboard.team.num-of-members" (count members-map))]]]
[:div.block.stats-block
[:div.label "Team projects"]
[:div.projects
[:span.icon i/folder]
[:span.text "4 projects"]]
[:div.files
[:span.icon i/file-html]
[:span.text "4 files"]]]]]]]))

View file

@ -0,0 +1,117 @@
;; 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/.
;;
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.main.ui.dashboard.team-form
(:require
[app.common.data :as d]
[app.common.spec :as us]
[app.config :as cfg]
[app.main.data.auth :as da]
[app.main.data.dashboard :as dd]
[app.main.data.messages :as dm]
[app.main.data.modal :as modal]
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.ui.components.forms :refer [input submit-button form]]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [t tr]]
[app.util.object :as obj]
[app.util.router :as rt]
[app.util.time :as dt]
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[rumext.alpha :as mf]))
(s/def ::name ::us/not-empty-string)
(s/def ::team-form
(s/keys :req-un [::name]))
(defn- on-create-success
[form response]
(let [msg "Team created successfuly"]
(st/emit! (dm/success msg)
(modal/hide)
(rt/nav :dashboard-projects {:team-id (:id response)}))))
(defn- on-update-success
[form response]
(let [msg "Team created successfuly"]
(st/emit! (dm/success msg)
(modal/hide))))
(defn- on-error
[form response]
(let [id (get-in @form [:clean-data :id])]
(if id
(st/emit! (dm/error "Error on updating team."))
(st/emit! (dm/error "Error on creating team.")))))
;; TODO: check global error handler
(defn- on-create-submit
[form]
(let [mdata {:on-success (partial on-create-success form)
:on-error (partial on-error form)}
params {:name (get-in @form [:clean-data :name])}]
(st/emit! (dd/create-team (with-meta params mdata)))))
(defn- on-update-submit
[form]
(let [mdata {:on-success (partial on-update-success form)
:on-error (partial on-error form)}
team (get @form :clean-data)]
(st/emit! (dd/update-team (with-meta team mdata))
(modal/hide))))
(mf/defc team-form-modal
{::mf/register modal/components
::mf/register-as :team-form}
[{:keys [team] :as props}]
(let [locale (mf/deref i18n/locale)
form (fm/use-form :spec ::team-form
:initial (or team {}))
on-submit
(mf/use-callback
(mf/deps team)
(if team
(partial on-update-submit form)
(partial on-create-submit form)))]
[:div.modal-overlay
[:div.modal-container.team-form-modal
[:div.modal-header
[:div.modal-header-title
(if team
[:h2 "Rename team"]
[:h2 "Create new team"])]
[:div.modal-close-button
{:on-click (st/emitf (modal/hide))} i/close]]
[:div.modal-content.generic-form
[:form
[:& input {:type "text"
:form form
:name :name
:label "Enter new team name:"}]]]
[:div.modal-footer
[:div.action-buttons
[:& submit-button
{:form form
:on-click on-submit
:label (if team
"Update team"
"Create team")}]]]]]))

View file

@ -14,53 +14,33 @@
[rumext.alpha :as mf]
[app.main.store :as st]
[app.main.ui.keyboard :as k]
[app.main.data.modal :as dm]
[app.util.dom :as dom]
[app.main.refs :as refs]
[potok.core :as ptk]
[app.main.data.modal :as mdm])
[app.main.refs :as refs])
(:import goog.events.EventType))
(defonce components (atom {}))
(defn show!
[type props]
(let [id (random-uuid)]
(st/emit! (mdm/show-modal id type props))))
(defn allow-click-outside! []
(st/emit! (mdm/update-modal {:allow-click-outside true})))
(defn disallow-click-outside! []
(st/emit! (mdm/update-modal {:allow-click-outside false})))
(defn hide!
[]
(st/emit! (mdm/hide-modal)))
(def hide (mdm/hide-modal))
(defn- on-esc-clicked
[event]
(when (k/esc? event)
(hide!)
(st/emit! (dm/hide))
(dom/stop-propagation event)))
(defn- on-pop-state
[event]
(dom/prevent-default event)
(dom/stop-propagation event)
(hide!)
(st/emit! (dm/hide))
(.forward js/history))
(defn- on-parent-clicked
[event parent-ref]
(let [parent (mf/ref-val parent-ref)
(let [parent (mf/ref-val parent-ref)
current (dom/get-target event)]
(when (and (dom/equals? (.-firstElementChild ^js parent) current)
(= (.-className ^js current) "modal-overlay"))
(dom/stop-propagation event)
(dom/prevent-default event)
(hide!))))
(st/emit! (dm/hide)))))
(defn- on-click-outside
[event wrapper-ref allow-click-outside]
@ -70,7 +50,7 @@
(when (and wrapper (not allow-click-outside) (not (.contains wrapper current)))
(dom/stop-propagation event)
(dom/prevent-default event)
(hide!))))
(st/emit! (dm/hide)))))
(mf/defc modal-wrapper
{::mf/wrap-props false
@ -78,6 +58,7 @@
[props]
(let [data (unchecked-get props "data")
wrapper-ref (mf/use-ref nil)
handle-click-outside
(fn [event]
(on-click-outside event wrapper-ref (:allow-click-outside data)))]
@ -89,14 +70,15 @@
(events/listen js/document EventType.CLICK handle-click-outside)]]
#(for [key keys]
(events/unlistenByKey key)))))
[:div.modal-wrapper {:ref wrapper-ref}
(mf/element
(get @components (:type data))
(get @dm/components (:type data))
(:props data))]))
(def modal-ref
(l/derived ::mdm/modal st/state))
(l/derived ::dm/modal st/state))
(mf/defc modal
[]

View file

@ -9,30 +9,46 @@
(ns app.main.ui.settings
(:require
[cuerdas.core :as str]
[potok.core :as ptk]
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.main.refs :as refs]
[app.main.store :as st]
[app.util.router :as rt]
[app.main.ui.settings.header :refer [header]]
[app.main.ui.settings.password :refer [password-page]]
[app.main.ui.settings.options :refer [options-page]]
[app.main.ui.settings.profile :refer [profile-page]]))
[app.main.ui.settings.password :refer [password-page]]
[app.main.ui.settings.profile :refer [profile-page]]
[app.main.ui.settings.sidebar :refer [sidebar]]
[app.main.ui.settings.change-email]
[app.main.ui.settings.delete-account]
[app.util.i18n :as i18n :refer [t]]
[rumext.alpha :as mf]))
(mf/defc header
{::mf/wrap [mf/memo]}
[{:keys [locale] :as props}]
(let [logout (constantly nil)]
[:header.dashboard-header
[:h1.dashboard-title (t locale "dashboard.header.your-account")]
[:a.btn-secondary.btn-small {:on-click logout}
(t locale "dashboard.logout")]]))
(mf/defc settings
[{:keys [route] :as props}]
(let [section (get-in route [:data :name])
profile (mf/deref refs/profile)]
[:main.settings-main
[:div.settings-content
[:& header {:section section :profile profile}]
(case section
:settings-profile (mf/element profile-page)
:settings-password (mf/element password-page)
:settings-options (mf/element options-page))]]))
profile (mf/deref refs/profile)
locale (mf/deref i18n/locale)]
[:section.dashboard-layout
[:& sidebar {:profile profile
:locale locale
:section section}]
[:div.dashboard-content
[:& header {:locale locale}]
[:section.dashboard-container
(case section
:settings-profile
[:& profile-page {:locale locale}]
:settings-password
[:& password-page {:locale locale}]
:settings-options
[:& options-page {:locale locale}])]]]))

View file

@ -12,14 +12,15 @@
[app.common.spec :as us]
[app.main.data.auth :as da]
[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 :refer [input submit-button form]]
[app.main.ui.components.forms :as fm]
[app.main.ui.icons :as i]
[app.main.ui.messages :as msgs]
[app.main.ui.modal :as modal]
[app.util.i18n :as i18n :refer [tr t]]
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[rumext.alpha :as mf]))
@ -47,55 +48,65 @@
(assoc-in data [:errors :email-1] error))))
:else
(let [msg (tr "errors.unexpected-error")]
(st/emit! (dm/error msg)))))
(rx/throw error)))
(defn- on-success
[profile data]
(let [msg (tr "auth.notifications.validation-email-sent" (:email profile))]
(st/emit! (dm/info msg) modal/hide)))
[form data]
(let [email (get-in @form [:clean-data :email-1])
message (tr "auth.notifications.validation-email-sent" email)]
(st/emit! (dm/info message)
(modal/hide))))
(defn- on-submit
[profile form event]
(let [data (with-meta {:email (get-in form [:clean-data :email-1])}
{:on-error (partial on-error form)
:on-success (partial on-success profile)})]
(st/emit! (du/request-email-change data))))
(mf/defc change-email-form
[{:keys [locale profile] :as props}]
[:section.modal-content.generic-form
[:h2 (t locale "settings.change-email-title")]
[:& msgs/inline-banner
{:type :info
:content (t locale "settings.change-email-info" (:email profile))}]
[:& form {:on-submit (partial on-submit profile)
:spec ::email-change-form
:validators [email-equality]
:initial {}}
[:& input {:type "text"
:name :email-1
:label (t locale "settings.new-email-label")
:trim true}]
[:& input {:type "text"
:name :email-2
:label (t locale "settings.confirm-email-label")
:trim true}]
[:& submit-button
{:label (t locale "settings.change-email-submit-label")}]]])
[form event]
(let [params {:email (get-in @form [:clean-data :email-1])}
mdata {:on-error (partial on-error form)
:on-success (partial on-success form)}]
(st/emit! (du/request-email-change (with-meta params mdata)))))
(mf/defc change-email-modal
{::mf/register modal/components
::mf/register-as :change-email}
[props]
[]
(let [locale (mf/deref i18n/locale)
profile (mf/deref refs/profile)]
[:div.modal-overlay
[:div.generic-modal.change-email-modal
[:span.close {:on-click #(modal/hide!)} i/close]
[:& change-email-form {:locale locale :profile profile}]]]))
profile (mf/deref refs/profile)
form (fm/use-form :spec ::email-change-form
:validators [email-equality]
:initial profile)
on-close
(mf/use-callback (st/emitf (modal/hide)))]
[:div.modal-overlay
[:div.modal-container.change-email-modal.form-container
[:& fm/form {:form form
:on-submit on-submit}
[:div.modal-header
[:div.modal-header-title
[:h2 (t locale "dashboard.settings.change-email-title")]]
[:div.modal-close-button
{:on-click on-close} i/close]]
[:div.modal-content
[:& msgs/inline-banner
{:type :info
:content (t locale "dashboard.settings.change-email-info" (:email profile))}]
[:div.fields-row
[:& fm/input {:type "text"
:name :email-1
:label (t locale "dashboard.settings.new-email-label")
:trim true}]]
[:div.fields-row
[:& fm/input {:type "text"
:name :email-2
:label (t locale "dashboard.settings.confirm-email-label")
:trim true}]]]
[:div.modal-footer
[:div.action-buttons
[:& fm/submit-button
{:label (t locale "dashboard.settings.change-email-submit-label")}]]]]]]))

View file

@ -10,37 +10,61 @@
(ns app.main.ui.settings.delete-account
(:require
[cljs.spec.alpha :as s]
[beicon.core :as rx]
[rumext.alpha :as mf]
[app.main.data.auth :as da]
[app.main.data.messages :as dm]
[app.main.data.users :as du]
[app.main.store :as st]
[app.main.ui.icons :as i]
[app.main.ui.messages :as msgs]
[app.main.ui.modal :as modal]
[app.main.data.modal :as modal]
[app.util.router :as rt]
[app.util.i18n :as i18n :refer [tr t]]))
(defn on-error
[{:keys [code] :as error}]
(if (= :owner-teams-with-people code)
(let [msg (tr "dashboard.notifications.profile-deletion-not-allowed")]
(rx/of (dm/error msg)))
(rx/throw error)))
(defn on-success
[x]
(st/emit! (rt/nav :auth-goodbye)))
(mf/defc delete-account-modal
{::mf/register modal/components
::mf/register-as :delete-account}
[props]
(let [locale (mf/deref i18n/locale)]
(let [locale (mf/deref i18n/locale)
on-close
(mf/use-callback (st/emitf (modal/hide)))
on-accept
(mf/use-callback
(st/emitf (modal/hide)
(da/request-account-deletion
(with-meta {} {:on-error on-error
:on-success on-success}))))]
[:div.modal-overlay
[:section.generic-modal.change-email-modal
[:span.close {:on-click #(modal/hide!)} i/close]
[:section.modal-content.generic-form
[:h2 (t locale "settings.delete-account-title")]
[:div.modal-container.change-email-modal
[:div.modal-header
[:div.modal-header-title
[:h2 (t locale "dashboard.settings.delete-account-title")]]
[:div.modal-close-button
{:on-click on-close} i/close]]
[:div.modal-content
[:& msgs/inline-banner
{:type :warning
:content (t locale "settings.delete-account-info")}]
:content (t locale "dashboard.settings.delete-account-info")}]]
[:div.modal-footer
[:div.action-buttons
[:button.btn-warning.btn-large {:on-click on-accept}
(t locale "dashboard.settings.yes-delete-my-account")]
[:button.btn-secondary.btn-large {:on-click on-close}
(t locale "dashboard.settings.cancel-and-keep-my-account")]]]]]))
[:div.button-row
[:button.btn-warning.btn-large
{:on-click #(do
(modal/hide!)
(st/emit! da/request-account-deletion))}
(t locale "settings.yes-delete-my-account")]
[:button.btn-secondary.btn-large
{:on-click #(modal/hide!)}
(t locale "settings.cancel-and-keep-my-account")]]]]]))

View file

@ -1,59 +0,0 @@
;; 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/.
;;
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.main.ui.settings.header
(:require
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.main.data.auth :as da]
[app.main.store :as st]
[app.util.i18n :as i18n :refer [tr t]]
[app.util.router :as rt]))
(mf/defc header
[{:keys [section profile] :as props}]
(let [profile? (= section :settings-profile)
password? (= section :settings-password)
options? (= section :settings-options)
team-id (:default-team-id profile)
go-back #(st/emit! (rt/nav :dashboard-team {:team-id team-id}))
logout #(st/emit! da/logout)
locale (mf/deref i18n/locale)
team-id (:default-team-id profile)]
[:header
[:section.secondary-menu
[:div.left {:on-click go-back}
[:span.icon i/arrow-slide]
[:span.label "Dashboard"]]
[:div.right {:on-click logout}
[:span.label "Log out"]
[:span.icon i/logout]]]
[:h1 "Your account"]
[:nav
[:a.nav-item
{:class (when profile? "current")
:on-click #(st/emit! (rt/nav :settings-profile))}
(t locale "settings.profile")]
[:a.nav-item
{:class (when password? "current")
:on-click #(st/emit! (rt/nav :settings-password))}
(t locale "settings.password")]
[:a.nav-item
{:class (when options? "current")
:on-click #(st/emit! (rt/nav :settings-options))}
(t locale "settings.options")]]]))
;; [:a.nav-item
;; {:class "foobar"
;; :on-click #(st/emit! (rt/nav :settings-profile))}
;; (t locale "settings.teams")]]]))

View file

@ -9,69 +9,72 @@
(ns app.main.ui.settings.options
(:require
[rumext.alpha :as mf]
[cljs.spec.alpha :as s]
[app.main.ui.icons :as i]
[app.main.data.users :as udu]
[app.common.spec :as us]
[app.main.data.messages :as dm]
[app.main.ui.components.forms :refer [select submit-button form]]
[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.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [t tr]]))
[app.util.i18n :as i18n :refer [t tr]]
[cljs.spec.alpha :as s]
[rumext.alpha :as mf]))
(s/def ::lang (s/nilable ::fm/not-empty-string))
(s/def ::theme (s/nilable ::fm/not-empty-string))
(s/def ::lang (s/nilable ::us/not-empty-string))
(s/def ::theme (s/nilable ::us/not-empty-string))
(s/def ::options-form
(s/keys :opt-un [::lang ::theme]))
(defn- on-error
[form error])
[form error]
(st/emit! (dm/error (tr "errors.generic"))))
(defn- on-success
[form]
(st/emit! (dm/success (tr "dashboard.notifications.profile-saved"))))
(defn- on-submit
[form event]
(dom/prevent-default event)
(let [data (:clean-data form)
on-success #(st/emit! (dm/success (tr "settings.notifications.profile-saved")))
on-error #(on-error % form)]
(st/emit! (udu/update-profile (with-meta data
{:on-success on-success
:on-error on-error})))))
(let [data (:clean-data @form)
mdata {:on-success (partial on-success form)
:on-error (partial on-error form)}]
(st/emit! (du/update-profile (with-meta data mdata)))))
(mf/defc options-form
[{:keys [locale profile] :as props}]
[:& form {:class "options-form"
:on-submit on-submit
:spec ::options-form
:initial profile}
[{:keys [locale] :as props}]
(let [profile (mf/deref refs/profile)
form (fm/use-form :spec ::options-form
:initial profile)]
[:& fm/form {:class "options-form"
:on-submit on-submit
:form form}
[:h2 (t locale "settings.language-change-title")]
[:h2 (t locale "dashboard.settings.language-change-title")]
[:& select {:options [{:label "English" :value "en"}
{:label "Français" :value "fr"}
{:label "Español" :value "es"}
{:label "Русский" :value "ru"}]
:label (t locale "settings.language-label")
:default "en"
:name :lang}]
[:div.fields-row
[:& fm/select {:options [{:label "English" :value "en"}
{:label "Français" :value "fr"}
{:label "Español" :value "es"}
{:label "Русский" :value "ru"}]
:label (t locale "dashboard.settings.language-label")
:default "en"
:name :lang}]]
[:h2 (t locale "settings.theme-change-title")]
[:& select {:label (t locale "settings.theme-label")
:name :theme
:default "default"
:options [{:label "Default" :value "default"}]}]
[:& submit-button
{:label (t locale "settings.profile-submit-label")}]])
[:h2 (t locale "dashboard.settings.theme-change-title")]
[:div.fields-row
[:& fm/select {:label (t locale "dashboard.settings.theme-label")
:name :theme
:default "default"
:options [{:label "Default" :value "default"}]}]]
[:& fm/submit-button
{:label (t locale "dashboard.settings.profile-submit-label")}]]))
;; --- Password Page
(mf/defc options-page
[props]
(let [locale (mf/deref i18n/locale)
profile (mf/deref refs/profile)]
[:section.settings-options.generic-form
[:div.forms-container
[:& options-form {:locale locale :profile profile}]]]))
[{:keys [locale]}]
[:div.dashboard-settings
[:div.form-container
[:& options-form {:locale locale}]]])

View file

@ -9,16 +9,16 @@
(ns app.main.ui.settings.password
(:require
[rumext.alpha :as mf]
[cljs.spec.alpha :as s]
[app.main.ui.icons :as i]
[app.main.data.users :as udu]
[app.common.spec :as us]
[app.main.data.messages :as dm]
[app.main.ui.components.forms :refer [input submit-button form]]
[app.main.data.users :as udu]
[app.main.store :as st]
[app.main.ui.components.forms :as fm]
[app.main.ui.icons :as i]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [t tr]]))
[app.util.i18n :as i18n :refer [t tr]]
[cljs.spec.alpha :as s]
[rumext.alpha :as mf]))
(defn- on-error
[form error]
@ -33,20 +33,20 @@
(defn- on-success
[form]
(let [msg (tr "settings.notifications.password-saved")]
(let [msg (tr "dashboard.notifications.password-saved")]
(st/emit! (dm/success msg))))
(defn- on-submit
[form event]
(dom/prevent-default event)
(let [params (with-meta (:clean-data form)
(let [params (with-meta (:clean-data @form)
{:on-success (partial on-success form)
:on-error (partial on-error form)})]
(st/emit! (udu/update-password params))))
(s/def ::password-1 ::fm/not-empty-string)
(s/def ::password-2 ::fm/not-empty-string)
(s/def ::password-old ::fm/not-empty-string)
(s/def ::password-1 ::us/not-empty-string)
(s/def ::password-2 ::us/not-empty-string)
(s/def ::password-old ::us/not-empty-string)
(defn- password-equality
[data]
@ -67,36 +67,38 @@
(mf/defc password-form
[{:keys [locale] :as props}]
[:& form {:class "password-form"
:on-submit on-submit
:spec ::password-form
:validators [password-equality]
:initial {}}
[:h2 (t locale "settings.password-change-title")]
(let [form (fm/use-form :spec ::password-form
:validators [password-equality]
:initial {})]
[:& fm/form {:class "password-form"
:on-submit on-submit
:form form}
[:h2 (t locale "dashboard.settings.password-change-title")]
[:div.fields-row
[:& fm/input
{:type "password"
:name :password-old
:label (t locale "dashboard.settings.old-password-label")}]]
[:& input
{:type "password"
:name :password-old
:label (t locale "settings.old-password-label")}]
[:div.fields-row
[:& fm/input
{:type "password"
:name :password-1
:label (t locale "dashboard.settings.new-password-label")}]]
[:& input
{:type "password"
:name :password-1
:label (t locale "settings.new-password-label")}]
[:div.fields-row
[:& fm/input
{:type "password"
:name :password-2
:label (t locale "dashboard.settings.confirm-password-label")}]]
[:& input
{:type "password"
:name :password-2
:label (t locale "settings.confirm-password-label")}]
[:& submit-button
{:label (t locale "settings.profile-submit-label")}]])
[:& fm/submit-button
{:label (t locale "dashboard.settings.profile-submit-label")}]]))
;; --- Password Page
(mf/defc password-page
[props]
(let [locale (mf/deref i18n/locale)]
[:section.settings-password.generic-form
[:div.forms-container
[:& password-form {:locale locale}]]]))
[{:keys [locale]}]
[:section.dashboard-settings.form-container
[:div.form-container
[:& password-form {:locale locale}]]])

View file

@ -9,91 +9,80 @@
(ns app.main.ui.settings.profile
(:require
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[rumext.alpha :as mf]
[app.common.spec :as us]
[app.main.data.messages :as dm]
[app.main.data.users :as udu]
[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 :refer [input submit-button form]]
[app.main.ui.components.file-uploader :refer [file-uploader]]
[app.main.ui.components.forms :as fm]
[app.main.ui.icons :as i]
[app.main.ui.messages :as msgs]
[app.main.ui.modal :as modal]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :as i18n :refer [tr t]]))
[app.util.i18n :as i18n :refer [tr t]]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[rumext.alpha :as mf]))
(s/def ::fullname ::fm/not-empty-string)
(s/def ::email ::fm/email)
(s/def ::fullname ::us/not-empty-string)
(s/def ::email ::us/email)
(s/def ::profile-form
(s/keys :req-un [::fullname ::lang ::theme ::email]))
(defn- on-success
[form]
(st/emit! (dm/success (tr "dashboard.notifications.profile-saved"))))
(defn- on-error
[error form]
[form error]
(st/emit! (dm/error (tr "errors.generic"))))
(defn- on-submit
[form event]
(let [data (:clean-data form)
on-success #(st/emit! (dm/success (tr "settings.notifications.profile-saved")))
on-error #(on-error % form)]
(st/emit! (udu/update-profile (with-meta data
{:on-success on-success
:on-error on-error})))))
(let [data (:clean-data @form)
mdata {:on-success (partial on-success form)
:on-error (partial on-error form)}]
(st/emit! (du/update-profile (with-meta data mdata)))))
;; --- Profile Form
(mf/defc profile-form
[{:keys [locale] :as props}]
(let [prof (mf/deref refs/profile)]
[:& form {:on-submit on-submit
:class "profile-form"
:spec ::profile-form
:initial prof}
[:& input
{:type "text"
:name :fullname
:label (t locale "settings.fullname-label")
:trim true}]
(let [profile (mf/deref refs/profile)
form (fm/use-form :spec ::profile-form
:initial profile)]
[:& fm/form {:on-submit on-submit
:form form
:class "profile-form"}
[:div.fields-row
[:& fm/input
{:type "text"
:name :fullname
:label (t locale "dashboard.settings.fullname-label")}]]
[:& input
{:type "email"
:name :email
:disabled true
:help-icon i/at
:label (t locale "settings.email-label")}]
[:div.fields-row
[:& fm/input
{:type "email"
:name :email
:disabled true
:help-icon i/at
:label (t locale "dashboard.settings.email-label")}]
(cond
(nil? (:pending-email prof))
[:div.options
[:div.change-email
[:a {:on-click #(modal/show! :change-email {})}
(t locale "settings.change-email-label")]]
(t locale "dashboard.settings.change-email-label")]]]]
(not= (:pending-email prof) (:email prof))
[:& msgs/inline-banner
{:type :info
:content (t locale "settings.change-email-info3" (:pending-email prof))
:actions [{:label (t locale "settings.cancel-email-change")
:callback #(st/emit! udu/cancel-email-change)}]}]
;; [:div.btn-secondary.btn-small
;; {:on-click #(st/emit! udu/cancel-email-change)}
;; (t locale "settings.cancel-email-change")]]
:else
[:& msgs/inline-banner
{:type :info
:content (t locale "settings.email-verification-pending")}])
[:& submit-button
{:label (t locale "settings.profile-submit-label")}]
[:& fm/submit-button
{:label (t locale "dashboard.settings.profile-submit-label")}]
[:div.links
[:div.link-item
[:a {:on-click #(modal/show! :delete-account {})}
(t locale "settings.remove-account-label")]]]]))
(t locale "dashboard.settings.remove-account-label")]]]]))
;; --- Profile Photo Form
@ -110,11 +99,11 @@
on-file-selected
(fn [file]
(st/emit! (udu/update-photo file)))]
(st/emit! (du/update-photo file)))]
[:form.avatar-form
[:div.image-change-field
[:span.update-overlay {:on-click on-image-click} (t locale "settings.update-photo-label")]
[:span.update-overlay {:on-click on-image-click} (t locale "dashboard.settings.update-photo-label")]
[:img {:src photo}]
[:& file-uploader {:accept "image/jpeg,image/png"
:multi false
@ -124,10 +113,9 @@
;; --- Profile Page
(mf/defc profile-page
{::mf/wrap-props false}
[props]
(let [locale (i18n/use-locale)]
[:section.settings-profile.generic-form
[:div.forms-container
[:& profile-photo-form {:locale locale}]
[:& profile-form {:locale locale}]]]))
[{:keys [locale]}]
[:div.dashboard-settings
[:div.form-container.two-columns
[:& profile-photo-form {:locale locale}]
[:& profile-form {:locale locale}]]])

View file

@ -0,0 +1,91 @@
;; 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/.
;;
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.main.ui.settings.sidebar
(:require
[app.common.spec :as us]
[app.main.data.auth :as da]
[app.main.data.messages :as dm]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.components.forms :as fm]
[app.main.ui.dashboard.sidebar :refer [profile-section]]
[app.main.ui.icons :as i]
[app.util.i18n :as i18n :refer [t tr]]
[app.util.object :as obj]
[app.util.router :as rt]
[app.util.time :as dt]
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[goog.functions :as f]
[okulary.core :as l]
[rumext.alpha :as mf]))
(mf/defc sidebar-content
[{:keys [locale profile section] :as props}]
(let [profile? (= section :settings-profile)
password? (= section :settings-password)
options? (= section :settings-options)
go-dashboard
(mf/use-callback
(mf/deps profile)
(st/emitf (rt/nav :dashboard-projects {:team-id (:default-team-id profile)})))
go-settings-profile
(mf/use-callback
(mf/deps profile)
(st/emitf (rt/nav :settings-profile)))
go-settings-password
(mf/use-callback
(mf/deps profile)
(st/emitf (rt/nav :settings-password)))
go-settings-options
(mf/use-callback
(mf/deps profile)
(st/emitf (rt/nav :settings-options)))]
[:div.sidebar-content
[:div.sidebar-content-section
[:div.back-to-dashboard {:on-click go-dashboard}
[:span.icon i/arrow-down]
[:span.text "Dashboard"]]]
[:hr]
[:div.sidebar-content-section
[:ul.sidebar-nav.no-overflow
[:li {:class (when profile? "current")
:on-click go-settings-profile}
i/user
[:span.element-title (t locale "dashboard.sidebar.profile")]]
[:li {:class (when password? "current")
:on-click go-settings-password}
i/lock
[:span.element-title (t locale "dashboard.sidebar.password")]]
[:li {:class (when options? "current")
:on-click go-settings-options}
i/tree
[:span.element-title (t locale "dashboard.sidebar.settings")]]]]]))
(mf/defc sidebar
{::mf/wrap [mf/memo]}
[{:keys [profile locale section]}]
[:div.dashboard-sidebar.settings
[:div.sidebar-inside
[:& sidebar-content {:locale locale
:profile profile
:section section}]
[:& profile-section {:profile profile
:locale locale}]]])

View file

@ -20,9 +20,11 @@
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.main.ui.workspace.colorpalette :refer [colorpalette]]
[app.main.ui.workspace.colorpicker]
[app.main.ui.workspace.context-menu :refer [context-menu]]
[app.main.ui.workspace.header :refer [header]]
[app.main.ui.workspace.left-toolbar :refer [left-toolbar]]
[app.main.ui.workspace.libraries]
[app.main.ui.workspace.rules :refer [horizontal-rule vertical-rule]]
[app.main.ui.workspace.scroll :as scroll]
[app.main.ui.workspace.sidebar :refer [left-sidebar right-sidebar]]

View file

@ -19,7 +19,7 @@
[app.common.uuid :refer [uuid]]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.colors :as dwc]
[app.main.ui.modal :as modal]
[app.main.data.modal :as modal]
[okulary.core :as l]
[app.main.refs :as refs]
[app.util.i18n :as i18n :refer [t]]))
@ -335,7 +335,7 @@
[:select {:on-change (fn [e]
(let [val (-> e dom/get-target dom/get-value)]
(reset! selected-library val)))
:value @selected-library}
:value @selected-library}
[:option {:value "recent"} (t locale "workspace.libraries.colors.recent-colors")]
[:option {:value "file"} (t locale "workspace.libraries.colors.file-library")]
(for [[_ {:keys [name id]}] shared-libs]

View file

@ -18,7 +18,7 @@
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.modal :as modal]
[app.main.data.modal :as modal]
[app.main.ui.workspace.presence :as presence]
[app.main.ui.keyboard :as kbd]
[app.util.i18n :as i18n :refer [t]]

View file

@ -18,7 +18,7 @@
[app.main.data.workspace :as dw]
[app.main.data.workspace.libraries :as dwl]
[app.main.ui.icons :as i]
[app.main.ui.modal :as modal]))
[app.main.data.modal :as modal]))
(def workspace-file
(l/derived :workspace-file st/state))

View file

@ -30,7 +30,7 @@
[app.main.ui.workspace.sidebar.options.typography :refer [typography-entry]]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.main.ui.modal :as modal]
[app.main.data.modal :as modal]
[app.main.ui.shapes.icon :as icon]
[app.util.data :refer [matches-search]]
[app.util.dom :as dom]

View file

@ -14,7 +14,7 @@
[app.util.dom :as dom]
[app.util.data :refer [classnames]]
[app.util.i18n :as i18n :refer [tr]]
[app.main.ui.modal :as modal]
[app.main.data.modal :as modal]
[app.common.data :as d]
[app.main.refs :as refs]))

View file

@ -16,7 +16,7 @@
[app.main.ui.hooks :as hooks]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.main.ui.modal :as modal]
[app.main.data.modal :as modal]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t]]
[app.util.router :as rt]

View file

@ -18,7 +18,7 @@
[promesa.core :as p]
[app.main.ui.icons :as i]
[app.main.ui.cursors :as cur]
[app.main.ui.modal :as modal]
[app.main.data.modal :as modal]
[app.common.data :as d]
[app.main.constants :as c]
[app.main.data.workspace :as dw]

View file

@ -10,34 +10,20 @@
(ns app.util.forms
(:refer-clojure :exclude [uuid])
(:require
[app.common.spec :as us]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.timers :as tm]
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[potok.core :as ptk]
[rumext.alpha :as mf]
[app.common.spec :as us]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]))
[rumext.alpha :as mf]))
;; --- Handlers Helpers
(defn- impl-mutator
[v update-fn]
(specify v
IReset
(-reset! [_ new-value]
(update-fn new-value))
ISwap
(-swap!
([self f] (update-fn f))
([self f x] (update-fn #(f % x)))
([self f x y] (update-fn #(f % x y)))
([self f x y more] (update-fn #(apply f % x y more))))))
(defn- interpret-problem
[acc {:keys [path pred val via in] :as problem}]
;; (prn "interpret-problem" problem)
(cond
(and (empty? path)
(list? pred)
@ -51,45 +37,100 @@
:else acc))
(declare create-form-mutator)
(defn use-form
[& {:keys [spec validators initial]}]
(let [[state update-state] (mf/useState {:data (if (fn? initial) (initial) initial)
:errors {}
:touched {}})
[& {:keys [spec validators 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))]
cleaned (s/conform spec (:data state))
problems (when (= ::s/invalid cleaned)
(::s/problems (s/explain-data spec (:data state))))
(mf/use-effect
(mf/deps initial)
(fn []
(if (fn? initial)
(swap! form update :data merge (initial))
(swap! form update :data merge initial))))
errors (merge (reduce interpret-problem {} problems)
(reduce (fn [errors vf]
(merge errors (vf (:data state))))
{} validators)
(:errors state))]
(-> (assoc state
:errors errors
:clean-data (when (not= cleaned ::s/invalid) cleaned)
:valid (and (empty? errors)
(not= cleaned ::s/invalid)))
(impl-mutator update-state))))
form))
(defn- wrap-update-fn
[f {:keys [spec validators]}]
(fn [& args]
(let [state (apply f args)
cleaned (s/conform spec (:data state))
problems (when (= ::s/invalid cleaned)
(::s/problems (s/explain-data spec (:data state))))
errors (merge (reduce interpret-problem {} problems)
(reduce (fn [errors vf]
(merge errors (vf (:data state))))
{} validators)
(:errors state))]
(assoc state
:errors errors
:clean-data (when (not= cleaned ::s/invalid) cleaned)
:valid (and (empty? errors)
(not= cleaned ::s/invalid))))))
(defn- create-form-mutator
[state-ref render opts]
(reify
IDeref
(-deref [_]
(mf/ref-val state-ref))
IReset
(-reset! [it new-value]
(mf/set-ref-val! state-ref new-value)
(render inc))
ISwap
(-swap! [self f]
(let [f (wrap-update-fn f opts)]
(mf/set-ref-val! state-ref (f (mf/ref-val state-ref)))
(render inc)))
(-swap! [self f x]
(let [f (wrap-update-fn f opts)]
(mf/set-ref-val! state-ref (f (mf/ref-val state-ref) x))
(render inc)))
(-swap! [self f x y]
(let [f (wrap-update-fn f opts)]
(mf/set-ref-val! state-ref (f (mf/ref-val state-ref) x y))
(render inc)))
(-swap! [self f x y more]
(let [f (wrap-update-fn f opts)]
(mf/set-ref-val! state-ref (apply f (mf/ref-val state-ref) x y more))
(render inc)))))
(defn on-input-change
([{:keys [data] :as form} field]
([form field]
(on-input-change form field false))
([{:keys [data] :as form} field trim?]
([form field trim?]
(fn [event]
(let [target (dom/get-target event)
value (dom/get-value target)]
value (dom/get-value target)]
(swap! form (fn [state]
(-> state
(assoc-in [:data field] (if trim? (str/trim value) value))
(update :errors dissoc field))))))))
(defn on-input-blur
[{:keys [touched] :as form} field]
[form field]
(fn [event]
(let [target (dom/get-target event)]
(let [target (dom/get-target event)
touched (get @form :touched)]
(when-not (get touched field)
(swap! form assoc-in [:touched field] true)))))

View file

@ -16,9 +16,10 @@
[app.util.transit :as t]))
(defn- conditional-decode
[{:keys [body headers] :as response}]
[{:keys [body headers status] :as response}]
(let [contentype (get headers "content-type")]
(if (str/starts-with? contentype "application/transit+json")
(if (and (str/starts-with? contentype "application/transit+json")
(pos? (count body)))
(assoc response :body (t/decode body))
response)))

View file

@ -5,34 +5,43 @@
;; Copyright (c) 2016 Andrey Antukh <niwi@niwi.nz>
(ns app.util.storage
(:require [app.util.transit :as t]))
(:require
[app.util.transit :as t]
[app.util.timers :as tm]
[app.common.exceptions :as ex]))
(defn- ^boolean is-worker?
[]
(or (= *target* "nodejs")
(not (exists? js/window))))
(defn- decode
[v]
(ex/ignoring (t/decode v)))
(def local
{:get #(decode (.getItem ^js js/localStorage (name %)))
:set #(.setItem ^js js/localStorage (name %1) (t/encode %2))})
(def session
{:get #(decode (.getItem ^js js/sessionStorage (name %)))
:set #(.setItem ^js js/sessionStorage (name %1) (t/encode %2))})
(defn- persist
[alias value]
[alias storage value]
(when-not (is-worker?)
(let [key (name alias)
value (t/encode value)]
(.setItem js/localStorage key value))))
(tm/schedule-on-idle
(fn [] ((:set storage) alias value)))))
(defn- load
[alias]
[alias storage]
(when-not (is-worker?)
(let [data (.getItem js/localStorage (name alias))]
(try
(t/decode data)
(catch :default e
(js/console.error "Error on loading data from local storage." e)
nil)))))
((:get storage) alias)))
(defn- make-storage
[alias]
(let [data (atom (load alias))]
(add-watch data :sub #(persist alias %4))
[alias storage]
(let [data (atom (load alias storage))]
(add-watch data :sub #(persist alias storage %4))
(reify
Object
(toString [_]
@ -66,5 +75,9 @@
(-lookup [_ key not-found]
(get @data key not-found)))))
(def storage
(make-storage "app"))
(defonce storage
(make-storage "app" local))
(defonce cache
(make-storage "cache" session))

View file

@ -8,7 +8,9 @@
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.util.timers
(:require [beicon.core :as rx]))
(:require
[beicon.core :as rx]
[promesa.core :as p]))
(defn schedule
([func]
@ -19,6 +21,11 @@
(-dispose [_]
(js/clearTimeout sem))))))
(defn asap
[f]
(-> (p/resolved nil)
(p/then f)))
(defn interval
[ms func]
(let [sem (js/setInterval #(func) ms)]