2024-03-11 18:43:56 +01:00

530 lines
17 KiB
Clojure

;; 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.dashboard.import
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.logging :as log]
[app.main.data.dashboard :as dd]
[app.main.data.events :as ev]
[app.main.data.messages :as msg]
[app.main.data.modal :as modal]
[app.main.errors :as errors]
[app.main.features :as features]
[app.main.store :as st]
[app.main.ui.components.file-uploader :refer [file-uploader]]
[app.main.ui.icons :as i]
[app.main.ui.notifications.context-notification :refer [context-notification]]
[app.main.worker :as uw]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
(log/set-level! :debug)
(def ^:const emit-delay 1000)
(defn use-import-file
[project-id on-finish-import]
(mf/use-fn
(mf/deps project-id on-finish-import)
(fn [entries]
(let [entries (->> entries
(mapv (fn [file]
{:name (.-name file)
:uri (wapi/create-uri file)}))
(not-empty))]
(when entries
(st/emit! (modal/show
{:type :import
:project-id project-id
:entries entries
:on-finish-import on-finish-import})))))))
(mf/defc import-form
{::mf/forward-ref true
::mf/props :obj}
[{:keys [project-id on-finish-import]} external-ref]
(let [on-file-selected (use-import-file project-id on-finish-import)]
[:form.import-file {:aria-hidden "true"}
[:& file-uploader {:accept ".penpot,.zip"
:multi true
:ref external-ref
:on-selected on-file-selected}]]))
(defn- update-entry-name
[entries file-id new-name]
(mapv (fn [entry]
(let [new-name (str/trim new-name)]
(cond-> entry
(and (= (:file-id entry) file-id)
(not= "" new-name))
(assoc :name new-name))))
entries))
(defn- remove-entry
[entries file-id]
(mapv (fn [entry]
(cond-> entry
(= (:file-id entry) file-id)
(assoc :deleted true)))
entries))
(defn- update-with-analyze-error
[entries uri error]
(->> entries
(mapv (fn [entry]
(cond-> entry
(= uri (:uri entry))
(-> (assoc :status :analyze-error)
(assoc :error error)))))))
(defn- update-with-analyze-result
[entries uri type result]
(let [existing-entries? (into #{} (keep :file-id) entries)
replace-entry
(fn [entry]
(if (and (= uri (:uri entry))
(= (:status entry) :analyzing))
(->> (:files result)
(remove (comp existing-entries? first))
(map (fn [[file-id file-data]]
(-> file-data
(assoc :file-id file-id)
(assoc :status :ready)
(assoc :uri uri)
(assoc :type type)))))
[entry]))]
(into [] (mapcat replace-entry) entries)))
(defn- mark-entries-importing
[entries]
(->> entries
(filter #(= :ready (:status %)))
(mapv #(assoc % :status :importing))))
(defn- update-entry-status
[entries file-id status progress errors]
(mapv (fn [entry]
(cond-> entry
(and (= file-id (:file-id entry)) (not= status :import-progress))
(assoc :status status)
(and (= file-id (:file-id entry)) (= status :import-progress))
(assoc :progress progress)
(= file-id (:file-id entry))
(assoc :errors errors)))
entries))
(defn- parse-progress-message
[message]
(case (:type message)
:upload-data
(tr "dashboard.import.progress.upload-data" (:current message) (:total message))
:upload-media
(tr "dashboard.import.progress.upload-media" (:file message))
:process-page
(tr "dashboard.import.progress.process-page" (:file message))
:process-colors
(tr "dashboard.import.progress.process-colors")
:process-typographies
(tr "dashboard.import.progress.process-typographies")
:process-media
(tr "dashboard.import.progress.process-media")
:process-components
(tr "dashboard.import.progress.process-components")
(str message)))
(defn- has-status-importing?
[item]
(= (:status item) :importing))
(defn- has-status-analyzing?
[item]
(= (:status item) :analyzing))
(defn- has-status-analyze-error?
[item]
(= (:status item) :analyzing))
(defn- has-status-success?
[item]
(and (= (:status item) :import-finish)
(empty? (:errors item))))
(defn- has-status-error?
[item]
(and (= (:status item) :import-finish)
(d/not-empty? (:errors item))))
(defn- has-status-ready?
[item]
(and (= :ready (:status item))
(not (:deleted item))))
(defn- analyze-entries
[state entries]
(->> (uw/ask-many!
{:cmd :analyze-import
:files entries
:features @features/features-ref})
(rx/mapcat #(rx/delay emit-delay (rx/of %)))
(rx/filter some?)
(rx/subs!
(fn [{:keys [uri data error type] :as msg}]
(if (some? error)
(swap! state update-with-analyze-error uri error)
(swap! state update-with-analyze-result uri type data))))))
(defn- import-files!
[state project-id entries]
(st/emit! (ptk/data-event ::ev/event {::ev/name "import-files"
:num-files (count entries)}))
(->> (uw/ask-many!
{:cmd :import-files
:project-id project-id
:files entries
:features @features/features-ref})
(rx/subs!
(fn [{:keys [file-id status message errors] :as msg}]
(swap! state update-entry-status file-id status message errors)))))
(mf/defc import-entry
{::mf/props :obj
::mf/memo true
::mf/private true}
[{:keys [entries entry edition can-be-deleted on-edit on-change on-delete]}]
(let [status (:status entry)
loading? (or (= :analyzing status)
(= :importing status))
analyze-error? (= :analyze-error status)
import-finish? (= :import-finish status)
import-error? (= :import-error status)
import-warn? (d/not-empty? (:errors entry))
ready? (= :ready status)
is-shared? (:shared entry)
progress (:progress entry)
file-id (:file-id entry)
editing? (and (some? file-id) (= edition file-id))
on-edit-key-press
(mf/use-fn
(fn [event]
(when (or (kbd/enter? event)
(kbd/esc? event))
(dom/prevent-default event)
(dom/stop-propagation event)
(dom/blur! (dom/get-target event)))))
on-edit-blur
(mf/use-fn
(mf/deps file-id on-change)
(fn [event]
(let [value (dom/get-target-val event)]
(on-change file-id value event))))
on-edit'
(mf/use-fn
(mf/deps file-id on-change)
(fn [event]
(when (fn? on-edit)
(on-edit file-id event))))
on-delete'
(mf/use-fn
(mf/deps file-id on-delete)
(fn [event]
(when (fn? on-delete)
(on-delete file-id event))))]
[:div {:class (stl/css-case
:file-entry true
:loading loading?
:success (and import-finish? (not import-warn?) (not import-error?))
:warning (and import-finish? import-warn? (not import-error?))
:error (or import-error? analyze-error?)
:editable (and ready? (not editing?)))}
[:div {:class (stl/css :file-name)}
[:div {:class (stl/css-case :file-icon true
:icon-fill ready?)}
(cond loading? i/loader-pencil
ready? i/logo-icon
import-warn? i/msg-warning
import-error? i/close
import-finish? i/tick
analyze-error? i/close)]
(if editing?
[:div {:class (stl/css :file-name-edit)}
[:input {:type "text"
:auto-focus true
:default-value (:name entry)
:on-key-press on-edit-key-press
:on-blur on-edit-blur}]]
[:div {:class (stl/css :file-name-label)}
(:name entry)
(when ^boolean is-shared?
[:span {:class (stl/css :icon)}
i/library])])
[:div {:class (stl/css :edit-entry-buttons)}
(when (and (= "application/zip" (:type entry))
(= status :ready))
[:button {:on-click on-edit'} i/curve])
(when can-be-deleted
[:button {:on-click on-delete'} i/delete])]]
(cond
analyze-error?
[:div {:class (stl/css :error-message)}
(if (some? (:error entry))
(tr (:error entry))
(tr "dashboard.import.analyze-error"))]
import-error?
[:div {:class (stl/css :error-message)}
(tr "dashboard.import.import-error")]
(and (not import-finish?) (some? progress))
[:div {:class (stl/css :progress-message)} (parse-progress-message progress)])
[:div {:class (stl/css :linked-libraries)}
(for [library-id (:libraries entry)]
(let [library-data (d/seek #(= library-id (:file-id %)) entries)
error? (or (:deleted library-data)
(:import-error library-data))]
(when (some? library-data)
[:div {:class (stl/css :linked-library)
:key (dm/str library-id)}
(:name library-data)
[:span {:class (stl/css-case
:linked-library-tag true
:error error?)}
i/detach]])))]]))
(mf/defc import-dialog
{::mf/register modal/components
::mf/register-as :import
::mf/props :obj}
[{:keys [project-id entries template on-finish-import]}]
(mf/with-effect []
;; dispose uris when the component is umount
(fn [] (run! wapi/revoke-uri (map :uri entries))))
(let [entries* (mf/use-state
(fn [] (mapv #(assoc % :status :analyzing) entries)))
entries (deref entries*)
status* (mf/use-state :analyzing)
status (deref status*)
edition* (mf/use-state nil)
edition (deref edition*)
template-finished* (mf/use-state nil)
template-finished (deref template-finished*)
on-template-cloned-success
(mf/use-fn
(fn []
(reset! status* :importing)
(reset! template-finished* true)
(st/emit! (dd/fetch-recent-files))))
on-template-cloned-error
(mf/use-fn
(fn [cause]
(reset! status* :error)
(reset! template-finished* true)
(errors/print-error! cause)
(rx/of (modal/hide)
(msg/error (tr "dashboard.libraries-and-templates.import-error")))))
continue-entries
(mf/use-fn
(mf/deps entries)
(fn []
(let [entries (filterv has-status-ready? entries)]
(swap! status* (constantly :importing))
(swap! entries* mark-entries-importing)
(import-files! entries* project-id entries))))
continue-template
(mf/use-fn
(mf/deps on-template-cloned-success
on-template-cloned-error
template)
(fn []
(let [mdata {:on-success on-template-cloned-success
:on-error on-template-cloned-error}
params {:project-id project-id :template-id (:id template)}]
(swap! status* (constantly :importing))
(st/emit! (dd/clone-template (with-meta params mdata))))))
on-edit
(mf/use-fn
(fn [file-id _event]
(swap! edition* (constantly file-id))))
on-entry-change
(mf/use-fn
(fn [file-id value]
(swap! edition* (constantly nil))
(swap! entries* update-entry-name file-id value)))
on-entry-delete
(mf/use-fn
(fn [file-id]
(swap! entries* remove-entry file-id)))
on-cancel
(mf/use-fn
(mf/deps edition)
(fn [event]
(when (nil? edition)
(dom/prevent-default event)
(st/emit! (modal/hide)))))
on-continue
(mf/use-fn
(mf/deps template
continue-template
continue-entries)
(fn [event]
(dom/prevent-default event)
(if (some? template)
(continue-template)
(continue-entries))))
on-accept
(mf/use-fn
(mf/deps on-finish-import)
(fn [event]
(dom/prevent-default event)
(st/emit! (modal/hide))
(when (fn? on-finish-import)
(on-finish-import))))
entries (filterv (comp not :deleted) entries)
num-importing (+ (count (filterv has-status-importing? entries))
(if (some? template) 1 0))
success-num (if (some? template)
1
(count (filterv has-status-success? entries)))
errors? (if (some? template)
(= status :error)
(or (some has-status-error? entries)
(zero? (count entries))))
pending-analysis? (some has-status-analyzing? entries)
pending-import? (and (or (nil? template)
(not template-finished))
(pos? num-importing))
valid-all-entries? (or (some? template)
(not (some has-status-analyze-error? entries)))
template-status
(cond
(and (= :importing status) pending-import?)
:importing
(and (= :importing status) (not ^boolean pending-import?))
:import-finish
:else
:ready)]
;; Run analyze operation on component mount
(mf/with-effect []
(let [sub (analyze-entries entries* entries)]
(partial rx/dispose! sub)))
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)} (tr "dashboard.import")]
[:button {:class (stl/css :modal-close-btn)
:on-click on-cancel} i/close]]
[:div {:class (stl/css :modal-content)}
(when (and (= :analyzing status) errors?)
[:& context-notification
{:type :warning
:content (tr "dashboard.import.import-warning")}])
(when (and (= :importing status) (not ^boolean pending-import?))
(cond
errors?
[:& context-notification
{:type :warning
:content (tr "dashboard.import.import-warning")}]
:else
[:& context-notification
{:type :success
:content (tr "dashboard.import.import-message" (i18n/c success-num))}]))
(for [entry entries]
[:& import-entry {:edition edition
:key (dm/str (:uri entry))
:entry entry
:entries entries
:on-edit on-edit
:on-change on-entry-change
:on-delete on-entry-delete
:can-be-deleted (> (count entries) 1)}])
(when (some? template)
[:& import-entry {:entry (assoc template :status template-status)
:can-be-deleted false}])]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
(when (= :analyzing status)
[:input {:class (stl/css :cancel-button)
:type "button"
:value (tr "labels.cancel")
:on-click on-cancel}])
(when (and (= :analyzing status) (not errors?))
[:input {:class (stl/css :accept-btn)
:type "button"
:value (tr "labels.continue")
:disabled (or pending-analysis? (not valid-all-entries?))
:on-click on-continue}])
(when (and (= :importing status) (not errors?))
[:input {:class (stl/css :accept-btn)
:type "button"
:value (tr "labels.accept")
:disabled (or pending-import? (not valid-all-entries?))
:on-click on-accept}])]]]]))