diff --git a/frontend/src/app/main/ui/workspace/libraries.cljs b/frontend/src/app/main/ui/workspace/libraries.cljs index 5c09d951ff..71c7f32405 100644 --- a/frontend/src/app/main/ui/workspace/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/libraries.cljs @@ -107,6 +107,58 @@ :token-sets token-sets :token-themes token-themes})) +(defn sort-linked-libraries + "Given the linked libraries of a file, each with a `:connected-to` + collection of the ids of the files that use it, return the libraries + ordered as a tree: each library is followed by the libraries nested + under it, marked with `:nested?` and `:parent-name`. + + Direct libraries (`:is-indirect` false) and libraries not used by + any other linked library are roots. Every library is returned + exactly once, at any depth and even when the dependencies form a + cycle." + [libraries] + (let [by-id (d/index-by :id libraries) + sort-by-name (partial sort-by (comp str/lower :name)) + + parents + (d/update-vals by-id + (fn [{:keys [id connected-to]}] + (into #{} + (filter #(and (not= % id) + (contains? by-id %))) + connected-to))) + + root? + (fn [{:keys [id is-indirect]}] + (or (false? is-indirect) + (empty? (get parents id)))) + + children + (fn [parent-id] + (->> libraries + (remove root?) + (filter #(contains? (get parents (:id %)) parent-id)) + (sort-by-name))) + + visit + (fn visit [[result visited :as acc] library parent] + (if (contains? visited (:id library)) + acc + (reduce #(visit %1 %2 library) + [(conj result (assoc library + :nested? (some? parent) + :parent-name (:name parent))) + (conj visited (:id library))] + (children (:id library)))))] + + ;; The second pass picks up libraries only reachable through a + ;; cycle, which have no root to hang from. + (as-> [[] #{}] $ + (reduce #(visit %1 %2 nil) $ (sort-by-name (filter root? libraries))) + (reduce #(visit %1 %2 nil) $ (sort-by-name libraries)) + (first $)))) + (defn- describe-library [components-count graphics-count colors-count typography-count] (let [all-zero? (and (zero? components-count) @@ -666,11 +718,6 @@ dependencies (mf/with-memo [shared-libraries] (into {} (map (juxt :id :library-file-ids) (vals shared-libraries)))) - library-names (mf/with-memo [shared-libraries] - (into {} (map (fn [{:keys [id name]}] - [id name]) - (vals shared-libraries)))) - find-connected-to (mf/use-fn (mf/deps dependencies) @@ -679,36 +726,10 @@ (keep (fn [[k v]] (when (contains? v library-id) k)))))) linked-libraries - (mf/with-memo [linked-libraries find-connected-to library-names] - (let [libs - (->> (vals linked-libraries) - (map #(assoc % :connected-to (find-connected-to (:id %))))) - - linked-ids - (into #{} (map :id) libs) - - sort-by-name - (partial sort-by (comp str/lower :name)) - - parent-id - (fn [{:keys [connected-to]}] - (first (filter linked-ids connected-to))) - - nested-by-parent - (->> libs - (filter parent-id) - (map #(assoc % - :nested? true - :parent-name (library-names (parent-id %)))) - (group-by parent-id) - (d/mapm (fn [_ v] (sort-by-name v))))] - - (->> libs - (remove parent-id) - (map #(assoc % :nested? false)) - (sort-by-name) - (mapcat (fn [{:keys [id] :as library}] - (cons library (get nested-by-parent id []))))))) + (mf/with-memo [linked-libraries find-connected-to] + (->> (vals linked-libraries) + (map #(assoc % :connected-to (find-connected-to (:id %)))) + (sort-linked-libraries))) linked-libraries-ids (mf/with-memo [linked-libraries] diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 493dddeb42..6a897ee8a1 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -120,6 +120,7 @@ [frontend-tests.ui.shortcuts-labels-test] [frontend-tests.ui.sidebar-scroll-test] [frontend-tests.ui.stroke-menu-test] + [frontend-tests.ui.workspace-libraries-test] [frontend-tests.util-clipboard-test] [frontend-tests.util-object-test] [frontend-tests.util-queue-test] @@ -260,6 +261,7 @@ 'frontend-tests.ui.shortcuts-labels-test 'frontend-tests.ui.sidebar-scroll-test 'frontend-tests.ui.stroke-menu-test + 'frontend-tests.ui.workspace-libraries-test 'frontend-tests.util-clipboard-test 'frontend-tests.util-object-test 'frontend-tests.util-queue-test diff --git a/frontend/test/frontend_tests/ui/workspace_libraries_test.cljs b/frontend/test/frontend_tests/ui/workspace_libraries_test.cljs new file mode 100644 index 0000000000..1ed82c488f --- /dev/null +++ b/frontend/test/frontend_tests/ui/workspace_libraries_test.cljs @@ -0,0 +1,101 @@ +;; 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 SUBSIDIARY SL + +(ns frontend-tests.ui.workspace-libraries-test + (:require + [app.main.ui.workspace.libraries :as wl] + [cljs.test :as t :include-macros true])) + +(defn- lib + "A linked library; `connected-to` lists the ids of the files using it." + [id indirect? connected-to] + {:id id :name (name id) :is-indirect indirect? :connected-to connected-to}) + +(defn- summarize + [libraries] + (mapv (juxt :id :nested? :parent-name) libraries)) + +(t/deftest sort-linked-libraries-cycle-with-direct-root + ;; file -> App, Foundations; App -> Foundations; + ;; Foundations <-> Comments (cycle, as left by an import) + (let [libraries [(lib :foundations false [:file :app :comments]) + (lib :app false [:file]) + (lib :comments true [:foundations])] + result (wl/sort-linked-libraries libraries)] + (t/is (= [[:app false nil] + [:foundations false nil] + [:comments true "foundations"]] + (summarize result))) + (t/is (= (count libraries) (count result))))) + +(t/deftest sort-linked-libraries-cycle-without-root + ;; The direct flag is still unknown (nil) while the shared files load, + ;; so both libraries are only reachable through the cycle. `:app` is + ;; not a linked library, so it is ignored as a parent. + (let [libraries [(lib :foundations nil [:app :comments]) + (lib :comments nil [:foundations])] + result (wl/sort-linked-libraries libraries)] + (t/is (= [[:comments false nil] + [:foundations true "comments"]] + (summarize result))) + (t/is (= (count libraries) (count result))))) + +(t/deftest sort-linked-libraries-cycle-of-three-without-root + ;; a -> b -> c -> a, all indirect: no root, nothing is lost + (let [libraries [(lib :c true [:b]) + (lib :b true [:a]) + (lib :a true [:c])] + result (wl/sort-linked-libraries libraries)] + (t/is (= [[:a false nil] + [:b true "a"] + [:c true "b"]] + (summarize result))) + (t/is (= (count libraries) (count result))))) + +(t/deftest sort-linked-libraries-deep-chain + ;; file -> a -> b -> c + (let [libraries [(lib :c true [:b]) + (lib :b true [:a]) + (lib :a false [:file])]] + (t/is (= [[:a false nil] + [:b true "a"] + [:c true "b"]] + (summarize (wl/sort-linked-libraries libraries)))))) + +(t/deftest sort-linked-libraries-diamond + ;; a -> b, c; b -> d; c -> d: d is listed once, under its first parent + (let [libraries [(lib :d true [:b :c]) + (lib :c true [:a]) + (lib :b true [:a]) + (lib :a false [:file])] + result (wl/sort-linked-libraries libraries)] + (t/is (= [[:a false nil] + [:b true "a"] + [:d true "b"] + [:c true "a"]] + (summarize result))) + (t/is (= (count libraries) (count result))))) + +(t/deftest sort-linked-libraries-self-dependency + (let [libraries [(lib :a true [:a])]] + (t/is (= [[:a false nil]] + (summarize (wl/sort-linked-libraries libraries)))))) + +(t/deftest sort-linked-libraries-parent-not-linked + ;; The only user of each library is not a linked library, so both are roots + (let [libraries [(lib :b true [:other]) + (lib :a true [:file])]] + (t/is (= [[:a false nil] + [:b false nil]] + (summarize (wl/sort-linked-libraries libraries)))))) + +(t/deftest sort-linked-libraries-without-dependencies + ;; Before get-team-shared-files answers, all libraries are flat + (let [libraries [(lib :b true nil) + (lib :a false nil)]] + (t/is (= [[:a false nil] + [:b false nil]] + (summarize (wl/sort-linked-libraries libraries))))))