diff --git a/CHANGES.md b/CHANGES.md index aa13d60df3..a8601ccf6e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -83,6 +83,7 @@ ### :bug: Bugs fixed +- Fix webhook checkbox position [Taiga #8634](https://tree.taiga.io/project/penpot/issue/8634) - Fix wrong props on padding input [Taiga #8254](https://tree.taiga.io/project/penpot/issue/8254) - Fix fill collapsed options [Taiga #8351](https://tree.taiga.io/project/penpot/issue/8351) - Fix scroll on color picker modal [Taiga #8353](https://tree.taiga.io/project/penpot/issue/8353) diff --git a/backend/resources/app/onboarding.edn b/backend/resources/app/onboarding.edn index a6449f5fd8..07a11859d8 100644 --- a/backend/resources/app/onboarding.edn +++ b/backend/resources/app/onboarding.edn @@ -36,4 +36,7 @@ :file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Open-Color-Scheme.penpot"} {:id "flex-layout-playground" :name "Flex Layout Playground" - :file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Flex-Layout-Playground.penpot"}] + :file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Flex-Layout-Playground.penpot"} + {:id "welcome" + :name "Welcome" + :file-uri "https://github.com/penpot/penpot-files/raw/binary-files/welcome.penpot"}] diff --git a/backend/scripts/repl b/backend/scripts/repl index a9efcb6234..9d3a5a808e 100755 --- a/backend/scripts/repl +++ b/backend/scripts/repl @@ -24,7 +24,7 @@ export PENPOT_FLAGS="\ enable-rpc-climit \ enable-rpc-rlimit \ enable-soft-rpc-rlimit \ - enable-file-snapshot \ + enable-auto-file-snapshot \ enable-webhooks \ enable-access-tokens \ enable-tiered-file-data-storage \ diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index 3adac1f55b..1ed3fa364d 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -27,9 +27,11 @@ [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] [app.setup :as-alias setup] + [app.setup.welcome-file :refer [create-welcome-file]] [app.tokens :as tokens] [app.util.services :as sv] [app.util.time :as dt] + [app.worker :as wrk] [cuerdas.core :as str])) (def schema:password @@ -241,6 +243,7 @@ params (d/without-nils params) token (tokens/generate (::setup/props cfg) params)] + (with-meta {:token token} {::audit/profile-id uuid/zero}))) @@ -350,7 +353,7 @@ :extra-data ptoken}))) (defn register-profile - [{:keys [::db/conn] :as cfg} {:keys [token fullname theme] :as params}] + [{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token fullname theme] :as params}] (let [theme (when (= theme "light") theme) claims (tokens/verify (::setup/props cfg) {:token token :iss :prepared-register}) params (-> claims @@ -380,8 +383,13 @@ invitation (when-let [token (:invitation-token params)] (tokens/verify (::setup/props cfg) {:token token :iss :team-invitation})) - props (audit/profile->props profile)] + props (audit/profile->props profile) + create-welcome-file-when-needed + (fn [] + (when (:create-welcome-file params) + (let [cfg (dissoc cfg ::db/conn)] + (wrk/submit! executor (create-welcome-file cfg profile)))))] (cond ;; When profile is blocked, we just ignore it and return plain data (:is-blocked profile) @@ -418,6 +426,7 @@ (if (:is-active profile) (-> (profile/strip-private-attrs profile) (rph/with-transform (session/create-fn cfg (:id profile))) + (rph/with-defer create-welcome-file-when-needed) (rph/with-meta {::audit/replace-props props ::audit/context {:action "login"} @@ -427,10 +436,12 @@ (when-not (eml/has-reports? conn (:email profile)) (send-email-verification! cfg profile)) - (rph/with-meta {:email (:email profile)} - {::audit/replace-props props - ::audit/context {:action "email-verification"} - ::audit/profile-id (:id profile)}))) + (-> {:email (:email profile)} + (rph/with-defer create-welcome-file-when-needed) + (rph/with-meta + {::audit/replace-props props + ::audit/context {:action "email-verification"} + ::audit/profile-id (:id profile)})))) :else (let [elapsed? (elapsed-verify-threshold? profile) @@ -462,7 +473,8 @@ [:map {:title "register-profile"} [:token schema:token] [:fullname [::sm/word-string {:max 100}]] - [:theme {:optional true} [:string {:max 10}]]]) + [:theme {:optional true} [:string {:max 10}]] + [:create-welcome-file {:optional true} :boolean]]) (sv/defmethod ::register-profile {::rpc/auth false diff --git a/backend/src/app/rpc/commands/files_update.clj b/backend/src/app/rpc/commands/files_update.clj index f010ac7b7f..bd98b70715 100644 --- a/backend/src/app/rpc/commands/files_update.clj +++ b/backend/src/app/rpc/commands/files_update.clj @@ -38,6 +38,20 @@ [clojure.set :as set] [promesa.exec :as px])) +(declare ^:private get-lagged-changes) +(declare ^:private send-notifications!) +(declare ^:private update-file) +(declare ^:private update-file*) +(declare ^:private process-changes-and-validate) +(declare ^:private take-snapshot?) +(declare ^:private delete-old-snapshots!) + +;; PUBLIC API; intended to be used outside of this module +(declare update-file!) +(declare update-file-data!) +(declare persist-file!) +(declare get-file) + ;; --- SCHEMA (def ^:private @@ -97,41 +111,6 @@ (or (contains? library-change-types type) (contains? file-change-types type))) -(def ^:private sql:get-file - "SELECT f.*, p.team_id - FROM file AS f - JOIN project AS p ON (p.id = f.project_id) - WHERE f.id = ? - AND (f.deleted_at IS NULL OR - f.deleted_at > now()) - FOR KEY SHARE") - -(defn get-file - [conn id] - (let [file (db/exec-one! conn [sql:get-file id])] - (when-not file - (ex/raise :type :not-found - :code :object-not-found - :hint (format "file with id '%s' does not exists" id))) - (update file :features db/decode-pgarray #{}))) - -(defn- wrap-with-pointer-map-context - [f] - (fn [cfg {:keys [id] :as file}] - (binding [pmap/*tracked* (pmap/create-tracked) - pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)] - (let [result (f cfg file)] - (feat.fdata/persist-pointers! cfg id) - result)))) - -(declare ^:private delete-old-snapshots!) -(declare ^:private get-lagged-changes) -(declare ^:private send-notifications!) -(declare ^:private take-snapshot?) -(declare ^:private update-file) -(declare ^:private update-file*) -(declare ^:private update-file-data) - ;; If features are specified from params and the final feature ;; set is different than the persisted one, update it on the ;; database. @@ -147,7 +126,8 @@ ::sm/result schema:update-file-result ::doc/module :files ::doc/added "1.17"} - [cfg {:keys [::rpc/profile-id id] :as params}] + [{:keys [::mtx/metrics] :as cfg} + {:keys [::rpc/profile-id id changes changes-with-metadata] :as params}] (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] (files/check-edition-permissions! conn profile-id id) (db/xact-lock! conn id) @@ -161,14 +141,30 @@ (cfeat/check-client-features! (:features params)) (cfeat/check-file-features! (:features file) (:features params))) - params (assoc params - :profile-id profile-id - :features features - :team team - :file file) + changes (if changes-with-metadata + (->> changes-with-metadata (mapcat :changes) vec) + (vec changes)) + + params (-> params + (assoc :profile-id profile-id) + (assoc :features features) + (assoc :team team) + (assoc :file file) + (assoc :changes changes)) + + cfg (assoc cfg ::timestamp (dt/now)) tpoint (dt/tpoint)] + + (when (> (:revn params) + (:revn file)) + (ex/raise :type :validation + :code :revn-conflict + :hint "The incoming revision number is greater that stored version." + :context {:incoming-revn (:revn params) + :stored-revn (:revn file)})) + ;; When newly computed features does not match exactly with ;; the features defined on team row, we update it. (when (not= features (:features team)) @@ -177,90 +173,126 @@ {:features features} {:id (:id team)}))) + (mtx/run! metrics {:id :update-file-changes :inc (count changes)}) + (binding [l/*context* (some-> (meta params) (get :app.http/request) (errors/request->context))] - (-> (update-file cfg params) + (-> (update-file* cfg params) (rph/with-defer #(let [elapsed (tpoint)] (l/trace :hint "update-file" :time (dt/format-duration elapsed)))))))))) -(defn update-file - [{:keys [::mtx/metrics] :as cfg} - {:keys [file features changes changes-with-metadata] :as params}] - (let [features (-> features - (set/difference cfeat/frontend-only-features) - (set/union (:features file))) - - update-fn (cond-> update-file* - (contains? features "fdata/pointer-map") - (wrap-with-pointer-map-context)) - - changes (if changes-with-metadata - (->> changes-with-metadata (mapcat :changes) vec) - (vec changes))] - - (when (> (:revn params) - (:revn file)) - (ex/raise :type :validation - :code :revn-conflict - :hint "The incoming revision number is greater that stored version." - :context {:incoming-revn (:revn params) - :stored-revn (:revn file)})) - - (mtx/run! metrics {:id :update-file-changes :inc (count changes)}) - - (binding [cfeat/*current* features - cfeat/*previous* (:features file)] - (let [file (assoc file :features features) - params (-> params - (assoc :file file) - (assoc :changes changes) - (assoc ::created-at (dt/now)))] - - (-> (update-fn cfg params) - (vary-meta assoc ::audit/replace-props - {:id (:id file) - :name (:name file) - :features (:features file) - :project-id (:project-id file) - :team-id (:team-id file)})))))) - (defn- update-file* - [{:keys [::db/conn ::wrk/executor] :as cfg} - {:keys [profile-id file changes session-id ::created-at skip-validate] :as params}] + "Internal function, part of the update-file process, that encapsulates + the changes application offload to a separated thread and emit all + corresponding notifications. + + Follow the inner implementation to `update-file-data!` function. + + Only intended for internal use on this module." + [{:keys [::db/conn ::wrk/executor ::timestamp] :as cfg} + {:keys [profile-id file features changes session-id skip-validate] :as params}] + (let [;; Retrieve the file data - file (feat.fdata/resolve-file-data cfg file) + file (feat.fdata/resolve-file-data cfg file) + + file (assoc file :features + (-> features + (set/difference cfeat/frontend-only-features) + (set/union (:features file)))) ;; Process the file data on separated thread for avoid to do ;; the CPU intensive operation on vthread. + file (px/invoke! executor + (fn [] + (binding [cfeat/*current* features + cfeat/*previous* (:features file)] + (update-file-data! cfg file + process-changes-and-validate + changes skip-validate))))] - file (px/invoke! executor (partial update-file-data cfg file changes skip-validate)) - features (db/create-array conn "text" (:features file))] - - ;; NOTE: if file was offloaded, we need to touch the referenced - ;; storage object because on this update operation the data will - ;; be overwritted. - (when (= "objects-storage" (:data-backend file)) + (when (feat.fdata/offloaded? file) (let [storage (sto/resolve cfg ::db/reuse-conn true)] - (sto/touch-object! storage (:data-ref-id file)))) - - (db/insert! conn :file-change - {:id (uuid/next) - :session-id session-id - :profile-id profile-id - :created-at created-at - :file-id (:id file) - :revn (:revn file) - :version (:version file) - :label (::snapshot-label file) - :data (::snapshot-data file) - :features (db/create-array conn "text" (:features file)) - :changes (blob/encode changes)} - {::db/return-keys false}) + (some->> (:data-ref-id file) (sto/touch-object! storage)))) + ;; TODO: move this to asynchronous task (when (::snapshot-data file) (delete-old-snapshots! cfg file)) + (persist-file! cfg file) + + (let [params (assoc params :file file) + response {:revn (:revn file) + :lagged (get-lagged-changes conn params)} + features (db/create-array conn "text" (:features file))] + + ;; Insert change (xlog) + (db/insert! conn :file-change + {:id (uuid/next) + :session-id session-id + :profile-id profile-id + :created-at timestamp + :file-id (:id file) + :revn (:revn file) + :version (:version file) + :features features + :label (::snapshot-label file) + :data (::snapshot-data file) + :changes (blob/encode changes)} + {::db/return-keys false}) + + ;; Send asynchronous notifications + (send-notifications! cfg params) + + (vary-meta response assoc ::audit/replace-props + {:id (:id file) + :name (:name file) + :features (:features file) + :project-id (:project-id file) + :team-id (:team-id file)})))) + +(defn update-file! + "A public api that allows apply a transformation to a file with all context setup." + [cfg file-id update-fn & args] + (let [file (get-file cfg file-id) + file (apply update-file-data! cfg file update-fn args)] + (persist-file! cfg file))) + +(def ^:private sql:get-file + "SELECT f.*, p.team_id + FROM file AS f + JOIN project AS p ON (p.id = f.project_id) + WHERE f.id = ? + AND (f.deleted_at IS NULL OR + f.deleted_at > now()) + FOR KEY SHARE") + +(defn get-file + "Get not-decoded file, only decodes the features set." + [conn id] + (let [file (db/exec-one! conn [sql:get-file id])] + (when-not file + (ex/raise :type :not-found + :code :object-not-found + :hint (format "file with id '%s' does not exists" id))) + (update file :features db/decode-pgarray #{}))) + +(defn persist-file! + "Function responsible of persisting already encoded file. Should be + used together with `get-file` and `update-file-data!`. + + It also updates the project modified-at attr." + [{:keys [::db/conn ::timestamp]} file] + (let [features (db/create-array conn "text" (:features file)) + ;; The timestamp can be nil because this function is also + ;; intended to be used outside of this module + modified-at (or timestamp (dt/now))] + + (db/update! conn :project + {:modified-at modified-at} + {:id (:project-id file)} + {::db/return-keys false}) + (db/update! conn :file {:revn (:revn file) :data (:data file) @@ -268,20 +300,95 @@ :features features :data-backend nil :data-ref-id nil - :modified-at created-at + :modified-at modified-at :has-media-trimmed false} - {:id (:id file)}) + {:id (:id file)} + {::db/return-keys false}))) - (db/update! conn :project - {:modified-at created-at} - {:id (:project-id file)}) +(defn- update-file-data! + "Perform a file data transformation in with all update context setup. - (let [params (assoc params :file file)] - ;; Send asynchronous notifications - (send-notifications! cfg params) + This function expected not-decoded file and transformation function. Returns + an encoded file. - {:revn (:revn file) - :lagged (get-lagged-changes conn params)}))) + This function is not responsible of saving the file. It only saves + fdata/pointer-map modified fragments." + + [cfg {:keys [id] :as file} update-fn & args] + (binding [pmap/*tracked* (pmap/create-tracked) + pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)] + (let [file (update file :data (fn [data] + (-> data + (blob/decode) + (assoc :id (:id file))))) + + ;; For avoid unnecesary overhead of creating multiple pointers + ;; and handly internally with objects map in their worst + ;; case (when probably all shapes and all pointers will be + ;; readed in any case), we just realize/resolve them before + ;; applying the migration to the file + file (if (fmg/need-migration? file) + (-> file + (update :data feat.fdata/process-pointers deref) + (update :data feat.fdata/process-objects (partial into {})) + (fmg/migrate-file)) + file) + + file (apply update-fn cfg file args) + + ;; TODO: reuse operations if file is migrated + ;; TODO: move encoding to a separated thread + file (if (take-snapshot? file) + (let [tpoint (dt/tpoint) + snapshot (-> (:data file) + (feat.fdata/process-pointers deref) + (feat.fdata/process-objects (partial into {})) + (blob/encode)) + elapsed (tpoint) + label (str "internal/snapshot/" (:revn file))] + + (l/trc :hint "take snapshot" + :file-id (str (:id file)) + :revn (:revn file) + :label label + :elapsed (dt/format-duration elapsed)) + + (-> file + (assoc ::snapshot-data snapshot) + (assoc ::snapshot-label label))) + file) + + file (cond-> file + (contains? cfeat/*current* "fdata/objects-map") + (feat.fdata/enable-objects-map) + + (contains? cfeat/*current* "fdata/pointer-map") + (feat.fdata/enable-pointer-map) + + :always + (update :data blob/encode))] + + (feat.fdata/persist-pointers! cfg id) + + file))) + +(defn- get-file-libraries + "A helper for preload file libraries, mainly used for perform file + semantical and structural validation" + [{:keys [::db/conn] :as cfg} file] + (->> (files/get-file-libraries conn (:id file)) + (into [file] (map (fn [{:keys [id]}] + (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id) + pmap/*tracked* nil] + ;; We do not resolve the objects maps here + ;; because there is a lower probability that all + ;; shapes needed to be loded into memory, so we + ;; leeave it on lazy status + (-> (files/get-file cfg id :migrate? false) + (update :data feat.fdata/process-pointers deref) ; ensure all pointers resolved + (update :data feat.fdata/process-objects (partial into {})) + (fmg/migrate-file)))))) + (d/index-by :id))) (defn- soft-validate-file-schema! [file] @@ -298,68 +405,19 @@ (l/error :hint "file validation error" :cause cause)))) -(defn- update-file-data - [{:keys [::db/conn] :as cfg} file changes skip-validate] - (let [file (update file :data (fn [data] - (-> data - (blob/decode) - (assoc :id (:id file))))) - ;; For avoid unnecesary overhead of creating multiple pointers - ;; and handly internally with objects map in their worst - ;; case (when probably all shapes and all pointers will be - ;; readed in any case), we just realize/resolve them before - ;; applying the migration to the file - file (if (fmg/need-migration? file) - (-> file - (update :data feat.fdata/process-pointers deref) - (update :data feat.fdata/process-objects (partial into {})) - (fmg/migrate-file)) - file) - - ;; WARNING: this ruins performance; maybe we need to find +(defn- process-changes-and-validate + [cfg file changes skip-validate] + (let [;; WARNING: this ruins performance; maybe we need to find ;; some other way to do general validation libs (when (and (or (contains? cf/flags :file-validation) (contains? cf/flags :soft-file-validation)) (not skip-validate)) - (->> (files/get-file-libraries conn (:id file)) - (into [file] (map (fn [{:keys [id]}] - (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id) - pmap/*tracked* nil] - ;; We do not resolve the objects maps here - ;; because there is a lower probability that all - ;; shapes needed to be loded into memory, so we - ;; leeave it on lazy status - (-> (files/get-file cfg id :migrate? false) - (update :data feat.fdata/process-pointers deref) ; ensure all pointers resolved - (update :data feat.fdata/process-objects (partial into {})) - (fmg/migrate-file)))))) - (d/index-by :id))) - + (get-file-libraries cfg file)) file (-> (files/check-version! file) (update :revn inc) (update :data cpc/process-changes changes) - (update :data d/without-nils)) - - file (if (take-snapshot? file) - (let [tpoint (dt/tpoint) - snapshot (-> (:data file) - (feat.fdata/process-pointers deref) - (feat.fdata/process-objects (partial into {})) - (blob/encode)) - elapsed (tpoint) - label (str "internal/snapshot/" (:revn file))] - - (l/trc :hint "take snapshot" - :file-id (str (:id file)) - :revn (:revn file) - :label label - :elapsed (dt/format-duration elapsed)) - - (-> file - (assoc ::snapshot-data snapshot) - (assoc ::snapshot-label label))) - file)] + (update :data d/without-nils))] (binding [pmap/*tracked* nil] (when (contains? cf/flags :soft-file-validation) @@ -376,15 +434,7 @@ (not skip-validate)) (val/validate-file-schema! file))) - (cond-> file - (contains? cfeat/*current* "fdata/objects-map") - (feat.fdata/enable-objects-map) - - (contains? cfeat/*current* "fdata/pointer-map") - (feat.fdata/enable-pointer-map) - - :always - (update :data blob/encode)))) + file)) (defn- take-snapshot? "Defines the rule when file `data` snapshot should be saved." @@ -426,8 +476,7 @@ result (db/exec-one! conn [sql:delete-snapshots id last-date])] (l/trc :hint "delete old snapshots" :file-id (str id) :total (db/get-update-count result))))) -(def ^:private - sql:lagged-changes +(def ^:private sql:lagged-changes "select s.id, s.revn, s.file_id, s.session_id, s.changes from file_change as s diff --git a/backend/src/app/rpc/commands/management.clj b/backend/src/app/rpc/commands/management.clj index e90b255fc6..30d0d3460f 100644 --- a/backend/src/app/rpc/commands/management.clj +++ b/backend/src/app/rpc/commands/management.clj @@ -396,8 +396,8 @@ ;; --- COMMAND: Clone Template -(defn- clone-template - [cfg {:keys [project-id ::rpc/profile-id] :as params} template] +(defn clone-template + [cfg {:keys [project-id profile-id] :as params} template] (db/tx-run! cfg (fn [{:keys [::db/conn ::wrk/executor] :as cfg}] ;; NOTE: the importation process performs some operations that ;; are not very friendly with virtual threads, and for avoid @@ -416,6 +416,7 @@ (doseq [file-id result] (let [props (assoc props :id file-id) event (-> (audit/event-from-rpc-params params) + (assoc ::audit/profile-id profile-id) (assoc ::audit/name "create-file") (assoc ::audit/props props))] (audit/submit! cfg event)))) @@ -437,7 +438,8 @@ [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id template-id] :as params}] (let [project (db/get-by-id pool :project project-id {:columns [:id :team-id]}) _ (teams/check-edition-permissions! pool profile-id (:team-id project)) - template (tmpl/get-template-stream cfg template-id)] + template (tmpl/get-template-stream cfg template-id) + params (assoc params :profile-id profile-id)] (when-not template (ex/raise :type :not-found diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index ce302571e4..3108fcbb2e 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -360,27 +360,31 @@ [:map {:title "update-profile-props"} [:props [:map-of :keyword :any]]])) +(defn update-profile-props + [{:keys [::db/conn] :as cfg} profile-id props] + (let [profile (get-profile conn profile-id ::sql/for-update true) + props (reduce-kv (fn [props k v] + ;; We don't accept namespaced keys + (if (simple-ident? k) + (if (nil? v) + (dissoc props k) + (assoc props k v)) + props)) + (:props profile) + props)] + + (db/update! conn :profile + {:props (db/tjson props)} + {:id profile-id}) + + (filter-props props))) + (sv/defmethod ::update-profile-props {::doc/added "1.0" ::sm/params schema:update-profile-props} - [{:keys [::db/pool]} {:keys [::rpc/profile-id props]}] - (db/with-atomic [conn pool] - (let [profile (get-profile conn profile-id ::sql/for-update true) - props (reduce-kv (fn [props k v] - ;; We don't accept namespaced keys - (if (simple-ident? k) - (if (nil? v) - (dissoc props k) - (assoc props k v)) - props)) - (:props profile) - props)] - - (db/update! conn :profile - {:props (db/tjson props)} - {:id profile-id}) - - (filter-props props)))) + [cfg {:keys [::rpc/profile-id props]}] + (db/tx-run! cfg (fn [cfg] + (update-profile-props cfg profile-id props)))) ;; --- MUTATION: Delete Profile diff --git a/backend/src/app/setup/welcome_file.clj b/backend/src/app/setup/welcome_file.clj new file mode 100644 index 0000000000..8de4acaa76 --- /dev/null +++ b/backend/src/app/setup/welcome_file.clj @@ -0,0 +1,64 @@ +;; 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) KALEIDOS INC + +(ns app.setup.welcome-file + (:require + [app.common.logging :as l] + [app.db :as db] + [app.rpc :as-alias rpc] + [app.rpc.climit :as-alias climit] + [app.rpc.commands.files-update :as fupdate] + [app.rpc.commands.management :as management] + [app.rpc.commands.profile :as profile] + [app.rpc.doc :as-alias doc] + [app.setup :as-alias setup] + [app.setup.templates :as tmpl] + [app.worker :as-alias wrk])) + +(def ^:private page-id #uuid "2c6952ee-d00e-8160-8004-d2250b7210cb") +(def ^:private shape-id #uuid "765e9f82-c44e-802e-8004-d72a10b7b445") + +(def ^:private update-path + [:data :pages-index page-id :objects shape-id + :content :children 0 :children 0 :children 0]) + +(def ^:private sql:mark-file-object-thumbnails-deleted + "UPDATE file_tagged_object_thumbnail + SET deleted_at = now() + WHERE file_id = ?") + +(def ^:private sql:mark-file-thumbnail-deleted + "UPDATE file_thumbnail + SET deleted_at = now() + WHERE file_id = ?") + +(defn- update-welcome-shape + [_ file name] + (let [text (str "Welcome to Penpot, " name "!")] + (-> file + (update-in update-path assoc :text text) + (update-in [:data :pages-index page-id :objects shape-id] assoc :name "Welcome to Penpot!") + (update-in [:data :pages-index page-id :objects shape-id] dissoc :position-data)))) + +(defn create-welcome-file + [cfg {:keys [id fullname] :as profile}] + (try + (let [cfg (dissoc cfg ::db/conn) + params {:profile-id (:id profile) + :project-id (:default-project-id profile)} + template-stream (tmpl/get-template-stream cfg "welcome") + file-id (-> (management/clone-template cfg params template-stream) + first)] + + (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] + (fupdate/update-file! cfg file-id update-welcome-shape fullname) + (profile/update-profile-props cfg id {:welcome-file-id file-id}) + (db/exec-one! conn [sql:mark-file-object-thumbnails-deleted file-id]) + (db/exec-one! conn [sql:mark-file-thumbnail-deleted file-id])))) + + (catch Throwable cause + (l/error :hint "unexpected error on create welcome file " :cause cause)))) + diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 8380ea13e0..e77b51d6a5 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -76,7 +76,7 @@ :enable-feature-fdata-pointer-map :enable-feature-fdata-objets-map :enable-feature-components-v2 - :enable-file-snapshot + :enable-auto-file-snapshot :disable-file-validation]) (defn state-init @@ -304,16 +304,18 @@ ([params] (update-file* *system* params)) ([system {:keys [file-id changes session-id profile-id revn] :or {session-id (uuid/next) revn 0}}] - (db/tx-run! system (fn [{:keys [::db/conn] :as system}] - (let [file (files.update/get-file conn file-id)] - (files.update/update-file system + (-> system + (assoc ::files.update/timestamp (dt/now)) + (db/tx-run! (fn [{:keys [::db/conn] :as system}] + (let [file (files.update/get-file conn file-id)] + (#'files.update/update-file* system {:id file-id :revn revn :file file :features (:features file) :changes changes :session-id session-id - :profile-id profile-id})))))) + :profile-id profile-id}))))))) (declare command!) diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index 40a2537597..9df9759375 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -190,10 +190,9 @@ [:type [:= :del-color]] [:id ::sm/uuid]]] + ;; DEPRECATED: remove before 2.3 [:add-recent-color - [:map {:title "AddRecentColorChange"} - [:type [:= :add-recent-color]] - [:color ::ctc/recent-color]]] + [:map {:title "AddRecentColorChange"}]] [:add-media [:map {:title "AddMediaChange"} @@ -656,18 +655,10 @@ [data {:keys [id]}] (ctcl/delete-color data id)) +;; DEPRECATED: remove before 2.3 (defmethod process-change :add-recent-color - [data {:keys [color]}] - ;; Moves the color to the top of the list and then truncates up to 15 - (update - data - :recent-colors - (fn [rc] - (let [rc (->> rc (d/removev (partial ctc/eq-recent-color? color))) - rc (-> rc (conj color))] - (cond-> rc - (> (count rc) 15) - (subvec 1)))))) + [data _] + data) ;; -- Media diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc index c3ecbd8a16..9c613a91d0 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -607,13 +607,6 @@ (reduce resize-parent changes all-parents))) ;; Library changes - -(defn add-recent-color - [changes color] - (-> changes - (update :redo-changes conj {:type :add-recent-color :color color}) - (apply-changes-local))) - (defn add-color [changes color] (-> changes diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc index c0c400a9a7..78a7f81145 100644 --- a/common/src/app/common/types/color.cljc +++ b/common/src/app/common/types/color.cljc @@ -107,17 +107,16 @@ [::sm/contains-any {:strict true} [:color :gradient :image]]]) (sm/register! ::rgb-color type:rgb-color) - (sm/register! ::color schema:color) (sm/register! ::gradient schema:gradient) (sm/register! ::image-color schema:image-color) (sm/register! ::recent-color schema:recent-color) -(def check-color! - (sm/check-fn schema:color)) +(def valid-color? + (sm/lazy-validator schema:color)) -(def check-recent-color! - (sm/check-fn schema:recent-color)) +(def valid-recent-color? + (sm/lazy-validator schema:recent-color)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HELPERS @@ -392,13 +391,22 @@ (process-shape-colors shape sync-color))) -(defn eq-recent-color? +(defn- eq-recent-color? [c1 c2] (or (= c1 c2) (and (some? (:color c1)) (some? (:color c2)) (= (:color c1) (:color c2))))) +(defn add-recent-color + "Moves the color to the top of the list and then truncates up to 15" + [state file-id color] + (update state file-id (fn [colors] + (let [colors (d/removev (partial eq-recent-color? color) colors) + colors (conj colors color)] + (cond-> colors + (> (count colors) 15) + (subvec 1)))))) (defn stroke->color-att [stroke file-id shared-libs] diff --git a/frontend/src/app/main/data/users.cljs b/frontend/src/app/main/data/users.cljs index 1a61a0644d..ec8d598f4a 100644 --- a/frontend/src/app/main/data/users.cljs +++ b/frontend/src/app/main/data/users.cljs @@ -21,10 +21,12 @@ [app.main.repo :as rp] [app.util.i18n :as i18n :refer [tr]] [app.util.router :as rt] - [app.util.storage :refer [storage]] + [app.util.storage :as s] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) +(declare update-profile-props) + ;; --- SCHEMAS (def ^:private @@ -49,14 +51,14 @@ (defn get-current-team-id [profile] - (let [team-id (::current-team-id @storage)] + (let [team-id (::current-team-id @s/storage)] (or team-id (:default-team-id profile)))) (defn set-current-team! [team-id] (if (nil? team-id) - (swap! storage dissoc ::current-team-id) - (swap! storage assoc ::current-team-id team-id))) + (swap! s/storage dissoc ::current-team-id) + (swap! s/storage assoc ::current-team-id team-id))) ;; --- EVENT: fetch-teams @@ -76,9 +78,9 @@ ;; if not, dissoc it from storage. (let [ids (into #{} (map :id) teams)] - (when-let [ctid (::current-team-id @storage)] + (when-let [ctid (::current-team-id @s/storage)] (when-not (contains? ids ctid) - (swap! storage dissoc ::current-team-id))))))) + (swap! s/storage dissoc ::current-team-id))))))) (defn fetch-teams [] @@ -129,10 +131,10 @@ (effect [_ state _] (let [profile (:profile state) email (:email profile) - previous-profile (:profile @storage) + previous-profile (:profile @s/storage) previous-email (:email previous-profile)] (when profile - (swap! storage assoc :profile profile) + (swap! s/storage assoc :profile profile) (i18n/set-locale! (:lang profile)) (when (not= previous-email email) (set-current-team! nil))))))) @@ -152,9 +154,15 @@ profile. The profile can proceed from standard login or from accepting invitation, or third party auth signup or singin." [profile] - (letfn [(get-redirect-event [] - (let [team-id (get-current-team-id profile)] - (rt/nav' :dashboard-projects {:team-id team-id})))] + (letfn [(get-redirect-events [] + (let [team-id (get-current-team-id profile) + welcome-file-id (get-in profile [:props :welcome-file-id])] + (if (some? welcome-file-id) + (rx/of + (rt/nav' :workspace {:project-id (:default-project-id profile) + :file-id welcome-file-id}) + (update-profile-props {:welcome-file-id nil})) + (rx/of (rt/nav' :dashboard-projects {:team-id team-id})))))] (ptk/reify ::logged-in ev/Event @@ -171,10 +179,11 @@ ptk/WatchEvent (watch [_ _ _] (when (is-authenticated? profile) - (->> (rx/of (profile-fetched profile) - (fetch-teams) - (get-redirect-event) - (ws/initialize)) + (->> (rx/concat + (rx/of (profile-fetched profile) + (fetch-teams) + (ws/initialize)) + (get-redirect-events)) (rx/observe-on :async))))))) (declare login-from-register) @@ -311,7 +320,7 @@ ptk/EffectEvent (effect [_ _ _] ;; We prefer to keek some stuff in the storage like the current-team-id and the profile - (set-current-team! nil))))) + (swap! s/storage (constantly {})))))) (defn logout ([] (logout {})) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index dfb18f0d63..dd62ff70d8 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -79,6 +79,7 @@ [app.util.http :as http] [app.util.i18n :as i18n :refer [tr]] [app.util.router :as rt] + [app.util.storage :refer [storage]] [app.util.timers :as tm] [app.util.webapi :as wapi] [beicon.v2.core :as rx] @@ -335,6 +336,7 @@ ptk/UpdateEvent (update [_ state] (assoc state + :recent-colors (:recent-colors @storage) :workspace-ready? false :current-file-id file-id :current-project-id project-id diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 8e3589b50b..a6c6cb8b33 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -48,6 +48,7 @@ [app.util.color :as uc] [app.util.i18n :refer [tr]] [app.util.router :as rt] + [app.util.storage :as s] [app.util.time :as dt] [beicon.v2.core :as rx] [cuerdas.core :as str] @@ -132,16 +133,21 @@ (defn add-recent-color [color] + (dm/assert! "expected valid recent color map" - (ctc/check-recent-color! color)) + (ctc/valid-recent-color? color)) (ptk/reify ::add-recent-color - ptk/WatchEvent - (watch [it _ _] - (let [changes (-> (pcb/empty-changes it) - (pcb/add-recent-color color))] - (rx/of (dch/commit-changes changes)))))) + ptk/UpdateEvent + (update [_ state] + (let [file-id (:current-file-id state)] + (update state :recent-colors ctc/add-recent-color file-id color))) + + ptk/EffectEvent + (effect [_ state _] + (let [recent-colors (:recent-colors state)] + (swap! s/storage assoc :recent-colors recent-colors))))) (def clear-color-for-rename (ptk/reify ::clear-color-for-rename @@ -168,8 +174,11 @@ (dm/assert! "expected valid parameters" - (and (ctc/check-color! color) - (uuid? file-id))) + (ctc/valid-color? color)) + + (dm/assert! + "expected file-id" + (uuid? file-id)) (ptk/reify ::update-color ptk/WatchEvent diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 36ec7a425d..c0f32f6436 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -236,9 +236,10 @@ =)) (def workspace-recent-colors - (l/derived (fn [data] - (get data :recent-colors [])) - workspace-data)) + (l/derived (fn [state] + (when-let [file-id (:current-file-id state)] + (dm/get-in state [:recent-colors file-id]))) + st/state)) (def workspace-recent-fonts (l/derived (fn [data] diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 50cd947e66..8a53010de1 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -44,7 +44,30 @@ (mf/defc main-page {::mf/props :obj} [{:keys [route profile]}] - (let [{:keys [data params]} route] + (let [{:keys [data params]} route + props (get profile :props) + show-question-modal? + (and (contains? cf/flags :onboarding) + (not (:onboarding-viewed props)) + (not (contains? props :onboarding-questions))) + + show-newsletter-modal? + (and (contains? cf/flags :onboarding) + (not (:onboarding-viewed props)) + (not (contains? props :newsletter-updates)) + (contains? props :onboarding-questions)) + + show-team-modal? + (and (contains? cf/flags :onboarding) + (not (:onboarding-viewed props)) + (not (contains? props :onboarding-team-id)) + (contains? props :newsletter-updates)) + + show-release-modal? + (and (contains? cf/flags :onboarding) + (:onboarding-viewed props) + (not= (:release-notes-viewed props) (:main cf/version)) + (not= "0.0" (:main cf/version)))] [:& (mf/provider ctx/current-route) {:value route} (case (:name data) (:auth-login @@ -84,42 +107,19 @@ #_[:& app.main.ui.onboarding/onboarding-templates-modal] #_[:& app.main.ui.onboarding/onboarding-modal] #_[:& app.main.ui.onboarding.team-choice/onboarding-team-modal] - (when-let [props (get profile :props)] - (let [show-question-modal? - (and (contains? cf/flags :onboarding) - (not (:onboarding-viewed props)) - (not (contains? props :onboarding-questions))) - show-newsletter-modal? - (and (contains? cf/flags :onboarding) - (not (:onboarding-viewed props)) - (not (contains? props :newsletter-updates)) - (contains? props :onboarding-questions)) + (cond + show-question-modal? + [:& questions-modal] - show-team-modal? - (and (contains? cf/flags :onboarding) - (not (:onboarding-viewed props)) - (not (contains? props :onboarding-team-id)) - (contains? props :newsletter-updates)) + show-newsletter-modal? + [:& onboarding-newsletter] - show-release-modal? - (and (contains? cf/flags :onboarding) - (:onboarding-viewed props) - (not= (:release-notes-viewed props) (:main cf/version)) - (not= "0.0" (:main cf/version)))] + show-team-modal? + [:& onboarding-team-modal {:go-to-team? true}] - (cond - show-question-modal? - [:& questions-modal] - - show-newsletter-modal? - [:& onboarding-newsletter] - - show-team-modal? - [:& onboarding-team-modal] - - show-release-modal? - [:& release-notes-modal {:version (:main cf/version)}]))) + show-release-modal? + [:& release-notes-modal {:version (:main cf/version)}]) [:& dashboard-page {:route route :profile profile}]] :viewer @@ -154,6 +154,20 @@ page-id (some-> params :query :page-id uuid) layout (some-> params :query :layout keyword)] [:? {} + (when (cf/external-feature-flag "onboarding-03" "test") + (cond + show-question-modal? + [:& questions-modal] + + show-newsletter-modal? + [:& onboarding-newsletter] + + show-team-modal? + [:& onboarding-team-modal {:go-to-team? false}] + + show-release-modal? + [:& release-notes-modal {:version (:main cf/version)}])) + [:& workspace-page {:project-id project-id :file-id file-id :page-id page-id diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 192a96635d..8c3a8a6da1 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -39,7 +39,8 @@ form (fm/use-form :schema schema:register-form :initial initial) - submitted? (mf/use-state false) + submitted? + (mf/use-state false) on-error (mf/use-fn @@ -176,7 +177,9 @@ ::mf/private true} [{:keys [params on-success-callback]}] (let [form (fm/use-form :schema schema:register-validate-form :initial params) - submitted? (mf/use-state false) + + submitted? + (mf/use-state false) on-success (mf/use-fn @@ -208,7 +211,13 @@ (mf/deps on-success on-error) (fn [form _] (reset! submitted? true) - (let [params (:clean-data @form)] + (let [create-welcome-file? + (cf/external-feature-flag "onboarding-03" "test") + + params + (cond-> (:clean-data @form) + create-welcome-file? (assoc :create-welcome-file true))] + (->> (rp/cmd! :register-profile params) (rx/finalize #(reset! submitted? false)) (rx/subs! on-success on-error)))))] diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index d914ea773c..d606c40535 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -519,8 +519,10 @@ @include bodySmallTypography; color: var(--modal-title-foreground-color); } - -.custom-input-checkbox { +// TODO: This fix is temporary, the error is caused by the +// cascading order of the compiled css files. +// https://tree.taiga.io/project/penpot/task/8658 +.custom-input-checkbox.custom-input-checkbox { align-items: flex-start; } diff --git a/frontend/src/app/main/ui/dashboard/templates.cljs b/frontend/src/app/main/ui/dashboard/templates.cljs index 8927ff0532..1d6e079989 100644 --- a/frontend/src/app/main/ui/dashboard/templates.cljs +++ b/frontend/src/app/main/ui/dashboard/templates.cljs @@ -168,7 +168,9 @@ [{:keys [default-project-id profile project-id team-id]}] (let [templates (mf/deref builtin-templates) templates (mf/with-memo [templates] - (filterv #(not= (:id %) "tutorial-for-beginners") templates)) + (filterv #(and + (not= (:id %) "welcome") + (not= (:id %) "tutorial-for-beginners")) templates)) route (mf/deref refs/route) route-name (get-in route [:data :name]) diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index bb05d2b1cc..14bff15591 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -294,19 +294,21 @@ `key` for new values." [key default] (let [id (mf/use-id) - state (mf/use-state (get @storage key default)) + state* (mf/use-state #(get @storage key default)) + state (deref state*) stream (mf/with-memo [id] (->> mbc/stream (rx/filter #(not= (:id %) id)) (rx/filter #(= (:type %) key)) (rx/map deref)))] - (mf/with-effect [@state key id] - (mbc/emit! id key @state) - (swap! storage assoc key @state)) + (mf/with-effect [state key id] + (mbc/emit! id key state) + (swap! storage assoc key state)) - (use-stream stream (partial reset! state)) - state)) + (use-stream stream (partial reset! state*)) + + state*)) (defonce ^:private intersection-subject (rx/subject)) (defonce ^:private intersection-observer diff --git a/frontend/src/app/main/ui/hooks/resize.cljs b/frontend/src/app/main/ui/hooks/resize.cljs index 148cdf773d..7b57c1345a 100644 --- a/frontend/src/app/main/ui/hooks/resize.cljs +++ b/frontend/src/app/main/ui/hooks/resize.cljs @@ -6,6 +6,7 @@ (ns app.main.ui.hooks.resize (:require + [app.common.data :as d] [app.common.data.macros :as dm] [app.common.geom.point :as gpt] [app.common.logging :as log] @@ -20,6 +21,15 @@ (def last-resize-type nil) +(defn- get-initial-state + [initial file-id key] + (let [saved (dm/get-in @storage [::state file-id key])] + (d/nilv saved initial))) + +(defn- update-persistent-state + [data file-id key size] + (update-in data [::state file-id] assoc key size)) + (defn set-resize-type! [type] (set! last-resize-type type)) @@ -28,26 +38,28 @@ (use-resize-hook key initial min-val max-val axis negate? resize-type nil)) ([key initial min-val max-val axis negate? resize-type on-change-size] - (let [current-file-id (mf/use-ctx ctx/current-file-id) - size-state (mf/use-state (or (get-in @storage [::saved-resize current-file-id key]) initial)) - parent-ref (mf/use-ref nil) + (let [file-id (mf/use-ctx ctx/current-file-id) - dragging-ref (mf/use-ref false) + current-size* (mf/use-state #(get-initial-state initial file-id key)) + current-size (deref current-size*) + + parent-ref (mf/use-ref nil) + dragging-ref (mf/use-ref false) start-size-ref (mf/use-ref nil) - start-ref (mf/use-ref nil) + start-ref (mf/use-ref nil) on-pointer-down - (mf/use-callback - (mf/deps @size-state) + (mf/use-fn + (mf/deps current-size) (fn [event] (dom/capture-pointer event) - (mf/set-ref-val! start-size-ref @size-state) + (mf/set-ref-val! start-size-ref current-size) (mf/set-ref-val! dragging-ref true) (mf/set-ref-val! start-ref (dom/get-client-position event)) (set! last-resize-type resize-type))) on-lost-pointer-capture - (mf/use-callback + (mf/use-fn (fn [event] (dom/release-pointer event) (mf/set-ref-val! start-size-ref nil) @@ -56,40 +68,39 @@ (set! last-resize-type nil))) on-pointer-move - (mf/use-callback - (mf/deps min-val max-val negate?) + (mf/use-fn + (mf/deps min-val max-val negate? file-id key) (fn [event] (when (mf/ref-val dragging-ref) (let [start (mf/ref-val start-ref) - pos (dom/get-client-position event) + pos (dom/get-client-position event) delta (-> (gpt/to-vec start pos) (cond-> negate? gpt/negate) (get axis)) + start-size (mf/ref-val start-size-ref) new-size (-> (+ start-size delta) (max min-val) (min max-val))] - (reset! size-state new-size) - (swap! storage assoc-in [::saved-resize current-file-id key] new-size) - (when on-change-size (on-change-size new-size)))))) + (reset! current-size* new-size) + (swap! storage update-persistent-state file-id key new-size))))) set-size - (mf/use-callback - (mf/deps on-change-size) + (mf/use-fn + (mf/deps on-change-size file-id key) (fn [new-size] (let [new-size (mth/clamp new-size min-val max-val)] - (reset! size-state new-size) - (swap! storage assoc-in [::saved-resize current-file-id key] new-size) - (when on-change-size (on-change-size new-size)))))] + (reset! current-size* new-size) + (swap! storage update-persistent-state file-id key new-size))))] - (mf/use-effect - (fn [] - (when on-change-size (on-change-size @size-state)))) + (mf/with-effect [on-change-size current-size] + (when on-change-size + (on-change-size current-size))) {:on-pointer-down on-pointer-down :on-lost-pointer-capture on-lost-pointer-capture :on-pointer-move on-pointer-move :parent-ref parent-ref :set-size set-size - :size @size-state}))) + :size current-size}))) (defn use-resize-observer [callback] diff --git a/frontend/src/app/main/ui/onboarding/team_choice.cljs b/frontend/src/app/main/ui/onboarding/team_choice.cljs index e18d11ab18..79bcc98a98 100644 --- a/frontend/src/app/main/ui/onboarding/team_choice.cljs +++ b/frontend/src/app/main/ui/onboarding/team_choice.cljs @@ -66,7 +66,7 @@ (mf/defc team-form-step-2 {::mf/props :obj} - [{:keys [name on-back]}] + [{:keys [name on-back go-to-team?]}] (let [initial (mf/use-memo #(do {:role "editor" :name name})) @@ -85,7 +85,8 @@ (let [team-id (:id response)] (st/emit! (du/update-profile-props {:onboarding-team-id team-id :onboarding-viewed true}) - (rt/nav :dashboard-projects {:team-id team-id}))))) + (when go-to-team? + (rt/nav :dashboard-projects {:team-id team-id})))))) on-error (mf/use-fn @@ -240,7 +241,7 @@ (mf/defc onboarding-team-modal {::mf/props :obj} - [] + [{:keys [go-to-team?]}] (let [name* (mf/use-state nil) name (deref name*) @@ -262,6 +263,6 @@ [:& left-sidebar] [:div {:class (stl/css :separator)}] (if name - [:& team-form-step-2 {:name name :on-back on-back}] + [:& team-form-step-2 {:name name :on-back on-back :go-to-team? go-to-team?}] [:& team-form-step-1 {:on-submit on-submit}])]])) diff --git a/frontend/src/app/util/storage.cljs b/frontend/src/app/util/storage.cljs index cd9303edd2..80fd72f6e3 100644 --- a/frontend/src/app/util/storage.cljs +++ b/frontend/src/app/util/storage.cljs @@ -6,42 +6,80 @@ (ns app.util.storage (:require + ["lodash/debounce" :as ldebounce] [app.common.exceptions :as ex] [app.common.transit :as t] [app.util.globals :as g] - [app.util.timers :as tm])) + [cuerdas.core :as str])) -(defn- persist - [storage prev curr] - (run! (fn [key] - (let [prev* (get prev key) - curr* (get curr key)] - (when (not= curr* prev*) - (tm/schedule-on-idle - #(if (some? curr*) - (.setItem ^js storage (t/encode-str key) (t/encode-str curr*)) - (.removeItem ^js storage (t/encode-str key))))))) +;; Using ex/ignoring because can receive a DOMException like this when +;; importing the code as a library: Failed to read the 'localStorage' +;; property from 'Window': Storage is disabled inside 'data:' URLs. +(defonce ^:private local-storage + (ex/ignoring (unchecked-get g/global "localStorage"))) - (into #{} (concat (keys curr) - (keys prev))))) +(defn- encode-key + [k] + (assert (keyword? k) "key must be keyword") + (let [kns (namespace k) + kn (name k)] + (str "penpot:" kns "/" kn))) + +(defn- decode-key + [k] + (when (str/starts-with? k "penpot:") + (let [k (subs k 7)] + (if (str/starts-with? k "/") + (keyword (subs k 1)) + (let [[kns kn] (str/split k "/" 2)] + (keyword kns kn)))))) + +(defn- lookup-by-index + [result index] + (try + (let [key (.key ^js local-storage index) + key' (decode-key key)] + (if key' + (let [val (.getItem ^js local-storage key)] + (assoc! result key' (t/decode-str val))) + result)) + (catch :default _ + result))) (defn- load - [storage] - (when storage - (let [len (.-length ^js storage)] - (reduce (fn [res index] - (let [key (.key ^js storage index) - val (.getItem ^js storage key)] - (try - (assoc res (t/decode-str key) (t/decode-str val)) - (catch :default _e - res)))) - {} - (range len))))) + [] + (when (some? local-storage) + (let [length (.-length ^js local-storage)] + (loop [index 0 + result (transient {})] + (if (< index length) + (recur (inc index) + (lookup-by-index result index)) + (persistent! result)))))) -;; Using ex/ignoring because can receive a DOMException like this when importing the code as a library: -;; Failed to read the 'localStorage' property from 'Window': Storage is disabled inside 'data:' URLs. -(defonce storage (atom (load (ex/ignoring (unchecked-get g/global "localStorage"))))) +(defonce ^:private latest-state (load)) -(add-watch storage :persistence #(persist js/localStorage %3 %4)) +(defn- on-change* + [curr-state] + (let [prev-state latest-state] + (try + (run! (fn [key] + (let [prev-val (get prev-state key) + curr-val (get curr-state key)] + (when-not (identical? curr-val prev-val) + (if (some? curr-val) + (.setItem ^js local-storage (encode-key key) (t/encode-str curr-val)) + (.removeItem ^js local-storage (encode-key key)))))) + (into #{} (concat (keys curr-state) + (keys prev-state)))) + (finally + (set! latest-state curr-state))))) +(defonce on-change + (ldebounce on-change* 2000 #js {:leading false :trailing true})) + + +(defonce storage (atom latest-state)) +(add-watch storage :persistence + (fn [_ _ _ curr-state] + (on-change curr-state)))