From d5abc52dac26014efe1bf7e01bc23e2e868d84bc Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Tue, 27 Jan 2026 10:04:53 +0100 Subject: [PATCH] :tada: Add first integration with nitrate (#7803) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :bug: Display missing selected tokens set info (#8098) * :bug: Display missing selected tokens set info * :sparkles: Add integration tests to verify current active set * :tada: Integration with nitrate platform * :bug: Fix nitrate get-teams returns deleted teams * :sparkles: Add nitrate to tmux devenv * :sparkles: Add retry and validation to nitrate module * :sparkles: Add photoUrl to profile on nitrate authenticate * :sparkles: Move nitrate url to an env variable * :recycle: Change Nitrate organization-id schema to text * :recycle: Cleanup unused imports * :wrench: Add control-center to nginx * :sparkles: Add create org link * :wrench: Fix nginx entrypoint * :bug: Fix control-center proxy pass * :tada: Add nitrate licence check * Revert ":sparkles: Add nitrate to tmux devenv" This reverts commit dc6f6c458995dac55cab7be365ced0972760a058. * :sparkles: Add feature flag check * :bug: Rename licences for licenses * :sparkles: MR changes * :sparkles: MR changes 2 * :paperclip: Add the ability to have local config on start backend * :paperclip: Add FIXME comment --------- Co-authored-by: Xaviju Co-authored-by: Juanfran Co-authored-by: Yamila Moreno Co-authored-by: Marina López Co-authored-by: Andrey Antukh --- .gitignore | 3 + backend/scripts/_env | 3 + backend/scripts/repl | 4 + backend/scripts/run | 5 + backend/scripts/start-dev | 4 + backend/src/app/config.clj | 2 + backend/src/app/main.clj | 5 + backend/src/app/nitrate.clj | 147 ++++++++++++++++++ backend/src/app/rpc.clj | 1 + backend/src/app/rpc/commands/profile.clj | 13 +- backend/src/app/rpc/commands/teams.clj | 5 +- backend/src/app/rpc/management/nitrate.clj | 112 +++++++++++++ common/src/app/common/flags.cljc | 5 +- docker/devenv/files/nginx.conf | 10 +- docker/images/files/nginx-entrypoint.sh | 3 +- docker/images/files/nginx.conf.template | 8 + .../playwright/ui/specs/tokens/sets.spec.js | 28 ++++ frontend/src/app/main/data/dashboard.cljs | 14 ++ .../src/app/main/ui/dashboard/sidebar.cljs | 128 +++++++++++++-- .../src/app/main/ui/nitrate/nitrate_form.cljs | 48 ++++++ .../src/app/main/ui/nitrate/nitrate_form.scss | 52 +++++++ .../main/ui/workspace/tokens/management.cljs | 16 +- frontend/translations/en.po | 3 + frontend/translations/es.po | 3 + 24 files changed, 590 insertions(+), 32 deletions(-) create mode 100644 backend/src/app/nitrate.clj create mode 100644 backend/src/app/rpc/management/nitrate.clj create mode 100644 frontend/src/app/main/ui/nitrate/nitrate_form.cljs create mode 100644 frontend/src/app/main/ui/nitrate/nitrate_form.scss diff --git a/.gitignore b/.gitignore index 4956f56eb7..105d3897a3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ .rebel_readline_history .repl .shadow-cljs +.pnpm-store/ /*.jpg /*.md /*.png @@ -44,6 +45,7 @@ /backend/resources/public/media /backend/target/ /backend/experiments +/backend/scripts/_env.local /bundle* /cd.md /clj-profiler/ @@ -74,6 +76,7 @@ /library/target/ /library/*.zip /external +/penpot-nitrate clj-profiler/ node_modules diff --git a/backend/scripts/_env b/backend/scripts/_env index f5cca5538b..3155974924 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -13,6 +13,7 @@ export PENPOT_FLAGS="\ disable-login-with-google \ disable-login-with-github \ disable-login-with-gitlab \ + disable-telemetry \ enable-backend-worker \ enable-backend-asserts \ disable-feature-fdata-pointer-map \ @@ -55,6 +56,8 @@ export PENPOT_OBJECTS_STORAGE_BACKEND=s3 export PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=http://minio:9000 export PENPOT_OBJECTS_STORAGE_S3_BUCKET=penpot +export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/control-center + export JAVA_OPTS="\ -Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \ -Djdk.attach.allowAttachSelf \ diff --git a/backend/scripts/repl b/backend/scripts/repl index 2229d69716..a2c179065e 100755 --- a/backend/scripts/repl +++ b/backend/scripts/repl @@ -3,6 +3,10 @@ SCRIPT_DIR=$(dirname $0); source $SCRIPT_DIR/_env; +if [ -f $SCRIPT_DIR/_env.local ]; then + source $SCRIPT_DIR/_env.local; +fi + # Initialize MINIO config setup_minio; diff --git a/backend/scripts/run b/backend/scripts/run index f48d0d060f..49b2bd4ba6 100755 --- a/backend/scripts/run +++ b/backend/scripts/run @@ -3,6 +3,11 @@ SCRIPT_DIR=$(dirname $0); source $SCRIPT_DIR/_env; + +if [ -f $SCRIPT_DIR/_env.local ]; then + source $SCRIPT_DIR/_env.local; +fi + export OPTIONS="-A:dev" entrypoint=${1:-app.main}; diff --git a/backend/scripts/start-dev b/backend/scripts/start-dev index eaa4ebca70..d3389a0a6f 100755 --- a/backend/scripts/start-dev +++ b/backend/scripts/start-dev @@ -3,6 +3,10 @@ SCRIPT_DIR=$(dirname $0); source $SCRIPT_DIR/_env; +if [ -f $SCRIPT_DIR/_env.local ]; then + source $SCRIPT_DIR/_env.local; +fi + # Initialize MINIO config setup_minio; diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index de030f2e11..7ba69d7710 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -225,6 +225,8 @@ [:netty-io-threads {:optional true} ::sm/int] [:executor-threads {:optional true} ::sm/int] + [:nitrate-backend-uri {:optional true} ::sm/uri] + ;; DEPRECATED [:assets-storage-backend {:optional true} :keyword] [:storage-assets-fs-directory {:optional true} :string] diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 1fa26fabe1..1923b5f054 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -323,6 +323,7 @@ {::http.client/client (ig/ref ::http.client/client) ::db/pool (ig/ref ::db/pool) ::rds/pool (ig/ref ::rds/pool) + :app.nitrate/client (ig/ref :app.nitrate/client) ::wrk/executor (ig/ref ::wrk/netty-executor) ::session/manager (ig/ref ::session/manager) ::ldap/provider (ig/ref ::ldap/provider) @@ -339,6 +340,9 @@ ::email/blacklist (ig/ref ::email/blacklist) ::email/whitelist (ig/ref ::email/whitelist)} + :app.nitrate/client + {::http.client/client (ig/ref ::http.client/client)} + :app.rpc/management-methods {::http.client/client (ig/ref ::http.client/client) ::db/pool (ig/ref ::db/pool) @@ -348,6 +352,7 @@ ::sto/storage (ig/ref ::sto/storage) ::mtx/metrics (ig/ref ::mtx/metrics) ::mbus/msgbus (ig/ref ::mbus/msgbus) + :app.nitrate/client (ig/ref :app.nitrate/client) ::rds/client (ig/ref ::rds/client) ::setup/props (ig/ref ::setup/props)} diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj new file mode 100644 index 0000000000..b04e834975 --- /dev/null +++ b/backend/src/app/nitrate.clj @@ -0,0 +1,147 @@ +;; 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.nitrate + "Module that make calls to the external nitrate aplication" + (:require + [app.common.logging :as l] + [app.common.schema :as sm] + [app.config :as cf] + [app.http.client :as http] + [app.rpc :as-alias rpc] + [app.setup :as-alias setup] + [app.util.json :as json] + [clojure.core :as c] + [integrant.core :as ig])) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- request-builder + [cfg method uri management-key profile-id] + (fn [] + (http/req! cfg {:method method + :headers {"content-type" "application/json" + "accept" "application/json" + "x-shared-key" management-key + "x-profile-id" (str profile-id)} + :uri uri + :version :http1.1}))) + + +(defn- with-retries + [handler max-retries] + (fn [] + (loop [attempt 1] + (let [result (try + (handler) + (catch Exception e + (if (< attempt max-retries) + ::retry + (do + ;; TODO Error handling + (l/error :hint "request fail after multiple retries" :cause e) + nil))))] + (if (= result ::retry) + (recur (inc attempt)) + result))))) + + +(defn- with-validate [handler uri schema] + (fn [] + (let [coercer-http (sm/coercer schema + :type :validation + :hint (str "invalid data received calling " uri))] + (try + (coercer-http (-> (handler) :body json/decode)) + (catch Exception e + ;; TODO Error handling + (l/error :hint "error validating json response" :cause e) + nil))))) + +(defn- request-to-nitrate + [{:keys [::management-key] :as cfg} method uri schema {:keys [::rpc/profile-id] :as params}] + (let [full-http-call (-> (request-builder cfg method uri management-key profile-id) + (with-retries 3) + (with-validate uri schema))] + (full-http-call))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; API +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn call + [cfg method params] + (when (contains? cf/flags :nitrate) + (let [client (get cfg ::client) + method (get client method)] + (method params)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private schema:organization + [:map + [:id ::sm/text] + [:name ::sm/text]]) + +(def ^:private schema:user + [:map + [:valid ::sm/boolean]]) + +(defn- get-team-org + [cfg {:keys [team-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :get (str baseuri "/api/teams/" (str team-id)) schema:organization params))) + +(defn- is-valid-user + [cfg {:keys [profile-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params))) + + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; INITIALIZATION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defmethod ig/init-key ::client + [_ {:keys [::setup/props] :as cfg}] + (if (contains? cf/flags :nitrate) + (let [management-key (or (cf/get :management-api-key) + (get props :management-key)) + cfg (assoc cfg ::management-key management-key)] + {:get-team-org (partial get-team-org cfg) + :is-valid-user (partial is-valid-user cfg)}) + {})) + +(defmethod ig/halt-key! ::client + [_ {:keys []}] + (do :stuff)) + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; UTILS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + + +(defn add-nitrate-licence-to-profile + [cfg profile] + (try + (let [nitrate-licence (call cfg :is-valid-user {:profile-id (:id profile)})] + (assoc profile :nitrate-licence (:valid nitrate-licence))) + (catch Throwable cause + (l/error :hint "failed to get nitrate licence" + :profile-id (:id profile) + :cause cause) + profile))) + +(defn add-org-to-team + [cfg team params] + (let [params (assoc (or params {}) :team-id (:id team)) + org (call cfg :get-team-org params)] + (assoc team :organization-id (:id org) :organization-name (:name org)))) \ No newline at end of file diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 7d4a241817..7e286360ad 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -301,6 +301,7 @@ (let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)] (->> (sv/scan-ns 'app.rpc.management.subscription + 'app.rpc.management.nitrate 'app.rpc.management.exporter) (map (partial process-method cfg "management" wrap-management)) (into {})))) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 631f84af58..3d2f2b1351 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -21,6 +21,7 @@ [app.loggers.audit :as audit] [app.main :as-alias main] [app.media :as media] + [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.climit :as climit] [app.rpc.doc :as-alias doc] @@ -88,6 +89,8 @@ ;; --- QUERY: Get profile (own) + + (sv/defmethod ::get-profile {::rpc/auth false ::doc/added "1.18" @@ -98,9 +101,13 @@ ;; no profile-id is in session, and when db call raises not found. In all other ;; cases we need to reraise the exception. (try - (-> (get-profile pool profile-id) - (strip-private-attrs) - (update :props filter-props)) + (let [profile (-> (get-profile pool profile-id) + (strip-private-attrs) + (update :props filter-props))] + (if (contains? cf/flags :nitrate) + (nitrate/add-nitrate-licence-to-profile cfg profile) + profile)) + (catch Throwable _ {:id uuid/zero :fullname "Anonymous User"}))) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 3cd69c5b76..603e12187b 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -23,6 +23,7 @@ [app.main :as-alias main] [app.media :as media] [app.msgbus :as mbus] + [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] @@ -190,7 +191,9 @@ ::sm/params schema:get-teams} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] (dm/with-open [conn (db/open pool)] - (get-teams conn profile-id))) + (cond->> (get-teams conn profile-id) + (contains? cf/flags :nitrate) + (map #(nitrate/add-org-to-team cfg % params))))) (def ^:private sql:get-owned-teams "SELECT t.id, t.name, diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj new file mode 100644 index 0000000000..86ef1db999 --- /dev/null +++ b/backend/src/app/rpc/management/nitrate.clj @@ -0,0 +1,112 @@ +;; 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.rpc.management.nitrate + "Internal Nitrate HTTP API. + Provides authenticated access to organization management and token validation endpoints. + All requests must include a valid shared key token in the `x-shared-key` header, and + a cookie `auth-token` with the user token. + They will return `401 Unauthorized` if the shared key or user token are invalid." + (:require + [app.common.schema :as sm] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.msgbus :as mbus] + [app.rpc :as-alias rpc] + [app.rpc.commands.files :as files] + [app.rpc.commands.profile :as profile] + [app.rpc.doc :as doc] + [app.util.services :as sv])) + +;; ---- API: authenticate +(def ^:private schema:profile + [:map + [:id ::sm/uuid] + [:name :string] + [:email :string] + [:photo-url :string]]) + +(sv/defmethod ::authenticate + "Authenticate an user + @api GET /authenticate + @returns + 200 OK: Returns the authenticated user." + {::doc/added "2.12" + ::sm/result schema:profile} + [cfg {:keys [::rpc/profile-id] :as params}] + (let [profile (profile/get-profile cfg profile-id)] + {:id (get profile :id) + :name (get profile :fullname) + :email (get profile :email) + :photo-url (files/resolve-public-uri (get profile :photo-id))})) + +;; ---- API: get-teams + +(def ^:private sql:get-teams + "SELECT t.* + FROM team AS t + JOIN team_profile_rel AS tpr ON t.id = tpr.team_id + WHERE tpr.profile_id = ? + AND tpr.is_owner = 't' + AND t.is_default = 'f' + AND t.deleted_at is null;") + +(def ^:private schema:team + [:map + [:id ::sm/uuid] + [:name :string]]) + +(def ^:private schema:get-teams-result + [:vector schema:team]) + +(sv/defmethod ::get-teams + "List teams for which current user is owner. + @api GET /get-teams + @returns + 200 OK: Returns the list of teams for the user." + {::doc/added "2.12" + ::sm/result schema:get-teams-result} + [cfg {:keys [::rpc/profile-id]}] + (when (contains? cf/flags :nitrate) + (let [current-user-id (-> (profile/get-profile cfg profile-id) :id)] + (->> (db/exec! cfg [sql:get-teams current-user-id]) + (map #(select-keys % [:id :name])))))) + +;; ---- API: notify-team-change + +(def ^:private schema:notify-team-change + [:map + [:id ::sm/uuid] + [:organization-id ::sm/text]]) + + +(sv/defmethod ::notify-team-change + "Notify to Penpot a team change from nitrate + @api POST /notify-team-change + @returns + 200 OK" + {::doc/added "2.12" + ::sm/params schema:notify-team-change + ::rpc/auth false} + [cfg {:keys [id organization-id organization-name]}] + (when (contains? cf/flags :nitrate) + (let [msgbus (::mbus/msgbus cfg)] + (mbus/pub! msgbus + ;;TODO There is a bug on dashboard with teams notifications. + ;;For now we send it to uuid/zero instead of team-id + :topic uuid/zero + :message {:type :team-org-change + :team-id id + :organization-id organization-id + :organization-name organization-name})))) + + + + + + + diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index b5c7923698..2207fb9547 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -148,7 +148,10 @@ ;; A temporal flag, enables backend code use more extensivelly ;; redis for caching data - :redis-cache}) + :redis-cache + + ;; Activates the nitrate module + :nitrate}) (def all-flags (set/union email login varia)) diff --git a/docker/devenv/files/nginx.conf b/docker/devenv/files/nginx.conf index 0c227e244d..824c950d3d 100644 --- a/docker/devenv/files/nginx.conf +++ b/docker/devenv/files/nginx.conf @@ -141,8 +141,14 @@ http { proxy_pass http://127.0.0.1:5000; } - location /nitrate/ { - proxy_pass http://127.0.0.1:3000/; + location /control-center { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } location /wasm-playground { diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index 1c6621fba3..39546de336 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -29,8 +29,9 @@ update_flags /var/www/app/js/config.js export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060} export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061} +export PENPOT_NITRATE_URI=${PENPOT_NITRATE_URI:-http://penpot-nitrate:3000} export PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE:-367001600} # Default to 350MiB -envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE" \ +envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE" \ < /tmp/nginx.conf.template > /etc/nginx/nginx.conf PENPOT_DEFAULT_INTERNAL_RESOLVER="$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf)" diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index 88a55567b1..fb2ba0ac5f 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -139,6 +139,14 @@ http { proxy_pass $PENPOT_BACKEND_URI/ws/notifications; } + location /control-center { + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $http_cf_connecting_ip; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass $PENPOT_NITRATE_URI$request_uri; + } + include /etc/nginx/overrides/server.d/*.conf; location / { diff --git a/frontend/playwright/ui/specs/tokens/sets.spec.js b/frontend/playwright/ui/specs/tokens/sets.spec.js index d46ddae191..f5dd6aa7fb 100644 --- a/frontend/playwright/ui/specs/tokens/sets.spec.js +++ b/frontend/playwright/ui/specs/tokens/sets.spec.js @@ -216,4 +216,32 @@ test.describe("Tokens: Sets Tab", () => { await expect(tokenSetItems.nth(1)).toHaveAttribute("aria-checked", "false"); await expect(tokenSetItems.nth(2)).toHaveAttribute("aria-checked", "true"); }); + + test("Display active set and verify if is enabled", async ({ page }) => { + const { tokenThemesSetsSidebar, tokensSidebar, tokenSetItems } = + await setupTokensFile(page); + + // Create set + await tokenThemesSetsSidebar + .getByRole("button", { name: "Add set" }) + .click(); + await changeSetInput(tokenThemesSetsSidebar, "Inactive set"); + await tokenThemesSetsSidebar + .getByRole("button", { name: "Inactive set" }) + .click(); + let activeSetTitle = await tokensSidebar.getByTestId( + "active-token-set-title", + ); + await expect(activeSetTitle).toHaveText("TOKENS - Inactive set"); + const inactiveSetInfo = await tokensSidebar.getByTitle( + "This set is not active.", + ); + await expect(inactiveSetInfo).toBeVisible(); + + // Switch active set + + await tokenThemesSetsSidebar.getByRole("button", { name: "theme" }).click(); + await expect(activeSetTitle).toHaveText("TOKENS - theme"); + await expect(inactiveSetInfo).not.toBeVisible(); + }); }); diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index a1acdf7e0c..792357e22f 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -15,6 +15,7 @@ [app.common.time :as ct] [app.common.types.project :refer [valid-project?]] [app.common.uuid :as uuid] + [app.config :as cf] [app.main.constants :as mconst] [app.main.data.common :as dcm] [app.main.data.event :as ev] @@ -683,12 +684,25 @@ (rx/of (dcm/change-team-role params) (modal/hide))))) +(defn handle-change-team-org + [{:keys [team-id organization-id organization-name]}] + (ptk/reify ::handle-change-team-org + ptk/UpdateEvent + (update [_ state] + (if (contains? cf/flags :nitrate) + (d/update-in-when state [:teams team-id] assoc + :organization-id organization-id + :organization-name organization-name) + state)))) + + (defn- process-message [{:keys [type] :as msg}] (case type :notification (dcm/handle-notification msg) :team-role-change (handle-change-team-role msg) :team-membership-change (dcm/team-membership-change msg) + :team-org-change (handle-change-team-org msg) nil)) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index d9460c6f3f..0ca0a3514d 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -35,6 +35,7 @@ [app.main.ui.dashboard.team-form] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.icons :as deprecated-icon] + [app.main.ui.nitrate.nitrate-form] [app.util.dom :as dom] [app.util.dom.dnd :as dnd] [app.util.i18n :as i18n :refer [tr]] @@ -280,8 +281,8 @@ (mf/defc teams-selector-dropdown* {::mf/private true} - [{:keys [team profile teams] :rest props}] - (let [on-create-click + [{:keys [team profile teams show-default-team allow-create-teams allow-create-org] :rest props}] + (let [on-create-team-click (mf/use-fn #(st/emit! (modal/show :team-form {}))) on-team-click @@ -290,18 +291,27 @@ (let [team-id (-> (dom/get-current-target event) (dom/get-data "value") (uuid/parse))] - (st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))] + (st/emit! (dcm/go-to-dashboard-recent :team-id team-id))))) + + on-create-org-click + (mf/use-fn + (fn [] + (if (:nitrate-licence profile) + ;; TODO update when org creation route is ready + (dom/open-new-window "/control-center/org/create") + (st/emit! (modal/show :nitrate-form {})))))] [:> dropdown-menu* props - [:> dropdown-menu-item* {:on-click on-team-click - :data-value (:default-team-id profile) - :class (stl/css :team-dropdown-item)} - [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon] + (when show-default-team + [:> dropdown-menu-item* {:on-click on-team-click + :data-value (:default-team-id profile) + :class (stl/css :team-dropdown-item)} + [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon] - [:span {:class (stl/css :team-text)} (tr "dashboard.your-penpot")] - (when (= (:default-team-id profile) (:id team)) - tick-icon)] + [:span {:class (stl/css :team-text)} (tr "dashboard.your-penpot")] + (when (= (:default-team-id profile) (:id team)) + tick-icon)]) (for [team-item (remove :is-default (vals teams))] [:> dropdown-menu-item* {:on-click on-team-click @@ -322,11 +332,19 @@ (when (= (:id team-item) (:id team)) tick-icon)]) - [:hr {:role "separator" :class (stl/css :team-separator)}] - [:> dropdown-menu-item* {:on-click on-create-click - :class (stl/css :team-dropdown-item :action)} - [:span {:class (stl/css :icon-wrapper)} add-icon] - [:span {:class (stl/css :team-text)} (tr "dashboard.create-new-team")]]])) + (when allow-create-teams + [:hr {:role "separator" :class (stl/css :team-separator)}] + [:> dropdown-menu-item* {:on-click on-create-team-click + :class (stl/css :team-dropdown-item :action)} + [:span {:class (stl/css :icon-wrapper)} add-icon] + [:span {:class (stl/css :team-text)} (tr "dashboard.create-new-team")]]) + + (when allow-create-org + [:hr {:role "separator" :class (stl/css :team-separator)}] + [:> dropdown-menu-item* {:on-click on-create-org-click + :class (stl/css :team-dropdown-item :action)} + [:span {:class (stl/css :icon-wrapper)} add-icon] + [:span {:class (stl/css :team-text)} (tr "dashboard.create-new-org")]])])) (mf/defc team-options-dropdown* {::mf/private true} @@ -476,9 +494,80 @@ :data-testid "delete-team"} (tr "dashboard.delete-team")])])) + +(mf/defc sidebar-org-switch* + [{:keys [team profile]}] + (let [teams (->> (mf/deref refs/teams) + vals + (group-by :organization-id) + (map (fn [[_group entries]] (first entries))) + vec + (d/index-by :id)) + + teams (update-vals teams + (fn [t] + (assoc t :name (str "ORG: " (:organization-name t))))) + + team (assoc team :name (str "ORG: " (:organization-name team))) + + show-teams-menu* + (mf/use-state false) + + show-teams-menu? + (deref show-teams-menu*) + + on-show-teams-click + (mf/use-fn + (fn [event] + (dom/stop-propagation event) + (swap! show-teams-menu* not))) + + on-show-teams-keydown + (mf/use-fn + (fn [event] + (when (or (kbd/space? event) + (kbd/enter? event)) + (dom/prevent-default event) + (dom/stop-propagation event) + (some-> (dom/get-current-target event) + (dom/click!))))) + close-teams-menu + (mf/use-fn #(reset! show-teams-menu* false))] + + [:div {:class (stl/css :sidebar-team-switch)} + [:div {:class (stl/css :switch-content)} + [:button {:class (stl/css :current-team) + :on-click on-show-teams-click + :on-key-down on-show-teams-keydown} + + [:div {:class (stl/css :team-name)} + [:img {:src (cf/resolve-team-photo-url team) + :class (stl/css :team-picture) + :alt (:name team)}] + [:span {:class (stl/css :team-text) :title (:name team)} (:name team)]] + + arrow-icon]] + + ;; Teams Dropdown + + [:> teams-selector-dropdown* {:show show-teams-menu? + :on-close close-teams-menu + :id "organizations-list" + :class (stl/css :dropdown :teams-dropdown) + :team team + :profile profile + :teams teams + :show-default-team false + :allow-create-teams false + :allow-create-org true}]])) + (mf/defc sidebar-team-switch* [{:keys [team profile]}] - (let [teams (mf/deref refs/teams) + (let [nitrate? (contains? cf/flags :nitrate) + org-id (when nitrate? (:organization-id team)) + teams (cond->> (mf/deref refs/teams) + nitrate? + (filter #(= (-> % val :organization-id) org-id))) subscription (get team :subscription) @@ -586,7 +675,10 @@ :class (stl/css :dropdown :teams-dropdown) :team team :profile profile - :teams teams}] + :teams teams + :show-default-team true + :allow-create-teams true + :allow-create-org false}] [:> team-options-dropdown* {:show show-team-options-menu? :on-close close-team-options-menu @@ -703,6 +795,8 @@ [:* [:div {:class (stl/css-case :sidebar-content true) :ref container} + (when (contains? cf/flags :nitrate) + [:> sidebar-org-switch* {:team team :profile profile}]) [:> sidebar-team-switch* {:team team :profile profile}] [:> sidebar-search* {:search-term search-term diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs new file mode 100644 index 0000000000..1eb680367b --- /dev/null +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs @@ -0,0 +1,48 @@ +;; 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.nitrate.nitrate-form + (:require-macros [app.main.style :as stl]) + (:require + [app.main.data.modal :as modal] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.icons :as deprecated-icon] + [app.util.dom :as dom] + [rumext.v2 :as mf])) + +;; FIXME: rename to `form` (remove the nitrate prefix from namespace, +;; because it is already under nitrate) + +(mf/defc nitrate-form-modal* + {::mf/register modal/components + ::mf/register-as :nitrate-form} + [] + (let [on-click + (mf/use-fn + (fn [] + (dom/open-new-window "/control-center/licenses/start")))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container)} + [:div {:class (stl/css :nitrate-form)} + + [:div {:class (stl/css :modal-header)} + [:h2 {:class (stl/css :modal-title)} + "BUY NITRATE"] + + [:button {:class (stl/css :modal-close-btn) + :on-click modal/hide!} deprecated-icon/close]] + + [:div {:class (stl/css :modal-content)} + "Nitrate is so cool! You should buy it!"] + + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons)} + [:> button* {:variant "primary" + :on-click on-click} + "BUY NOW!"]]]]]])) + + diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.scss b/frontend/src/app/main/ui/nitrate/nitrate_form.scss new file mode 100644 index 0000000000..4c9d9192b1 --- /dev/null +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.scss @@ -0,0 +1,52 @@ +// 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 "refactor/common-refactor.scss" as deprecated; + +.modal-overlay { + @extend .modal-overlay-base; +} + +.modal-container { + @extend .modal-container-base; +} + +.modal-header { + margin-bottom: deprecated.$s-24; +} + +.modal-title { + @include deprecated.uppercaseTitleTipography; + color: var(--modal-title-foreground-color); +} + +.modal-close-btn { + @extend .modal-close-btn-base; +} + +.modal-content { + margin-bottom: deprecated.$s-24; +} + +.nitrate-form { + min-width: deprecated.$s-400; +} + +.action-buttons { + @extend .modal-action-btns; +} + +.cancel-button { + @extend .modal-cancel-btn; +} + +.accept-btn { + @extend .modal-accept-btn; + + &.danger { + @extend .modal-danger-btn; + } +} diff --git a/frontend/src/app/main/ui/workspace/tokens/management.cljs b/frontend/src/app/main/ui/workspace/tokens/management.cljs index 4ac8623f46..a8e6f7685a 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management.cljs @@ -46,7 +46,7 @@ {::mf/private true} [{:keys [tokens-lib selected-token-set-id]}] (let [selected-token-set - (mf/with-memo [tokens-lib] + (mf/with-memo [tokens-lib selected-token-set-id] (when selected-token-set-id (some-> tokens-lib (ctob/get-set selected-token-set-id)))) @@ -62,18 +62,20 @@ [:div {:class (stl/css :sets-header-container)} [:> text* {:as "span" :typography "headline-small" - :class (stl/css :sets-header)} + :class (stl/css :sets-header) + :data-testid "active-token-set-title"} (tr "workspace.tokens.tokens-section-title" (ctob/get-name selected-token-set))] - [:div {:class (stl/css :sets-header-status) :title (tr "workspace.tokens.inactive-set-description")} + (when (and (some? selected-token-set-id) + (not (token-set-active? (ctob/get-name selected-token-set)))) + [:div {:class (stl/css :sets-header-status) :title (tr "workspace.tokens.inactive-set-description")} ;; NOTE: when no set in tokens-lib, the selected-token-set-id ;; will be `nil`, so for properly hide the inactive message we ;; check that at least `selected-token-set-id` has a value - (when (and (some? selected-token-set-id) - (not (token-set-active? (ctob/get-name selected-token-set)))) + [:* [:> icon* {:class (stl/css :sets-header-status-icon) :icon-id i/eye-off}] [:> text* {:as "span" :typography "body-small" :class (stl/css :sets-header-status-text)} - (tr "workspace.tokens.inactive-set")]])]])) + (tr "workspace.tokens.inactive-set")]]])])) (mf/defc tokens-section* {::mf/private true} @@ -158,7 +160,7 @@ [:& token-context-menu] [:> token-node-context-menu* {:on-delete-node delete-node}] - [:& selected-set-info* {:tokens-lib tokens-lib + [:> selected-set-info* {:tokens-lib tokens-lib :selected-token-set-id selected-token-set-id}] (for [type filled-group] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index a514937354..db993ec3f8 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -433,6 +433,9 @@ msgstr "(copy)" msgid "dashboard.create-new-team" msgstr "Create new team" +msgid "dashboard.create-new-org" +msgstr "Create new org" + #: src/app/main/ui/workspace/main_menu.cljs:661 msgid "dashboard.create-version-menu" msgstr "Pin this version" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index b111c942e6..3a0f137e87 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -442,6 +442,9 @@ msgstr "(copia)" msgid "dashboard.create-new-team" msgstr "Crear nuevo equipo" +msgid "dashboard.create-new-org" +msgstr "Crear nueva organización" + #: src/app/main/ui/workspace/main_menu.cljs:661 msgid "dashboard.create-version-menu" msgstr "Guardar esta versión"