From 7eb590e9fdf0c583b67d548fbba30695bd081757 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 30 Jul 2025 08:48:37 +0200 Subject: [PATCH] :recycle: Refactor versions sidebar --- common/src/app/common/types/profile.cljc | 23 + frontend/src/app/main/data/profile.cljs | 11 +- .../src/app/main/data/workspace/versions.cljs | 16 +- frontend/src/app/main/ui/ds.cljs | 9 +- .../src/app/main/ui/ds/product/avatar.cljs | 51 +- .../app/main/ui/ds/product/avatar.stories.jsx | 16 +- .../{user_milestone.cljs => milestone.cljs} | 69 +- .../{user_milestone.scss => milestone.scss} | 0 .../main/ui/ds/product/milestone.stories.jsx | 59 ++ ...ed_milestone.cljs => milestone_group.cljs} | 66 +- ...ed_milestone.scss => milestone_group.scss} | 0 ...tories.jsx => milestone_group.stories.jsx} | 17 +- .../ui/ds/product/user_milestone.stories.jsx | 61 -- .../main/ui/workspace/sidebar/versions.cljs | 591 +++++++++--------- 14 files changed, 511 insertions(+), 478 deletions(-) create mode 100644 common/src/app/common/types/profile.cljc rename frontend/src/app/main/ui/ds/product/{user_milestone.cljs => milestone.cljs} (54%) rename frontend/src/app/main/ui/ds/product/{user_milestone.scss => milestone.scss} (100%) create mode 100644 frontend/src/app/main/ui/ds/product/milestone.stories.jsx rename frontend/src/app/main/ui/ds/product/{autosaved_milestone.cljs => milestone_group.cljs} (55%) rename frontend/src/app/main/ui/ds/product/{autosaved_milestone.scss => milestone_group.scss} (100%) rename frontend/src/app/main/ui/ds/product/{autosaved_milestone.stories.jsx => milestone_group.stories.jsx} (55%) delete mode 100644 frontend/src/app/main/ui/ds/product/user_milestone.stories.jsx diff --git a/common/src/app/common/types/profile.cljc b/common/src/app/common/types/profile.cljc new file mode 100644 index 0000000000..24272cab67 --- /dev/null +++ b/common/src/app/common/types/profile.cljc @@ -0,0 +1,23 @@ +;; 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.common.types.profile + (:require + [app.common.schema :as sm] + [app.common.time :as cm])) + +(def schema:profile + [:map {:title "Profile"} + [:id ::sm/uuid] + [:created-at {:optional true} ::cm/inst] + [:fullname {:optional true} :string] + [:email {:optional true} :string] + [:lang {:optional true} :string] + [:theme {:optional true} :string] + [:photo-id {:optional true} ::sm/uuid] + ;; Only present on resolved profile objects, the resolve process + ;; takes the photo-id or geneates an image from the name + [:photo-url {:optional true} :string]]) diff --git a/frontend/src/app/main/data/profile.cljs b/frontend/src/app/main/data/profile.cljs index 75a42c0e26..223942df86 100644 --- a/frontend/src/app/main/data/profile.cljs +++ b/frontend/src/app/main/data/profile.cljs @@ -9,6 +9,7 @@ [app.common.data :as d] [app.common.schema :as sm] [app.common.spec :as us] + [app.common.types.profile :refer [schema:profile]] [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.event :as ev] @@ -27,16 +28,6 @@ ;; --- SCHEMAS -(def ^:private - schema:profile - [:map {:title "Profile"} - [:id ::sm/uuid] - [:created-at {:optional true} :any] - [:fullname {:optional true} :string] - [:email {:optional true} :string] - [:lang {:optional true} :string] - [:theme {:optional true} :string]]) - (def check-profile (sm/check-fn schema:profile)) diff --git a/frontend/src/app/main/data/workspace/versions.cljs b/frontend/src/app/main/data/workspace/versions.cljs index ce17291f7a..dd46c3cf16 100644 --- a/frontend/src/app/main/data/workspace/versions.cljs +++ b/frontend/src/app/main/data/workspace/versions.cljs @@ -27,9 +27,9 @@ (declare fetch-versions) -(defn init-version-state +(defn init-versions-state [] - (ptk/reify ::init-version-state + (ptk/reify ::init-versions-state ptk/UpdateEvent (update [_ state] (assoc state :workspace-versions default-state)) @@ -38,9 +38,9 @@ (watch [_ _ _] (rx/of (fetch-versions))))) -(defn update-version-state +(defn update-versions-state [version-state] - (ptk/reify ::update-version-state + (ptk/reify ::update-versions-state ptk/UpdateEvent (update [_ state] (update state :workspace-versions merge version-state)))) @@ -52,7 +52,7 @@ (watch [_ state _] (when-let [file-id (:current-file-id state)] (->> (rp/cmd! :get-file-snapshots {:file-id file-id}) - (rx/map #(update-version-state {:status :loaded :data %}))))))) + (rx/map #(update-versions-state {:status :loaded :data %}))))))) (defn create-version [] @@ -73,7 +73,7 @@ (rx/mapcat #(rp/cmd! :create-file-snapshot {:file-id file-id :label label})) (rx/mapcat (fn [{:keys [id]}] - (rx/of (update-version-state {:editing id}) + (rx/of (update-versions-state {:editing id}) (fetch-versions)))))))))) (defn rename-version @@ -86,7 +86,7 @@ (watch [_ state _] (let [file-id (:current-file-id state)] (rx/merge - (rx/of (update-version-state {:editing false}) + (rx/of (update-versions-state {:editing nil}) (ptk/event ::ev/event {::ev/name "rename-version" :file-id file-id})) (->> (rp/cmd! :update-file-snapshot {:id id :label label}) @@ -144,7 +144,7 @@ (->> (rp/cmd! :update-file-snapshot params) (rx/mapcat (fn [_] - (rx/of (update-version-state {:editing id}) + (rx/of (update-versions-state {:editing id}) (fetch-versions) (ptk/event ::ev/event {::ev/name "pin-version"}))))))))) diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs index 0fddb71e97..33eaff367f 100644 --- a/frontend/src/app/main/ui/ds.cljs +++ b/frontend/src/app/main/ui/ds.cljs @@ -27,13 +27,13 @@ [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] [app.main.ui.ds.notifications.shared.notification-pill :refer [notification-pill*]] [app.main.ui.ds.notifications.toast :refer [toast*]] - [app.main.ui.ds.product.autosaved-milestone :refer [autosaved-milestone*]] [app.main.ui.ds.product.avatar :refer [avatar*]] [app.main.ui.ds.product.cta :refer [cta*]] [app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]] [app.main.ui.ds.product.input-with-meta :refer [input-with-meta*]] [app.main.ui.ds.product.loader :refer [loader*]] - [app.main.ui.ds.product.user-milestone :refer [user-milestone*]] + [app.main.ui.ds.product.milestone :refer [milestone*]] + [app.main.ui.ds.product.milestone-group :refer [milestone-group*]] [app.main.ui.ds.storybook :as sb] [app.main.ui.ds.tooltip.tooltip :refer [tooltip*]] [app.main.ui.ds.utilities.date :refer [date*]] @@ -41,7 +41,6 @@ [app.util.i18n :as i18n] [rumext.v2 :as mf])) - (i18n/init! cf/translations) (def default @@ -72,8 +71,8 @@ :Swatch swatch* :Cta cta* :Avatar avatar* - :AutosavedMilestone autosaved-milestone* - :UserMilestone user-milestone* + :Milestone milestone* + :MilestoneGroup milestone-group* :Date date* ;; meta / misc :meta diff --git a/frontend/src/app/main/ui/ds/product/avatar.cljs b/frontend/src/app/main/ui/ds/product/avatar.cljs index fe52a47ab5..86a995966d 100644 --- a/frontend/src/app/main/ui/ds/product/avatar.cljs +++ b/frontend/src/app/main/ui/ds/product/avatar.cljs @@ -9,37 +9,44 @@ [app.main.style :as stl]) (:require [app.common.data :as d] + [app.common.schema :as sm] + [app.common.types.profile :refer [schema:profile]] + [app.config :as cfg] [app.util.avatars :as avatars] - [rumext.v2 :as mf])) + [rumext.v2 :as mf] + [rumext.v2.util :as mfu])) (def ^:private schema:avatar [:map [:class {:optional true} :string] [:tag {:optional true} :string] - [:name {:optional true} [:maybe :string]] - [:url {:optional true} [:maybe :string]] - [:color {:optional true} [:maybe :string]] + [:profile schema:profile] [:selected {:optional true} :boolean] [:variant {:optional true} [:maybe [:enum "S" "M" "L"]]]]) -(mf/defc avatar* - {::mf/schema schema:avatar} +(defn- get-url + [{:keys [photo-url photo-id fullname]}] + (or photo-url + (some-> photo-id cfg/resolve-media) + (avatars/generate {:name fullname}))) - [{:keys [tag class name color url selected variant] :rest props}] - (let [variant (or variant "S") - url (if (and (some? url) (d/not-empty? url)) - url - (avatars/generate {:name name :color color}))] - [:> (or tag "div") - {:class (d/append-class - class - (stl/css-case :avatar true - :avatar-small (= variant "S") - :avatar-medium (= variant "M") - :avatar-large (= variant "L") - :is-selected selected)) - :style {"--avatar-color" color} - :title name} +(mf/defc avatar* + {::mf/schema (sm/schema schema:avatar)} + [{:keys [tag class profile selected variant]}] + (let [variant (d/nilv variant "S") + profile (if (object? profile) + (mfu/bean profile) + profile) + href (mf/with-memo [profile] + (get-url profile)) + class' (stl/css-case :avatar true + :avatar-small (= variant "S") + :avatar-medium (= variant "M") + :avatar-large (= variant "L") + :is-selected selected)] + [:> (d/nilv tag "div") + {:class [class class'] + :title (:fullname profile)} [:div {:class (stl/css :avatar-image)} - [:img {:alt name :src url}]]])) + [:img {:alt (:fullname profile) :src href}]]])) diff --git a/frontend/src/app/main/ui/ds/product/avatar.stories.jsx b/frontend/src/app/main/ui/ds/product/avatar.stories.jsx index b6e6f72dfa..6bbecd3448 100644 --- a/frontend/src/app/main/ui/ds/product/avatar.stories.jsx +++ b/frontend/src/app/main/ui/ds/product/avatar.stories.jsx @@ -13,9 +13,6 @@ export default { url: { control: { type: "text" }, }, - color: { - control: { type: "color" }, - }, variant: { options: ["S", "M", "L"], control: { type: "select" }, @@ -27,11 +24,20 @@ export default { args: { name: "Ada Lovelace", url: "/images/avatar-blue.jpg", - color: "#79d4ff", variant: "S", selected: false, }, - render: ({ ...args }) => , + render: ({name, url, ...args }) => { + const profile = { + id: "00000000-0000-0000-0000-000000000000", + fullname: name + }; + if (url) { + profile.photoUrl = url; + }; + + return ; + } }; export const Default = {}; diff --git a/frontend/src/app/main/ui/ds/product/user_milestone.cljs b/frontend/src/app/main/ui/ds/product/milestone.cljs similarity index 54% rename from frontend/src/app/main/ui/ds/product/user_milestone.cljs rename to frontend/src/app/main/ui/ds/product/milestone.cljs index 94ba862b3a..177acf17c2 100644 --- a/frontend/src/app/main/ui/ds/product/user_milestone.cljs +++ b/frontend/src/app/main/ui/ds/product/milestone.cljs @@ -4,83 +4,84 @@ ;; ;; Copyright (c) KALEIDOS INC -(ns app.main.ui.ds.product.user-milestone +(ns app.main.ui.ds.product.milestone (:require-macros [app.main.style :as stl]) (:require + [app.common.schema :as sm] [app.common.time :as ct] + [app.common.types.profile :refer [schema:profile]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.controls.input :refer [input*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.foundations.typography :as t] [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.product.avatar :refer [avatar*]] - [app.main.ui.ds.utilities.date :refer [valid-date?]] [app.util.i18n :as i18n :refer [tr]] - [app.util.object :as obj] [rumext.v2 :as mf])) +(def ^:private schema:callback + [:maybe [:fn fn?]]) + (def ^:private schema:milestone [:map [:class {:optional true} :string] [:active {:optional true} :boolean] [:editing {:optional true} :boolean] [:locked {:optional true} :boolean] - [:user - [:map - [:name {:optional true} [:maybe :string]] - [:avatar {:optional true} [:maybe :string]] - [:color {:optional true} [:maybe :string]]]] + [:profile {:optional true} schema:profile] [:label :string] - [:date [:fn valid-date?]] - [:onOpenMenu {:optional true} [:maybe [:fn fn?]]] - [:onFocusInput {:optional true} [:maybe [:fn fn?]]] - [:onBlurInput {:optional true} [:maybe [:fn fn?]]] - [:onKeyDownInput {:optional true} [:maybe [:fn fn?]]]]) + [:created-at ::ct/inst] + [:on-open-menu {:optional true} schema:callback] + [:on-focus-menu {:optional true} schema:callback] + [:on-blur-menu {:optional true} schema:callback] + [:on-key-down-input {:optional true} schema:callback]]) -(mf/defc user-milestone* - {::mf/schema schema:milestone} - [{:keys [class active editing locked user label date - onOpenMenu onFocusInput onBlurInput onKeyDownInput] :rest props}] - (let [class' (stl/css-case :milestone true - :is-selected active) - props (mf/spread-props props {:class [class class'] - :data-testid "milestone"}) - date (if (ct/inst? date) - date - (ct/inst date))] +(mf/defc milestone* + {::mf/schema (sm/schema schema:milestone)} + [{:keys [class active editing locked label created-at profile + on-open-menu on-focus-input on-blur-input on-key-down-input] :rest props}] + (let [class' + (stl/css-case :milestone true + :is-selected active) + props + (mf/spread-props props + {:class [class class'] + :data-testid "milestone"}) + created-at + (if (ct/inst? created-at) + created-at + (ct/inst created-at))] [:> :div props - [:> avatar* {:name (obj/get user "name") - :url (obj/get user "avatar") - :color (obj/get user "color") + [:> avatar* {:profile profile :variant "S" :class (stl/css :avatar)}] - (if editing + (if ^boolean editing [:> input* {:class (stl/css :name-input) :variant "seamless" :default-value label :auto-focus true - :on-focus onFocusInput - :on-blur onBlurInput - :on-key-down onKeyDownInput}] + :on-focus on-focus-input + :on-blur on-blur-input + :on-key-down on-key-down-input}] [:div {:class (stl/css :name-wrapper)} [:> text* {:as "span" :typography t/body-small :class (stl/css :name)} label] (when locked [:> i/icon* {:icon-id i/lock :class (stl/css :lock-icon)}])]) [:* - [:time {:date-time (ct/format-inst date :iso) + [:time {:date-time (ct/format-inst created-at :iso) :class (stl/css :date)} - (ct/timeago date)] + (ct/timeago created-at)] [:div {:class (stl/css :milestone-buttons)} [:> icon-button* {:class (stl/css :menu-button) :variant "ghost" :icon "menu" :aria-label (tr "workspace.versions.version-menu") - :on-click onOpenMenu}]]]])) + :on-click on-open-menu}]]]])) diff --git a/frontend/src/app/main/ui/ds/product/user_milestone.scss b/frontend/src/app/main/ui/ds/product/milestone.scss similarity index 100% rename from frontend/src/app/main/ui/ds/product/user_milestone.scss rename to frontend/src/app/main/ui/ds/product/milestone.scss diff --git a/frontend/src/app/main/ui/ds/product/milestone.stories.jsx b/frontend/src/app/main/ui/ds/product/milestone.stories.jsx new file mode 100644 index 0000000000..1af1762f26 --- /dev/null +++ b/frontend/src/app/main/ui/ds/product/milestone.stories.jsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import Components from "@target/components"; + +const { Milestone } = Components; + +export default { + title: "Product/Milestones/Milestone", + component: Milestone, + + argTypes: { + profileName: { + control: { type: "text" }, + }, + profileAvatar: { + control: { type: "text" }, + }, + label: { + control: { type: "text" }, + }, + createdAt: { + control: { type: "date" }, + }, + active: { + control: { type: "boolean" }, + }, + editing: { + control: { type: "boolean" }, + }, + locked: { + control: { type: "boolean" }, + }, + }, + args: { + label: "Milestone 1", + profileName: "Ada Lovelace", + profileAvatar: "/images/avatar-blue.jpg", + createdAt: 1735686000000, + active: false, + editing: false, + }, + render: ({ profileName, profileAvatar, profileColor, createdAt, ...args }) => { + const profile = { + id: "00000000-0000-0000-0000-000000000000", + fullname: profileName + }; + + if (profileAvatar) { + profile.photoUrl = profileAvatar; + } + + if (createdAt instanceof Number) { + createdAt = new Date(createdAt); + } + + return ; + }, +}; + +export const Default = {}; diff --git a/frontend/src/app/main/ui/ds/product/autosaved_milestone.cljs b/frontend/src/app/main/ui/ds/product/milestone_group.cljs similarity index 55% rename from frontend/src/app/main/ui/ds/product/autosaved_milestone.cljs rename to frontend/src/app/main/ui/ds/product/milestone_group.cljs index c3eb610edf..41a2694108 100644 --- a/frontend/src/app/main/ui/ds/product/autosaved_milestone.cljs +++ b/frontend/src/app/main/ui/ds/product/milestone_group.cljs @@ -4,58 +4,78 @@ ;; ;; Copyright (c) KALEIDOS INC -(ns app.main.ui.ds.product.autosaved-milestone +(ns app.main.ui.ds.product.milestone-group (:require-macros - [app.common.data.macros :as dm] [app.main.style :as stl]) (:require [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.schema :as sm] + [app.common.time :as cm] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.foundations.typography :as t] [app.main.ui.ds.foundations.typography.text :refer [text*]] - [app.main.ui.ds.utilities.date :refer [date* valid-date?]] + [app.main.ui.ds.utilities.date :refer [date*]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) -(def ^:private schema:milestone +(def ^:private schema:milestone-group [:map [:class {:optional true} :string] [:active {:optional true} :boolean] - [:versionToggled {:optional true} :boolean] [:label :string] - [:autosavedMessage :string] - [:snapshots [:vector [:fn valid-date?]]]]) + [:snapshots [:vector ::cm/inst]]]) -(mf/defc autosaved-milestone* - {::mf/schema schema:milestone} - [{:keys [class active versionToggled label autosavedMessage snapshots - onClickSnapshotMenu onToggleExpandSnapshots] :rest props}] - (let [class (d/append-class class (stl/css-case :milestone true :is-selected active)) - props (mf/spread-props props {:class class :data-testid "milestone"}) +(mf/defc milestone-group* + {::mf/schema (sm/schema schema:milestone-group)} + [{:keys [class active label snapshots on-menu-click] :rest props}] + (let [class' + (stl/css-case :milestone true + :is-selected active) - handle-click-menu + props + (mf/spread-props props + {:class [class class'] + :data-testid "milestone"}) + + open* + (mf/use-state false) + + open? + (deref open*) + + on-toggle-visibility + (mf/use-fn (fn [] (swap! open* not))) + + on-menu-click (mf/use-fn - (mf/deps onClickSnapshotMenu) + (mf/deps on-menu-click) (fn [event] (let [index (-> (dom/get-current-target event) (dom/get-data "index") (d/parse-integer))] - (when onClickSnapshotMenu - (onClickSnapshotMenu event index)))))] - [:> "div" props + (when (fn? on-menu-click) + (on-menu-click index event)))))] + + [:> :div props [:> text* {:as "span" :typography t/body-small :class (stl/css :name)} label] [:div {:class (stl/css :snapshots)} [:button {:class (stl/css :toggle-snapshots) :aria-label (tr "workspace.versions.expand-snapshot") - :on-click onToggleExpandSnapshots} + :on-click on-toggle-visibility} [:> i/icon* {:icon-id i/clock :class (stl/css :icon-clock)}] - [:> text* {:as "span" :typography t/body-medium :class (stl/css :toggle-message)} autosavedMessage] - [:> i/icon* {:icon-id i/arrow :class (stl/css-case :icon-arrow true :icon-arrow-toggled versionToggled)}]] + [:> text* {:as "span" + :typography t/body-medium + :class (stl/css :toggle-message)} + (tr "workspace.versions.autosaved.entry" (count snapshots))] + [:> i/icon* {:icon-id i/arrow + :class (stl/css-case :icon-arrow true + :icon-arrow-toggled open?)}]] - (when versionToggled + (when ^boolean open? (for [[idx d] (d/enumerate snapshots)] [:div {:key (dm/str "entry-" idx) :class (stl/css :version-entry)} @@ -65,5 +85,5 @@ :icon "menu" :aria-label (tr "workspace.versions.version-menu") :data-index idx - :on-click handle-click-menu}]]))]])) + :on-click on-menu-click}]]))]])) diff --git a/frontend/src/app/main/ui/ds/product/autosaved_milestone.scss b/frontend/src/app/main/ui/ds/product/milestone_group.scss similarity index 100% rename from frontend/src/app/main/ui/ds/product/autosaved_milestone.scss rename to frontend/src/app/main/ui/ds/product/milestone_group.scss diff --git a/frontend/src/app/main/ui/ds/product/autosaved_milestone.stories.jsx b/frontend/src/app/main/ui/ds/product/milestone_group.stories.jsx similarity index 55% rename from frontend/src/app/main/ui/ds/product/autosaved_milestone.stories.jsx rename to frontend/src/app/main/ui/ds/product/milestone_group.stories.jsx index 204185c46a..293ad54528 100644 --- a/frontend/src/app/main/ui/ds/product/autosaved_milestone.stories.jsx +++ b/frontend/src/app/main/ui/ds/product/milestone_group.stories.jsx @@ -1,11 +1,11 @@ import * as React from "react"; import Components from "@target/components"; -const { AutosavedMilestone } = Components; +const { MilestoneGroup } = Components; export default { - title: "Product/Milestones/Autosaved", - component: AutosavedMilestone, + title: "Product/Milestones/MilestoneGroup", + component: MilestoneGroup, argTypes: { label: { @@ -27,17 +27,10 @@ export default { args: { label: "Milestone 1", active: false, - versionToggled: false, - snapshots: [1737452413841, 1737452422063, 1737452431603], - autosavedMessage: "3 autosave versions", + snapshots: [1737452413841, 1737452422063, 1737452431603] }, render: ({ ...args }) => { - const user = { - name: args.userName, - avatar: args.userAvatar, - color: args.userColor, - }; - return ; + return ; }, }; diff --git a/frontend/src/app/main/ui/ds/product/user_milestone.stories.jsx b/frontend/src/app/main/ui/ds/product/user_milestone.stories.jsx deleted file mode 100644 index ab55121078..0000000000 --- a/frontend/src/app/main/ui/ds/product/user_milestone.stories.jsx +++ /dev/null @@ -1,61 +0,0 @@ -import * as React from "react"; -import Components from "@target/components"; - -const { UserMilestone } = Components; - -export default { - title: "Product/Milestones/User", - component: UserMilestone, - - argTypes: { - userName: { - control: { type: "text" }, - }, - userAvatar: { - control: { type: "text" }, - }, - userColor: { - control: { type: "color" }, - }, - label: { - control: { type: "text" }, - }, - date: { - control: { type: "date" }, - }, - active: { - control: { type: "boolean" }, - }, - editing: { - control: { type: "boolean" }, - }, - autosaved: { - control: { type: "boolean" }, - }, - versionToggled: { - control: { type: "boolean" }, - }, - snapshots: { - control: { type: "object" }, - }, - }, - args: { - label: "Milestone 1", - userName: "Ada Lovelace", - userAvatar: "/images/avatar-blue.jpg", - userColor: "#79d4ff", - date: 1735686000000, - active: false, - editing: false, - }, - render: ({ ...args }) => { - const user = { - name: args.userName, - avatar: args.userAvatar, - color: args.userColor, - }; - return ; - }, -}; - -export const Default = {}; diff --git a/frontend/src/app/main/ui/workspace/sidebar/versions.cljs b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs index a832ef92a0..2f8088f742 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/versions.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs @@ -20,9 +20,9 @@ [app.main.ui.dashboard.subscription :refer [get-subscription-type]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i] - [app.main.ui.ds.product.autosaved-milestone :refer [autosaved-milestone*]] [app.main.ui.ds.product.cta :refer [cta*]] - [app.main.ui.ds.product.user-milestone :refer [user-milestone*]] + [app.main.ui.ds.product.milestone :refer [milestone*]] + [app.main.ui.ds.product.milestone-group :refer [milestone-group*]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] @@ -31,10 +31,10 @@ [okulary.core :as l] [rumext.v2 :as mf])) -(def versions +(def ^:private versions (l/derived :workspace-versions st/state)) -(defn get-versions-stored-days +(defn- get-versions-stored-days [team] (let [subscription-type (get-subscription-type (:subscription team))] (cond @@ -42,7 +42,7 @@ (= subscription-type "enterprise") 90 :else 7))) -(defn get-versions-warning-subtext +(defn- get-versions-warning-subtext [team] (let [subscription-type (get-subscription-type (:subscription team)) is-owner? (-> team :permissions :is-owner) @@ -58,333 +58,330 @@ (tr "subscription.workspace.versions.warning.subtext-member" email-owner email-owner)) (tr "workspace.versions.warning.subtext" support-email)))) -(defn group-snapshots +(defn- group-snapshots [data] (->> (concat (->> data - (filterv #(= "user" (:created-by %))) + (filter #(= "user" (:created-by %))) (map #(assoc % :type :version))) (->> data - (filterv #(= "system" (:created-by %))) + (filter #(= "system" (:created-by %))) (group-by #(ct/format-inst (:created-at %) :iso-date)) (map (fn [[day entries]] {:type :snapshot :created-at (ct/inst day) :snapshots entries})))) (sort-by :created-at) + (map-indexed (fn [index item] + (assoc item :index index))) (reverse))) -(mf/defc version-entry - [{:keys [entry profile current-profile on-restore-version on-delete-version on-rename-version on-lock-version on-unlock-version editing?]}] - (let [show-menu? (mf/use-state false) - - handle-open-menu - (mf/use-fn - (fn [] - (reset! show-menu? true))) - - handle-close-menu - (mf/use-fn - (fn [] - (reset! show-menu? false))) - - handle-rename-version - (mf/use-fn - (mf/deps entry) - (fn [] - (st/emit! (dwv/update-version-state {:editing (:id entry)})))) - - handle-restore-version - (mf/use-fn - (mf/deps entry on-restore-version) - (fn [] - (when on-restore-version - (on-restore-version (:id entry))))) - - handle-delete-version - (mf/use-callback - (mf/deps entry on-delete-version) - (fn [] - (when on-delete-version - (on-delete-version (:id entry))))) - - handle-lock-version - (mf/use-callback - (mf/deps entry on-lock-version) - (fn [] - (when on-lock-version - (on-lock-version (:id entry))))) - - handle-unlock-version - (mf/use-callback - (mf/deps entry on-unlock-version) - (fn [] - (when on-unlock-version - (on-unlock-version (:id entry))))) - - handle-name-input-focus - (mf/use-fn - (fn [event] - (dom/select-text! (dom/get-target event)))) - - handle-name-input-blur - (mf/use-fn - (mf/deps entry on-rename-version) - (fn [event] - (let [label (str/trim (dom/get-target-val event))] - (when (and (not (str/empty? label)) - (some? on-rename-version)) - (on-rename-version (:id entry) label)) - (st/emit! (dwv/update-version-state {:editing nil}))))) - - handle-name-input-key-down - (mf/use-fn - (mf/deps handle-name-input-blur) - (fn [event] - (cond - (kbd/enter? event) - (handle-name-input-blur event) - - (kbd/esc? event) - (st/emit! (dwv/update-version-state {:editing nil})))))] - - [:li {:class (stl/css :version-entry-wrap)} - [:> user-milestone* {:label (:label entry) - :user #js {:name (:fullname profile) - :avatar (cfg/resolve-profile-photo-url profile) - :color (:color profile)} - :editing editing? - :date (:created-at entry) - :locked (boolean (:locked-by entry)) - :onOpenMenu handle-open-menu - :onFocusInput handle-name-input-focus - :onBlurInput handle-name-input-blur - :onKeyDownInput handle-name-input-key-down}] - - [:& dropdown {:show @show-menu? :on-close handle-close-menu} - (let [current-user-id (:id current-profile) - version-creator-id (:profile-id entry) - locked-by-id (:locked-by entry) - is-version-creator? (= current-user-id version-creator-id) - is-locked? (some? locked-by-id) - is-locked-by-me? (= current-user-id locked-by-id) - can-rename? is-version-creator? - can-lock? (and is-version-creator? (not is-locked?)) - can-unlock? (and is-version-creator? is-locked-by-me?) - can-delete? (or (not is-locked?) (and is-locked? is-locked-by-me?))] - [:ul {:class (stl/css :version-options-dropdown)} - (when can-rename? - [:li {:class (stl/css :menu-option) - :role "button" - :on-click handle-rename-version} (tr "labels.rename")]) - [:li {:class (stl/css :menu-option) - :role "button" - :on-click handle-restore-version} (tr "labels.restore")] - (cond - can-unlock? - [:li {:class (stl/css :menu-option) - :role "button" - :on-click handle-unlock-version} (tr "labels.unlock")] - can-lock? - [:li {:class (stl/css :menu-option) - :role "button" - :on-click handle-lock-version} (tr "labels.lock")]) - (when can-delete? - [:li {:class (stl/css :menu-option) - :role "button" - :on-click handle-delete-version} (tr "labels.delete")])])]])) - -(mf/defc snapshot-entry - [{:keys [index is-expanded entry on-toggle-expand on-pin-snapshot on-restore-snapshot]}] - - (let [open-menu (mf/use-state nil) - entry-ref (mf/use-ref nil) - - handle-toggle-expand - (mf/use-fn - (mf/deps index on-toggle-expand) - (fn [] - (when on-toggle-expand - (on-toggle-expand index)))) - - handle-pin-snapshot - (mf/use-fn - (mf/deps on-pin-snapshot) - (fn [event] - (let [node (dom/get-current-target event) - id (-> (dom/get-data node "id") uuid/parse)] - (when on-pin-snapshot (on-pin-snapshot id))))) - - handle-restore-snapshot - (mf/use-fn - (mf/deps on-restore-snapshot) - (fn [event] - (let [node (dom/get-current-target event) - id (-> (dom/get-data node "id") uuid/parse)] - (when on-restore-snapshot (on-restore-snapshot id))))) - - - handle-open-snapshot-menu - (mf/use-fn - (mf/deps entry) - (fn [event index] - (let [snapshot (nth (:snapshots entry) index) - current-bb (-> entry-ref mf/ref-val dom/get-bounding-rect :top) - target-bb (-> event dom/get-target dom/get-bounding-rect :top) - offset (+ (- target-bb current-bb) 32)] - (swap! open-menu assoc - :snapshot (:id snapshot) - :offset offset))))] - - [:li {:ref entry-ref :class (stl/css :version-entry-wrap)} - [:> autosaved-milestone* - {:label (tr "workspace.versions.autosaved.version" - (ct/format-inst (:created-at entry) :localized-date)) - :autosavedMessage (tr "workspace.versions.autosaved.entry" (count (:snapshots entry))) - :snapshots (mapv :created-at (:snapshots entry)) - :versionToggled is-expanded - :onClickSnapshotMenu handle-open-snapshot-menu - :onToggleExpandSnapshots handle-toggle-expand}] - - [:& dropdown {:show (some? @open-menu) - :on-close #(reset! open-menu nil)} - [:ul {:class (stl/css :version-options-dropdown) - :style {"--offset" (dm/str (:offset @open-menu) "px")}} - [:li {:class (stl/css :menu-option) - :role "button" - :data-id (dm/str (:snapshot @open-menu)) - :on-click handle-restore-snapshot} - (tr "workspace.versions.button.restore")] - [:li {:class (stl/css :menu-option) - :role "button" - :data-id (dm/str (:snapshot @open-menu)) - :on-click handle-pin-snapshot} - (tr "workspace.versions.button.pin")]]]])) - -(mf/defc versions-toolbox* - [] - (let [profiles (mf/deref refs/profiles) - profile (mf/deref refs/profile) - team (mf/deref refs/team) - - expanded (mf/use-state #{}) - - {:keys [status data editing]} - (mf/deref versions) - - ;; Store users that have a version - data-users - (mf/use-memo - (mf/deps data) - (fn [] - (into #{} (keep (fn [{:keys [created-by profile-id]}] - (when (= "user" created-by) profile-id))) data))) - data - (mf/use-memo - (mf/deps @versions) - (fn [] - (->> data - (filter #(or (not (:filter @versions)) - (and - (= "user" (:created-by %)) - (= (:filter @versions) (:profile-id %))))) - (group-snapshots)))) - - handle-create-version - (mf/use-fn - (fn [] - (st/emit! (dwv/create-version)))) - - handle-toggle-expand - (mf/use-fn - (fn [id] - (swap! expanded - (fn [expanded] - (let [has-element? (contains? expanded id)] - (cond-> expanded - has-element? (disj id) - (not has-element?) (conj id))))))) - - handle-rename-version - (mf/use-fn - (fn [id label] - (st/emit! (dwv/rename-version id label)))) - - - handle-restore-version - (mf/use-fn - (fn [origin id] - (st/emit! - (ntf/dialog +(defn- open-restore-version-dialog + [origin id] + (st/emit! (ntf/dialog :content (tr "workspace.versions.restore-warning") :controls :inline-actions :cancel {:label (tr "workspace.updates.dismiss") :callback #(st/emit! (ntf/hide))} :accept {:label (tr "labels.restore") :callback #(st/emit! (dwv/restore-version id origin))} - :tag :restore-dialog)))) + :tag :restore-dialog))) - handle-restore-version-pinned +(mf/defc version-entry* + {::mf/private true} + [{:keys [entry current-profile on-restore on-delete on-rename on-lock on-unlock on-edit on-cancel-edit is-editing]}] + (let [show-menu? (mf/use-state false) + profiles (mf/deref refs/profiles) + + created-by (get profiles (:profile-id entry)) + + on-open-menu + (mf/use-fn #(reset! show-menu? true)) + + on-close-menu + (mf/use-fn #(reset! show-menu? false)) + + on-edit (mf/use-fn - (mf/deps handle-restore-version) - (fn [id] - (handle-restore-version :version id))) + (mf/deps on-edit entry) + (fn [event] + (on-edit (:id entry) event))) - handle-restore-version-snapshot + on-restore (mf/use-fn - (mf/deps handle-restore-version) - (fn [id] - (handle-restore-version :snapshot id))) + (mf/deps entry on-restore) + (fn [] + (when (fn? on-restore) + (on-restore (:id entry))))) - handle-delete-version + on-delete + (mf/use-callback + (mf/deps entry on-delete) + (fn [event] + (when (fn? on-delete) + (on-delete (:id entry) event)))) + + on-lock + (mf/use-callback + (mf/deps entry on-lock) + (fn [] + (when on-lock + (on-lock (:id entry))))) + + on-unlock + (mf/use-callback + (mf/deps entry on-unlock) + (fn [] + (when on-unlock + (on-unlock (:id entry))))) + + on-name-input-focus + (mf/use-fn + (fn [event] + (dom/select-text! (dom/get-target event)))) + + on-name-input-blur + (mf/use-fn + (mf/deps entry on-rename on-cancel-edit) + (fn [event] + (let [label (str/trim (dom/get-target-val event))] + (if (and (not (str/empty? label)) + (fn? on-rename)) + (on-rename (:id entry) label event) + (on-cancel-edit (:id entry) event))))) + + on-name-input-key-down + (mf/use-fn + (mf/deps entry on-cancel-edit on-name-input-blur) + (fn [event] + (cond + (kbd/enter? event) + (on-name-input-blur event) + + (kbd/esc? event) + (when (fn? on-cancel-edit) + (on-cancel-edit (:id entry) event)))))] + + [:li {:class (stl/css :version-entry-wrap)} + [:> milestone* {:label (:label entry) + :profile created-by + :editing is-editing + :created-at (:created-at entry) + :locked (some? (:locked-by entry)) + :on-open-menu on-open-menu + :on-focus-input on-name-input-focus + :on-blur-input on-name-input-blur + :on-key-down-input on-name-input-key-down}] + + [:& dropdown {:show @show-menu? + :on-close on-close-menu} + (let [current-user-id (:id current-profile) + locked-by-id (:locked-by entry) + im-the-owner? (= current-user-id (:id created-by)) + is-locked-by-me? (= current-user-id locked-by-id) + is-locked? (some? locked-by-id) + can-delete? (or (not is-locked?) + (and is-locked? + is-locked-by-me?))] + [:ul {:class (stl/css :version-options-dropdown)} + (when im-the-owner? + [:li {:class (stl/css :menu-option) + :role "button" + :on-click on-edit} + (tr "labels.rename")]) + + [:li {:class (stl/css :menu-option) + :role "button" + :on-click on-restore} + (tr "labels.restore")] + + (cond + is-locked-by-me? + [:li {:class (stl/css :menu-option) + :role "button" + :on-click on-unlock} + (tr "labels.unlock")] + + (and im-the-owner? (not is-locked?)) + [:li {:class (stl/css :menu-option) + :role "button" + :on-click on-lock} + (tr "labels.lock")]) + + (when can-delete? + [:li {:class (stl/css :menu-option) + :role "button" + :on-click on-delete} + (tr "labels.delete")])])]])) + +(mf/defc snapshot-entry* + [{:keys [entry on-pin-snapshot on-restore-snapshot]}] + + (let [open-menu* (mf/use-state nil) + entry-ref (mf/use-ref nil) + + on-pin-snapshot + (mf/use-fn + (mf/deps on-pin-snapshot) + (fn [event] + (let [node (dom/get-current-target event) + id (-> node + (dom/get-data "id") + (uuid/parse))] + (when (fn? on-pin-snapshot) + (on-pin-snapshot id event))))) + + on-restore-snapshot + (mf/use-fn + (mf/deps on-restore-snapshot) + (fn [event] + (let [node (dom/get-current-target event) + id (-> node + (dom/get-data "id") + (uuid/parse))] + (when (fn? on-restore-snapshot) + (on-restore-snapshot id event))))) + + on-open-snapshot-menu + (mf/use-fn + (mf/deps entry) + (fn [index event] + (let [snapshot (nth (:snapshots entry) index) + current-bb (-> entry-ref mf/ref-val dom/get-bounding-rect :top) + target-bb (-> event dom/get-target dom/get-bounding-rect :top) + offset (+ (- target-bb current-bb) 32)] + (swap! open-menu* assoc + :snapshot (:id snapshot) + :offset offset))))] + + [:li {:ref entry-ref :class (stl/css :version-entry-wrap)} + [:> milestone-group* + {:label (tr "workspace.versions.autosaved.version" + (ct/format-inst (:created-at entry) :localized-date)) + :snapshots (mapv :created-at (:snapshots entry)) + :on-menu-click on-open-snapshot-menu}] + + [:& dropdown {:show (some? @open-menu*) + :on-close #(reset! open-menu* nil)} + [:ul {:class (stl/css :version-options-dropdown) + :style {"--offset" (dm/str (:offset @open-menu*) "px")}} + [:li {:class (stl/css :menu-option) + :role "button" + :data-id (dm/str (:snapshot @open-menu*)) + :on-click on-restore-snapshot} + (tr "workspace.versions.button.restore")] + [:li {:class (stl/css :menu-option) + :role "button" + :data-id (dm/str (:snapshot @open-menu*)) + :on-click on-pin-snapshot} + (tr "workspace.versions.button.pin")]]]])) + +(mf/defc versions-toolbox* + [] + (let [profiles (mf/deref refs/profiles) + profile (mf/deref refs/profile) + team (mf/deref refs/team) + + {:keys [status data editing] :as state} + (mf/deref versions) + + users + (mf/with-memo [data] + (into #{} + (keep (fn [{:keys [created-by profile-id]}] + (when (= "user" created-by) + profile-id))) + data)) + + entries + (mf/with-memo [state] + (->> (:data state) + (filter #(or (not (:filter state)) + (and (= "user" (:created-by %)) + (= (:filter state) (:profile-id %))))) + (group-snapshots))) + + on-create-version + (mf/use-fn + (fn [] (st/emit! (dwv/create-version)))) + + on-edit-version + (mf/use-fn + (fn [id _event] + (st/emit! (dwv/update-versions-state {:editing id})))) + + on-cancel-version-edition + (mf/use-fn + (fn [_id _event] + (st/emit! (dwv/update-versions-state {:editing nil})))) + + on-rename-version + (mf/use-fn + (fn [id label] + (st/emit! (dwv/rename-version id label)))) + + on-restore-version + (mf/use-fn + (fn [id _event] + (open-restore-version-dialog :version id))) + + on-restore-snapshot + (mf/use-fn + (fn [id _event] + (open-restore-version-dialog :snapshot id))) + + on-delete-version (mf/use-fn (fn [id] (st/emit! (dwv/delete-version id)))) - handle-pin-version + on-pin-version (mf/use-fn - (fn [id] - (st/emit! (dwv/pin-version id)))) + (fn [id] (st/emit! (dwv/pin-version id)))) - handle-lock-version + on-lock-version (mf/use-fn (fn [id] (st/emit! (dwv/lock-version id)))) - handle-unlock-version + on-unlock-version (mf/use-fn (fn [id] (st/emit! (dwv/unlock-version id)))) - handle-change-filter + on-change-filter (mf/use-fn (fn [filter] (cond (= :all filter) - (st/emit! (dwv/update-version-state {:filter nil})) + (st/emit! (dwv/update-versions-state {:filter nil})) (= :own filter) - (st/emit! (dwv/update-version-state {:filter (:id profile)})) + (st/emit! (dwv/update-versions-state {:filter (:id profile)})) :else - (st/emit! (dwv/update-version-state {:filter filter})))))] + (st/emit! (dwv/update-versions-state {:filter filter}))))) + + options + (mf/with-memo [users profile] + (let [current-profile-id (get profile :id)] + (into [{:value :all :label (tr "workspace.versions.filter.all")} + {:value :own :label (tr "workspace.versions.filter.mine")}] + (keep (fn [id] + (when (not= id current-profile-id) + (when-let [fullname (-> profiles (get id) (get :fullname))] + {:value id :label (tr "workspace.versions.filter.user" fullname)})))) + users)))] (mf/with-effect [] - (st/emit! (dwv/init-version-state))) + (st/emit! (dwv/init-versions-state))) [:div {:class (stl/css :version-toolbox)} [:& select {:default-value :all :aria-label (tr "workspace.versions.filter.label") - :options (into [{:value :all :label (tr "workspace.versions.filter.all")} - {:value :own :label (tr "workspace.versions.filter.mine")}] - (->> data-users - (keep - (fn [id] - (let [{:keys [fullname]} (get profiles id)] - (when (not= id (:id profile)) - {:value id :label (tr "workspace.versions.filter.user" fullname)})))))) - :on-change handle-change-filter}] + :options options + :on-change on-change-filter}] (cond (= status :loading) @@ -397,7 +394,7 @@ (tr "workspace.versions.button.save") [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.versions.button.save") - :on-click handle-create-version + :on-click on-create-version :icon "pin"}]] (if (empty? data) @@ -406,28 +403,26 @@ [:div {:class (stl/css :versions-entry-empty-msg)} (tr "workspace.versions.empty")]] [:ul {:class (stl/css :versions-entries)} - (for [[idx-entry entry] (->> data (map-indexed vector))] + (for [entry entries] (case (:type entry) :version - [:& version-entry {:key idx-entry - :entry entry - :editing? (= (:id entry) editing) - :profile (get profiles (:profile-id entry)) - :current-profile profile - :on-rename-version handle-rename-version - :on-restore-version handle-restore-version-pinned - :on-delete-version handle-delete-version - :on-lock-version handle-lock-version - :on-unlock-version handle-unlock-version}] + [:> version-entry* {:key (:index entry) + :entry entry + :is-editing (= (:id entry) editing) + :current-profile profile + :on-edit on-edit-version + :on-cancel-edit on-cancel-version-edition + :on-rename on-rename-version + :on-restore on-restore-version + :on-delete on-delete-version + :on-lock on-lock-version + :on-unlock on-unlock-version}] :snapshot - [:& snapshot-entry {:key idx-entry - :index idx-entry - :entry entry - :is-expanded (contains? @expanded idx-entry) - :on-toggle-expand handle-toggle-expand - :on-restore-snapshot handle-restore-version-snapshot - :on-pin-snapshot handle-pin-version}] + [:> snapshot-entry* {:key (:index entry) + :entry entry + :on-restore-snapshot on-restore-snapshot + :on-pin-snapshot on-pin-version}] nil))])