Import/export UI and final touches

This commit is contained in:
alonso.torres 2021-07-01 17:47:32 +02:00 committed by Andrés Moya
parent 1b1c0ff9e4
commit d0ab813520
12 changed files with 856 additions and 117 deletions

View file

@ -13,8 +13,10 @@
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.context :as ctx]
[app.main.ui.dashboard.export]
[app.main.ui.dashboard.files :refer [files-section]]
[app.main.ui.dashboard.fonts :refer [fonts-page font-providers-page]]
[app.main.ui.dashboard.import]
[app.main.ui.dashboard.libraries :refer [libraries-page]]
[app.main.ui.dashboard.projects :refer [projects-section]]
[app.main.ui.dashboard.search :refer [search-page]]
@ -131,4 +133,3 @@
:section section
:search-term search-term
:team team}])])]]))

View file

@ -0,0 +1,92 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) UXBOX Labs SL
(ns app.main.ui.dashboard.export
(:require
[app.common.data :as d]
[app.main.data.modal :as modal]
[app.main.store :as st]
[app.main.ui.icons :as i]
[app.main.worker :as uw]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[beicon.core :as rx]
[rumext.alpha :as mf]))
(def ^:const options [:all :merge :detach])
(mf/defc export-dialog
{::mf/register modal/components
::mf/register-as :export}
[{:keys [team-id files]}]
(let [selected-option (mf/use-state :all)
cancel-fn
(mf/use-callback
(fn [event]
(dom/prevent-default event)
(st/emit! (modal/hide))))
accept-fn
(mf/use-callback
(mf/deps @selected-option)
(fn [event]
(dom/prevent-default event)
(->> (uw/ask-many!
{:cmd :export-file
:team-id team-id
:export-type @selected-option
:files files})
(rx/subs
(fn [msg]
(when (= :finish (:type msg))
(dom/trigger-download-uri (:filename msg) (:mtype msg) (:uri msg))))))
(st/emit! (modal/hide))))
on-change-handler
(mf/use-callback
(fn [_ type]
(reset! selected-option type)))]
[:div.modal-overlay
[:div.modal-container.export-dialog
[:div.modal-header
[:div.modal-header-title
[:h2 (tr "dashboard.export.title")]]
[:div.modal-close-button
{:on-click cancel-fn} i/close]]
[:div.modal-content
[:p.explain (tr "dashboard.export.explain")]
[:p.detail (tr "dashboard.export.detail")]
(for [type [:all :merge :detach]]
(let [selected? (= @selected-option type)]
[:div.export-option {:class (when selected? "selected")}
[:label.option-container
[:h3 (tr (str "dashboard.export.options." (d/name type) ".title"))]
[:p (tr (str "dashboard.export.options." (d/name type) ".message"))]
[:input {:type "radio"
:checked selected?
:on-change #(on-change-handler % type)
:name "export-option"}]
[:span {:class "option-radio-check"}]]]))]
[:div.modal-footer
[:div.action-buttons
[:input.cancel-button
{:type "button"
:value (tr "labels.cancel")
:on-click cancel-fn}]
[:input.accept-button
{:class "primary"
:type "button"
:value (tr "labels.export")
:on-click accept-fn}]]]]]))

View file

@ -13,8 +13,6 @@
[app.main.store :as st]
[app.main.ui.components.context-menu :refer [context-menu]]
[app.main.ui.context :as ctx]
[app.main.worker :as uw]
[app.util.debug :as d]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.router :as rt]
@ -158,18 +156,11 @@
on-export-files
(fn [_]
(->> (uw/ask-many!
{:cmd :export-file
:team-id current-team-id
:files (->> files (mapv :id))})
(rx/subs
(fn [msg]
(case (:type msg)
:progress
(prn "[Progress]" (:data msg))
:finish
(dom/trigger-download-uri (:filename msg) (:mtype msg) (:uri msg)))))))]
(st/emit!
(modal/show
{:type :export
:team-id current-team-id
:files (->> files (mapv :id))})))]
(mf/use-effect
(fn []
@ -195,8 +186,7 @@
[[(tr "dashboard.duplicate-multi" file-count) on-duplicate]
(when (or (seq current-projects) (seq other-teams))
[(tr "dashboard.move-to-multi" file-count) nil sub-options])
(when (d/debug? :export)
[(tr "dashboard.export-multi" file-count) on-export-files])
[(tr "dashboard.export-multi" file-count) on-export-files]
[:separator]
[(tr "labels.delete-multi-files" file-count) on-delete]]
@ -208,8 +198,7 @@
(if (:is-shared file)
[(tr "dashboard.remove-shared") on-del-shared]
[(tr "dashboard.add-shared") on-add-shared])
(when (d/debug? :export)
[(tr "dashboard.export-single") on-export-files])
[(tr "dashboard.export-single") on-export-files]
[:separator]
[(tr "labels.delete") on-delete]])]

View file

@ -6,37 +6,41 @@
(ns app.main.ui.dashboard.import
(:require
[app.common.data :as d]
[app.main.data.modal :as modal]
[app.main.store :as st]
[app.main.ui.components.file-uploader :refer [file-uploader]]
[app.main.ui.icons :as i]
[app.main.worker :as uw]
[app.util.data :refer [classnames]]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.logging :as log]
[beicon.core :as rx]
[rumext.alpha :as mf]))
(log/set-level! :debug)
(defn rx-delay-emit [ms ob]
(->> ob (rx/mapcat #(rx/delay ms (rx/of %)))))
(defn use-import-file
[project-id on-finish-import]
(mf/use-callback
(mf/deps project-id on-finish-import)
(fn [files]
(when files
(let [files (->> files (mapv dom/create-uri))]
(->> (uw/ask-many!
{:cmd :import-file
:project-id project-id
:files files})
(rx/subs
(fn [result]
(log/debug :action "import-result" :result result))
(fn [err]
(log/debug :action "import-error" :result err))
(fn []
(log/debug :action "import-end")
(when on-finish-import (on-finish-import))))))))))
(let [files (->> files
(mapv
(fn [file]
{:name (.-name file)
:uri (dom/create-uri file)})))]
(st/emit! (modal/show
{:type :import
:project-id project-id
:files files
:on-finish-import on-finish-import})))))))
(mf/defc import-form
{::mf/forward-ref true}
@ -49,6 +53,264 @@
:ref external-ref
:on-selected on-file-selected}]]))
(defn update-file [files file-id new-name]
(->> files
(mapv
(fn [file]
(cond-> file
(= (:file-id file) file-id)
(assoc :name new-name))))))
(defn remove-file [files file-id]
(->> files
(mapv
(fn [file]
(cond-> file
(= (:file-id file) file-id)
(assoc :deleted? true))))))
(defn set-analyze-error
[files uri]
(->> files
(mapv (fn [file]
(cond-> file
(= uri (:uri file))
(assoc :status :analyze-error))))))
(defn set-analyze-result [files uri data]
(let [exiting-files? (into #{} (->> files (map :file-id) (filter some?)))
replace-file
(fn [file]
(if (and (= uri (:uri file) )
(= (:status file) :analyzing))
(->> (:files data)
(remove (comp exiting-files? first) )
(mapv (fn [[file-id file-data]]
(-> file-data
(assoc :file-id file-id
:status :ready
:uri uri)))))
[file]))]
(into [] (mapcat replace-file) files)))
(defn mark-files-importing [files]
(->> files
(filter #(= :ready (:status %)))
(mapv #(assoc % :status :importing))))
(defn update-status [files file-id status]
(->> files
(mapv (fn [file]
(cond-> file
(= file-id (:file-id file))
(assoc :status status))))))
(mf/defc import-entry
[{:keys [state file editing?]}]
(let [loading? (or (= :analyzing (:status file))
(= :importing (:status file)))
load-success? (= :import-success (:status file))
analyze-error? (= :analyze-error (:status file))
import-error? (= :import-error (:status file))
ready? (= :ready (:status file))
is-shared? (:shared file)
handle-edit-key-press
(mf/use-callback
(fn [e]
(when (or (kbd/enter? e) (kbd/esc? e))
(dom/prevent-default e)
(dom/stop-propagation e)
(dom/blur! (dom/get-target e)))))
handle-edit-blur
(mf/use-callback
(mf/deps file)
(fn [e]
(let [value (dom/get-target-val e)]
(swap! state #(-> (assoc % :editing nil)
(update :files update-file (:file-id file) value))))))
handle-edit-entry
(mf/use-callback
(mf/deps file)
(fn []
(swap! state assoc :editing (:file-id file))))
handle-remove-entry
(mf/use-callback
(mf/deps file)
(fn []
(swap! state update :files remove-file (:file-id file))))]
[:div.file-entry
{:class (classnames :loading loading?
:success load-success?
:error (or import-error? analyze-error?)
:editable (and ready? (not editing?)))}
[:div.file-name
[:div.file-icon
(cond loading? i/loader-pencil
ready? i/logo-icon
load-success? i/tick
import-error? i/close
analyze-error? i/close)]
(if editing?
[:div.file-name-edit
[:input {:type "text"
:auto-focus true
:default-value (:name file)
:on-key-press handle-edit-key-press
:on-blur handle-edit-blur}]]
[:div.file-name-label (:name file) (when is-shared? i/library)])
[:div.edit-entry-buttons
[:button {:on-click handle-edit-entry} i/pencil]
[:button {:on-click handle-remove-entry} i/trash]]]
(when analyze-error?
[:div.error-message
(tr "dashboard.import.analyze-error")])
(when import-error?
[:div.error-message
(tr "dashboard.import.import-error")])
[:div.linked-libraries
(for [library-id (:libraries file)]
(let [library-data (->> @state :files (d/seek #(= library-id (:file-id %))))
error? (or (:deleted? library-data) (:import-error library-data))]
(when (some? library-data)
[:div.linked-library-tag {:class (when error? "error")}
(if error? i/unchain i/chain) (:name library-data)])))]]))
(mf/defc import-dialog
{::mf/register modal/components
::mf/register-as :import}
[{:keys [project-id files on-finish-import]}]
(let [state (mf/use-state
{:status :analyzing
:editing nil
:files (->> files
(mapv #(assoc % :status :analyzing)))})
analyze-import
(mf/use-callback
(fn [files]
(->> (uw/ask-many!
{:cmd :analyze-import
:files (->> files (mapv :uri))})
(rx-delay-emit 1000)
(rx/subs
(fn [{:keys [uri data error] :as msg}]
(log/debug :msg msg)
(if (some? error)
(swap! state update :files set-analyze-error uri)
(swap! state update :files set-analyze-result uri data)))))))
import-files
(mf/use-callback
(fn [project-id files]
(->> (uw/ask-many!
{:cmd :import-files
:project-id project-id
:files files})
(rx-delay-emit 1000)
(rx/subs
(fn [{:keys [file-id status] :as msg}]
(log/debug :msg msg)
(swap! state update :files update-status file-id status))))))
handle-cancel
(mf/use-callback
(mf/deps (:editing @state))
(fn [event]
(when (nil? (:editing @state))
(dom/prevent-default event)
(st/emit! (modal/hide)))))
handle-continue
(mf/use-callback
(mf/deps project-id (:files @state))
(fn [event]
(dom/prevent-default event)
(let [files (->> @state :files (filterv #(= :ready (:status %))))]
(import-files project-id files))
(swap! state
(fn [state]
(-> state
(assoc :status :importing)
(update :files mark-files-importing))))))
handle-accept
(mf/use-callback
(fn [event]
(dom/prevent-default event)
(st/emit! (modal/hide))
(when on-finish-import (on-finish-import))))
success-files (->> @state :files (filter #(= (:status %) :import-success)) count)
pending-analysis? (> (->> @state :files (filter #(= (:status %) :analyzing)) count) 0)
pending-import? (> (->> @state :files (filter #(= (:status %) :importing)) count) 0)]
(mf/use-effect
(fn []
(let [sub (analyze-import files)]
#(rx/dispose! sub))))
(mf/use-effect
(fn []
;; dispose uris when the component is umount
#(doseq [file files]
(dom/revoke-uri (:uri file)))))
[:div.modal-overlay
[:div.modal-container.import-dialog
[:div.modal-header
[:div.modal-header-title
[:h2 (tr "dashboard.import")]]
[:div.modal-close-button
{:on-click handle-cancel} i/close]]
[:div.modal-content
(when (and (= :importing (:status @state))
(not pending-import?))
[:div.feedback-banner
[:div.icon i/checkbox-checked]
[:div.message (tr "dashboard.import.import-message" success-files)]])
(for [file (->> (:files @state) (filterv (comp not :deleted?)))]
(let [editing? (and (some? (:file-id file))
(= (:file-id file) (:editing @state)))]
[:& import-entry {:state state
:file file
:editing? editing?}]))]
[:div.modal-footer
[:div.action-buttons
[:input.cancel-button
{:type "button"
:value (tr "labels.cancel")
:on-click handle-cancel}]
(when (= :analyzing (:status @state))
[:input.accept-button
{:class "primary"
:type "button"
:value (tr "labels.continue")
:disabled pending-analysis?
:on-click handle-continue}])
(when (= :importing (:status @state))
[:input.accept-button
{:class "primary"
:type "button"
:value (tr "labels.accept")
:disabled pending-import?
:on-click handle-accept}])]]]]))

View file

@ -14,7 +14,6 @@
[app.main.ui.components.context-menu :refer [context-menu]]
[app.main.ui.context :as ctx]
[app.main.ui.dashboard.import :as udi]
[app.util.debug :as d]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.router :as rt]
@ -107,8 +106,7 @@
[(tr "dashboard.move-to") nil
(for [team teams]
[(:name team) (on-move (:id team))])])
(when (d/debug? :import)
[(tr "dashboard.import") on-import-files])
[(tr "dashboard.import") on-import-files]
[:separator]
[(tr "labels.delete") on-delete]]}]]))

View file

@ -9,8 +9,9 @@
(defmacro icon-xref
[id]
(let [href (str "#icon-" (name id))]
(let [href (str "#icon-" (name id))
class (str "icon-" (name id))]
`(rumext.alpha/html
[:svg {:width 500 :height 500}
[:svg {:width 500 :height 500 :class ~class}
[:use {:xlinkHref ~href}]])))

View file

@ -15,4 +15,4 @@
(mf/defc loader
[]
(when (mf/deref st/loader)
[:div.loader-content i/loader]))
[:div.loader-content i/loader-pencil]))

View file

@ -213,6 +213,10 @@
[node]
(.focus node))
(defn blur!
[node]
(.blur node))
(defn fullscreen?
[]
(cond

View file

@ -26,7 +26,7 @@
(defn create-manifest
"Creates a manifest entry for the given files"
[team-id file-id files]
[team-id file-id export-type files]
(letfn [(format-page [manifest page]
(-> manifest
(assoc (str (:id page))
@ -47,6 +47,7 @@
:pages pages
:pagesIndex index
:libraries (->> (:libraries file) (into #{}) (mapv str))
:exportType (d/name export-type)
:hasComponents (d/not-empty? (get-in file [:data :components]))
:hasMedia (d/not-empty? (get-in file [:data :media]))
:hasColors (d/not-empty? (get-in file [:data :colors]))
@ -158,8 +159,36 @@
(-> file
(assoc :libraries libraries-ids)))))))
(defn merge-assets [target-file assets-files]
(let [merge-file-assets
(fn [target file]
(-> target
(update-in [:data :colors] merge (get-in file [:data :colors]))
(update-in [:data :typographies] merge (get-in file [:data :typographies]))
(update-in [:data :media] merge (get-in file [:data :media]))
(update-in [:data :components] merge (get-in file [:data :components]))))]
(->> assets-files
(reduce merge-file-assets target-file))))
(defn detach-libraries
[files file-id]
files)
(defn process-export
[file-id export-type files]
(case export-type
:all files
:merge (let [file-list (-> files (d/without-keys [file-id]) vals)]
(-> (select-keys files [file-id])
(update file-id merge-assets file-list)
(update file-id dissoc :libraries)))
:detach (-> (select-keys files [file-id])
(update file-id detach-libraries file-id))))
(defn collect-files
[file-id]
[file-id export-type]
(letfn [(fetch-dependencies [[files pending]]
(if (empty? pending)
@ -185,17 +214,18 @@
(->> (rx/of [files pending])
(rx-expand fetch-dependencies)
(rx/last)
(rx/map first)))))
(rx/map first)
(rx/map #(process-export file-id export-type %))))))
(defn export-file
[team-id file-id]
[team-id file-id export-type]
(let [files-stream (->> (collect-files file-id)
(let [files-stream (->> (collect-files file-id export-type)
(rx/share))
manifest-stream
(->> files-stream
(rx/map #(create-manifest team-id file-id %))
(rx/map #(create-manifest team-id file-id export-type %))
(rx/map #(vector "manifest.json" %)))
render-stream
@ -258,10 +288,10 @@
(rx/map #(vector (get files file-id) %)))))))))
(defmethod impl/handler :export-file
[{:keys [team-id files] :as message}]
[{:keys [team-id files export-type] :as message}]
(->> (rx/from files)
(rx/mapcat #(export-file team-id %))
(rx/mapcat #(export-file team-id % export-type))
(rx/map
(fn [value]
(if (contains? value :type)

View file

@ -84,25 +84,26 @@
(let [id-mapping-atom (atom {})
resolve
(fn [id-mapping id]
(assert (uuid? id))
(assert (uuid? id) (str id))
(get id-mapping id))
set-id
(fn [id-mapping id]
(assert (uuid? id))
(assert (uuid? id) (str id))
(cond-> id-mapping
(nil? (resolve id-mapping id))
(assoc id (uuid/next))))]
(fn [id]
(swap! id-mapping-atom set-id id)
(resolve @id-mapping-atom id))))
(when (some? id)
(swap! id-mapping-atom set-id id)
(resolve @id-mapping-atom id)))))
(defn create-file
"Create a new file on the back-end"
[context file-id]
[context]
(let [resolve (:resolve context)
file-id (resolve file-id)]
file-id (resolve (:file-id context))]
(rp/mutation
:create-temp-file
{:id file-id
@ -111,19 +112,19 @@
:project-id (:project-id context)
:data (-> cp/empty-file-data (assoc :id file-id))})))
(defn persist-file [file]
(rp/mutation :persist-temp-file {:id (:id file)}))
(defn link-file-libraries
"Create a new file on the back-end"
[context file-id]
[context]
(let [resolve (:resolve context)
file-id (resolve file-id)
file-id (resolve (:file-id context))
libraries (->> context :libraries (mapv resolve))]
(->> (rx/from libraries)
(rx/map #(hash-map :file-id file-id :library-id %))
(rx/flat-map (partial rp/mutation :link-file-to-library)))))
(defn persist-file [file]
(rp/mutation :persist-temp-file {:id (:id file)}))
(defn send-changes
"Creates batches of changes to be sent to the backend"
[file]
@ -391,65 +392,59 @@
(rx/flat-map (partial process-library-typographies context))
(rx/flat-map (partial process-library-media context))
(rx/flat-map (partial process-library-components context))
(rx/flat-map send-changes)
(rx/ignore)))
(rx/flat-map send-changes)))
(defn create-files [context manifest]
(->> manifest :files rx/from
(defn create-files
[context files]
(let [data (group-by :file-id files)]
(rx/concat
(->> (rx/from files)
(rx/map #(merge context %))
(rx/flat-map
(fn [context]
(->> (create-file context)
(rx/map #(vector % (first (get data (:file-id context)))))))))
(->> (rx/from files)
(rx/map #(merge context %))
(rx/flat-map link-file-libraries)
(rx/ignore)))))
(defmethod impl/handler :analyze-import
[{:keys [files]}]
(->> (rx/from files)
(rx/flat-map
(fn [[file-id file-desc]]
(create-file (merge context file-desc) file-id)))
(rx/reduce #(assoc %1 (:id %2) %2) {})))
(fn [uri]
(->> (rx/of uri)
(rx/flat-map uz/load-from-url)
(rx/flat-map #(get-file {:zip %} :manifest))
(rx/map (comp d/kebab-keys cip/string->uuid))
(rx/map #(hash-map :uri uri :data %))
(rx/catch #(rx/of {:uri uri :error (.-message %)})))))))
(defn link-libraries [context manifest]
(->> manifest :files rx/from
(rx/flat-map
(fn [[file-id file-desc]]
(link-file-libraries (merge context file-desc) file-id)))))
(defn process-files [context manifest files]
(->> manifest :files rx/from
(rx/flat-map
(fn [[file-id file-desc]]
(let [resolve (:resolve context)
context (-> context
(merge file-desc)
(assoc :file-id file-id))
file (get files (resolve file-id))]
(process-file context file))))))
(defn process-package
[context]
(->> (get-file context :manifest)
(rx/map (comp d/kebab-keys cip/string->uuid))
;; Create the temporary files
(rx/mapcat (fn [manifest]
(->> (create-files context manifest)
(rx/map #(vector manifest %)))))
;; Set-up the files dependencies
(rx/mapcat (fn [[manifest files]]
(rx/concat
(link-libraries context manifest)
(rx/of [manifest files]))))
;; Creates files data
(rx/mapcat (fn [[manifest files]]
(process-files context manifest files)))
;; Mark temporary files as persisted
(rx/mapcat persist-file)))
(defmethod impl/handler :import-file
(defmethod impl/handler :import-files
[{:keys [project-id files]}]
(let [context {:project-id project-id
:resolve (resolve-factory)}]
(->> (rx/from files)
(rx/flat-map uz/load-from-url)
(rx/map #(assoc context :zip %))
(rx/flat-map process-package)
(rx/catch
(fn [err]
(.error js/console "ERROR" err (clj->js (.-data err))))))))
(->> (create-files context files)
(rx/catch #(.error js/console "IMPORT ERROR" %))
(rx/flat-map
(fn [[file data]]
(->> (uz/load-from-url (:uri data))
(rx/map #(-> context (assoc :zip %) (merge data)))
(rx/flat-map #(process-file % file))
(rx/map
(fn [_]
{:status :import-success
:file-id (:file-id data)}))
(rx/catch
(fn [err]
(.error js/console "ERROR" (:file-id data) err)
(rx/of {:status :import-error
:file-id (:file-id data)
:error (.-message err)
:error-data (clj->js (.-data err))})))))))))