From bb9daf7c03580de0e7d251aa12b7e05d629d094d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Schr=C3=B6dl?= Date: Fri, 6 Jun 2025 15:37:10 +0200 Subject: [PATCH] :sparkles: Add export tokens modal with multi-file export (#6649) --- CHANGES.md | 1 + common/src/app/common/json.cljc | 2 +- common/src/app/common/types/tokens_lib.cljc | 46 ++++-- .../common_tests/types/tokens_lib_test.cljc | 53 +++++++ frontend/src/app/main/ui/workspace.cljs | 1 + .../ui/workspace/tokens/modals/export.cljs | 144 ++++++++++++++++++ .../ui/workspace/tokens/modals/export.scss | 125 +++++++++++++++ .../app/main/ui/workspace/tokens/sidebar.cljs | 8 +- frontend/translations/en.po | 24 +++ frontend/translations/es.po | 20 +++ 10 files changed, 403 insertions(+), 21 deletions(-) create mode 100644 frontend/src/app/main/ui/workspace/tokens/modals/export.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/modals/export.scss diff --git a/CHANGES.md b/CHANGES.md index badf6ed85d..70b082c156 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -28,6 +28,7 @@ on [its own changelog](library/CHANGES.md) - Support system color scheme [Github #5030](https://github.com/penpot/penpot/issues/5030) - Persist ruler visibility across files and reloads [GitHub #4586](https://github.com/penpot/penpot/issues/4586) - Update google fonts (at 2025/05/19) [Taiga 10792](https://tree.taiga.io/project/penpot/us/10792) +- Adds tokens multi file export [Github #117](https://github.com/tokens-studio/penpot/issues/117) ### :bug: Bugs fixed - Fix getCurrentUser for plugins api [Taiga #11057](https://tree.taiga.io/project/penpot/issue/11057) diff --git a/common/src/app/common/json.cljc b/common/src/app/common/json.cljc index 2b6fd0e6b5..97e1b35af5 100644 --- a/common/src/app/common/json.cljc +++ b/common/src/app/common/json.cljc @@ -98,7 +98,7 @@ (defn encode [data & {:as opts}] #?(:clj (j/write-str data opts) - :cljs (.stringify js/JSON (->js data opts)))) + :cljs (.stringify js/JSON (->js data opts) nil (:indent opts)))) (defn decode [data & {:as opts}] diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index cb2f6b8b77..e6ca9e89b5 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -1420,8 +1420,13 @@ Will return a value that matches this schema: :else (parse-multi-set-dtcg-json decoded-json)))) -(defn export-dtcg-json - "Convert a TokensLib into a plain clojure map, suitable to be encoded as a multi sets json string in DTCG format." +(defn- token->dtcg-token [token] + (cond-> {"$value" (:value token) + "$type" (cto/token-type->dtcg-token-type (:type token))} + (:description token) (assoc "$description" (:description token)))) + +(defn- dtcg-export-themes + "Extract themes for a dtcg json export." [tokens-lib] (let [themes-xform (comp @@ -1443,22 +1448,37 @@ Will return a value that matches this schema: (into [] themes-xform)) ;; Active themes without exposing hidden penpot theme - active-themes-clear + active-themes (-> (get-active-theme-paths tokens-lib) - (disj hidden-theme-path)) + (disj hidden-theme-path))] + {:themes themes + :active-themes active-themes})) - update-token-fn - (fn [token] - (cond-> {"$value" (:value token) - "$type" (cto/token-type->dtcg-token-type (:type token))} - (:description token) (assoc "$description" (:description token)))) +(defn export-dtcg-multi-file + "Convert a TokensLib into a plain clojure map, suitable to be encoded as a multi json files each encoded in DTCG format." + [tokens-lib] + (let [{:keys [themes active-themes]} (dtcg-export-themes tokens-lib) + sets (->> (get-sets tokens-lib) + (map (fn [{:keys [name tokens]}] + [(str name ".json") (tokens-tree tokens :update-token-fn token->dtcg-token)])) + (into {}))] + (-> sets + (assoc "$themes.json" themes) + (assoc "$metadata.json" {"tokenSetOrder" (get-ordered-set-names tokens-lib) + "activeThemes" active-themes + "activeSets" (get-active-themes-set-names tokens-lib)})))) + +(defn export-dtcg-json + "Convert a TokensLib into a plain clojure map, suitable to be encoded as a multi sets json string in DTCG format." + [tokens-lib] + (let [{:keys [themes active-themes]} (dtcg-export-themes tokens-lib) name-set-tuples (->> (get-set-tree tokens-lib) (tree-seq d/ordered-map? vals) (filter (partial instance? TokenSet)) (map (fn [{:keys [name tokens]}] - [name (tokens-tree tokens :update-token-fn update-token-fn)]))) + [name (tokens-tree tokens :update-token-fn token->dtcg-token)]))) ordered-set-names (mapv first name-set-tuples) @@ -1471,9 +1491,9 @@ Will return a value that matches this schema: (-> sets (assoc "$themes" themes) - (assoc-in ["$metadata" "tokenSetOrder"] ordered-set-names) - (assoc-in ["$metadata" "activeThemes"] active-themes-clear) - (assoc-in ["$metadata" "activeSets"] active-set-names)))) + (assoc "$metadata" {"tokenSetOrder" ordered-set-names + "activeThemes" active-themes + "activeSets" active-set-names})))) (defn get-tokens-of-unknown-type "Search for all tokens in the decoded json file that have a type that is not currently diff --git a/common/test/common_tests/types/tokens_lib_test.cljc b/common/test/common_tests/types/tokens_lib_test.cljc index d7023e6db0..095c044048 100644 --- a/common/test/common_tests/types/tokens_lib_test.cljc +++ b/common/test/common_tests/types/tokens_lib_test.cljc @@ -1507,3 +1507,56 @@ "$type" "color" "$description" ""}}}}}] (t/is (= expected result))))) + +#?(:clj + (t/deftest export-dtcg-multi-file + (let [now (dt/now) + tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :name "some/set" + :tokens {"colors.red.600" + (ctob/make-token + {:name "colors.red.600" + :type :color + :value "#e53e3e"}) + "spacing.multi-value" + (ctob/make-token + {:name "spacing.multi-value" + :type :spacing + :value "{dimension.sm} {dimension.xl}" + :description "You can have multiple values in a single spacing token"}) + "button.primary.background" + (ctob/make-token + {:name "button.primary.background" + :type :color + :value "{accent.default}"})})) + (ctob/add-theme (ctob/make-token-theme :name "theme-1" + :group "group-1" + :external-id "test-id-01" + :modified-at now + :sets #{"some/set"})) + (ctob/toggle-theme-active? "group-1" "theme-1")) + result (ctob/export-dtcg-multi-file tokens-lib) + expected {"$themes.json" [{"description" "" + "group" "group-1" + "is-source" false + "modified-at" now + "id" "test-id-01" + "name" "theme-1" + "selectedTokenSets" {"some/set" "enabled"}}] + "$metadata.json" {"tokenSetOrder" ["some/set"] + "activeThemes" #{"group-1/theme-1"} + "activeSets" #{"some/set"}} + "some/set.json" + {"colors" {"red" {"600" {"$value" "#e53e3e" + "$type" "color" + "$description" ""}}} + "spacing" + {"multi-value" + {"$value" "{dimension.sm} {dimension.xl}" + "$type" "spacing" + "$description" "You can have multiple values in a single spacing token"}} + "button" + {"primary" {"background" {"$value" "{accent.default}" + "$type" "color" + "$description" ""}}}}}] + (t/is (= expected result))))) diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index 31784f222b..797907024c 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -33,6 +33,7 @@ [app.main.ui.workspace.sidebar.collapsable-button :refer [collapsed-button]] [app.main.ui.workspace.sidebar.history :refer [history-toolbox*]] [app.main.ui.workspace.tokens.modals] + [app.main.ui.workspace.tokens.modals.export] [app.main.ui.workspace.tokens.modals.import] [app.main.ui.workspace.tokens.modals.settings] [app.main.ui.workspace.tokens.modals.themes] diff --git a/frontend/src/app/main/ui/workspace/tokens/modals/export.cljs b/frontend/src/app/main/ui/workspace/tokens/modals/export.cljs new file mode 100644 index 0000000000..f925ea6a95 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/modals/export.cljs @@ -0,0 +1,144 @@ +;; 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.main.ui.workspace.tokens.modals.export + (:require-macros [app.main.style :as stl]) + (:require + [app.common.json :as json] + [app.common.types.tokens-lib :as ctob] + [app.main.data.modal :as modal] + [app.main.refs :as refs] + [app.main.ui.components.code-block :refer [code-block]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*]] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]] + [app.util.dom :as dom] + [app.util.i18n :refer [tr]] + [app.util.webapi :as wapi] + [app.util.zip :as zip] + [rumext.v2 :as mf])) + +(mf/defc export-tab* + {::mf/private true} + [{:keys [on-export is-disabled children]}] + [:div {:class (stl/css :export-preview)} + (when-not is-disabled + [:> text* {:as "span" :typography "body-medium" :class (stl/css :preview-label)} + (tr "workspace.tokens.export.preview")]) + (if is-disabled + [:div {:class (stl/css :disabled-message)} + (tr "workspace.tokens.export.no-tokens-themes-sets")] + children) + [:div {:class (stl/css :export-actions)} + [:> button* {:variant "secondary" + :type "button" + :on-click modal/hide!} + (tr "labels.cancel")] + [:> button* {:variant "primary" + :type "button" + :disabled is-disabled + :on-click on-export} + (tr "workspace.tokens.export")]]]) + +(mf/defc single-file-tab* + {::mf/private true} + [] + (let [tokens-data (some-> (deref refs/tokens-lib) + (ctob/export-dtcg-json)) + tokens-json (some-> tokens-data + (json/encode :key-fn identity :indent 2)) + is-disabled (empty? tokens-data) + on-export + (mf/use-fn + (mf/deps tokens-json) + (fn [] + (when tokens-json + (->> (wapi/create-blob (or tokens-json "{}") "application/json") + (dom/trigger-download "tokens.json")))))] + [:> export-tab* {:is-disabled is-disabled + :on-export on-export} + [:div {:class (stl/css :json-preview)} + [:> code-block {:code tokens-json :type "json"}]]])) + +(defn download-tokens-zip! [multi-file-entries] + (let [writer (-> (zip/blob-writer {:mtype "application/zip"}) + (zip/writer))] + (doseq [[path content] multi-file-entries] + (zip/add writer path (json/encode content :key-fn identity :indent 2))) + (-> (zip/close writer) + (.then #(dom/trigger-download "tokens.zip" %))))) + +(mf/defc multi-file-tab* + {::mf/private true} + [] + (let [files (some->> (deref refs/tokens-lib) + (ctob/export-dtcg-multi-file)) + is-disabled (or (empty? files) + (every? (fn [[_ v]] (empty? v)) files)) + on-export + (mf/use-fn + (mf/deps files) + (fn [] + (download-tokens-zip! files)))] + [:> export-tab* {:on-export on-export + :is-disabled is-disabled} + [:div {:class (stl/css :preview-container)} + [:ul {:class (stl/css :file-list)} + (for [[path] files] + [:li {:key path + :class (stl/css :file-item)} + [:div {:class (stl/css :file-icon)} + [:> icon* {:icon-id "document"}]] + [:div {:class (stl/css :file-name) :title path} + path]])]]])) + +(mf/defc export-modal-body* + {::mf/private true} + [] + (let [selected-tab (mf/use-state "single-file") + + on-change-tab + (mf/use-fn + (fn [tab-id] + (reset! selected-tab tab-id))) + + single-file-content + (mf/html [:> single-file-tab*]) + + multiple-files-content + (mf/html [:> multi-file-tab*]) + + tabs #js [#js {:label (tr "workspace.tokens.export.single-file") + :id "single-file" + :content single-file-content} + #js {:label (tr "workspace.tokens.export.multiple-files") + :id "multiple-files" + :content multiple-files-content}]] + + [:div {:class (stl/css :export-modal-wrapper)} + [:> heading* {:level 2 :typography "headline-medium" :class (stl/css :export-modal-title)} + (tr "workspace.tokens.export-tokens")] + + [:> tab-switcher* + {:tabs tabs + :selected @selected-tab + :on-change-tab on-change-tab}]])) + +(mf/defc export-modal* + {::mf/register modal/components + ::mf/register-as :tokens/export} + [] + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-dialog)} + [:> icon-button* {:class (stl/css :close-btn) + :on-click modal/hide! + :aria-label (tr "labels.close") + :variant "ghost" + :icon "close"}] + [:> export-modal-body*]]]) diff --git a/frontend/src/app/main/ui/workspace/tokens/modals/export.scss b/frontend/src/app/main/ui/workspace/tokens/modals/export.scss new file mode 100644 index 0000000000..f38db8616a --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/modals/export.scss @@ -0,0 +1,125 @@ +// 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 + +@use "../../../ds/typography.scss" as t; +@use "../../../ds/_sizes.scss" as *; +@use "../../../ds/_borders.scss" as *; +@import "refactor/common-refactor.scss"; + +.modal-overlay { + @extend .modal-overlay-base; +} + +.modal-dialog { + --modal-width: 32rem; + --modal-padding: var(--sp-xxxl); + --container-max-height: 16rem; + @extend .modal-container-base; + user-select: none; + width: var(--modal-width); + max-width: 100%; +} + +.export-modal-wrapper { + display: flex; + flex-direction: column; + gap: var(--sp-xxl); +} + +.export-modal-title { + color: var(--color-foreground-primary); +} + +.export-preview { + display: flex; + flex-direction: column; + gap: var(--sp-m); + padding-top: var(--sp-m); +} + +.preview-label { + color: var(--color-foreground-secondary); +} + +.preview-container { + border: $b-1 solid var(--color-background-quaternary); + border-radius: $br-8; + overflow-y: auto; + padding: var(--sp-xs) var(--sp-m); + max-height: var(--container-max-height); +} + +.file-list { + width: 100%; + margin-bottom: 0; + overflow-y: auto; +} + +.file-item { + display: flex; + align-items: center; + width: 100%; + cursor: default; + color: var(--color-foreground-secondary); + border: $br-2 solid transparent; +} + +.file-icon { + flex-shrink: 0; + display: flex; + align-items: center; +} + +.file-name { + @include textEllipsis; + @include t.use-typography("body-medium"); + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: var(--sp-s); +} + +.export-actions { + display: flex; + justify-content: flex-end; + gap: var(--sp-s); +} + +.close-btn { + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-s); +} + +.json-preview { + width: 100%; +} + +.json-preview pre { + border: $b-1 solid var(--color-background-quaternary); + border-radius: $br-8; + margin: 0; + max-height: var(--container-max-height); + overflow-y: auto; + overflow-x: auto; + word-wrap: normal; + white-space: pre; + max-width: calc(var(--modal-width) - var(--modal-padding) * 2); +} + +.disabled-message { + @include t.use-typography("body-small"); + color: var(--color-foreground-secondary); + display: flex; + align-items: center; + justify-content: center; + border: $b-1 solid var(--color-background-quaternary); + border-radius: $br-8; + overflow-y: auto; + padding: var(--sp-s) var(--sp-m); + max-height: var(--container-max-height); +} diff --git a/frontend/src/app/main/ui/workspace/tokens/sidebar.cljs b/frontend/src/app/main/ui/workspace/tokens/sidebar.cljs index 1d1ce5f634..223e5e2279 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sidebar.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sidebar.cljs @@ -8,7 +8,6 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] - [app.common.json :as json] [app.common.types.tokens-lib :as ctob] [app.config :as cf] [app.main.data.event :as ev] @@ -37,7 +36,6 @@ [app.util.array :as array] [app.util.dom :as dom] [app.util.i18n :refer [tr]] - [app.util.webapi :as wapi] [okulary.core :as l] [potok.v2.core :as ptk] [rumext.v2 :as mf] @@ -387,11 +385,7 @@ (mf/use-fn (fn [] (st/emit! (ptk/data-event ::ev/event {::ev/name "export-tokens"})) - (let [tokens-json (some-> (deref refs/tokens-lib) - (ctob/export-dtcg-json) - (json/encode :key-fn identity))] - (->> (wapi/create-blob (or tokens-json "{}") "application/json") - (dom/trigger-download "tokens.json"))))) + (modal/show! :tokens/export {}))) on-modal-show (mf/use-fn diff --git a/frontend/translations/en.po b/frontend/translations/en.po index b49b757f73..5d59b9e37a 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -7249,6 +7249,30 @@ msgstr "Importing a JSON file will override all your current tokens, sets and th msgid "workspace.tokens.import-warning" msgstr "Importing tokens will override all your current tokens, sets and themes." +#: src/app/main/ui/workspace/tokens/modals/export.cljs:74 +msgid "workspace.tokens.export" +msgstr "Export" + +#: src/app/main/ui/workspace/tokens/modals/export.cljs:47 +msgid "workspace.tokens.export-tokens" +msgstr "Export tokens" + +#: src/app/main/ui/workspace/tokens/modals/export.cljs:51 +msgid "workspace.tokens.export.single-file" +msgstr "Single file" + +#: src/app/main/ui/workspace/tokens/modals/export.cljs:54 +msgid "workspace.tokens.export.multiple-files" +msgstr "Multiple files" + +#: src/app/main/ui/workspace/tokens/modals/export.cljs:60 +msgid "workspace.tokens.export.preview" +msgstr "Preview:" + +#: src/app/main/ui/workspace/tokens/modals/export.cljs:37 +msgid "workspace.tokens.export.no-tokens-themes-sets" +msgstr "There are no tokens, themes or sets to export." + #: src/app/main/ui/workspace/tokens/sidebar.cljs:341 msgid "workspace.tokens.inactive-set" msgstr "Inactive" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 0eb403f648..65a3b8d2ea 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -7265,6 +7265,26 @@ msgstr "Al importar un fichero JSON sobreescribirás todos tus tokens, sets y th msgid "workspace.tokens.import-warning" msgstr "Al importar tokens sobreescribirás todos tus tokens, sets y themes." +#: src/app/main/ui/workspace/tokens/modals/export.cljs:74 +msgid "workspace.tokens.export" +msgstr "Exportar" + +#: src/app/main/ui/workspace/tokens/modals/export.cljs:51 +msgid "workspace.tokens.export.single-file" +msgstr "fichero único" + +#: src/app/main/ui/workspace/tokens/modals/export.cljs:54 +msgid "workspace.tokens.export.multiple-files" +msgstr "Múltiples ficheros" + +#: src/app/main/ui/workspace/tokens/modals/export.cljs:60 +msgid "workspace.tokens.export.preview" +msgstr "Previsualizar:" + +#: src/app/main/ui/workspace/tokens/modals/export.cljs:37 +msgid "workspace.tokens.export.no-tokens-themes-sets" +msgstr "No existen tokens, temas o sets para exportar." + #: src/app/main/ui/workspace/tokens/sidebar.cljs:341 msgid "workspace.tokens.inactive-set" msgstr "Inactivo"