Compare commits

..

5 Commits

Author SHA1 Message Date
Juanfran
5ebe630a95 wip 2026-02-04 14:48:17 +01:00
Juanfran
48be4125ca wip 2026-02-04 13:35:40 +01:00
Juanfran
d41c34b03a wip 2026-02-04 13:16:07 +01:00
Juanfran
34c99401ac Add example ui storybook 2026-02-02 15:59:22 +01:00
Juanfran
ec0966f153 ⬆️ Update plugins dependencies 2026-02-02 15:56:48 +01:00
344 changed files with 13600 additions and 55186 deletions

View File

@@ -1,45 +0,0 @@
name: "MCP CI"
on:
pull_request:
branches:
- develop
- staging
- main
types:
- opened
- synchronize
paths:
- 'mcp/**'
push:
branches:
- develop
- staging
- main
paths:
- 'mcp/**'
jobs:
test:
name: "Test"
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup
working-directory: ./mcp
run: ./scripts/setup
- name: Check
working-directory: ./mcp
run: |
pnpm run fmt:check;
pnpm -r run build;
pnpm -r run types:check;

View File

@@ -32,7 +32,6 @@
- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135)
- Fix boolean operators in menu for boards [Taiga #13174](https://tree.taiga.io/project/penpot/issue/13174)
- Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186)
- Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128)
## 2.13.0 (Unreleased)
@@ -74,9 +73,6 @@
- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113)
- Fix several race conditions on path editor [Github #8187](https://github.com/penpot/penpot/pull/8187)
- Fix app freeze when introducing an error on a very long token name [Taiga #13214](https://tree.taiga.io/project/penpot/issue/13214)
- Fix import a file with shadow tokens [Taiga #13229](https://tree.taiga.io/project/penpot/issue/13229)
- Fix allow spaces on token description [Taiga #13184](https://tree.taiga.io/project/penpot/issue/13184)
- Fix error when creating a token with an invalid name [Taiga #13219](https://tree.taiga.io/project/penpot/issue/13219)
## 2.12.1

View File

@@ -5,6 +5,7 @@
<meta name="robots" content="noindex,nofollow">
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono">
<style>
{% include "app/templates/styles.css" %}
</style>

View File

@@ -5,25 +5,23 @@ penpot - error list
{% endblock %}
{% block content %}
<nav>
<div class="title">
<a href="/dbg"> [BACK]</a>
<h1>Error reports (last 300)</h1>
<a class="{% if version = 3 %}strong{% endif %}" href="?version=3">[BACKEND ERRORS]</a>
<a class="{% if version = 4 %}strong{% endif %}" href="?version=4">[FRONTEND ERRORS]</a>
</div>
</nav>
<main class="horizontal-list">
<ul>
{% for item in items %}
<li>
<a class="date" href="/dbg/error/{{item.id}}">{{item.created-at}}</a>
<a class="hint" href="/dbg/error/{{item.id}}">
<span class="title">{{item.hint|abbreviate:150}}</span>
</a>
</li>
{% endfor %}
</ul>
</main>
<nav>
<div class="title">
<h1>Error reports (last 200)
<a href="/dbg">[GO BACK]</a>
</h1>
</div>
</nav>
<main class="horizontal-list">
<ul>
{% for item in items %}
<li>
<a class="date" href="/dbg/error/{{item.id}}">{{item.created-at}}</a>
<a class="hint" href="/dbg/error/{{item.id}}">
<span class="title">{{item.hint|abbreviate:150}}</span>
</a>
</li>
{% endfor %}
</ul>
</main>
{% endblock %}

View File

@@ -6,7 +6,7 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v3)
{% block content %}
<nav>
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="/dbg/error">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div>
<div>[<a href="#props">props</a>]</div>
<div>[<a href="#context">context</a>]</div>

View File

@@ -1,46 +0,0 @@
{% extends "app/templates/base.tmpl" %}
{% block title %}
Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
{% endblock %}
{% block content %}
<nav>
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div>
<!-- <div>[<a href="#props">props</a>]</div> -->
<div>[<a href="#context">context</a>]</div>
{% if report %}
<div>[<a href="#report">report</a>]</div>
{% endif %}
</nav>
<main>
<div class="table">
<div class="table-row multiline">
<div id="head" class="table-key">HEAD</div>
<div class="table-val">
<h1><span class="not-important">Hint:</span> <br/> {{hint}}</h1>
<h2><span class="not-important">Reported at:</span> <br/> {{created-at}}</h2>
<h2><span class="not-important">Report ID:</span> <br/> {{id}}</h2>
</div>
</div>
<div class="table-row multiline">
<div id="context" class="table-key">CONTEXT: </div>
<div class="table-val">
<pre>{{context}}</pre>
</div>
</div>
{% if report %}
<div class="table-row multiline">
<div id="report" class="table-key">REPORT:</div>
<div class="table-val">
<pre>{{report}}</pre>
</div>
</div>
{% endif %}
</div>
</main>
{% endblock %}

View File

@@ -1,5 +1,5 @@
* {
font-family: monospace;
font-family: "JetBrains Mono", monospace;
font-size: 12px;
}
@@ -36,10 +36,6 @@ small {
color: #888;
}
.strong {
font-weight: 900;
}
.not-important {
color: #888;
font-weight: 200;
@@ -61,26 +57,14 @@ nav {
nav > .title {
display: flex;
justify-content: center;
width: 100%;
}
nav > .title > a {
color: black;
text-decoration: none;
}
nav > .title > a.strong {
text-decoration: underline;
}
nav > .title > h1 {
padding: 0px;
margin: 0px;
font-size: 11px;
display: block;
}
nav > .title > * {
padding: 0px 6px;
}
nav > div {

View File

@@ -232,22 +232,13 @@
(-> (io/resource "app/templates/error-report.v3.tmpl")
(tmpl/render (-> content
(assoc :id id)
(assoc :version 3)
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
(render-template-v4 [{:keys [content id created-at]}]
(-> (io/resource "app/templates/error-report.v4.tmpl")
(tmpl/render (-> content
(assoc :id id)
(assoc :version 4)
(assoc :created-at (ct/format-inst created-at :rfc1123))))))]
(if-let [report (get-report request)]
(let [result (case (:version report)
1 (render-template-v1 report)
2 (render-template-v2 report)
3 (render-template-v3 report)
4 (render-template-v4 report))]
3 (render-template-v3 report))]
{::yres/status 200
::yres/body result
::yres/headers {"content-type" "text/html; charset=utf-8"
@@ -255,22 +246,20 @@
{::yres/status 404
::yres/body "not found"})))
(def ^:private sql:error-reports
(def sql:error-reports
"SELECT id, created_at,
content->>'~:hint' AS hint
FROM server_error_report
WHERE version = ?
ORDER BY created_at DESC
LIMIT 300")
LIMIT 200")
(defn- error-list-handler
[{:keys [::db/pool]} {:keys [params]}]
(let [version (or (some-> (get params :version) parse-long) 3)
items (->> (db/exec! pool [sql:error-reports version])
(map #(update % :created-at ct/format-inst :rfc1123)))]
(defn error-list-handler
[{:keys [::db/pool]} _request]
(let [items (->> (db/exec! pool [sql:error-reports])
(map #(update % :created-at ct/format-inst :rfc1123)))]
{::yres/status 200
::yres/body (-> (io/resource "app/templates/error-list.tmpl")
(tmpl/render {:items items :version version}))
(tmpl/render {:items items}))
::yres/headers {"content-type" "text/html; charset=utf-8"
"x-robots-tag" "noindex"}}))

View File

@@ -32,7 +32,7 @@
(assoc :request/ip-addr (inet/parse-request request))
(assoc :request/profile-id (get claims :uid))
(assoc :request/auth-data auth)
(assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
(assoc :version/frontend (or (yreq/get-header request "x-frontend-version") "unknown")))))
(defmulti handle-error
(fn [cause _ _]

View File

@@ -113,8 +113,6 @@
;; COLLECTOR API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare ^:private prepare-context-from-request)
;; Defines a service that collects the audit/activity log using
;; internal database. Later this audit log can be transferred to
;; an external storage and data cleared.
@@ -128,8 +126,6 @@
[::props {:optional true} [:map-of :keyword :any]]
[::context {:optional true} [:map-of :keyword :any]]
[::tracked-at {:optional true} ::ct/inst]
[::created-at {:optional true} ::ct/inst]
[::source {:optional true} ::sm/text]
[::webhooks/event? {:optional true} ::sm/boolean]
[::webhooks/batch-timeout {:optional true} ::ct/duration]
[::webhooks/batch-key {:optional true}
@@ -138,8 +134,36 @@
(def ^:private check-event
(sm/check-fn schema:event))
(def valid-event?
(sm/validator schema:event))
(defn- prepare-context-from-request
[request]
(let [client-event-origin (get-client-event-origin request)
client-version (get-client-version request)
client-user-agent (get-client-user-agent request)
session-id (get-external-session-id request)
key-id (::http/auth-key-id request)
token-id (::actoken/id request)
token-type (::actoken/type request)]
(d/without-nils
{:external-session-id session-id
:initiator (or key-id "app")
:access-token-id (some-> token-id str)
:access-token-type (some-> token-type str)
:client-event-origin client-event-origin
:client-user-agent client-user-agent
:client-version client-version
:version (:full cf/version)})))
(defn event-from-rpc-params
"Create a base event skeleton with pre-filled some important
data that can be extracted from RPC params object"
[params]
(let [context (some-> params meta ::http/request prepare-context-from-request)
event {::type "action"
::profile-id (or (::rpc/profile-id params) uuid/zero)
::ip-addr (::rpc/ip-addr params)}]
(cond-> event
(some? context)
(assoc ::context context))))
(defn prepare-event
[cfg mdata params result]
@@ -188,38 +212,6 @@
(::webhooks/event? resultm)
false)}))
(defn- prepare-context-from-request
"Prepare backend event context from request"
[request]
(let [client-event-origin (get-client-event-origin request)
client-version (get-client-version request)
client-user-agent (get-client-user-agent request)
session-id (get-external-session-id request)
key-id (::http/auth-key-id request)
token-id (::actoken/id request)
token-type (::actoken/type request)]
(d/without-nils
{:external-session-id session-id
:initiator (or key-id "app")
:access-token-id (some-> token-id str)
:access-token-type (some-> token-type str)
:client-event-origin client-event-origin
:client-user-agent client-user-agent
:client-version client-version
:version (:full cf/version)})))
(defn event-from-rpc-params
"Create a base event skeleton with pre-filled some important
data that can be extracted from RPC params object"
[params]
(let [context (some-> params meta ::http/request prepare-context-from-request)
event {::type "action"
::profile-id (or (::rpc/profile-id params) uuid/zero)
::ip-addr (::rpc/ip-addr params)}]
(cond-> event
(some? context)
(assoc ::context context))))
(defn- event->params
[event]
(let [params {:id (uuid/next)
@@ -246,10 +238,8 @@
(defn- handle-event!
[cfg event]
(let [tnow (ct/now)
params (-> (event->params event)
(assoc :created-at tnow)
(update :tracked-at #(or % tnow)))]
(let [params (event->params event)
tnow (ct/now)]
(when (contains? cf/flags :audit-log-logger)
(l/log! ::l/logger "app.audit"
@@ -265,7 +255,10 @@
;; NOTE: this operation may cause primary key conflicts on inserts
;; because of the timestamp precission (two concurrent requests), in
;; this case we just retry the operation.
(append-audit-entry cfg params))
(let [params (-> params
(assoc :created-at tnow)
(update :tracked-at #(or % tnow)))]
(append-audit-entry cfg params)))
(when (and (or (contains? cf/flags :telemetry)
(cf/get :telemetry-enabled))
@@ -276,6 +269,8 @@
;;
;; NOTE: this is only executed when general audit log is disabled
(let [params (-> params
(assoc :created-at tnow)
(update :tracked-at #(or % tnow))
(assoc :props {})
(assoc :context {}))]
(append-audit-entry cfg params)))

View File

@@ -14,7 +14,6 @@
[app.common.schema :as sm]
[app.config :as cf]
[app.db :as db]
[app.loggers.audit :as audit]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
[promesa.exec :as px]
@@ -29,108 +28,69 @@
(defonce enabled (atom true))
(defn- persist-on-database!
[pool id version report]
[pool id report]
(when-not (db/read-only? pool)
(db/insert! pool :server-error-report
{:id id
:version version
:version 3
:content (db/tjson report)})))
(defn- concurrent-exception?
[cause]
(or (instance? java.util.concurrent.CompletionException cause)
(instance? java.util.concurrent.ExecutionException cause)))
(defn record->report
[{:keys [::l/context ::l/message ::l/props ::l/logger ::l/level ::l/cause] :as record}]
(assert (l/valid-record? record) "expectd valid log record")
(let [data (if (concurrent-exception? cause)
(ex-data (ex-cause cause))
(ex-data cause))
(if (or (instance? java.util.concurrent.CompletionException cause)
(instance? java.util.concurrent.ExecutionException cause))
(-> record
(assoc ::trace (ex/format-throwable cause :data? true :explain? false :header? false :summary? false))
(assoc ::l/cause (ex-cause cause))
(record->report))
ctx (-> context
(assoc :service/tenant (cf/get :tenant))
(assoc :service/host (cf/get :host))
(assoc :service/public-uri (str (cf/get :public-uri)))
(assoc :backend/version (:full cf/version))
(assoc :logger/name logger)
(assoc :logger/level level)
(dissoc :request/params :value :params :data))]
(let [data (ex-data cause)
ctx (-> context
(assoc :tenant (cf/get :tenant))
(assoc :host (cf/get :host))
(assoc :public-uri (str (cf/get :public-uri)))
(assoc :logger/name logger)
(assoc :logger/level level)
(dissoc :request/params :value :params :data))]
(merge
{:context (-> (into (sorted-map) ctx)
(pp/pprint-str :length 50))
:props (pp/pprint-str props :length 50)
:hint (or (when-let [message (ex-message cause)]
(if-let [props-hint (:hint props)]
(str props-hint ": " message)
message))
@message)
:trace (or (::trace record)
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))}
(merge
{:context (-> (into (sorted-map) ctx)
(pp/pprint-str :length 50))
:props (pp/pprint-str props :length 50)
:hint (or (when-let [message (ex-message cause)]
(if-let [props-hint (:hint props)]
(str props-hint ": " message)
message))
@message)
:trace (or (::trace record)
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))}
(when-let [params (or (:request/params context) (:params context))]
{:params (pp/pprint-str params :length 20 :level 20)})
(when-let [params (or (:request/params context) (:params context))]
{:params (pp/pprint-str params :length 20 :level 20)})
(when-let [value (:value context)]
{:value (pp/pprint-str value :length 30 :level 13)})
(when-let [value (:value context)]
{:value (pp/pprint-str value :length 30 :level 13)})
(when-let [data (some-> data (dissoc ::s/problems ::s/value ::s/spec ::sm/explain :hint))]
{:data (pp/pprint-str data :length 30 :level 13)})
(when-let [data (some-> data (dissoc ::s/problems ::s/value ::s/spec ::sm/explain :hint))]
{:data (pp/pprint-str data :length 30 :level 13)})
(when-let [explain (ex/explain data :length 30 :level 13)]
{:explain explain}))))
(when-let [explain (ex/explain data :length 30 :level 13)]
{:explain explain})))))
(defn- handle-log-record
"Convert the log record into a report object and persist it on the database"
(defn error-record?
[{:keys [::l/level]}]
(= :error level))
(defn- handle-event
[{:keys [::db/pool]} {:keys [::l/id] :as record}]
(try
(let [uri (cf/get :public-uri)
report (-> record record->report d/without-nils)]
(l/dbg :hint "registering error on database"
:id id
:src "logging"
:uri (str uri "/dbg/error/" id))
(persist-on-database! pool id 3 report))
(catch Throwable cause
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
(l/debug :hint "registering error on database" :id id
:uri (str uri "/dbg/error/" id))
(defn- event->report
[{:keys [::audit/context ::audit/props ::audit/ip-addr] :as record}]
(let [context
(reduce-kv (fn [context k v]
(let [k' (keyword "frontend" (name k))]
(-> context
(dissoc k)
(assoc k' v))))
context
context)
context
(-> context
(assoc :backend/tenant (cf/get :tenant))
(assoc :backend/host (cf/get :host))
(assoc :backend/public-uri (str (cf/get :public-uri)))
(assoc :backend/version (:full cf/version))
(assoc :frontend/ip-addr ip-addr))]
{:context (-> (into (sorted-map) context)
(pp/pprint-str :length 50))
:props (pp/pprint-str props :length 50)
:hint (get props :hint)
:report (get props :report)}))
(defn- handle-audit-event
"Convert the log record into a report object and persist it on the database"
[{:keys [::db/pool]} {:keys [::audit/id] :as event}]
(try
(let [uri (cf/get :public-uri)
report (-> event event->report d/without-nils)]
(l/dbg :hint "registering error on database"
:id id
:src "audit-log"
:uri (str uri "/dbg/error/" id))
(persist-on-database! pool id 4 report))
(persist-on-database! pool id report))
(catch Throwable cause
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
@@ -140,49 +100,26 @@
(defmethod ig/init-key ::reporter
[_ cfg]
(let [input (sp/chan :buf (sp/sliding-buffer 256))
thread (px/thread
{:name "penpot/reporter/database"}
(l/info :hint "initializing database error persistence")
(try
(loop []
(when-let [item (sp/take! input)]
(cond
(::l/id item)
(handle-log-record cfg item)
(let [input (sp/chan :buf (sp/sliding-buffer 64)
:xf (filter error-record?))]
(add-watch l/log-record ::reporter #(sp/put! input %4))
(::audit/id item)
(handle-audit-event cfg item)
:else
(l/warn :hint "received unexpected item" :item item))
(recur)))
(catch InterruptedException _
(l/debug :hint "reporter interrupted"))
(catch Throwable cause
(l/error :hint "unexpected error" :cause cause))
(finally
(l/info :hint "reporter terminated"))))]
(add-watch l/log-record ::reporter
(fn [_ _ _ record]
(when (= :error (::l/level record))
(sp/put! input record))))
{::input input
::thread thread}))
(px/thread {:name "penpot/database-reporter"}
(l/info :hint "initializing database error persistence")
(try
(loop []
(when-let [record (sp/take! input)]
(handle-event cfg record)
(recur)))
(catch InterruptedException _
(l/debug :hint "reporter interrupted"))
(catch Throwable cause
(l/error :hint "unexpected error" :cause cause))
(finally
(sp/close! input)
(remove-watch l/log-record ::reporter)
(l/info :hint "reporter terminated"))))))
(defmethod ig/halt-key! ::reporter
[_ {:keys [::input ::thread]}]
(remove-watch l/log-record ::reporter)
(sp/close! input)
(px/interrupt! thread))
(defn emit
"Emit an event/report into the database reporter"
[cfg event]
(when-let [{:keys [::input]} (get cfg ::reporter)]
(sp/put! input event)))
[_ thread]
(some-> thread px/interrupt!))

View File

@@ -9,10 +9,9 @@
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.uri :as u]
[app.config :as cf]
[app.http.client :as http]
[app.loggers.audit :as audit]
[app.loggers.database :as ldb]
[app.util.json :as json]
[integrant.core :as ig]
[promesa.exec :as px]
@@ -21,27 +20,24 @@
(defonce enabled (atom true))
(defn- send-mattermost-notification!
[cfg {:keys [id] :as report}]
[cfg {:keys [id public-uri] :as report}]
(let [url (u/join (cf/get :public-uri) "/dbg/error/" id)
text (str "Exception: " url " "
(let [text (str "Exception: " public-uri "/dbg/error/" id " "
(when-let [pid (:profile-id report)]
(str "(pid: #uuid-" pid ")"))
"\n"
"- host: #" (:host report) "\n"
"- tenant: #" (:tenant report) "\n"
"- origin: #" (:origin report) "\n"
"- href: `" (:href report) "`\n"
"- logger: #" (:logger report) "\n"
"- request-path: `" (:request-path report) "`\n"
"- frontend-version: `" (:frontend-version report) "`\n"
"- backend-version: `" (:backend-version report) "`\n"
"\n"
(when-let [trace (:trace report)]
(str "```\n"
"Trace:\n"
trace
"```")))
"```\n"
"Trace:\n"
(:trace report)
"```")
resp (http/req! cfg
{:uri (cf/get :error-report-webhook)
@@ -54,49 +50,28 @@
(l/warn :hint "error on sending data"
:response (pr-str resp)))))
(defn- record->report
(defn record->report
[{:keys [::l/context ::l/id ::l/cause] :as record}]
(assert (l/valid-record? record) "expectd valid log record")
(let [public-uri (cf/get :public-uri)]
{:id id
:origin "logging"
:tenant (cf/get :tenant)
:host (cf/get :host)
:backend-version (:full cf/version)
:frontend-version (:frontend/version context)
:profile-id (:request/profile-id context)
:href (-> public-uri
(assoc :path (:request/path context))
(str))
:trace (ex/format-throwable cause :detail? false :header? false)}))
(defn- audit-event->report
[{:keys [::audit/context ::audit/props ::audit/id] :as event}]
{:id id
:origin "audit-log"
:tenant (cf/get :tenant)
:host (cf/get :host)
:backend-version (:full cf/version)
:frontend-version (:version context)
:profile-id (:audit/profile-id event)
:href (get props :href)})
:public-uri (cf/get :public-uri)
:backend-version (or (:version/backend context) (:full cf/version))
:frontend-version (:version/frontend context)
:profile-id (:request/profile-id context)
:request-path (:request/path context)
:logger (::l/logger record)
:trace (ex/format-throwable cause :detail? false :header? false)})
(defn- handle-log-record
(defn handle-event
[cfg record]
(try
(let [report (record->report record)]
(send-mattermost-notification! cfg report))
(catch Throwable cause
(l/warn :hint "unhandled error" :cause cause))))
(defn- handle-audit-event
[cfg record]
(try
(let [report (audit-event->report record)]
(send-mattermost-notification! cfg report))
(catch Throwable cause
(l/warn :hint "unhandled error" :cause cause))))
(when @enabled
(try
(let [report (record->report record)]
(send-mattermost-notification! cfg report))
(catch Throwable cause
(l/warn :hint "unhandled error" :cause cause)))))
(defmethod ig/assert-key ::reporter
[_ params]
@@ -105,49 +80,27 @@
(defmethod ig/init-key ::reporter
[_ cfg]
(when-let [uri (cf/get :error-report-webhook)]
(let [input (sp/chan :buf (sp/sliding-buffer 256))
thread (px/thread
{:name "penpot/reporter/mattermost"}
(l/info :hint "initializing error reporter" :uri uri)
(try
(loop []
(when-let [item (sp/take! input)]
(when @enabled
(cond
(::l/id item)
(handle-log-record cfg item)
(::audit/id item)
(handle-audit-event cfg item)
:else
(l/warn :hint "received unexpected item" :item item)))
(recur)))
(catch InterruptedException _
(l/debug :hint "reporter interrupted"))
(catch Throwable cause
(l/error :hint "unexpected error" :cause cause))
(finally
(l/info :hint "reporter terminated"))))]
(add-watch l/log-record ::reporter
(fn [_ _ _ record]
(when (= :error (::l/level record))
(sp/put! input record))))
{::input input
::thread thread})))
(px/thread
{:name "penpot/mattermost-reporter"
:virtual true}
(l/info :hint "initializing error reporter" :uri uri)
(let [input (sp/chan :buf (sp/sliding-buffer 128)
:xf (filter ldb/error-record?))]
(add-watch l/log-record ::reporter #(sp/put! input %4))
(try
(loop []
(when-let [msg (sp/take! input)]
(handle-event cfg msg)
(recur)))
(catch InterruptedException _
(l/debug :hint "reporter interrupted"))
(catch Throwable cause
(l/error :hint "unexpected error" :cause cause))
(finally
(sp/close! input)
(remove-watch l/log-record ::reporter)
(l/info :hint "reporter terminated")))))))
(defmethod ig/halt-key! ::reporter
[_ {:keys [::input ::thread]}]
(remove-watch l/log-record ::reporter)
(some-> input sp/close!)
[_ thread]
(some-> thread px/interrupt!))
(defn emit
"Emit an event/report into the mattermost reporter"
[cfg event]
(when-let [{:keys [::input]} (get cfg ::reporter)]
(sp/put! input event)))

View File

@@ -337,13 +337,7 @@
::setup/props (ig/ref ::setup/props)
::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)
:app.loggers.database/reporter
(ig/ref :app.loggers.database/reporter)
:app.loggers.mattermost/reporter
(ig/ref :app.loggers.mattermost/reporter)}
::email/whitelist (ig/ref ::email/whitelist)}
:app.nitrate/client
{::http.client/client (ig/ref ::http.client/client)

View File

@@ -456,10 +456,7 @@
:fn (mg/resource "app/migrations/sql/0142-add-sso-provider-table.sql")}
{:name "0143-http-session-v2-table"
:fn (mg/resource "app/migrations/sql/0143-add-http-session-v2-table.sql")}
{:name "0144-mod-server-error-report-table"
:fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")}])
:fn (mg/resource "app/migrations/sql/0143-add-http-session-v2-table.sql")}])
(defn apply-migrations!
[pool name migrations]

View File

@@ -1,4 +0,0 @@
ALTER TABLE server_error_report DROP CONSTRAINT server_error_report_pkey;
ALTER TABLE server_error_report ADD PRIMARY KEY (id);
CREATE INDEX server_error_report__version__idx
ON server_error_report ( version );

View File

@@ -16,8 +16,6 @@
[app.db :as db]
[app.http :as-alias http]
[app.loggers.audit :as-alias audit]
[app.loggers.database :as loggers.db]
[app.loggers.mattermost :as loggers.mm]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.doc :as-alias doc]
@@ -38,79 +36,52 @@
:context])
(defn- event->row [event]
[(::audit/id event)
(::audit/name event)
(::audit/source event)
(::audit/type event)
(::audit/tracked-at event)
(::audit/created-at event)
(::audit/profile-id event)
(db/inet (::audit/ip-addr event))
(db/tjson (::audit/props event))
(db/tjson (d/without-nils (::audit/context event)))])
[(uuid/next)
(:name event)
(:source event)
(:type event)
(:timestamp event)
(:created-at event)
(:profile-id event)
(db/inet (:ip-addr event))
(db/tjson (:props event))
(db/tjson (d/without-nils (:context event)))])
(defn- adjust-timestamp
[{:keys [::audit/tracked-at ::audit/created-at] :as event}]
(let [margin (inst-ms (ct/diff tracked-at created-at))]
[{:keys [timestamp created-at] :as event}]
(let [margin (inst-ms (ct/diff timestamp created-at))]
(if (or (neg? margin)
(> margin 3600000))
;; If event is in future or lags more than 1 hour, we reasign
;; tracked-at to the server creation date
;; timestamp to the server creation date
(-> event
(assoc ::audit/tracked-at created-at)
(update ::audit/context assoc :original-tracked-at tracked-at))
(assoc :timestamp created-at)
(update :context assoc :original-timestamp timestamp))
event)))
(defn- exception-event?
[{:keys [::audit/type ::audit/name] :as ev}]
(and (= "action" type)
(or (= "unhandled-exception" name)
(= "exception-page" name))))
(def ^:private xf:map-event-row
(comp
(map adjust-timestamp)
(map event->row)))
(defn- get-events
[{:keys [::rpc/request-at ::rpc/profile-id events] :as params}]
(defn- handle-events
[{:keys [::db/pool]} {:keys [::rpc/profile-id events] :as params}]
(let [request (-> params meta ::http/request)
ip-addr (inet/parse-request request)
xform (map (fn [event]
{::audit/id (uuid/next)
::audit/type (:type event)
::audit/name (:name event)
::audit/props (:props event)
::audit/context (:context event)
::audit/profile-id profile-id
::audit/ip-addr ip-addr
::audit/source "frontend"
::audit/tracked-at (:timestamp event)
::audit/created-at request-at}))]
(sequence xform events)))
(defn- handle-events
[{:keys [::db/pool] :as cfg} params]
(let [events (get-events params)]
;; Look for error reports and save them on internal reports table
(when-let [events (->> events
(sequence (filter exception-event?))
(not-empty))]
(run! (partial loggers.db/emit cfg) events)
(run! (partial loggers.mm/emit cfg) events))
;; Process and save events
tnow (ct/now)
xform (comp
(map (fn [event]
(-> event
(assoc :created-at tnow)
(assoc :profile-id profile-id)
(assoc :ip-addr ip-addr)
(assoc :source "frontend"))))
(filter :profile-id)
(map adjust-timestamp)
(map event->row))
events (sequence xform events)]
(when (seq events)
(let [rows (sequence xf:map-event-row events)]
(db/insert-many! pool :audit-log event-columns rows)))))
(db/insert-many! pool :audit-log event-columns events))))
(def ^:private valid-event-types
(def valid-event-types
#{"action" "identify" "trigger"})
(def ^:private schema:frontend-event
(def schema:event
[:map {:title "Event"}
[:name
[:and {:gen/elements ["update-file", "get-profile"]}
@@ -122,13 +93,12 @@
[::sm/one-of {:format "string"} valid-event-types]]]
[:props
[:map-of :keyword ::sm/any]]
[:timestamp ::ct/inst]
[:context {:optional true}
[:map-of :keyword ::sm/any]]])
(def ^:private schema:push-audit-events
(def schema:push-audit-events
[:map {:title "push-audit-events"}
[:events [:vector schema:frontend-event]]])
[:events [:vector schema:event]]])
(sv/defmethod ::push-audit-events
{::climit/id :submit-audit-events/by-profile

View File

@@ -103,14 +103,6 @@
(assoc-in [::db/pool ::db/username] (:database-username config))
(assoc-in [::db/pool ::db/password] (:database-password config))
(assoc-in [:app.rpc/methods :app.setup/templates] templates)
(assoc-in [:app.rpc/methods :app.setup/templates] templates)
(update :app.rpc/methods
(fn [state]
(-> state
(assoc :app.setup/templates templates)
(assoc :app.loggers.mattermost/reporter nil)
(assoc :app.loggers.database/reporter nil))))
(dissoc :app.srepl/server
:app.http/server
:app.http/route

View File

@@ -239,63 +239,22 @@
(recur cause))))))]
(with-out-str
(print-all cause))))
:cljs
(defn format-throwable
[cause & {:as opts}]
(with-out-str
(when-let [exdata (ex-data cause)]
(when-let [hint (get exdata :hint)]
(when (str/index-of hint "\n")
(println "Hint:")
(println "--------------------")
(println hint)
(println)))
(when-let [explain (get exdata ::sm/explain)]
(println "Explain:")
(println "--------------------")
(println (sm/humanize-explain explain))
(println))
(when-let [explain (get exdata :explain)]
(println "Server Explain:")
(println "--------------------")
(println explain))
(println "Data:")
(println "--------------------")
(pp/pprint (dissoc exdata ::sm/explain :explain))
(println))
(when-let [trace (.-stack cause)]
(println "Trace:")
(println "--------------------")
(println (.-stack cause))))))
(defn first-line
[s]
(let [break-index (str/index-of s "\n")]
(if (pos? break-index)
(subs s 0 break-index)
s)))
(print-all cause)))))
(defn print-throwable
[cause & {:as opts}]
#?(:clj
(println (format-throwable cause opts))
:cljs
(let [prefix (get opts :prefix)
data (ex-data cause)
title (cond->> (or (some-> (:hint data) first-line)
(ex-message cause))
(string? prefix)
(str prefix ": "))]
(let [prefix (get opts :prefix "exception")
title (str prefix ": " (ex-message cause))
exdata (ex-data cause)]
(js/console.group title)
(try
(js/console.log (format-throwable cause))
(finally
(js/console.groupEnd))))))
(when-let [explain (get exdata ::sm/explain)]
(println (sm/humanize-explain explain)))
(js/console.log "\nData:")
(pp/pprint (dissoc exdata ::sm/explain))
(js/console.log "\nTrace:")
(js/console.error (.-stack cause)))))

View File

@@ -27,7 +27,6 @@
[app.common.types.path :as path]
[app.common.types.shape :as cts]
[app.common.types.shape-tree :as ctst]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.common.types.typographies-list :as ctyl]
[app.common.types.typography :as ctt]
@@ -379,7 +378,7 @@
[:type [:= :set-token]]
[:set-id ::sm/uuid]
[:token-id ::sm/uuid]
[:attrs [:maybe cto/schema:token-attrs]]]]
[:attrs [:maybe ctob/schema:token-attrs]]]]
[:set-token-set
[:map {:title "SetTokenSetChange"}

View File

@@ -8,227 +8,8 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.i18n :refer [tr]]
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[clojure.set :as set]
[cuerdas.core :as str]
[malli.core :as m]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HIGH LEVEL SCHEMAS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Token value
(defn- token-value-empty-fn
[{:keys [value]}]
(when (or (str/empty? value)
(str/blank? value))
(tr "workspace.tokens.empty-input")))
(def schema:token-value-generic
[::sm/text {:error/fn token-value-empty-fn}])
(def schema:token-value-composite-ref
[::sm/text {:error/fn token-value-empty-fn}])
(def schema:token-value-font-family
[:vector :string])
(def schema:token-value-typography-map
[:map
[:font-family {:optional true} schema:token-value-font-family]
[:font-weight {:optional true} schema:token-value-generic]
[:font-size {:optional true} schema:token-value-generic]
[:line-height {:optional true} schema:token-value-generic]
[:letter-spacing {:optional true} schema:token-value-generic]
[:paragraph-spacing {:optional true} schema:token-value-generic]
[:text-decoration {:optional true} schema:token-value-generic]
[:text-case {:optional true} schema:token-value-generic]])
(def schema:token-value-typography
[:or
schema:token-value-typography-map
schema:token-value-composite-ref])
(def schema:token-value-shadow-vector
[:vector
[:map
[:offset-x :string]
[:offset-y :string]
[:blur
[:and
:string
[:fn {:error/fn #(tr "workspace.tokens.shadow-token-blur-value-error")}
(fn [blur]
(let [n (d/parse-double blur)]
(or (nil? n) (not (< n 0)))))]]]
[:spread
[:and
:string
[:fn {:error/fn #(tr "workspace.tokens.shadow-token-spread-value-error")}
(fn [spread]
(let [n (d/parse-double spread)]
(or (nil? n) (not (< n 0)))))]]]
[:color :string]
[:inset {:optional true} :boolean]]])
(def schema:token-value-shadow
[:or
schema:token-value-shadow-vector
schema:token-value-composite-ref])
(defn make-token-value-schema
[token-type]
[:multi {:dispatch (constantly token-type)
:title "Token Value"}
[:font-family schema:token-value-font-family]
[:typography schema:token-value-typography]
[:shadow schema:token-value-shadow]
[::m/default schema:token-value-generic]])
;; Token
(defn make-token-name-schema
"Dynamically generates a schema to check a token name, adding translated error messages
and two additional validations:
- Min and max length.
- Checks if other token with a path derived from the name already exists at `tokens-tree`.
e.g. it's not allowed to create a token `foo.bar` if a token `foo` already exists."
[tokens-tree]
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(-> cto/schema:token-name
(sm/update-properties assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(not (ctob/token-name-path-exists? % tokens-tree))]])
(def schema:token-description
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}])
(defn make-token-schema
[tokens-tree token-type]
[:and
(sm/merge
cto/schema:token-attrs
[:map
[:name (make-token-name-schema tokens-tree)]
[:value (make-token-value-schema token-type)]
[:description {:optional true} schema:token-description]])
[:fn {:error/field :value
:error/fn #(tr "workspace.tokens.self-reference")}
(fn [{:keys [name value]}]
(when (and name value)
(not (cto/token-value-self-reference? name value))))]])
(defn convert-dtcg-token
"Convert token attributes as they come from a decoded json, with DTCG types, to internal types.
Eg. From this:
{'name' 'body-text'
'type' 'typography'
'value' {
'fontFamilies' ['Arial' 'Helvetica' 'sans-serif']
'fontSize' '16px'
'fontWeights' 'normal'}}
to this
{:name 'body-text'
:type :typography
:value {
:font-family ['Arial' 'Helvetica' 'sans-serif']
:font-size '16px'
:font-weight 'normal'}}"
[token-attrs]
(let [name (get token-attrs "name")
type (get token-attrs "type")
value (get token-attrs "value")
description (get token-attrs "description")
type (cto/dtcg-token-type->token-type type)
value (case type
:font-family (ctob/convert-dtcg-font-family value)
:typography (ctob/convert-dtcg-typography-composite value)
:shadow (ctob/convert-dtcg-shadow-composite value)
value)]
(d/without-nils {:name name
:type type
:value value
:description description})))
;; Token set
(defn make-token-set-name-schema
"Generates a dynamic schema to check a token set name:
- Validate name length.
- Checks if other token set with a path derived from the name already exists in the tokens lib."
[tokens-lib set-id]
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
[:fn {:error/fn #(tr "errors.token-set-already-exists" (:value %))}
(fn [name]
(or (nil? tokens-lib)
(let [set (ctob/get-set-by-name tokens-lib name)]
(or (nil? set) (= (ctob/get-id set) set-id)))))]])
(def schema:token-set-description
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}])
(defn make-token-set-schema
[tokens-lib set-id]
(sm/merge
ctob/schema:token-set-attrs
[:map
[:name [:and (make-token-set-name-schema tokens-lib set-id)
[:fn #(ctob/normalized-set-name? %)]]]
[:description {:optional true} schema:token-set-description]]))
;; Token theme
(defn make-token-theme-group-schema
"Generates a dynamic schema to check a token theme group:
- Validate group length.
- Checks if other token theme with the same name already exists in the new group in the tokens lib."
[tokens-lib name theme-id]
[:and
[:string {:min 0 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
[:fn {:error/fn #(tr "errors.token-theme-already-exists" (:value %))}
(fn [group]
(or (nil? tokens-lib)
(let [theme (ctob/get-theme-by-name tokens-lib group name)]
(or (nil? theme) (= (:id theme) theme-id)))))]])
(defn make-token-theme-name-schema
"Generates a dynamic schema to check a token theme name:
- Validate name length.
- Checks if other token theme with the same name already exists in the same group in the tokens lib."
[tokens-lib group theme-id]
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
[:fn {:error/fn #(tr "errors.token-theme-already-exists" (str group "/" (:value %)))}
(fn [name]
(or (nil? tokens-lib)
(let [theme (ctob/get-theme-by-name tokens-lib group name)]
(or (nil? theme) (= (:id theme) theme-id)))))]])
(def schema:token-theme-description
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}])
(defn make-token-theme-schema
[tokens-lib group name theme-id]
(sm/merge
ctob/schema:token-theme-attrs
[:map
[:group (make-token-theme-group-schema tokens-lib name theme-id)] ;; TODO how to keep error-fn from here?
[:name (make-token-theme-name-schema tokens-lib group theme-id)]
[:description {:optional true} schema:token-theme-description]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[cuerdas.core :as str]))
(def parseable-token-value-regexp
"Regexp that can be used to parse a number value out of resolved token value.
@@ -299,6 +80,56 @@
(defn shapes-applied-all? [ids-by-attributes shape-ids attributes]
(every? #(set/superset? (get ids-by-attributes %) shape-ids) attributes))
(defn token-name->path
"Splits token-name into a path vector split by `.` characters.
Will concatenate multiple `.` characters into one."
[token-name]
(str/split token-name #"\.+"))
(defn token-name->path-selector
"Splits token-name into map with `:path` and `:selector` using `token-name->path`.
`:selector` is the last item of the names path
`:path` is everything leading up the the `:selector`."
[token-name]
(let [path-segments (token-name->path token-name)
last-idx (dec (count path-segments))
[path [selector]] (split-at last-idx path-segments)]
{:path (seq path)
:selector selector}))
(defn token-name-path-exists?
"Traverses the path from `token-name` down a `token-tree` and checks if a token at that path exists.
It's not allowed to create a token inside a token. E.g.:
Creating a token with
{:name \"foo.bar\"}
in the tokens tree:
{\"foo\" {:name \"other\"}}"
[token-name token-names-tree]
(let [{:keys [path selector]} (token-name->path-selector token-name)
path-target (reduce
(fn [acc cur]
(let [target (get acc cur)]
(cond
;; Path segment doesn't exist yet
(nil? target) (reduced false)
;; A token exists at this path
(:name target) (reduced true)
;; Continue traversing the true
:else target)))
token-names-tree path)]
(cond
(boolean? path-target) path-target
(get path-target :name) true
:else (-> (get path-target selector)
(seq)
(boolean)))))
(defn color-token? [token]
(= (:type token) :color))

View File

@@ -1,15 +0,0 @@
;; 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.i18n
"Dummy i18n functions, to be used by code in common that needs translations.")
(defn tr
"This function will be monkeypatched at runtime with the real function in frontend i18n.
Here it just returns the key passed as argument. This way the result can be used in
unit tests or backend code for logs or error messages."
[key & _args]
key)

View File

@@ -2016,9 +2016,7 @@
(let [;; We need to sync only the position relative to the origin of the component.
;; (see update-attrs for a full explanation)
previous-shape (reposition-shape previous-shape prev-root current-root)
touched (get previous-shape :touched #{})
text-auto? (and (cfh/text-shape? current-shape)
(contains? #{:auto-height :auto-width} (:grow-type current-shape)))]
touched (get previous-shape :touched #{})]
(loop [attrs updatable-attrs
roperations [{:type :set-touched :touched (:touched previous-shape)}]
@@ -2027,10 +2025,6 @@
(let [attr-group (get ctk/sync-attrs attr)
skip-operations?
(or
;; For auto text, avoid copying geometry-driven attrs on switch.
(and text-auto?
(contains? #{:points :selrect :width :height :position-data} attr))
;; If the attribute is not valid for the destiny, don't copy it
(not (cts/is-allowed-switch-keep-attr? attr (:type current-shape)))

View File

@@ -58,7 +58,7 @@
(cto/shape-attr->token-attrs attr changed-sub-attr))]
(if (some #(contains? tokens %) token-attrs)
(pcb/update-shapes changes [shape-id] #(cto/unapply-tokens-from-shape % token-attrs))
(pcb/update-shapes changes [shape-id] #(cto/unapply-token-id % token-attrs))
changes)))
check-shape

View File

@@ -11,7 +11,6 @@
#?(:clj [malli.dev.pretty :as mdp])
#?(:clj [malli.dev.virhe :as v])
[app.common.data :as d]
[app.common.json :as json]
[app.common.math :as mth]
[app.common.pprint :as pp]
[app.common.schema.generators :as sg]
@@ -93,31 +92,6 @@
[& items]
(apply mu/merge (map schema items)))
(defn assoc-key
"Add a key & value to a schema of type [:map]. If the first level node of the schema
is not a map, will do a depth search to find the first map node and add the key there."
([s k v]
(assoc-key s k {} v))
([s k opts v] ;; change order of opts and v to match static schema defintions (e.g. [:something {:optional true} ::sm/integer])
(let [s (schema s)
v (schema v)]
(if (= (m/type s) :map)
(mu/assoc s k v opts)
(if-let [path (mu/find-first s (fn [s' path _] (when (= (m/type s') :map) path)))]
(mu/assoc-in s (conj path k) v opts)
s)))))
(defn dissoc-key
"Remove a key from a schema of type [:map]. If the first level node of the schema
is not a map, will do a depth search to find the first map node and remove the key there."
[s k]
(let [s (schema s)]
(if (= (m/type s) :map)
(mu/dissoc s k)
(if-let [path (mu/find-first s (fn [s' path _] (when (= (m/type s') :map) path)))]
(mu/update-in s path mu/dissoc k)
s))))
(defn ref?
[s]
(m/-ref-schema? s))
@@ -296,13 +270,6 @@
(let [explain (fn [] (me/with-error-messages explain))]
((mdp/prettifier variant message explain default-options)))))
(defn validation-errors
"Checks a value against a schema. If valid, returns nil. If not, returns a list
of english error messages."
[value schema]
(let [explainer (explainer schema)]
(-> value explainer simplify not-empty)))
(defmacro ignoring
[expr]
(if (:ns &env)
@@ -883,32 +850,6 @@
:encode/string str
::oapi/type "boolean"}})
(defn parse-keyword
[v]
(if (string? v)
(-> v (json/read-kebab-key) (keyword))
v))
(defn format-keyword
[v]
(if (keyword? v)
(-> v (name) (json/write-camel-key))
v))
(register!
{:type ::keyword
:pred keyword?
:type-properties
{:title "keyword"
:description "keyword"
:error/message "expected keyword"
:error/code "errors.invalid-keyword"
:gen/gen sg/keyword
:decode/string parse-keyword
:decode/json parse-keyword
:encode/string format-keyword
::oapi/type "string"}})
(register!
{:type ::contains-any
:min 1

View File

@@ -9,13 +9,13 @@
[app.common.data :as d]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.time :as ct]
[clojure.data :as data]
[clojure.set :as set]
[cuerdas.core :as str]
[malli.util :as mu]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GENERAL HELPERS
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- schema-keys
@@ -45,7 +45,7 @@
[token-name token-value]
(let [token-references (find-token-value-references token-value)
self-reference? (get token-references token-name)]
(boolean self-reference?)))
self-reference?))
(defn references-token?
"Recursively check if a value references the token name. Handles strings, maps, and sequences."
@@ -59,26 +59,6 @@
(some true? (map #(references-token? % token-name) value))
:else false))
(defn composite-token-reference?
"Predicate if a composite token is a reference value - a string pointing to another token."
[token-value]
(string? token-value))
(defn update-token-value-references
"Recursively update token references within a token value, supporting complex token values (maps, sequences, strings)."
[value old-name new-name]
(cond
(string? value)
(str/replace value
(re-pattern (str "\\{" (str/replace old-name "." "\\.") "\\}"))
(str "{" new-name "}"))
(map? value)
(d/update-vals value #(update-token-value-references % old-name new-name))
(sequential? value)
(mapv #(update-token-value-references % old-name new-name) value)
:else
value))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SCHEMA
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -86,6 +66,7 @@
(def token-type->dtcg-token-type
{:boolean "boolean"
:border-radius "borderRadius"
:shadow "shadow"
:color "color"
:dimensions "dimension"
:font-family "fontFamilies"
@@ -96,7 +77,6 @@
:opacity "opacity"
:other "other"
:rotation "rotation"
:shadow "shadow"
:sizing "sizing"
:spacing "spacing"
:string "string"
@@ -114,13 +94,14 @@
"boxShadow" :shadow)))
(def composite-token-type->dtcg-token-type
"When converting the type of one element inside a composite token, an additional type
:line-height is available, that is not allowed for a standalone token."
"Custom set of conversion keys for composite typography token with `:line-height` available.
(Penpot doesn't support `:line-height` token)"
(assoc token-type->dtcg-token-type
:line-height "lineHeights"))
(def composite-dtcg-token-type->token-type
"Same as above, in the opposite direction."
"Custom set of conversion keys for composite typography token with `:line-height` available.
(Penpot doesn't support `:line-height` token)"
(assoc dtcg-token-type->token-type
"lineHeights" :line-height
"lineHeight" :line-height))
@@ -128,114 +109,83 @@
(def token-types
(into #{} (keys token-type->dtcg-token-type)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SCHEMA: Token
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def token-name-validation-regex
#"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$")
(def schema:token-name
"A token name can contains letters, numbers, underscores the character $ and dots, but
not start with $ or end with a dot. The $ character does not have any special meaning,
but dots separate token groups (e.g. color.primary.background)."
[:re {:title "TokenName"
:gen/gen sg/text}
token-name-validation-regex])
(def schema:token-type
[::sm/one-of {:decode/json (fn [type]
(if (string? type)
(dtcg-token-type->token-type type)
type))}
token-types])
(def schema:token-attrs
[:map {:title "Token"}
[:id ::sm/uuid]
[:name schema:token-name]
[:type schema:token-type]
[:value ::sm/any]
[:description {:optional true} :string]
[:modified-at {:optional true} ::ct/inst]])
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SCHEMA: Token application to shape
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; All the following schemas define the `:applied-tokens` attribute of a shape.
;; This attribute is a map <token-attribute> -> <token-name>.
;; Token attributes approximately match shape attributes, but not always.
;; For each schema there is a `*keys` set including all the possible token attributes
;; to which a token of the corresponding type can be applied.
;; Some token types can be applied to some attributes only if the shape has a
;; particular condition (i.e. has a layout itself or is a layout item).
(def ^:private schema:border-radius
[:map {:title "BorderRadiusTokenAttrs"}
[:r1 {:optional true} schema:token-name]
[:r2 {:optional true} schema:token-name]
[:r3 {:optional true} schema:token-name]
[:r4 {:optional true} schema:token-name]])
(def border-radius-keys (schema-keys schema:border-radius))
(def token-name-ref
[:re {:title "TokenNameRef" :gen/gen sg/text}
#"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$"])
(def ^:private schema:color
[:map
[:fill {:optional true} schema:token-name]
[:stroke-color {:optional true} schema:token-name]])
[:fill {:optional true} token-name-ref]
[:stroke-color {:optional true} token-name-ref]])
(def color-keys (schema-keys schema:color))
(def ^:private schema:border-radius
[:map {:title "BorderRadiusTokenAttrs"}
[:r1 {:optional true} token-name-ref]
[:r2 {:optional true} token-name-ref]
[:r3 {:optional true} token-name-ref]
[:r4 {:optional true} token-name-ref]])
(def border-radius-keys (schema-keys schema:border-radius))
(def ^:private schema:shadow
[:map {:title "ShadowTokenAttrs"}
[:shadow {:optional true} token-name-ref]])
(def shadow-keys (schema-keys schema:shadow))
(def ^:private schema:stroke-width
[:map
[:stroke-width {:optional true} token-name-ref]])
(def stroke-width-keys (schema-keys schema:stroke-width))
(def ^:private schema:sizing-base
[:map {:title "SizingBaseTokenAttrs"}
[:width {:optional true} schema:token-name]
[:height {:optional true} schema:token-name]])
[:width {:optional true} token-name-ref]
[:height {:optional true} token-name-ref]])
(def ^:private schema:sizing-layout-item
[:map {:title "SizingLayoutItemTokenAttrs"}
[:layout-item-min-w {:optional true} schema:token-name]
[:layout-item-max-w {:optional true} schema:token-name]
[:layout-item-min-h {:optional true} schema:token-name]
[:layout-item-max-h {:optional true} schema:token-name]])
(def sizing-layout-item-keys (schema-keys schema:sizing-layout-item))
[:layout-item-min-w {:optional true} token-name-ref]
[:layout-item-max-w {:optional true} token-name-ref]
[:layout-item-min-h {:optional true} token-name-ref]
[:layout-item-max-h {:optional true} token-name-ref]])
(def ^:private schema:sizing
(-> (reduce mu/union [schema:sizing-base
schema:sizing-layout-item])
(mu/update-properties assoc :title "SizingTokenAttrs")))
(def sizing-layout-item-keys (schema-keys schema:sizing-layout-item))
(def sizing-keys (schema-keys schema:sizing))
(def ^:private schema:opacity
[:map {:title "OpacityTokenAttrs"}
[:opacity {:optional true} token-name-ref]])
(def opacity-keys (schema-keys schema:opacity))
(def ^:private schema:spacing-gap
[:map {:title "SpacingGapTokenAttrs"}
[:row-gap {:optional true} schema:token-name]
[:column-gap {:optional true} schema:token-name]])
[:row-gap {:optional true} token-name-ref]
[:column-gap {:optional true} token-name-ref]])
(def ^:private schema:spacing-padding
[:map {:title "SpacingPaddingTokenAttrs"}
[:p1 {:optional true} schema:token-name]
[:p2 {:optional true} schema:token-name]
[:p3 {:optional true} schema:token-name]
[:p4 {:optional true} schema:token-name]])
(def ^:private schema:spacing-gap-padding
(-> (reduce mu/union [schema:spacing-gap
schema:spacing-padding])
(mu/update-properties assoc :title "SpacingGapPaddingTokenAttrs")))
(def spacing-gap-padding-keys (schema-keys schema:spacing-gap-padding))
[:p1 {:optional true} token-name-ref]
[:p2 {:optional true} token-name-ref]
[:p3 {:optional true} token-name-ref]
[:p4 {:optional true} token-name-ref]])
(def ^:private schema:spacing-margin
[:map {:title "SpacingMarginTokenAttrs"}
[:m1 {:optional true} schema:token-name]
[:m2 {:optional true} schema:token-name]
[:m3 {:optional true} schema:token-name]
[:m4 {:optional true} schema:token-name]])
(def spacing-margin-keys (schema-keys schema:spacing-margin))
[:m1 {:optional true} token-name-ref]
[:m2 {:optional true} token-name-ref]
[:m3 {:optional true} token-name-ref]
[:m4 {:optional true} token-name-ref]])
(def ^:private schema:spacing
(-> (reduce mu/union [schema:spacing-gap
@@ -243,13 +193,16 @@
schema:spacing-margin])
(mu/update-properties assoc :title "SpacingTokenAttrs")))
(def spacing-margin-keys (schema-keys schema:spacing-margin))
(def spacing-keys (schema-keys schema:spacing))
(def ^:private schema:stroke-width
[:map
[:stroke-width {:optional true} schema:token-name]])
(def ^:private schema:spacing-gap-padding
(-> (reduce mu/union [schema:spacing-gap
schema:spacing-padding])
(mu/update-properties assoc :title "SpacingGapPaddingTokenAttrs")))
(def stroke-width-keys (schema-keys schema:stroke-width))
(def spacing-gap-padding-keys (schema-keys schema:spacing-gap-padding))
(def ^:private schema:dimensions
(-> (reduce mu/union [schema:sizing
@@ -260,109 +213,91 @@
(def dimensions-keys (schema-keys schema:dimensions))
(def ^:private schema:font-family
(def ^:private schema:axis
[:map
[:font-family {:optional true} schema:token-name]])
[:x {:optional true} token-name-ref]
[:y {:optional true} token-name-ref]])
(def font-family-keys (schema-keys schema:font-family))
(def ^:private schema:font-size
[:map {:title "FontSizeTokenAttrs"}
[:font-size {:optional true} schema:token-name]])
(def font-size-keys (schema-keys schema:font-size))
(def ^:private schema:font-weight
[:map
[:font-weight {:optional true} schema:token-name]])
(def font-weight-keys (schema-keys schema:font-weight))
(def ^:private schema:letter-spacing
[:map {:title "LetterSpacingTokenAttrs"}
[:letter-spacing {:optional true} schema:token-name]])
(def letter-spacing-keys (schema-keys schema:letter-spacing))
(def ^:private schema:line-height ;; This is not available for standalone tokens, only typography
[:map {:title "LineHeightTokenAttrs"}
[:line-height {:optional true} schema:token-name]])
(def line-height-keys (schema-keys schema:line-height))
(def axis-keys (schema-keys schema:axis))
(def ^:private schema:rotation
[:map {:title "RotationTokenAttrs"}
[:rotation {:optional true} schema:token-name]])
[:rotation {:optional true} token-name-ref]])
(def rotation-keys (schema-keys schema:rotation))
(def ^:private schema:font-size
[:map {:title "FontSizeTokenAttrs"}
[:font-size {:optional true} token-name-ref]])
(def font-size-keys (schema-keys schema:font-size))
(def ^:private schema:letter-spacing
[:map {:title "LetterSpacingTokenAttrs"}
[:letter-spacing {:optional true} token-name-ref]])
(def letter-spacing-keys (schema-keys schema:letter-spacing))
(def ^:private schema:font-family
[:map
[:font-family {:optional true} token-name-ref]])
(def font-family-keys (schema-keys schema:font-family))
(def ^:private schema:text-case
[:map
[:text-case {:optional true} token-name-ref]])
(def text-case-keys (schema-keys schema:text-case))
(def ^:private schema:font-weight
[:map
[:font-weight {:optional true} token-name-ref]])
(def font-weight-keys (schema-keys schema:font-weight))
(def ^:private schema:typography
[:map
[:typography {:optional true} token-name-ref]])
(def typography-token-keys (schema-keys schema:typography))
(def ^:private schema:text-decoration
[:map
[:text-decoration {:optional true} token-name-ref]])
(def text-decoration-keys (schema-keys schema:text-decoration))
(def typography-keys (set/union font-size-keys
letter-spacing-keys
font-family-keys
font-weight-keys
text-case-keys
text-decoration-keys
font-weight-keys
typography-token-keys
#{:line-height}))
(def ^:private schema:number
(-> (reduce mu/union [schema:line-height
(-> (reduce mu/union [[:map [:line-height {:optional true} token-name-ref]]
schema:rotation])
(mu/update-properties assoc :title "NumberTokenAttrs")))
(def number-keys (schema-keys schema:number))
(def ^:private schema:opacity
[:map {:title "OpacityTokenAttrs"}
[:opacity {:optional true} schema:token-name]])
(def opacity-keys (schema-keys schema:opacity))
(def ^:private schema:shadow
[:map {:title "ShadowTokenAttrs"}
[:shadow {:optional true} schema:token-name]])
(def shadow-keys (schema-keys schema:shadow))
(def ^:private schema:text-case
[:map
[:text-case {:optional true} schema:token-name]])
(def text-case-keys (schema-keys schema:text-case))
(def ^:private schema:text-decoration
[:map
[:text-decoration {:optional true} schema:token-name]])
(def text-decoration-keys (schema-keys schema:text-decoration))
(def ^:private schema:typography
[:map
[:typography {:optional true} schema:token-name]])
(def typography-token-keys (schema-keys schema:typography))
(def typography-keys (set/union font-family-keys
font-size-keys
font-weight-keys
font-weight-keys
letter-spacing-keys
line-height-keys
text-case-keys
text-decoration-keys
typography-token-keys))
(def ^:private schema:axis
[:map
[:x {:optional true} schema:token-name]
[:y {:optional true} schema:token-name]])
(def axis-keys (schema-keys schema:axis))
(def all-keys (set/union axis-keys
(def all-keys (set/union color-keys
border-radius-keys
color-keys
dimensions-keys
number-keys
opacity-keys
rotation-keys
shadow-keys
sizing-keys
spacing-keys
stroke-width-keys
sizing-keys
opacity-keys
spacing-keys
dimensions-keys
axis-keys
rotation-keys
typography-keys
typography-token-keys))
typography-token-keys
number-keys))
(def ^:private schema:tokens
[:map {:title "GenericTokenAttrs"}])
@@ -383,28 +318,11 @@
schema:text-decoration
schema:dimensions])
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS for conversion between token attrs and shape attrs
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn token-attr?
[attr]
(contains? all-keys attr))
(defn token-attr->shape-attr
"Returns the actual shape attribute affected when a token have been applied
to a given `token-attr`."
[token-attr]
(case token-attr
:fill :fills
:stroke-color :strokes
:stroke-width :strokes
token-attr))
(defn shape-attr->token-attrs
"Returns the token-attr affected when a given attribute in a shape is changed.
The sub-attr is for attributes that may have multiple values, like strokes
(may be width or color) and layout padding & margin (may have 4 edges)."
([shape-attr] (shape-attr->token-attrs shape-attr nil))
([shape-attr changed-sub-attr]
(cond
@@ -446,13 +364,21 @@
(number-keys shape-attr) #{shape-attr}
(axis-keys shape-attr) #{shape-attr})))
(defn token-attr->shape-attr
[token-attr]
(case token-attr
:fill :fills
:stroke-color :strokes
:stroke-width :strokes
token-attr))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS for token attributes by shape type
;; TOKEN SHAPE ATTRIBUTES
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private position-attributes #{:x :y})
(def position-attributes #{:x :y})
(def ^:private generic-attributes
(def generic-attributes
(set/union color-keys
stroke-width-keys
rotation-keys
@@ -461,22 +387,20 @@
shadow-keys
position-attributes))
(def ^:private rect-attributes
(def rect-attributes
(set/union generic-attributes
border-radius-keys))
(def ^:private frame-with-layout-attributes
(def frame-with-layout-attributes
(set/union rect-attributes
spacing-gap-padding-keys))
(def ^:private text-attributes
(def text-attributes
(set/union generic-attributes
typography-keys
number-keys))
(defn shape-type->attributes
"Returns what token attributes may be applied to a shape depending on its type
and if it is a frame with a layout."
[type is-layout]
(case type
:bool generic-attributes
@@ -492,14 +416,12 @@
nil))
(defn appliable-attrs-for-shape
"Returns which ones of the given `attributes` can be applied to a shape
of type `shape-type` and `is-layout`."
"Returns intersection of shape `attributes` for `shape-type`."
[attributes shape-type is-layout]
(set/intersection attributes (shape-type->attributes shape-type is-layout)))
(defn any-appliable-attr-for-shape?
"Returns if any of the given `attributes` can be applied to a shape
of type `shape-type` and `is-layout`."
"Checks if `token-type` supports given shape `attributes`."
[attributes token-type is-layout]
(d/not-empty? (appliable-attrs-for-shape attributes token-type is-layout)))
@@ -510,6 +432,42 @@
typography-keys
#{:fill}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TOKENS IN SHAPES
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- toggle-or-apply-token
"Remove any shape attributes from token if they exists.
Othewise apply token attributes."
[shape token]
(let [[shape-leftover token-leftover _matching] (data/diff (:applied-tokens shape) token)]
(merge {} shape-leftover token-leftover)))
(defn- token-from-attributes [token attributes]
(->> (map (fn [attr] [attr (:name token)]) attributes)
(into {})))
(defn- apply-token-to-attributes [{:keys [shape token attributes]}]
(let [token (token-from-attributes token attributes)]
(toggle-or-apply-token shape token)))
(defn apply-token-to-shape
[{:keys [shape token attributes] :as _props}]
(let [applied-tokens (apply-token-to-attributes {:shape shape
:token token
:attributes attributes})]
(update shape :applied-tokens #(merge % applied-tokens))))
(defn unapply-token-id [shape attributes]
(update shape :applied-tokens d/without-keys attributes))
(defn unapply-layout-item-tokens
"Unapplies all layout item related tokens from shape."
[shape]
(let [layout-item-attrs (set/union sizing-layout-item-keys
spacing-margin-keys)]
(unapply-token-id shape layout-item-attrs)))
(def tokens-by-input
"A map from input name to applicable token for that input."
{:width #{:sizing :dimensions}
@@ -539,48 +497,7 @@
:stroke-color #{:color}})
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS for tokens application
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TODO it seems that this function is redundant, maybe?
;; (defn- toggle-or-apply-token
;; "Remove any shape attributes from token if they exists.
;; Othewise apply token attributes."
;; [shape token]
;; (let [[only-in-shape only-in-token _matching] (data/diff (:applied-tokens shape) token)]
;; (merge {} only-in-shape only-in-token)))
(defn- generate-attr-map [token attributes]
(->> (map (fn [attr] [attr (:name token)]) attributes)
(into {})))
(defn apply-token-to-shape
"Applies the token to the given attributes in the shape."
[{:keys [shape token attributes] :as _props}]
(let [map-to-apply (generate-attr-map token attributes)]
(update shape :applied-tokens #(merge % map-to-apply))))
;; (defn apply-token-to-shape
;; [{:keys [shape token attributes] :as _props}]
;; (let [map-to-apply (generate-attr-map token attributes)
;; applied-tokens (toggle-or-apply-token shape map-to-apply)]
;; (update shape :applied-tokens #(merge % applied-tokens))))
(defn unapply-tokens-from-shape
"Removes any token applied to the given attributes in the shape."
[shape attributes]
(update shape :applied-tokens d/without-keys attributes))
(defn unapply-layout-item-tokens
"Unapplies all layout item related tokens from shape."
[shape]
(let [layout-item-attrs (set/union sizing-layout-item-keys
spacing-margin-keys)]
(unapply-tokens-from-shape shape layout-item-attrs)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS for typography tokens
;; TYPOGRAPHY
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn split-font-family
@@ -643,3 +560,32 @@
(when (font-weight-values weight)
(cond-> {:weight weight}
italic? (assoc :style "italic")))))
(defn typography-composite-token-reference?
"Predicate if a typography composite token is a reference value - a string pointing to another reference token."
[token-value]
(string? token-value))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SHADOW
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn shadow-composite-token-reference?
"Predicate if a shadow composite token is a reference value - a string pointing to another reference token."
[token-value]
(string? token-value))
(defn update-token-value-references
"Recursively update token references within a token value, supporting complex token values (maps, sequences, strings)."
[value old-name new-name]
(cond
(string? value)
(str/replace value
(re-pattern (str "\\{" (str/replace old-name "." "\\.") "\\}"))
(str "{" new-name "}"))
(map? value)
(d/update-vals value #(update-token-value-references % old-name new-name))
(sequential? value)
(mapv #(update-token-value-references % old-name new-name) value)
:else
value))

View File

@@ -114,19 +114,25 @@
[o]
(instance? Token o))
(def schema:token-attrs
[:map {:title "Token"}
[:id ::sm/uuid]
[:name cto/token-name-ref]
[:type [::sm/one-of cto/token-types]]
[:value ::sm/any]
[:description {:optional true} :string]
[:modified-at {:optional true} ::ct/inst]])
(declare make-token)
(def schema:token
[:and {:gen/gen (->> (sg/generator cto/schema:token-attrs)
[:and {:gen/gen (->> (sg/generator schema:token-attrs)
(sg/fmap #(make-token %)))}
(sm/required-keys cto/schema:token-attrs)
(sm/required-keys schema:token-attrs)
[:fn token?]])
(def ^:private check-token-attrs
(sm/check-fn cto/schema:token-attrs :hint "expected valid params for token"))
(def decode-token-attrs
(sm/lazy-decoder cto/schema:token-attrs sm/json-transformer))
(sm/check-fn schema:token-attrs :hint "expected valid params for token"))
(def check-token
(sm/check-fn schema:token :hint "expected valid token"))
@@ -311,18 +317,10 @@
[o]
(instance? TokenSetLegacy o))
(declare make-token-set)
(declare normalized-set-name?)
(def schema:token-set-name
[:and
:string
[:fn #(normalized-set-name? %)]]) ;; The #() is necessary because the function is only declared, not defined
(def schema:token-set-attrs
[:map {:title "TokenSet"}
[:id ::sm/uuid]
[:name schema:token-set-name]
[:name :string]
[:description {:optional true} :string]
[:modified-at {:optional true} ::ct/inst]
[:tokens {:optional true
@@ -344,6 +342,8 @@
:string schema:token]
[:fn d/ordered-map?]]]])
(declare make-token-set)
(def schema:token-set
[:schema {:gen/gen (->> (sg/generator schema:token-set-attrs)
(sg/fmap #(make-token-set %)))}
@@ -404,25 +404,12 @@
(split-set-name name))
(cpn/join-path :separator set-separator :with-spaces? false))))
(defn normalized-set-name?
"Check if a set name is normalized (no extra spaces)."
[name]
(= name (normalize-set-name name)))
(defn replace-last-path-name
"Replaces the last element in a `path` vector with `name`."
[path name]
(-> (into [] (drop-last path))
(conj name)))
(defn make-child-name
"Generate the name of a set child of `parent-set` adding the name `name`."
[parent-set name]
(if-let [parent-path (get-set-path parent-set)]
(->> (concat parent-path (split-set-name name))
(join-set-path))
(normalize-set-name name)))
;; The following functions will be removed after refactoring the internal structure of TokensLib,
;; since we'll no longer need group prefixes to differentiate between sets and set-groups.
@@ -1383,13 +1370,10 @@ Will return a value that matches this schema:
(def ^:private check-tokens-lib-map
(sm/check-fn schema:tokens-lib-map :hint "invalid tokens-lib internal data structure"))
(defn tokens-lib?
[o]
(instance? TokensLib o))
(defn valid-tokens-lib?
[o]
(and (tokens-lib? o) (valid? o)))
(and (instance? TokensLib o)
(valid? o)))
(defn- ensure-hidden-theme
"A helper that is responsible to ensure that the hidden theme always
@@ -1451,50 +1435,6 @@ Will return a value that matches this schema:
(rename copy-name)
(reid (uuid/next))))))
(defn- token-name->path-selector
"Splits token-name into map with `:path` and `:selector` using `token-name->path`.
`:selector` is the last item of the names path
`:path` is everything leading up the the `:selector`."
[token-name]
(let [path-segments (get-token-path {:name token-name})
last-idx (dec (count path-segments))
[path [selector]] (split-at last-idx path-segments)]
{:path (seq path)
:selector selector}))
(defn token-name-path-exists?
"Traverses the path from `token-name` down a `tokens-tree` and checks if a token at that path exists.
It's not allowed to create a token inside a token. E.g.:
Creating a token with
{:name \"foo.bar\"}
in the tokens tree:
{\"foo\" {:name \"other\"}}"
[token-name tokens-tree]
(let [{:keys [path selector]} (token-name->path-selector token-name)
path-target (reduce
(fn [acc cur]
(let [target (get acc cur)]
(cond
;; Path segment doesn't exist yet
(nil? target) (reduced false)
;; A token exists at this path
(:name target) (reduced true)
;; Continue traversing the true
:else target)))
tokens-tree
path)]
(cond
(boolean? path-target) path-target
(get path-target :name) true
:else (-> (get path-target selector)
(seq)
(boolean)))))
;; === Import / Export from JSON format
;; Supported formats:
@@ -1527,12 +1467,11 @@ Will return a value that matches this schema:
(def ^:private schema:dtcg-node
[:schema {:registry
{::simple-value
[:or :string :int :double ::sm/boolean]
[:or :string :int :double]
::value
[:or
[:ref ::simple-value]
[:vector ::simple-value]
[:vector [:map-of :string ::simple-value]]
[:map-of :string [:or
[:ref ::simple-value]
[:vector ::simple-value]]]]}}

View File

@@ -6,34 +6,34 @@
(ns common-tests.files.tokens-test
(:require
[app.common.files.tokens :as cfo]
[app.common.files.tokens :as cft]
[clojure.test :as t]))
(t/deftest test-parse-token-value
(t/testing "parses double from a token value"
(t/is (= {:value 100.1 :unit nil} (cfo/parse-token-value "100.1")))
(t/is (= {:value -9.0 :unit nil} (cfo/parse-token-value "-9"))))
(t/is (= {:value 100.1 :unit nil} (cft/parse-token-value "100.1")))
(t/is (= {:value -9.0 :unit nil} (cft/parse-token-value "-9"))))
(t/testing "trims white-space"
(t/is (= {:value -1.3 :unit nil} (cfo/parse-token-value " -1.3 "))))
(t/is (= {:value -1.3 :unit nil} (cft/parse-token-value " -1.3 "))))
(t/testing "parses unit: px"
(t/is (= {:value 70.3 :unit "px"} (cfo/parse-token-value " 70.3px "))))
(t/is (= {:value 70.3 :unit "px"} (cft/parse-token-value " 70.3px "))))
(t/testing "parses unit: %"
(t/is (= {:value -10.0 :unit "%"} (cfo/parse-token-value "-10%"))))
(t/is (= {:value -10.0 :unit "%"} (cft/parse-token-value "-10%"))))
(t/testing "parses unit: px")
(t/testing "returns nil for any invalid characters"
(t/is (nil? (cfo/parse-token-value " -1.3a "))))
(t/is (nil? (cft/parse-token-value " -1.3a "))))
(t/testing "doesnt accept invalid double"
(t/is (nil? (cfo/parse-token-value ".3")))))
(t/is (nil? (cft/parse-token-value ".3")))))
(t/deftest token-applied-test
(t/testing "matches passed token with `:token-attributes`"
(t/is (true? (cfo/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:x}))))
(t/is (true? (cft/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:x}))))
(t/testing "doesn't match empty token"
(t/is (nil? (cfo/token-applied? {} {:applied-tokens {:x "a"}} #{:x}))))
(t/is (nil? (cft/token-applied? {} {:applied-tokens {:x "a"}} #{:x}))))
(t/testing "does't match passed token `:id`"
(t/is (nil? (cfo/token-applied? {:name "b"} {:applied-tokens {:x "a"}} #{:x}))))
(t/is (nil? (cft/token-applied? {:name "b"} {:applied-tokens {:x "a"}} #{:x}))))
(t/testing "doesn't match passed `:token-attributes`"
(t/is (nil? (cfo/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:y})))))
(t/is (nil? (cft/token-applied? {:name "a"} {:applied-tokens {:x "a"}} #{:y})))))
(t/deftest shapes-ids-by-applied-attributes
(t/testing "Returns set of matched attributes that fit the applied token"
@@ -54,7 +54,7 @@
shape-applied-x-y
shape-applied-all
shape-applied-none]
expected (cfo/shapes-ids-by-applied-attributes {:name "1"} shapes attributes)]
expected (cft/shapes-ids-by-applied-attributes {:name "1"} shapes attributes)]
(t/is (= (:x expected) (shape-ids shape-applied-x
shape-applied-x-y
shape-applied-all)))
@@ -62,21 +62,34 @@
shape-applied-x-y
shape-applied-all)))
(t/is (= (:z expected) (shape-ids shape-applied-all)))
(t/is (true? (cfo/shapes-applied-all? expected (shape-ids shape-applied-all) attributes)))
(t/is (false? (cfo/shapes-applied-all? expected (apply shape-ids shapes) attributes)))
(t/is (true? (cft/shapes-applied-all? expected (shape-ids shape-applied-all) attributes)))
(t/is (false? (cft/shapes-applied-all? expected (apply shape-ids shapes) attributes)))
(shape-ids shape-applied-x
shape-applied-x-y
shape-applied-all))))
(t/deftest tokens-applied-test
(t/testing "is true when single shape matches the token and attributes"
(t/is (true? (cfo/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}}
(t/is (true? (cft/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}}
{:applied-tokens {:x "b"}}]
#{:x}))))
(t/testing "is false when no shape matches the token or attributes"
(t/is (nil? (cfo/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "b"}}
(t/is (nil? (cft/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "b"}}
{:applied-tokens {:x "b"}}]
#{:x})))
(t/is (nil? (cfo/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}}
(t/is (nil? (cft/shapes-token-applied? {:name "a"} [{:applied-tokens {:x "a"}}
{:applied-tokens {:x "a"}}]
#{:y})))))
(t/deftest name->path-test
(t/is (= ["foo" "bar" "baz"] (cft/token-name->path "foo.bar.baz")))
(t/is (= ["foo" "bar" "baz"] (cft/token-name->path "foo..bar.baz")))
(t/is (= ["foo" "bar" "baz"] (cft/token-name->path "foo..bar.baz...."))))
(t/deftest token-name-path-exists?-test
(t/is (true? (cft/token-name-path-exists? "border-radius" {"border-radius" {"sm" {:name "sm"}}})))
(t/is (true? (cft/token-name-path-exists? "border-radius" {"border-radius" {:name "sm"}})))
(t/is (true? (cft/token-name-path-exists? "border-radius.sm" {"border-radius" {:name "sm"}})))
(t/is (true? (cft/token-name-path-exists? "border-radius.sm.x" {"border-radius" {:name "sm"}})))
(t/is (false? (cft/token-name-path-exists? "other" {"border-radius" {:name "sm"}})))
(t/is (false? (cft/token-name-path-exists? "dark.border-radius.md" {"dark" {"border-radius" {"sm" {:name "sm"}}}}))))

View File

@@ -255,28 +255,28 @@
(cls/generate-update-shapes [(:id frame1)]
(fn [shape]
(-> shape
(cto/unapply-tokens-from-shape [:r1 :r2 :r3 :r4])
(cto/unapply-tokens-from-shape [:rotation])
(cto/unapply-tokens-from-shape [:opacity])
(cto/unapply-tokens-from-shape [:stroke-width])
(cto/unapply-tokens-from-shape [:stroke-color])
(cto/unapply-tokens-from-shape [:fill])
(cto/unapply-tokens-from-shape [:width :height])))
(cto/unapply-token-id [:r1 :r2 :r3 :r4])
(cto/unapply-token-id [:rotation])
(cto/unapply-token-id [:opacity])
(cto/unapply-token-id [:stroke-width])
(cto/unapply-token-id [:stroke-color])
(cto/unapply-token-id [:fill])
(cto/unapply-token-id [:width :height])))
(:objects page)
{})
(cls/generate-update-shapes [(:id text1)]
(fn [shape]
(-> shape
(cto/unapply-tokens-from-shape [:font-size])
(cto/unapply-tokens-from-shape [:letter-spacing])
(cto/unapply-tokens-from-shape [:font-family])))
(cto/unapply-token-id [:font-size])
(cto/unapply-token-id [:letter-spacing])
(cto/unapply-token-id [:font-family])))
(:objects page)
{})
(cls/generate-update-shapes [(:id circle1)]
(fn [shape]
(-> shape
(cto/unapply-tokens-from-shape [:layout-item-max-h :layout-item-min-h :layout-item-max-w :layout-item-min-w])
(cto/unapply-tokens-from-shape [:m1 :m2 :m3 :m4])))
(cto/unapply-token-id [:layout-item-max-h :layout-item-min-h :layout-item-max-w :layout-item-min-w])
(cto/unapply-token-id [:m1 :m2 :m3 :m4])))
(:objects page)
{}))

View File

@@ -8,19 +8,20 @@
(:require
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/deftest test-valid-token-name-schema
;; Allow regular namespace token names
(t/is (true? (sm/validate cto/schema:token-name "Foo")))
(t/is (true? (sm/validate cto/schema:token-name "foo")))
(t/is (true? (sm/validate cto/schema:token-name "FOO")))
(t/is (true? (sm/validate cto/schema:token-name "Foo.Bar.Baz")))
(t/is (true? (sm/validate cto/token-name-ref "Foo")))
(t/is (true? (sm/validate cto/token-name-ref "foo")))
(t/is (true? (sm/validate cto/token-name-ref "FOO")))
(t/is (true? (sm/validate cto/token-name-ref "Foo.Bar.Baz")))
;; Disallow trailing tokens
(t/is (false? (sm/validate cto/schema:token-name "Foo.Bar.Baz....")))
(t/is (false? (sm/validate cto/token-name-ref "Foo.Bar.Baz....")))
;; Disallow multiple separator dots
(t/is (false? (sm/validate cto/schema:token-name "Foo..Bar.Baz")))
(t/is (false? (sm/validate cto/token-name-ref "Foo..Bar.Baz")))
;; Disallow any special characters
(t/is (false? (sm/validate cto/schema:token-name "Hey Foo.Bar")))
(t/is (false? (sm/validate cto/schema:token-name "Hey😈Foo.Bar")))
(t/is (false? (sm/validate cto/schema:token-name "Hey%Foo.Bar"))))
(t/is (false? (sm/validate cto/token-name-ref "Hey Foo.Bar")))
(t/is (false? (sm/validate cto/token-name-ref "Hey😈Foo.Bar")))
(t/is (false? (sm/validate cto/token-name-ref "Hey%Foo.Bar"))))

View File

@@ -678,35 +678,35 @@
(t/deftest list-active-themes-tokens-bug-taiga-10617
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "Mode/Dark"
(ctob/add-set (ctob/make-token-set :name "Mode / Dark"
:tokens {"red"
(ctob/make-token :name "red"
:type :color
:value "#700000")}))
(ctob/add-set (ctob/make-token-set :name "Mode/Light"
(ctob/add-set (ctob/make-token-set :name "Mode / Light"
:tokens {"red"
(ctob/make-token :name "red"
:type :color
:value "#ff0000")}))
(ctob/add-set (ctob/make-token-set :name "Device/Desktop"
(ctob/add-set (ctob/make-token-set :name "Device / Desktop"
:tokens {"border1"
(ctob/make-token :name "border1"
:type :border-radius
:value 30)}))
(ctob/add-set (ctob/make-token-set :name "Device/Mobile"
(ctob/add-set (ctob/make-token-set :name "Device / Mobile"
:tokens {"border1"
(ctob/make-token :name "border1"
:type :border-radius
:value 50)}))
(ctob/add-theme (ctob/make-token-theme :group "App"
:name "Mobile"
:sets #{"Mode/Dark" "Device/Mobile"}))
:sets #{"Mode / Dark" "Device / Mobile"}))
(ctob/add-theme (ctob/make-token-theme :group "App"
:name "Web"
:sets #{"Mode/Dark" "Mode/Light" "Device/Desktop"}))
:sets #{"Mode / Dark" "Mode / Light" "Device / Desktop"}))
(ctob/add-theme (ctob/make-token-theme :group "Brand"
:name "Brand A"
:sets #{"Mode/Dark" "Mode/Light" "Device/Desktop" "Device/Mobile"}))
:sets #{"Mode / Dark" "Mode / Light" "Device / Desktop" "Device / Mobile"}))
(ctob/add-theme (ctob/make-token-theme :group "Brand"
:name "Brand B"
:sets #{}))
@@ -2013,11 +2013,3 @@
(t/is (some? imported-ref))
(t/is (= (:type original-ref) (:type imported-ref)))
(t/is (= (:value imported-ref) (:value original-ref))))))))
(t/deftest token-name-path-exists?-test
(t/is (true? (ctob/token-name-path-exists? "border-radius" {"border-radius" {"sm" {:name "sm"}}})))
(t/is (true? (ctob/token-name-path-exists? "border-radius" {"border-radius" {:name "sm"}})))
(t/is (true? (ctob/token-name-path-exists? "border-radius.sm" {"border-radius" {:name "sm"}})))
(t/is (true? (ctob/token-name-path-exists? "border-radius.sm.x" {"border-radius" {:name "sm"}})))
(t/is (false? (ctob/token-name-path-exists? "other" {"border-radius" {:name "sm"}})))
(t/is (false? (ctob/token-name-path-exists? "dark.border-radius.md" {"dark" {"border-radius" {"sm" {:name "sm"}}}}))))

View File

@@ -181,8 +181,7 @@ FROM base AS setup-utils
ENV CLJKONDO_VERSION=2026.01.19 \
BABASHKA_VERSION=1.12.208 \
CLJFMT_VERSION=0.15.6 \
PIXI_VERSION=0.63.2
CLJFMT_VERSION=0.15.6
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
@@ -225,26 +224,6 @@ RUN set -ex; \
tar -xf /tmp/babashka.tar.gz; \
rm -rf /tmp/babashka.tar.gz;
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
BINARY_URL="https://github.com/prefix-dev/pixi/releases/download/v$PIXI_VERSION/pixi-aarch64-unknown-linux-musl.tar.gz"; \
;; \
amd64|x86_64) \
BINARY_URL="https://github.com/prefix-dev/pixi/releases/download/v$PIXI_VERSION/pixi-x86_64-unknown-linux-musl.tar.gz"; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
exit 1; \
;; \
esac; \
cd /tmp; \
curl -LfsSo /tmp/pixi.tar.gz ${BINARY_URL}; \
cd /opt/utils/bin; \
tar -xf /tmp/pixi.tar.gz; \
rm -rf /tmp/pixi.tar.gz;
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \

View File

@@ -46,11 +46,6 @@ services:
- 9090:9090
- 9091:9091
# MCP
- 4400:4400
- 4401:4401
- 4402:4402
environment:
- EXTERNAL_UID=${CURRENT_USER_ID}
# SMTP setup

View File

@@ -121,28 +121,6 @@ http {
proxy_http_version 1.1;
}
location /mcp {
alias /home/penpot/penpot/mcp/packages/plugin/dist;
proxy_http_version 1.1;
}
location /mcp/ws {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_pass http://127.0.0.1:4402;
proxy_http_version 1.1;
}
location /mcp/stream {
proxy_pass http://127.0.0.1:4401/mcp;
proxy_http_version 1.1;
}
location /mcp/sse {
proxy_pass http://127.0.0.1:4401/sse;
proxy_http_version 1.1;
}
location /admin {
proxy_pass http://127.0.0.1:6063/admin;
}

View File

@@ -1,58 +0,0 @@
FROM ubuntu:24.04
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v22.21.1 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:$PATH
RUN set -ex; \
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \
mkdir -p /etc/resolvconf/resolv.conf.d; \
echo "nameserver 127.0.0.11" > /etc/resolvconf/resolv.conf.d/tail; \
apt-get -qq update; \
apt-get -qqy --no-install-recommends install \
curl \
tzdata \
locales \
ca-certificates \
; \
rm -rf /var/lib/apt/lists/*; \
echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \
locale-gen; \
find /usr/share/i18n/locales/ -type f ! -name "en_US" ! -name "POSIX" ! -name "C" -delete;
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \
;; \
amd64|x86_64) \
BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
exit 1; \
;; \
esac; \
curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \
mkdir -p /opt/node; \
cd /opt/node; \
tar -xf /tmp/nodejs.tar.gz --strip-components=1; \
chown -R root /opt/node; \
rm -rf /tmp/nodejs.tar.gz; \
corepack enable; \
mkdir -p /opt/penpot; \
chown -R penpot:penpot /opt/penpot;
ARG BUNDLE_PATH="./bundle-mcp/"
COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/mcp/
WORKDIR /opt/penpot/mcp
USER penpot:penpot
RUN ./setup
CMD ["node", "index.js", "--multi-user"]

View File

@@ -198,13 +198,6 @@ services:
## Valkey (or previously Redis) is used for the websockets notifications.
PENPOT_REDIS_URI: redis://penpot-valkey/0
penpot-mcp:
image: penpotapp/mcp:${PENPOT_VERSION:-latest}
restart: always
networks:
- penpot
penpot-postgres:
image: "postgres:15"
restart: always

View File

@@ -130,7 +130,6 @@ http {
}
location /readyz {
access_log off;
proxy_pass $PENPOT_BACKEND_URI$request_uri;
}

View File

@@ -51,6 +51,7 @@
"@penpot/svgo": "penpot/svgo#v3.2",
"@penpot/text-editor": "workspace:./text-editor",
"@playwright/test": "1.58.0",
"@penpot/ui": "workspace:./packages/ui",
"@storybook/addon-docs": "10.1.11",
"@storybook/addon-themes": "10.1.11",
"@storybook/addon-vitest": "10.1.11",

View File

@@ -0,0 +1,4 @@
{
"presets": [],
"plugins": []
}

1
frontend/packages/ui/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist/

View File

@@ -0,0 +1,23 @@
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/lib/**/*.@(mdx|stories.@(js|jsx|ts|tsx))'],
addons: [],
framework: {
name: getAbsolutePath('@storybook/react-vite'),
options: {
builder: {
viteConfigPath: 'vite.config.mts',
},
},
},
};
function getAbsolutePath(value: string): any {
return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`)));
}
export default config;

View File

@@ -0,0 +1,11 @@
# UI
A React component library with TypeScript for the Penpot ecosystem.
## Commands
Run from workspace root:
- **`pnpm storybook:ui`** - Start Storybook for component development
- **`pnpm build:ui`** - Build the library for production
- **`pnpm start:ui`** - Build in watch mode for development

View File

@@ -0,0 +1,39 @@
{
"name": "@penpot/ui",
"version": "0.0.1",
"types": "./dist/index.d.ts",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js"
},
"./style.css": "./dist/style.css"
},
"scripts": {
"watch": "vite build --watch",
"build": "vite build"
},
"devDependencies": {
"@babel/core": "^7.14.5",
"@babel/preset-react": "^7.14.5",
"@storybook/react": "10.2.0",
"@storybook/react-vite": "10.2.0",
"@testing-library/dom": "10.4.0",
"@testing-library/react": "16.3.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jsx-a11y": "6.10.1",
"eslint-plugin-react": "7.35.0",
"eslint-plugin-react-hooks": "7.0.1",
"react-compiler-runtime": "^1.0.0",
"storybook": "10.2.0",
"vite-plugin-dts": "^4.5.4"
},
"peerDependencies": {
"react": ">=19.2",
"react-dom": ">=19.2"
}
}

View File

@@ -0,0 +1 @@
export * from './lib/example/Example';

View File

@@ -0,0 +1,5 @@
.container {
background-color: #f0f0f0;
padding: 16px;
border: 2px solid #000;
}

View File

@@ -0,0 +1,10 @@
import { render } from '@testing-library/react';
import Example from './Example';
describe('Example', () => {
it('should render successfully', () => {
const { baseElement } = render(<Example />);
expect(baseElement).toBeTruthy();
});
});

View File

@@ -0,0 +1,12 @@
import { Example } from './Example';
import type { Meta, StoryObj } from '@storybook/react-vite';
const meta = {
title: 'UI/Example',
component: Example,
} satisfies Meta<typeof Example>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {};

View File

@@ -0,0 +1,21 @@
import { useState } from 'react';
import styles from './Example.module.css';
export function Example() {
const [count, setCount] = useState(0);
return (
<div className={styles.container}>
<h1>Example!</h1>
<div>
<h2>Counter: {count}</h2>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
</div>
);
}
export default Example;

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"noEmit": true,
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"jsx": "react-jsx",
"types": ["vite/client", "vitest"],
"baseUrl": "."
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
},
{
"path": "./tsconfig.storybook.json"
}
]
}

View File

@@ -0,0 +1,37 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"node",
"vite/client"
]
},
"exclude": [
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"**/*.stories.ts",
"**/*.stories.js",
"**/*.stories.jsx",
"**/*.stories.tsx"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
}

View File

@@ -0,0 +1,28 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
]
}

View File

@@ -0,0 +1,28 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"emitDecoratorMetadata": true,
"outDir": "",
"module": "esnext",
"moduleResolution": "bundler"
},
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/**/*.spec.js",
"src/**/*.test.js",
"src/**/*.spec.tsx",
"src/**/*.test.tsx",
"src/**/*.spec.jsx",
"src/**/*.test.js"
],
"include": [
"src/**/*.stories.ts",
"src/**/*.stories.js",
"src/**/*.stories.jsx",
"src/**/*.stories.tsx",
"src/**/*.stories.mdx",
".storybook/*.js",
".storybook/*.ts"
]
}

View File

@@ -0,0 +1,66 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
import { copyFileSync } from 'node:fs';
const copyCssPlugin = () => ({
name: 'copy-css',
closeBundle: () => {
try {
copyFileSync(
'dist/index.css',
'../../resources/public/css/ui.css',
);
} catch (e) {
console.log('Error copying css file', e);
}
},
});
export default defineConfig(() => ({
root: import.meta.dirname,
plugins: [
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
dts({
entryRoot: 'src',
tsconfigPath: path.join(import.meta.dirname, 'tsconfig.lib.json'),
pathsToAliases: false,
}),
copyCssPlugin(),
],
build: {
outDir: 'dist/',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
entry: 'src/index.ts',
name: 'ui',
fileName: 'index',
formats: ['es' as const],
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
},
},
test: {
name: 'ui',
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../coverage/libs/ui',
provider: 'v8' as const,
},
},
}));

View File

@@ -1,5 +1,4 @@
import { defineConfig, devices } from "@playwright/test";
import { platform } from "os";
/**
* Read environment variables from file.
@@ -7,10 +6,6 @@ import { platform } from "os";
*/
// require('dotenv').config();
const userAgent = platform === 'darwin' ?
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" :
undefined;
/**
* @see https://playwright.dev/docs/test-configuration
*/
@@ -48,20 +43,12 @@ export default defineConfig({
projects: [
{
name: "default",
use: { ...devices["Desktop Chrome"] },
testDir: "./playwright/ui/specs",
use: {
...devices["Desktop Chrome"],
viewport: { width: 1920, height: 1080 }, // Add custom viewport size
video: 'retain-on-failure',
trace: 'retain-on-failure',
userAgent,
},
snapshotPathTemplate: "{testDir}/{testFilePath}-snapshots/{arg}.png",
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.001,
},
},
}
},
{
name: "ds",

View File

@@ -1,7 +0,0 @@
{
"~:file-id": "~u8d38942d-b01f-800e-8007-79ee6a9bac45",
"~:tag": "component",
"~:object-id": "8d38942d-b01f-800e-8007-79ee6a9bac45/8d38942d-b01f-800e-8007-79ee6a9bac46/6b68aedd-4c5b-80b9-8007-7b38c1d34ce4/component",
"~:media-id": "~ube2dc82e-615b-486b-a193-8768bdb51d7a",
"~:created-at": "~m1769523563389"
}

View File

@@ -253,7 +253,7 @@ export class WorkspacePage extends BaseWebSocketPage {
async #waitForWebSocketReadiness() {
// TODO: find a better event to settle whether the app is ready to receive notifications via ws
await expect(this.pageName).toHaveText("Page 1", { timeout: 30000 })
await expect(this.pageName).toHaveText("Page 1");
}
async sendPresenceMessage(fixture) {
@@ -383,46 +383,19 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.page.keyboard.press("T");
await this.page.waitForTimeout(timeToWait);
await this.clickAndMove(x1, y1, x2, y2);
await expect(this.page.getByTestId("text-editor")).toBeVisible();
await this.page.waitForTimeout(timeToWait);
if (initialText) {
await this.page.keyboard.type(initialText);
}
}
/**
* Copies the selected element into the clipboard, or copy the
* content of the locator into the clipboard.
* Copies the selected element into the clipboard.
*
* @returns {Promise<void>}
*/
async copy(kind = "keyboard", locator = undefined) {
if (kind === "context-menu" && locator) {
await locator.click({ button: "right" });
await this.page.getByText("Copy", { exact: true }).click();
} else {
await this.page.keyboard.press("ControlOrMeta+C");
}
// wait for the clipboard to be updated
await this.page.waitForFunction(async () => {
const content = await navigator.clipboard.readText()
return content !== "";
}, { timeout: 1000 });
}
async cut(kind = "keyboard", locator = undefined) {
if (kind === "context-menu" && locator) {
await locator.click({ button: "right" });
await this.page.getByText("Cut", { exact: true }).click();
} else {
await this.page.keyboard.press("ControlOrMeta+X");
}
// wait for the clipboard to be updated
await this.page.waitForFunction(async () => {
const content = await navigator.clipboard.readText()
return content !== "";
}, { timeout: 1000 });
async copy() {
return this.page.keyboard.press("Control+C");
}
/**
@@ -434,9 +407,9 @@ export class WorkspacePage extends BaseWebSocketPage {
async paste(kind = "keyboard") {
if (kind === "context-menu") {
await this.viewport.click({ button: "right" });
return this.page.getByText("Paste", { exact: true }).click();
return this.page.getByText("PasteCtrlV").click();
}
return this.page.keyboard.press("ControlOrMeta+V");
return this.page.keyboard.press("Control+V");
}
async panOnViewportAt(x, y, width, height) {
@@ -475,11 +448,11 @@ export class WorkspacePage extends BaseWebSocketPage {
const layer = this.layers
.getByTestId("layer-row")
.filter({ hasText: name });
const button = layer.getByTestId("toggle-content");
const button = layer.getByRole("button");
await expect(button).toBeVisible();
await button.waitFor();
await button.click(clickOptions);
await button.waitFor({ ariaExpanded: true });
await this.page.waitForTimeout(500);
}
async expectSelectedLayer(name) {
@@ -522,7 +495,13 @@ export class WorkspacePage extends BaseWebSocketPage {
async clickColorPalette(clickOptions = {}) {
await this.palette
.getByRole("button", { name: /Color Palette/ })
.getByRole("button", { name: "Color Palette (Alt+P)" })
.click(clickOptions);
}
async clickColorPalette(clickOptions = {}) {
await this.palette
.getByRole("button", { name: "Color Palette (Alt+P)" })
.click(clickOptions);
}

View File

@@ -15,8 +15,6 @@ test("User can complete the onboarding", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
const onboardingPage = new OnboardingPage(page);
await dashboardPage.mockConfigFlags(["enable-onboarding"]);
await dashboardPage.goToDashboard();
await expect(
page.getByRole("heading", { name: "Help us get to know you" }),

View File

@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
import { WasmWorkspacePage, WASM_FLAGS } from "../pages/WasmWorkspacePage";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);

View File

@@ -5,7 +5,7 @@ import { WorkspacePage } from "../pages/WorkspacePage";
const timeToWait = 100;
test.beforeEach(async ({ page, context }) => {
await Clipboard.enable(context, Clipboard.Permission.ALL);
await Clipboard.enable(context, Clipboard.Permission.ONLY_WRITE);
await WorkspacePage.init(page);
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);
@@ -110,7 +110,7 @@ test("Update an already created text shape by prepending text", async ({
await workspace.textEditor.stopEditing();
});
test.skip("Update an already created text shape by inserting text in between", async ({
test("Update an already created text shape by inserting text in between", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
@@ -151,7 +151,7 @@ test("Update a new text shape appending text by pasting text", async ({
await workspace.textEditor.stopEditing();
});
test.skip("Update a new text shape prepending text by pasting text", async ({
test("Update a new text shape prepending text by pasting text", async ({
page,
context,
}) => {

View File

@@ -158,7 +158,7 @@ test.describe("Tokens - creation", () => {
const selfReferenceError = "Token has self reference";
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
@@ -189,7 +189,7 @@ test.describe("Tokens - creation", () => {
// 2. Invalid value → disabled + error message
await valueField.fill("1");
const invalidValueErrorNode =
tokensUpdateCreateModal.getByText(invalidValueError);
tokensUpdateCreateModal.getByText(invalidValueError);
await expect(invalidValueErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -197,7 +197,7 @@ test.describe("Tokens - creation", () => {
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -207,7 +207,7 @@ test.describe("Tokens - creation", () => {
await valueField.fill("{color.primary}");
const selfRefErrorNode =
tokensUpdateCreateModal.getByText(selfReferenceError);
tokensUpdateCreateModal.getByText(selfReferenceError);
await expect(selfRefErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -320,7 +320,7 @@ test.describe("Tokens - creation", () => {
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -356,7 +356,7 @@ test.describe("Tokens - creation", () => {
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -366,7 +366,7 @@ test.describe("Tokens - creation", () => {
await valueField.fill("{my-token}");
const selfRefErrorNode =
tokensUpdateCreateModal.getByText(selfReferenceError);
tokensUpdateCreateModal.getByText(selfReferenceError);
await expect(selfRefErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -459,13 +459,13 @@ test.describe("Tokens - creation", () => {
test("User creates font weight token", async ({ page }) => {
const invalidValueError =
"Invalid font weight value: use numeric values (100-950) or standard names (thin, light, regular, bold, etc.) optionally followed by 'Italic'";
"Invalid font weight value: use numeric values (100-950) or standard names (thin, light, regular, bold, etc.) optionally followed by 'Italic'";
const emptyNameError = "Name should be at least 1 character";
const selfReferenceError = "Token has self reference";
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -501,7 +501,7 @@ test.describe("Tokens - creation", () => {
await valueField.fill("red");
const invalidValueErrorNode =
tokensUpdateCreateModal.getByText(invalidValueError);
tokensUpdateCreateModal.getByText(invalidValueError);
await expect(invalidValueErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -510,7 +510,7 @@ test.describe("Tokens - creation", () => {
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -520,7 +520,7 @@ test.describe("Tokens - creation", () => {
await valueField.fill("{my-token}");
const selfRefErrorNode =
tokensUpdateCreateModal.getByText(selfReferenceError);
tokensUpdateCreateModal.getByText(selfReferenceError);
await expect(selfRefErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -595,13 +595,13 @@ test.describe("Tokens - creation", () => {
test("User creates text case token", async ({ page }) => {
const invalidValueError =
"Invalid token value: only none, Uppercase, Lowercase or Capitalize are accepted";
"Invalid token value: only none, Uppercase, Lowercase or Capitalize are accepted";
const emptyNameError = "Name should be at least 1 character";
const selfReferenceError = "Token has self reference";
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -637,7 +637,7 @@ test.describe("Tokens - creation", () => {
await valueField.fill("red");
const invalidValueErrorNode =
tokensUpdateCreateModal.getByText(invalidValueError);
tokensUpdateCreateModal.getByText(invalidValueError);
await expect(invalidValueErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -646,7 +646,7 @@ test.describe("Tokens - creation", () => {
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -656,7 +656,7 @@ test.describe("Tokens - creation", () => {
await valueField.fill("{my-token}");
const selfRefErrorNode =
tokensUpdateCreateModal.getByText(selfReferenceError);
tokensUpdateCreateModal.getByText(selfReferenceError);
await expect(selfRefErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -711,13 +711,13 @@ test.describe("Tokens - creation", () => {
test("User creates text decoration token", async ({ page }) => {
const invalidValueError =
"Invalid token value: only none, underline and strike-through are accepted";
"Invalid token value: only none, underline and strike-through are accepted";
const emptyNameError = "Name should be at least 1 character";
const selfReferenceError = "Token has self reference";
const missingReferenceError = "Missing token references";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -755,7 +755,7 @@ test.describe("Tokens - creation", () => {
await valueField.fill("red");
const invalidValueErrorNode =
tokensUpdateCreateModal.getByText(invalidValueError);
tokensUpdateCreateModal.getByText(invalidValueError);
await expect(invalidValueErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -764,7 +764,7 @@ test.describe("Tokens - creation", () => {
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -774,7 +774,7 @@ test.describe("Tokens - creation", () => {
await valueField.fill("{my-token}");
const selfRefErrorNode =
tokensUpdateCreateModal.getByText(selfReferenceError);
tokensUpdateCreateModal.getByText(selfReferenceError);
await expect(selfRefErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -831,7 +831,7 @@ test.describe("Tokens - creation", () => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page, { flags: ["enable-token-shadow"] });
await setupEmptyTokensFile(page, { flags: ["enable-token-shadow"] });
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -900,7 +900,7 @@ test.describe("Tokens - creation", () => {
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -977,9 +977,9 @@ test.describe("Tokens - creation", () => {
await nameField.fill("my-token-2");
const referenceToggle =
tokensUpdateCreateModal.getByTestId("reference-opt");
tokensUpdateCreateModal.getByTestId("reference-opt");
const compositeToggle =
tokensUpdateCreateModal.getByTestId("composite-opt");
tokensUpdateCreateModal.getByTestId("composite-opt");
await referenceToggle.click();
const referenceInput = tokensUpdateCreateModal.getByPlaceholder(
@@ -1012,7 +1012,7 @@ test.describe("Tokens - creation", () => {
page,
}) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
await setupTypographyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
@@ -1038,7 +1038,7 @@ test.describe("Tokens - creation", () => {
// Switch to reference tab, should not be submittable either
const referenceTabButton =
tokensUpdateCreateModal.getByTestId("reference-opt");
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceTabButton.click();
await expect(submitButton).toBeDisabled();
});
@@ -1047,7 +1047,7 @@ test.describe("Tokens - creation", () => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page, { flags: ["enable-token-shadow"] });
await setupEmptyTokensFile(page, {flags: ["enable-token-shadow"]});
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -1116,7 +1116,7 @@ test.describe("Tokens - creation", () => {
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -1198,9 +1198,9 @@ test.describe("Tokens - creation", () => {
await nameField.fill("my-token-2");
const referenceToggle =
tokensUpdateCreateModal.getByTestId("reference-opt");
tokensUpdateCreateModal.getByTestId("reference-opt");
const compositeToggle =
tokensUpdateCreateModal.getByTestId("composite-opt");
tokensUpdateCreateModal.getByTestId("composite-opt");
await referenceToggle.click();
const referenceInput = tokensUpdateCreateModal.getByPlaceholder(
@@ -1232,7 +1232,7 @@ test.describe("Tokens - creation", () => {
test("User creates typography token", async ({ page }) => {
const emptyNameError = "Name should be at least 1 character";
const { tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
// Open modal
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -1287,7 +1287,7 @@ test.describe("Tokens - creation", () => {
await nameField.fill("");
const emptyNameErrorNode =
tokensUpdateCreateModal.getByText(emptyNameError);
tokensUpdateCreateModal.getByText(emptyNameError);
await expect(emptyNameErrorNode).toBeVisible();
await expect(submitButton).toBeDisabled();
@@ -1443,9 +1443,9 @@ test.describe("Tokens - creation", () => {
await nameField.fill("my-token-2");
const referenceToggle =
tokensUpdateCreateModal.getByTestId("reference-opt");
tokensUpdateCreateModal.getByTestId("reference-opt");
const compositeToggle =
tokensUpdateCreateModal.getByTestId("composite-opt");
tokensUpdateCreateModal.getByTestId("composite-opt");
await referenceToggle.click();
@@ -1479,7 +1479,7 @@ test.describe("Tokens - creation", () => {
test("User adds typography token with reference", async ({ page }) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
await setupTypographyTokensFile(page);
const newTokenTitle = "NewReference";
@@ -1508,7 +1508,7 @@ test.describe("Tokens - creation", () => {
});
const resolvedValue =
await tokensUpdateCreateModal.getByText("Resolved value:");
await tokensUpdateCreateModal.getByText("Resolved value:");
await expect(resolvedValue).toBeVisible();
await expect(resolvedValue).toContainText("Font Family: 42dot Sans");
await expect(resolvedValue).toContainText("Font Size: 100");
@@ -1531,7 +1531,7 @@ test.describe("Tokens - creation", () => {
test("User creates grouped color token", async ({ page }) => {
const { workspacePage, tokensUpdateCreateModal, tokensSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
@@ -1591,7 +1591,7 @@ test.describe("Tokens - creation", () => {
test("User duplicate color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
@@ -1613,11 +1613,15 @@ test.describe("Tokens - creation", () => {
});
});
test("User creates grouped color token", async ({ page }) => {
const { workspacePage, tokensUpdateCreateModal, tokensSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
await tokensSidebar.getByRole("button", { name: "Add Token: Color" }).click();
await tokensSidebar
.getByRole("button", { name: "Add Token: Color" })
.click();
// Create grouped color token with mouse
@@ -1643,7 +1647,9 @@ test("User creates grouped color token", async ({ page }) => {
await expect(tokensSidebar.getByLabel("primary")).toBeEnabled();
});
test("User cant create regular token with value missing", async ({ page }) => {
test("User cant create regular token with value missing", async ({
page,
}) => {
const { tokensUpdateCreateModal } = await setupEmptyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
@@ -1671,7 +1677,7 @@ test("User cant create regular token with value missing", async ({ page }) => {
test("User duplicate color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
@@ -1697,7 +1703,7 @@ test.describe("Tokens tab - edition", () => {
page,
}) => {
const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } =
await setupTypographyTokensFile(page);
await setupTypographyTokensFile(page);
await tokensSidebar
.getByRole("button")
@@ -1718,8 +1724,8 @@ test.describe("Tokens tab - edition", () => {
// Fill font-family to verify to verify that input value doesn't get split into list of characters
const fontFamilyField = tokensUpdateCreateModal
.getByLabel("Font family")
.first();
.getByLabel("Font family")
.first();
await fontFamilyField.fill("OneWord");
// Invalidate incorrect values for font size
@@ -1740,11 +1746,11 @@ test.describe("Tokens tab - edition", () => {
const fontWeightField = tokensUpdateCreateModal.getByLabel(/Font Weight/i);
const letterSpacingField =
tokensUpdateCreateModal.getByLabel(/Letter Spacing/i);
tokensUpdateCreateModal.getByLabel(/Letter Spacing/i);
const lineHeightField = tokensUpdateCreateModal.getByLabel(/Line Height/i);
const textCaseField = tokensUpdateCreateModal.getByLabel(/Text Case/i);
const textDecorationField =
tokensUpdateCreateModal.getByLabel(/Text Decoration/i);
tokensUpdateCreateModal.getByLabel(/Text Decoration/i);
// Capture all values before switching tabs
const originalValues = {
@@ -1759,14 +1765,14 @@ test.describe("Tokens tab - edition", () => {
// Switch to reference tab and back to composite tab
const referenceTabButton =
tokensUpdateCreateModal.getByTestId("reference-opt");
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceTabButton.click();
// Empty reference tab should be disabled
await expect(saveButton).toBeDisabled();
const compositeTabButton =
tokensUpdateCreateModal.getByTestId("composite-opt");
tokensUpdateCreateModal.getByTestId("composite-opt");
await compositeTabButton.click();
// Filled composite tab should be enabled
@@ -1793,7 +1799,7 @@ test.describe("Tokens tab - edition", () => {
page,
}) => {
const { tokensUpdateCreateModal, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
@@ -1829,7 +1835,7 @@ test.describe("Tokens tab - edition", () => {
page,
}) => {
const { workspacePage, tokensUpdateCreateModal, tokenThemesSetsSidebar } =
await setupEmptyTokensFile(page);
await setupEmptyTokensFile(page);
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await tokensTabPanel
@@ -1884,7 +1890,7 @@ test.describe("Tokens tab - edition", () => {
test.describe("Tokens tab - delete", () => {
test("User delete color token", async ({ page }) => {
const { tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
@@ -1904,7 +1910,7 @@ test.describe("Tokens tab - delete", () => {
});
test("User removes node and all child tokens", async ({ page }) => {
const { tokensSidebar } = await setupTokensFile(page);
const { tokensSidebar, workspacePage } = await setupTokensFile(page);
await expect(tokensSidebar).toBeVisible();
@@ -1913,7 +1919,7 @@ test.describe("Tokens tab - delete", () => {
// Verify that the node and child token are visible before deletion
const colorNode = tokensSidebar.getByRole("button", {
name: "colors",
name: "blue",
exact: true,
});
const colorNodeToken = tokensSidebar.getByRole("button", {
@@ -1937,13 +1943,5 @@ test.describe("Tokens tab - delete", () => {
await expect(colorNode).not.toBeVisible();
// Verify that child token is also removed
await expect(colorNodeToken).not.toBeVisible();
// Save the type button to verify that expands/folds
const tokenTypeButton = await tokensSidebar.getByRole("button", {
name: "Color",
exact: true,
});
await expect(tokenTypeButton).toHaveAttribute("aria-expanded", "false");
});
});

View File

@@ -1,19 +1,12 @@
import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../pages/WorkspacePage";
import { WorkspacePage } from "../pages/WorkspacePage";
import { BaseWebSocketPage } from "../pages/BaseWebSocketPage";
import { Clipboard } from "../../helpers/Clipboard";
test.beforeEach(async ({ page, context }) => {
await Clipboard.enable(context, Clipboard.Permission.ALL);
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-variants.json");
});
test.afterEach(async ({ context }) => {
context.clearPermissions();
});
const setupVariantsFile = async (workspacePage) => {
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
@@ -41,9 +34,9 @@ const setupVariantsFileWithVariant = async (workspacePage) => {
await setupVariantsFile(workspacePage);
await workspacePage.clickLeafLayer("Rectangle");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.waitForTimeout(500);
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.waitForTimeout(500);
// We wait until layer-row starts looking like it an component
@@ -163,7 +156,7 @@ test("User duplicates a variant container", async ({ page }) => {
await variant.container.click();
//Duplicate the variant container
await workspacePage.page.keyboard.press("ControlOrMeta+d");
await workspacePage.page.keyboard.press("Control+d");
const variant_original = await findVariant(workspacePage, 1); // On duplicate, the new item is the first
const variant_duplicate = await findVariant(workspacePage, 0);
@@ -176,27 +169,25 @@ test("User duplicates a variant container", async ({ page }) => {
await validateVariant(variant_duplicate);
});
test("User copy paste a variant container", async ({ page, context }) => {
test("User copy paste a variant container", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
// Access to the read/write clipboard necesary for this functionality
await setupVariantsFileWithVariant(workspacePage);
await workspacePage.mockRPC(
/create-file-object-thumbnail.*/,
"workspace/create-file-object-thumbnail.json",
);
const variant = findVariantNoWait(workspacePage, 0);
// await variant.container.waitFor();
// Select the variant container
await variant.container.click();
await workspacePage.page.waitForTimeout(1000);
// Copy the variant container
await workspacePage.clickLeafLayer("Rectangle");
await workspacePage.copy("keyboard");
await workspacePage.page.keyboard.press("Control+c");
// Paste the variant container
await workspacePage.clickAt(400, 400);
await workspacePage.paste("keyboard");
const variants = workspacePage.layers.getByText("Rectangle");
await expect(variants).toHaveCount(2);
await workspacePage.page.keyboard.press("Control+v");
const variantDuplicate = findVariantNoWait(workspacePage, 0);
const variantOriginal = findVariantNoWait(workspacePage, 1);
@@ -221,17 +212,18 @@ test("User cut paste a variant container", async ({ page }) => {
await variant.container.click();
//Cut the variant container
await workspacePage.cut("keyboard");
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.page.waitForTimeout(500);
//Paste the variant container
await workspacePage.clickAt(500, 500);
await workspacePage.paste("keyboard");
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.page.waitForTimeout(500);
const variantPasted = await findVariant(workspacePage, 0);
// Expand the layers
await workspacePage.clickToggableLayer("Rectangle");
await variantPasted.container.locator("button").first().click();
// The variants are valid
await validateVariant(variantPasted);
@@ -247,34 +239,27 @@ test("User cut paste a variant container into a board, and undo twice", async ({
//Create a board
await workspacePage.boardButton.click();
// NOTE: this board should not intersect the existing variants, otherwise
// this test is flaky
await workspacePage.clickWithDragViewportAt(200, 200, 100, 100);
await workspacePage.clickWithDragViewportAt(500, 500, 100, 100);
await workspacePage.clickAt(495, 495);
const board = await workspacePage.rootShape.locator("Board");
// Select the variant container
// await variant.container.click();
await workspacePage.clickLeafLayer("Rectangle");
await variant.container.click();
//Cut the variant container
await workspacePage.cut("keyboard");
await expect(variant.container).not.toBeVisible();
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.page.waitForTimeout(500);
//Select the board
await workspacePage.clickLeafLayer("Board");
//Paste the variant container inside the board
await workspacePage.paste("keyboard");
await expect(variant.container).toBeVisible();
await workspacePage.page.keyboard.press("Control+v");
//Undo twice
await workspacePage.page.keyboard.press("ControlOrMeta+z");
await expect(variant.container).not.toBeVisible();
await workspacePage.page.keyboard.press("ControlOrMeta+z");
await expect(variant.container).toBeVisible();
await workspacePage.page.keyboard.press("Control+z");
await workspacePage.page.keyboard.press("Control+z");
await workspacePage.page.waitForTimeout(500);
const variantAfterUndo = await findVariant(workspacePage, 0);
@@ -291,12 +276,12 @@ test("User copy paste a variant", async ({ page }) => {
// Select the variant1
await variant.variant1.click();
// Copy the variant
await workspacePage.copy("keyboard");
//Cut the variant
await workspacePage.page.keyboard.press("Control+c");
// Paste the variant
//Paste the variant
await workspacePage.clickAt(500, 500);
await workspacePage.paste("keyboard");
await workspacePage.page.keyboard.press("Control+v");
const copy = await workspacePage.layers
.getByTestId("layer-row")
@@ -317,11 +302,11 @@ test("User cut paste a variant outside the container", async ({ page }) => {
await variant.variant1.click();
//Cut the variant
await workspacePage.cut("keyboard");
await workspacePage.page.keyboard.press("Control+x");
//Paste the variant
await workspacePage.clickAt(500, 500);
await workspacePage.paste("keyboard");
await workspacePage.page.keyboard.press("Control+v");
const component = await workspacePage.layers
.getByTestId("layer-row")
@@ -339,11 +324,15 @@ test("User drag and drop a variant outside the container", async ({ page }) => {
const variant = await findVariant(workspacePage, 0);
// Drag and drop the variant
// FIXME: to make this test more resilient, we should get the bounding box of the Value 1 variant
// and use it to calculate the target position
await workspacePage.clickWithDragViewportAt(600, 500, 0, 300);
await workspacePage.clickWithDragViewportAt(350, 400, 0, 200);
await expect(workspacePage.layers.getByText("Rectangle / Value 1")).toBeVisible();
const component = await workspacePage.layers
.getByTestId("layer-row")
.filter({ has: workspacePage.page.getByText("Rectangle / Value 1") })
.filter({ has: workspacePage.page.getByTestId("icon-component") });
//The component exists and is visible
await expect(component).toBeVisible();
});
test("User cut paste a component inside a variant", async ({ page }) => {
@@ -356,14 +345,14 @@ test("User cut paste a component inside a variant", async ({ page }) => {
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.keyboard.press("Control+k");
//Cut the component
await workspacePage.cut("keyboard");
await workspacePage.page.keyboard.press("Control+x");
//Paste the component inside the variant
await variant.container.click();
await workspacePage.paste("keyboard");
await workspacePage.page.keyboard.press("Control+v");
const variant3 = await workspacePage.layers
.getByTestId("layer-row")
@@ -387,7 +376,7 @@ test("User cut paste a component with path inside a variant", async ({
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.keyboard.press("Control+k");
//Rename the component
await workspacePage.layers.getByText("Ellipse").dblclick();
@@ -398,11 +387,11 @@ test("User cut paste a component with path inside a variant", async ({
await workspacePage.page.keyboard.press("Enter");
//Cut the component
await workspacePage.cut("keyboard");
await workspacePage.page.keyboard.press("Control+x");
//Paste the component inside the variant
await variant.container.click();
await workspacePage.paste("keyboard");
await workspacePage.page.keyboard.press("Control+v");
const variant3 = await workspacePage.layers
.getByTestId("layer-row")
@@ -426,7 +415,7 @@ test("User drag and drop a component with path inside a variant", async ({
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.keyboard.press("Control+k");
//Rename the component
await workspacePage.layers.getByText("Ellipse").dblclick();
@@ -437,7 +426,7 @@ test("User drag and drop a component with path inside a variant", async ({
await workspacePage.page.keyboard.press("Enter");
//Drag and drop the component the component
await workspacePage.clickWithDragViewportAt(510, 510, 200, 0);
await workspacePage.clickWithDragViewportAt(510, 510, 0, -200);
const variant3 = await workspacePage.layers
.getByTestId("layer-row")
@@ -457,8 +446,8 @@ test("User cut paste a variant into another container", async ({ page }) => {
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("Control+k");
const variantOrigin = await findVariantNoWait(workspacePage, 1);
@@ -468,11 +457,11 @@ test("User cut paste a variant into another container", async ({ page }) => {
await variantOrigin.variant1.click();
//Cut the variant
await workspacePage.cut("keyboard");
await workspacePage.page.keyboard.press("Control+x");
//Paste the variant
await workspacePage.layers.getByText("Ellipse").first().click();
await workspacePage.paste("keyboard");
await workspacePage.page.keyboard.press("Control+v");
const variant3 = workspacePage.layers
.getByTestId("layer-row")

View File

@@ -1,13 +1,13 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
import { WorkspacePage } from "../pages/WorkspacePage";
import { presenceFixture, joinFixture2, joinFixture3 } from "../../data/workspace/ws-notifications";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
await WorkspacePage.init(page);
});
test("User loads worskpace with empty file", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
@@ -16,7 +16,7 @@ test("User loads worskpace with empty file", async ({ page }) => {
});
test("User opens a file with a bad page id", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace({
@@ -29,7 +29,7 @@ test("User opens a file with a bad page id", async ({ page }) => {
test("User receives presence notifications updates in the workspace", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
@@ -63,7 +63,7 @@ test("BUG 13058 - Presence list shows up to 3 user avatars", async ({
});
test("User draws a rect", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
"update-file?id=*",
@@ -74,12 +74,13 @@ test("User draws a rect", async ({ page }) => {
await workspacePage.rectShapeButton.click();
await workspacePage.clickWithDragViewportAt(128, 128, 200, 100);
await workspacePage.hideUI();
await expect(workspacePage.canvas).toHaveScreenshot();
const shape = await workspacePage.rootShape.locator("rect");
await expect(shape).toHaveAttribute("width", "200");
await expect(shape).toHaveAttribute("height", "100");
});
test("User makes a group", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
/get\-file\?/,
@@ -95,14 +96,14 @@ test("User makes a group", async ({ page }) => {
pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375",
});
await workspacePage.clickLeafLayer("Rectangle");
await workspacePage.page.keyboard.press("ControlOrMeta+g");
await workspacePage.page.keyboard.press("Control+g");
await workspacePage.expectSelectedLayer("Group");
});
test("Bug 7654 - Toolbar keeps toggling on and off on spacebar press", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
@@ -112,10 +113,10 @@ test("Bug 7654 - Toolbar keeps toggling on and off on spacebar press", async ({
await workspacePage.expectHiddenToolbarOptions();
});
test("Bug 7525 - User moves a scrollbar and no selection rectangle appears", async ({
test("Bug 7525 - User moves a scrollbar and no selciont rectangle appears", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
/get\-file\?/,
@@ -131,8 +132,8 @@ test("Bug 7525 - User moves a scrollbar and no selection rectangle appears", asy
pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375",
});
// Move created rect to a corner, in order to get scrollbars
await workspacePage.panOnViewportAt(128, 128, 600, 600);
// Move created rect to a corner, in orther to get scrollbars
await workspacePage.panOnViewportAt(128, 128, 300, 300);
// Check scrollbars appear
const horizontalScrollbar = workspacePage.horizontalScrollbar;
@@ -151,7 +152,7 @@ test("Bug 7525 - User moves a scrollbar and no selection rectangle appears", asy
test("User adds a library and its automatically selected in the color palette", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
"link-file-to-library",
@@ -196,7 +197,7 @@ test("User adds a library and its automatically selected in the color palette",
test("Bug 10179 - Drag & drop doesn't add colors to the Recent Colors palette", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
await workspacePage.moveButton.click();
@@ -239,7 +240,7 @@ test("Bug 10179 - Drag & drop doesn't add colors to the Recent Colors palette",
test("Bug 7489 - Workspace-palette items stay hidden when opening with keyboard-shortcut", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
@@ -256,7 +257,7 @@ test("Bug 7489 - Workspace-palette items stay hidden when opening with keyboard-
test("Bug 8784 - Use keyboard arrow to move inside a text input does not change tabs", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
await workspacePage.pageName.click();
@@ -266,7 +267,7 @@ test("Bug 8784 - Use keyboard arrow to move inside a text input does not change
});
test("Bug 9066 - Problem with grid layout", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(/get\-file\?/, "workspace/get-file-9066.json");
@@ -294,7 +295,7 @@ test("Bug 9066 - Problem with grid layout", async ({ page }) => {
});
test("User have toolbar", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
@@ -303,7 +304,7 @@ test("User have toolbar", async ({ page }) => {
});
test("User have edition menu entries", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
@@ -319,7 +320,7 @@ test("User have edition menu entries", async ({ page }) => {
});
test("Copy/paste properties", async ({ page, context }) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(
/get\-file\?/,
@@ -385,23 +386,23 @@ test("Copy/paste properties", async ({ page, context }) => {
});
test("[Taiga #9929] Paste text in workspace", async ({ page, context }) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.evaluate(() => navigator.clipboard.writeText("Lorem ipsum dolor"));
await workspacePage.viewport.click({ button: "right" });
await page.getByText(/^Paste/i).click();
await page.getByText("PasteCtrlV").click();
await workspacePage.viewport
.getByRole("textbox")
.getByText("Lorem ipsum dolor");
});
test("[Taiga #9930] Zoom fit all doesn't fit all shapes", async ({
test("[Taiga #9930] Zoom fit all doesn't fits all", async ({
page,
context,
}) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(/get\-file\?/, "workspace/get-file-9930.json");
await workspacePage.goToWorkspace({
@@ -409,18 +410,16 @@ test("[Taiga #9930] Zoom fit all doesn't fit all shapes", async ({
pageId: "fb9798e7-a547-80ae-8005-9ffda4a13e2c",
});
const zoom = page.getByTitle("Zoom");
const zoom = await page.getByTitle("Zoom");
await zoom.click();
const zoomIn = page.getByRole("button", { name: "Zoom in" });
const zoomIn = await page.getByRole("button", { name: "Zoom in" });
await zoomIn.click();
await zoomIn.click();
await zoomIn.click();
// Zoom fit all
await page.keyboard.press("Shift+1");
// Select all shapes to display selrect
await workspacePage.page.keyboard.press("ControlOrMeta+a");
const ids = [
"shape-165d1e5a-5873-8010-8005-9ffdbeaeec59",
@@ -442,7 +441,7 @@ test("[Taiga #9930] Zoom fit all doesn't fit all shapes", async ({
const viewportBoundingBox = await workspacePage.viewport.boundingBox();
for (const id of ids) {
const shape = page.locator(`.viewport-selrect`);
const shape = await page.locator(`.ws-shape-wrapper > g#${id}`);
const shapeBoundingBox = await shape.boundingBox();
expect(contains(viewportBoundingBox, shapeBoundingBox)).toBeTruthy();
}
@@ -451,7 +450,7 @@ test("[Taiga #9930] Zoom fit all doesn't fit all shapes", async ({
test("Bug 9877, user navigation to dashboard from header goes to blank page", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
@@ -468,7 +467,7 @@ test("Bug 9877, user navigation to dashboard from header goes to blank page", as
test("Bug 8371 - Flatten option is not visible in context menu", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockGetFile("workspace/get-file-8371.json");
await workspacePage.goToWorkspace({

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

1961
frontend/pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -7,3 +7,4 @@ packages:
- "packages/draft-js"
- "packages/mousetrap"
- "text-editor"
- "packages/ui"

View File

@@ -18,6 +18,7 @@
<meta name="twitter:creator" content="@penpotapp">
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">
<link id="theme" href="css/main.css?version={{& version_tag}}" rel="stylesheet" type="text/css" />
<link href="css/ui.css?ts={{& ts}}" rel="stylesheet" type="text/css" />
{{#isDebug}}
<link href="css/debug.css?version={{& version_tag}}" rel="stylesheet" type="text/css" />
{{/isDebug}}

View File

@@ -1,90 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<title>WASM + WebGL2 Canvas</title>
<style>
body {
margin: 0;
background: #111;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
overflow: hidden;
}
canvas {
width: 100%;
height: 100%;
position: absolute;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, addShapeSolidStrokeFill
} from './js/lib.js';
const canvas = document.getElementById("canvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const params = new URLSearchParams(document.location.search);
const shapes = params.get("shapes") || 1000;
initWasmModule().then(Module => {
init(Module);
assignCanvas(canvas);
Module._set_canvas_background(hexToU32ARGB("#FABADA", 1));
Module._init_shapes_pool(shapes + 1);
setupInteraction(canvas);
const children = [];
for (let i = 0; i < shapes; i++) {
const uuid = crypto.randomUUID();
children.push(uuid);
useShape(uuid);
Module._set_parent(0, 0, 0, 0);
Module._set_shape_type(3);
const x1 = getRandomInt(0, canvas.width);
const y1 = getRandomInt(0, canvas.height);
const width = getRandomInt(20, 100);
const height = getRandomInt(20, 100);
Module._set_shape_selrect(x1, y1, x1 + width, y1 + height);
const color = getRandomColor();
const argb = hexToU32ARGB(color, getRandomFloat(0.1, 1.0));
addShapeSolidFill(argb)
Module._add_shape_center_stroke(10, 0, 0, 0);
const argb2 = hexToU32ARGB(color, getRandomFloat(0.1, 1.0));
addShapeSolidStrokeFill(argb2);
// Add shadows
// Shadow 1: drop-shadow, #dedede opacity 0.33, blur 4, spread -2, offsetX 0, offsetY 2
Module._add_shape_shadow(hexToU32ARGB("#dedede", 0.33), 4, -2, 0, 2, 0, false);
// Shadow 2: drop-shadow, #dedede opacity 1, blur 12, spread -8, offsetX 0, offsetY 12
Module._add_shape_shadow(hexToU32ARGB("#dedede", 1), 12, -8, 0, 12, 0, false);
// Shadow 3: inner-shadow, #002046 opacity 0.12, blur 12, spread -8, offsetX 0, offsetY -4
Module._add_shape_shadow(hexToU32ARGB("#002046", 0.12), 12, -8, 0, -4, 1, false);
}
useShape("00000000-0000-0000-0000-000000000000");
setShapeChildren(children);
performance.mark('render:begin');
Module._set_view(1, 0, 0);
Module._render(Date.now());
performance.mark('render:end');
const { duration } = performance.measure('render', 'render:begin', 'render:end');
// alert(`render time: ${duration.toFixed(2)}ms`);
});
</script>
</body>
</html>

View File

@@ -1,6 +1,20 @@
import * as esbuild from "esbuild";
import { readFile } from "node:fs/promises";
/**
* esbuild plugin to watch a directory recursively
*/
const watchExtraDirPlugin = {
name: 'watch-extra-dir',
setup(build) {
build.onLoad({ filter: /target\/index.js/, namespace: 'file' }, async (args) => {
return {
watchDirs: ["packages/ui/dist"],
};
});
}
};
const filter =
/react-virtualized[/\\]dist[/\\]es[/\\]WindowScroller[/\\]utils[/\\]onScroll\.js$/;
@@ -36,7 +50,7 @@ const config = {
js: '"use strict";\nvar global = globalThis;',
},
outfile: "resources/public/js/libs.js",
plugins: [fixReactVirtualized, rebuildNotify],
plugins: [fixReactVirtualized, rebuildNotify, watchExtraDirPlugin],
};
async function watch() {

View File

@@ -44,20 +44,6 @@
(update [_ state]
(update-in state [:workspace-local :open-plugins] (fnil conj #{}) id))))
(defn reset-plugin-flags
[id]
(ptk/reify ::reset-plugin-flags
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-local :plugin-flags] assoc id {}))))
(defn set-plugin-flag
[id key value]
(ptk/reify ::set-plugin-flag
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-local :plugin-flags id] assoc key value))))
(defn remove-current-plugin
[id]
(ptk/reify ::remove-current-plugin
@@ -68,9 +54,7 @@
(defn- load-plugin!
[{:keys [plugin-id name description host code icon permissions]}]
(try
(st/emit! (save-current-plugin plugin-id)
(reset-plugin-flags plugin-id))
(st/emit! (save-current-plugin plugin-id))
(.ɵloadPlugin
^js ug/global
#js {:pluginId plugin-id

View File

@@ -8,7 +8,7 @@
(:require
["@tokens-studio/sd-transforms" :as sd-transforms]
["style-dictionary$default" :as sd]
[app.common.files.tokens :as cfo]
[app.common.files.tokens :as cft]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.time :as ct]
@@ -83,7 +83,7 @@
[value]
(let [number? (or (number? value)
(numeric-string? value))
parsed-value (cfo/parse-token-value value)
parsed-value (cft/parse-token-value value)
out-of-bounds (or (>= (:value parsed-value) sm/max-safe-int)
(<= (:value parsed-value) sm/min-safe-int))]
@@ -109,7 +109,7 @@
"Parses `value` of a number `sd-token` into a map like `{:value 1 :unit \"px\"}`.
If the `value` is not parseable and/or has missing references returns a map with `:errors`."
[value]
(let [parsed-value (cfo/parse-token-value value)
(let [parsed-value (cft/parse-token-value value)
out-of-bounds (or (>= (:value parsed-value) sm/max-safe-int)
(<= (:value parsed-value) sm/min-safe-int))]
(if (and parsed-value (not out-of-bounds))
@@ -127,7 +127,7 @@
If the `value` is parseable but is out of range returns a map with `warnings`."
[value]
(let [missing-references? (seq (cto/find-token-value-references value))
parsed-value (cfo/parse-token-value value)
parsed-value (cft/parse-token-value value)
out-of-scope (not (<= 0 (:value parsed-value) 1))
references (seq (cto/find-token-value-references value))]
(cond (and parsed-value (not out-of-scope))
@@ -151,7 +151,7 @@
If the `value` is parseable but is out of range returns a map with `warnings`."
[value]
(let [missing-references? (seq (cto/find-token-value-references value))
parsed-value (cfo/parse-token-value value)
parsed-value (cft/parse-token-value value)
out-of-scope (< (:value parsed-value) 0)]
(cond
(and parsed-value (not out-of-scope))
@@ -249,7 +249,7 @@
:font-size-value font-size-value})]
(or error
(try
(when-let [{:keys [unit value]} (cfo/parse-token-value line-height-value)]
(when-let [{:keys [unit value]} (cft/parse-token-value line-height-value)]
(case unit
"%" (/ value 100)
"px" (/ value font-size-value)

View File

@@ -1410,7 +1410,6 @@
(dm/export dwt/start-move-selected)
(dm/export dwt/move-selected)
(dm/export dwt/update-position)
(dm/export dwt/update-positions)
(dm/export dwt/flip-horizontal-selected)
(dm/export dwt/flip-vertical-selected)
(dm/export dwly/set-opacity)

View File

@@ -46,9 +46,7 @@
[app.main.data.workspace.thumbnails :as dwt]
[app.main.data.workspace.transforms :as dwtr]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.data.workspace.zoom :as dwz]
[app.main.features :as features]
[app.main.features.pointer-map :as fpmap]
[app.main.refs :as refs]
[app.main.repo :as rp]
@@ -1014,13 +1012,6 @@
updated-objects (pcb/get-objects changes)
new-children-ids (cfh/get-children-ids-with-self updated-objects (:id new-shape))
new-text-ids (->> new-children-ids
(keep (fn [id]
(when-let [child (get updated-objects id)]
(when (and (cfh/text-shape? child)
(not= :fixed (:grow-type child)))
id))))
(vec))
[changes parents-of-swapped]
(if keep-touched?
@@ -1030,9 +1021,6 @@
(rx/of
(dwu/start-undo-transaction undo-id)
(dch/commit-changes changes)
(when (and (features/active-feature? state "render-wasm/v1")
(seq new-text-ids))
(dwwt/resize-wasm-text-all new-text-ids))
(ptk/data-event :layout/update {:ids update-layout-ids :undo-group undo-group})
(dwu/commit-undo-transaction undo-id)
(dws/select-shape (:id new-shape) false))))))

View File

@@ -712,7 +712,8 @@
(ctm/rotation-modifiers shape center angle))
modif-tree
(build-modif-tree ids objects get-modifier)
(-> (build-modif-tree ids objects get-modifier)
(gm/set-objects-modifiers objects))
modifiers
(mapv (fn [[id {:keys [modifiers]}]]

View File

@@ -11,6 +11,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
[app.common.math :as mth]
@@ -28,10 +29,10 @@
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.transforms :as dwt]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
[app.main.fonts :as fonts]
[app.main.router :as rt]
[app.render-wasm.api :as wasm.api]
[app.util.text-editor :as ted]
[app.util.text.content.styles :as styles]
[app.util.timers :as ts]
@@ -51,6 +52,50 @@
(declare v2-update-text-shape-content)
(declare v2-update-text-editor-styles)
(defn resize-wasm-text-modifiers
([shape]
(resize-wasm-text-modifiers shape (:content shape)))
([{:keys [id points selrect grow-type] :as shape} content]
(wasm.api/use-shape id)
(wasm.api/set-shape-text-content id content)
(wasm.api/set-shape-text-images id content)
(let [dimension (wasm.api/get-text-dimensions)
width-scale (if (#{:fixed :auto-height} grow-type)
1.0
(/ (:width dimension) (:width selrect)))
height-scale (if (= :fixed grow-type)
1.0
(/ (:height dimension) (:height selrect)))
resize-v (gpt/point width-scale height-scale)
origin (first points)]
{id
{:modifiers
(ctm/resize-modifiers
resize-v
origin
(:transform shape (gmt/matrix))
(:transform-inverse shape (gmt/matrix)))}})))
(defn resize-wasm-text
[id]
(ptk/reify ::resize-wasm-text
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)]
(rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape)))))))
(defn resize-wasm-text-all
[ids]
(ptk/reify ::resize-wasm-text-all
ptk/WatchEvent
(watch [_ _ _]
(->> (rx/from ids)
(rx/map resize-wasm-text)))))
;; -- Content helpers
(defn- v2-content-has-text?
@@ -133,7 +178,7 @@
{:undo-group (when new-shape? id)})
(dwm/apply-wasm-modifiers
(dwwt/resize-wasm-text-modifiers shape content)
(resize-wasm-text-modifiers shape content)
{:undo-group (when new-shape? id)})))))
(let [content (d/merge (ted/export-content content)
@@ -778,7 +823,7 @@
(when (features/active-feature? state "render-wasm/v1")
;; This delay is to give time for the font to be correctly rendered
;; in wasm.
(cond->> (rx/of (dwwt/resize-wasm-text id))
(cond->> (rx/of (resize-wasm-text id))
(contains? attrs :font-id)
(rx/delay 200)))))))
@@ -928,11 +973,11 @@
(if (and (not= :fixed (:grow-type shape)) finalize?)
(dwm/apply-wasm-modifiers
(dwwt/resize-wasm-text-modifiers shape content)
(resize-wasm-text-modifiers shape content)
{:undo-group (when new-shape? id)})
(dwm/set-wasm-modifiers
(dwwt/resize-wasm-text-modifiers shape content)
(resize-wasm-text-modifiers shape content)
{:undo-group (when new-shape? id)})))
(when finalize?

View File

@@ -7,7 +7,7 @@
(ns app.main.data.workspace.tokens.application
(:require
[app.common.data :as d]
[app.common.files.tokens :as cfo]
[app.common.files.tokens :as cft]
[app.common.types.component :as ctk]
[app.common.types.shape.layout :as ctsl]
[app.common.types.shape.radius :as ctsr]
@@ -27,9 +27,9 @@
[app.main.data.workspace.colors :as wdc]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.transforms :as dwtr]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
[app.main.fonts :as fonts]
[app.main.store :as st]
@@ -304,7 +304,7 @@
(and affects-layout?
(features/active-feature? state "render-wasm/v1"))
(rx/merge
(rx/of (dwwt/resize-wasm-text-all shape-ids))))))))
(rx/of (dwt/resize-wasm-text-all shape-ids))))))))
(defn update-line-height
([value shape-ids attributes] (update-line-height value shape-ids attributes nil))
@@ -363,7 +363,7 @@
:page-id page-id}))
(features/active-feature? state "render-wasm/v1")
(rx/merge
(rx/of (dwwt/resize-wasm-text-all shape-ids))))))))
(rx/of (dwt/resize-wasm-text-all shape-ids))))))))
(defn- create-font-family-text-attrs
[value]
@@ -440,7 +440,7 @@
:page-id page-id}))
(features/active-feature? state "render-wasm/v1")
(rx/merge
(rx/of (dwwt/resize-wasm-text-all shape-ids))))))))
(rx/of (dwt/resize-wasm-text-all shape-ids))))))))
(defn update-font-weight
([value shape-ids attributes] (update-font-weight value shape-ids attributes nil))
@@ -644,8 +644,8 @@
shape-ids (d/nilv (keys shapes) [])
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
tokenized-attributes (cfo/attributes-map attributes token)
resolved-value (get-in resolved-tokens [(cft/token-identifier token) :resolved-value])
tokenized-attributes (cft/attributes-map attributes token)
type (:type token)]
(rx/concat
(rx/of
@@ -704,7 +704,7 @@
ptk/WatchEvent
(watch [_ _ _]
(rx/of
(let [remove-token #(when % (cfo/remove-attributes-for-token attributes token %))]
(let [remove-token #(when % (cft/remove-attributes-for-token attributes token %))]
(dwsh/update-shapes
shape-ids
(fn [shape]
@@ -732,7 +732,7 @@
(get token-properties (:type token))
unapply-tokens?
(cfo/shapes-token-applied? token shapes (or attrs all-attributes attributes))
(cft/shapes-token-applied? token shapes (or attrs all-attributes attributes))
shape-ids (map :id shapes)]
@@ -755,43 +755,6 @@
:shape-ids shape-ids
:on-update-shape on-update-shape}))))))))
(defn toggle-border-radius-token
[{:keys [token attrs shape-ids expand-with-children]}]
(ptk/reify ::on-toggle-border-radius-token
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)
shapes (into [] (keep (d/getf objects)) shape-ids)
shapes
(if expand-with-children
(into []
(mapcat (fn [shape]
(if (= (:type shape) :group)
(keep objects (:shapes shape))
[shape])))
shapes)
shapes)
{:keys [attributes all-attributes]}
(get token-properties (:type token))
unapply-tokens?
(cfo/shapes-token-applied? token shapes (or attrs all-attributes attributes))
shape-ids (map :id shapes)]
(if unapply-tokens?
(rx/of
(unapply-token {:attributes (or attrs all-attributes attributes)
:token token
:shape-ids shape-ids}))
(rx/of
(apply-token {:attributes attrs
:token token
:shape-ids shape-ids
:on-update-shape update-shape-radius-for-corners})))))))
(defn apply-token-on-selected
[color-operations token]
(ptk/reify ::apply-token-on-selected

View File

@@ -6,7 +6,7 @@
(ns app.main.data.workspace.tokens.color
(:require
[app.common.files.tokens :as cfo]
[app.common.files.tokens :as cft]
[app.main.data.tinycolor :as tinycolor]))
(defn color-bullet-color [token-color-value]
@@ -17,5 +17,5 @@
(tinycolor/->hex-string tc))))
(defn resolved-token-bullet-color [{:keys [resolved-value] :as token}]
(when (and resolved-value (cfo/color-token? token))
(when (and resolved-value (cft/color-token? token))
(color-bullet-color resolved-value)))

View File

@@ -62,52 +62,6 @@
(watch [_ _ _]
(rx/of (dwsh/update-shapes [id] #(merge % attrs)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Toggle tree nodes
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- remove-paths-recursively
[path paths]
(->> paths
(remove #(str/starts-with? % (str path)))
vec))
(defn add-path
[path paths]
(let [split-path (cpn/split-path path :separator ".")
partial-paths (->> split-path
(reduce
(fn [acc segment]
(let [new-acc (if (empty? acc)
segment
(str (last acc) "." segment))]
(conj acc new-acc)))
[]))]
(->> paths
(into partial-paths)
distinct
vec)))
(defn clear-tokens-paths
[]
(ptk/reify ::clear-tokens-paths
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace-tokens :unfolded-token-paths] []))))
(defn toggle-token-path
[path]
(ptk/reify ::toggle-token-path
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-tokens :unfolded-token-paths]
(fn [paths]
(let [paths (or paths [])]
(if (some #(= % path) paths)
(remove-paths-recursively path paths)
(add-path path paths))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TOKENS Actions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -195,30 +149,27 @@
(defn create-token-set
[token-set]
(assert (ctob/token-set? token-set) "a token set is required") ;; TODO should check token-set-schema?
(ptk/reify ::create-token-set
ptk/WatchEvent
(watch [it state _]
(let [data (dsh/lookup-file-data state)
changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/set-token-set (ctob/get-id token-set) token-set))]
(rx/of (set-selected-token-set-id (ctob/get-id token-set))
(dch/commit-changes changes))))))
ptk/UpdateEvent
(update [_ state]
;; Clear possible local state
(update state :workspace-tokens dissoc :token-set-new-path))
(defn rename-token-set
[token-set new-name]
(assert (ctob/token-set? token-set) "a token set is required") ;; TODO should check token-set-schema after renaming?
(assert (string? new-name) "a new name is required") ;; TODO should assert normalized-set-name?
(ptk/reify ::update-token-set
ptk/WatchEvent
(watch [it state _]
(let [data (dsh/lookup-file-data state)
changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/rename-token-set (ctob/get-id token-set) new-name))]
(rx/of (set-selected-token-set-id (ctob/get-id token-set))
(dch/commit-changes changes))))))
(let [data (dsh/lookup-file-data state)
tokens-lib (get data :tokens-lib)
token-set (ctob/rename token-set (ctob/normalize-set-name (ctob/get-name token-set)))]
(if (and tokens-lib (ctob/get-set-by-name tokens-lib (ctob/get-name token-set)))
(rx/of (ntf/show {:content (tr "errors.token-set-already-exists")
:type :toast
:level :error
:timeout 9000}))
(let [changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/set-token-set (ctob/get-id token-set) token-set))]
(rx/of (set-selected-token-set-id (ctob/get-id token-set))
(dch/commit-changes changes))))))))
(defn rename-token-set-group
[set-group-path set-group-fname]
@@ -230,6 +181,26 @@
(rx/of
(dch/commit-changes changes))))))
(defn update-token-set
[token-set name]
(ptk/reify ::update-token-set
ptk/WatchEvent
(watch [it state _]
(let [data (dsh/lookup-file-data state)
name (ctob/normalize-set-name name (ctob/get-name token-set))
tokens-lib (get data :tokens-lib)]
(if (ctob/get-set-by-name tokens-lib name)
(rx/of (ntf/show {:content (tr "errors.token-set-already-exists")
:type :toast
:level :error
:timeout 9000}))
(let [changes (-> (pcb/empty-changes it)
(pcb/with-library-data data)
(pcb/rename-token-set (ctob/get-id token-set) name))]
(rx/of (set-selected-token-set-id (ctob/get-id token-set))
(dch/commit-changes changes))))))))
(defn duplicate-token-set
[id]
(ptk/reify ::duplicate-token-set
@@ -274,9 +245,8 @@
(pcb/with-library-data data)
(clt/generate-toggle-token-set tlib name))]
(rx/of
(dch/commit-changes changes)
(dwtp/propagate-workspace-tokens))))))
(rx/of (dch/commit-changes changes)
(dwtp/propagate-workspace-tokens))))))
(defn toggle-token-set-group
[group-path]
@@ -287,7 +257,6 @@
changes (-> (pcb/empty-changes)
(pcb/with-library-data data)
(clt/generate-toggle-token-set-group (get-tokens-lib state) group-path))]
(rx/of
(dch/commit-changes changes)
(dwtp/propagate-workspace-tokens))))))
@@ -505,7 +474,7 @@
(ctob/get-id token-set)
token-id)]
(let [tokens (vals (ctob/get-tokens tokens-lib (ctob/get-id token-set)))
unames (map :name tokens) ;; TODO: add function duplicate-token in tokens-lib
unames (map :name tokens)
suffix (tr "workspace.tokens.duplicate-suffix")
copy-name (cfh/generate-unique-name (:name token) unames :suffix suffix)
new-token (-> token
@@ -517,7 +486,35 @@
;; TOKEN UI OPS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn clean-tokens-paths
[]
(ptk/reify ::clean-tokens-paths
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace-tokens :unfolded-token-paths] []))))
(defn toggle-token-path
[path]
(ptk/reify ::toggle-token-path
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-tokens :unfolded-token-paths]
(fn [paths]
(let [paths (or paths [])]
(if (some #(= % path) paths)
(vec (remove #(or (= % path)
(str/starts-with? % (str path ".")))
paths))
(let [split-path (cpn/split-path path :separator ".")
partial-paths (reduce
(fn [acc segment]
(let [new-acc (if (empty? acc)
segment
(str (last acc) "." segment))]
(conj acc new-acc)))
[]
split-path)]
(into paths partial-paths)))))))))
(defn assign-token-context-menu
[{:keys [position] :as params}]

View File

@@ -406,13 +406,13 @@
(ctm/change-property :grow-type new-grow-type)))
modifiers)))
modif-tree (dwm/build-modif-tree ids objects get-modifier)]
modif-tree
(-> (dwm/build-modif-tree ids objects get-modifier)
(gm/set-objects-modifiers objects))]
(if (features/active-feature? state "render-wasm/v1")
(rx/of (dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true}))
(let [modif-tree (gm/set-objects-modifiers modif-tree objects)]
(rx/of (dwm/apply-modifiers* objects modif-tree nil options)))))))))
(rx/of (dwm/apply-modifiers* objects modif-tree nil options))))))))
(defn change-orientation
"Change orientation of shapes, from the sidebar options form.
@@ -1050,20 +1050,6 @@
:ignore-touched (:ignore-touched options)
:ignore-snap-pixel true}))))))))
(defn update-positions
"Move multiple shapes to a new position."
([ids position] (update-positions ids position nil))
([ids position options]
(assert (every? uuid? ids)
"expected valid coll of uuids")
(assert (map? position) "expected a valid map for `position`")
(ptk/reify ::update-positions
ptk/WatchEvent
(watch [_ _ _]
(->> ids
(map (fn [id] (update-position id position options)))
(rx/from))))))
(defn position-shapes
[shapes]
(ptk/reify ::position-shapes

View File

@@ -1,72 +0,0 @@
;; 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.data.workspace.wasm-text
"Helpers/events to resize wasm text shapes without depending on workspace.texts.
This exists to avoid circular deps:
workspace.texts -> workspace.libraries -> workspace.texts"
(:require
[app.common.files.helpers :as cfh]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.types.modifiers :as ctm]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.modifiers :as dwm]
[app.render-wasm.api :as wasm.api]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(defn resize-wasm-text-modifiers
([shape]
(resize-wasm-text-modifiers shape (:content shape)))
([{:keys [id points selrect grow-type] :as shape} content]
(wasm.api/use-shape id)
(wasm.api/set-shape-text-content id content)
(wasm.api/set-shape-text-images id content)
(let [dimension (wasm.api/get-text-dimensions)
width-scale (if (#{:fixed :auto-height} grow-type)
1.0
(/ (:width dimension) (:width selrect)))
height-scale (if (= :fixed grow-type)
1.0
(/ (:height dimension) (:height selrect)))
resize-v (gpt/point width-scale height-scale)
origin (first points)]
{id
{:modifiers
(ctm/resize-modifiers
resize-v
origin
(:transform shape (gmt/matrix))
(:transform-inverse shape (gmt/matrix)))}})))
(defn resize-wasm-text
"Resize a single text shape (auto-width/auto-height) by id.
No-op if the id is not a text shape or is :fixed."
[id]
(ptk/reify ::resize-wasm-text
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)]
(if (and (some? shape)
(cfh/text-shape? shape)
(not= :fixed (:grow-type shape)))
(rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape)))
(rx/empty))))))
(defn resize-wasm-text-all
"Resize all text shapes (auto-width/auto-height) from a collection of ids."
[ids]
(ptk/reify ::resize-wasm-text-all
ptk/WatchEvent
(watch [_ _ _]
(->> (rx/from ids)
(rx/map resize-wasm-text)))))

View File

@@ -9,16 +9,15 @@
(:require
[app.common.exceptions :as ex]
[app.common.pprint :as pp]
[app.config :as cf]
[app.common.schema :as sm]
[app.main.data.auth :as da]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.workspace :as-alias dw]
[app.main.router :as rt]
[app.main.store :as st]
[app.main.worker]
[app.util.globals :as g]
[app.util.globals :as glob]
[app.util.i18n :refer [tr]]
[app.util.timers :as ts]
[cuerdas.core :as str]
@@ -33,6 +32,44 @@
;; Will contain last uncaught exception
(def last-exception nil)
(defn- print-data!
[data]
(-> data
(dissoc ::sm/explain)
(dissoc :explain)
(dissoc ::trace)
(dissoc ::instance)
(pp/pprint {:width 70})))
(defn- print-explain!
[data]
(when-let [{:keys [errors] :as explain} (::sm/explain data)]
(let [errors (mapv #(update % :schema sm/form) errors)]
(pp/pprint errors {:width 100 :level 15 :length 20})))
(when-let [explain (:explain data)]
(js/console.log explain)))
(defn- print-trace!
[data]
(some-> data ::trace js/console.log))
(defn- print-group!
[message f]
(try
(js/console.group message)
(f)
(catch :default _ nil)
(finally
(js/console.groupEnd message))))
(defn print-cause!
[message cause]
(print-group! message (fn []
(print-data! cause)
(print-explain! cause)
(print-trace! cause))))
(defn exception->error-data
[cause]
(let [data (ex-data cause)]
@@ -41,6 +78,18 @@
(assoc ::instance cause)
(assoc ::trace (.-stack cause)))))
(defn print-error!
[cause]
(cond
(map? cause)
(print-cause! (:hint cause "Unexpected Error") cause)
(ex/error? cause)
(print-cause! (ex-message cause) (ex-data cause))
:else
(print-cause! (ex-message cause) (exception->error-data cause))))
(defn on-error
"A general purpose error handler."
[error]
@@ -55,66 +104,13 @@
;; Set the main potok error handler
(reset! st/on-error on-error)
(defn generate-report
[cause]
(try
(let [team-id (:current-team-id @st/state)
file-id (:current-file-id @st/state)
profile-id (:profile-id @st/state)
data (ex-data cause)]
(with-out-str
(println "Context:")
(println "--------------------")
(println "Hint: " (or (:hint data) (ex-message cause) "--"))
(println "Prof ID: " (str (or profile-id "--")))
(println "Team ID: " (str (or team-id "--")))
(when-let [file-id (or (:file-id data) file-id)]
(println "File ID: " (str file-id)))
(println "Version: " (:full cf/version))
(println "URI: " (str cf/public-uri))
(println "HREF: " (rt/get-current-href))
(println)
(println
(ex/format-throwable cause))
(println)
(println "Last events:")
(println "--------------------")
(pp/pprint @st/last-events {:length 200})
(println)))
(catch :default cause
(.error js/console "error on generating report" cause)
nil)))
(defn- show-not-blocking-error
"Show a non user blocking error notification"
[cause]
(let [data (ex-data cause)
hint (or (some-> (:hint data) ex/first-line)
(ex-message cause))]
(st/emit!
(ev/event {::ev/name "unhandled-exception"
:hint hint
:href (rt/get-current-href)
:type (get data :type :unknown)
:report (generate-report cause)})
(ntf/show {:content (tr "errors.unexpected-exception" hint)
:type :toast
:level :error
:timeout 3000}))))
(defmethod ptk/handle-error :default
[error]
(if (and (string? (:hint error))
(str/starts-with? (:hint error) "Assert failed:"))
(ptk/handle-error (assoc error :type :assertion))
(when-let [cause (::instance error)]
(ex/print-throwable cause :prefix "Unexpected Error")
(show-not-blocking-error cause))))
(st/async-emit! (rt/assign-exception error))
(print-group! "Unhandled Error"
(fn []
(print-trace! error)
(print-data! error))))
;; We receive a explicit authentication error; If the uri is for
;; workspace, dashboard, viewer or settings, then assign the exception
@@ -146,10 +142,10 @@
(defmethod ptk/handle-error :validation
[{:keys [code] :as error}]
(when-let [instance (get error ::instance)]
(ex/print-throwable instance :prefix "Validation Error"))
(print-group! "Validation Error"
(fn []
(print-data! error)
(print-explain! error)))
(cond
(= code :invalid-paste-data)
(let [message (tr "errors.paste-data-validation")]
@@ -197,14 +193,23 @@
:else
(st/async-emit! (rt/assign-exception error))))
;; This is a pure frontend error that can be caused by an active
;; assertion (assertion that is preserved on production builds). From
;; the user perspective this should be treated as internal error.
(defmethod ptk/handle-error :assertion
[error]
(when-let [cause (::instance error)]
(show-not-blocking-error cause)
(ex/print-throwable cause :prefix "Assertion Error")))
(ts/schedule
#(st/emit! (ntf/show {:content (tr "errors.internal-assertion-error")
:type :toast
:level :error
:timeout 3000})))
(print-group! "Internal Assertion Error"
(fn []
(print-trace! error)
(print-data! error)
(print-explain! error))))
;; ;; All the errors that happens on worker are handled here.
(defmethod ptk/handle-error :worker-error
@@ -216,8 +221,9 @@
:level :error
:timeout 3000})))
(some-> (::instance error)
(ex/print-throwable :prefix "Web Worker Error")))
(print-group! "Internal Worker Error"
(fn []
(print-data! error))))
;; Error on parsing an SVG
(defmethod ptk/handle-error :svg-parser
@@ -246,9 +252,11 @@
(defmethod ptk/handle-error ::exceptional-state
[error]
(when-let [instance (get error ::instance)]
(ex/print-throwable instance :prefix "Exceptional State"))
(ts/schedule #(st/emit! (rt/assign-exception error))))
(when-let [cause (::instance error)]
(js/console.log (.-stack cause)))
(ts/schedule
#(st/emit! (rt/assign-exception error))))
(defn- redirect-to-dashboard
[]
@@ -256,7 +264,7 @@
project-id (:current-project-id @st/state)]
(if (and project-id team-id)
(st/emit! (rt/nav :dashboard-files {:team-id team-id :project-id project-id}))
(set! (.-href g/location) ""))))
(set! (.-href glob/location) ""))))
(defmethod ptk/handle-error :restriction
[{:keys [code] :as error}]
@@ -304,10 +312,9 @@
:text (tr "errors.deprecated.contact.text")
:after (tr "errors.deprecated.contact.after")
:on-click #(st/emit! (rt/nav :settings-feedback))}}))
:else
(when-let [cause (::instance error)]
(ex/print-throwable cause :prefix "Restriction Error")
(show-not-blocking-error cause))))
(print-cause! "Restriction Error" error)))
;; This happens when the backed server fails to process the
;; request. This can be caused by an internal assertion or any other
@@ -315,9 +322,24 @@
(defmethod ptk/handle-error :server-error
[error]
(when-let [instance (get error ::instance)]
(ex/print-throwable instance :prefix "Server Error"))
(st/async-emit! (rt/assign-exception error)))
(st/async-emit! (rt/assign-exception error))
(print-group! "Server Error"
(fn []
(print-data! (dissoc error :data))
(when-let [werror (:data error)]
(cond
(= :assertion (:type werror))
(print-group! "Assertion Error"
(fn []
(print-data! werror)
(print-explain! werror)))
:else
(print-group! "Unexpected"
(fn []
(print-data! werror)
(print-explain! werror))))))))
(defonce uncaught-error-handler
(letfn [(is-ignorable-exception? [cause]
@@ -332,19 +354,28 @@
(when-let [cause (unchecked-get event "error")]
(set! last-exception cause)
(when-not (is-ignorable-exception? cause)
(ex/print-throwable cause :prefix "Uncaught Exception")
(ts/schedule #(show-not-blocking-error cause)))))
(ex/print-throwable cause :prefix "uncaught exception")
(st/async-emit!
(ntf/show {:content (tr "errors.unexpected-exception" (ex-message cause))
:type :toast
:level :error
:timeout 3000})))))
(on-unhandled-rejection [event]
(.preventDefault ^js event)
(when-let [cause (unchecked-get event "reason")]
(set! last-exception cause)
(ex/print-throwable cause :prefix "Uncaught Rejection")
(ts/schedule #(show-not-blocking-error cause))))]
(ex/print-throwable cause :prefix "uncaught rejection")
(st/async-emit!
(ntf/show {:content (tr "errors.unexpected-exception" (ex-message cause))
:type :toast
:level :error
:timeout 3000}))))]
(.addEventListener g/window "error" on-unhandled-error)
(.addEventListener g/window "unhandledrejection" on-unhandled-rejection)
(.addEventListener glob/window "error" on-unhandled-error)
(.addEventListener glob/window "unhandledrejection" on-unhandled-rejection)
(fn []
(.removeEventListener g/window "error" on-unhandled-error)
(.removeEventListener g/window "unhandledrejection" on-unhandled-rejection))))
(.removeEventListener glob/window "error" on-unhandled-error)
(.removeEventListener glob/window "unhandledrejection" on-unhandled-rejection))))

View File

@@ -9,12 +9,12 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as log]
[app.main.data.dashboard :as dd]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.errors :as errors]
[app.main.store :as st]
[app.main.ui.components.file-uploader :refer [file-uploader*]]
[app.main.ui.ds.product.loader :refer [loader*]]
@@ -360,7 +360,7 @@
on-error
(fn [cause]
(reset! status* :error)
(ex/print-throwable cause)
(errors/print-error! cause)
(rx/of (modal/hide)
(ntf/error (tr "dashboard.libraries-and-templates.import-error"))))

View File

@@ -36,12 +36,10 @@
(defn- hide-popover
[node]
(when (and (some? node)
(fn? (.-hidePopover node)))
(dom/unset-css-property! node "block-size")
(dom/unset-css-property! node "inset-block-start")
(dom/unset-css-property! node "inset-inline-start")
(.hidePopover ^js node)))
(dom/unset-css-property! node "block-size")
(dom/unset-css-property! node "inset-block-start")
(dom/unset-css-property! node "inset-inline-start")
(.hidePopover ^js node))
(defn- calculate-placement-bounding-rect
"Given a placement, calcultates the bounding rect for it taking in

View File

@@ -16,7 +16,7 @@
(def context (mf/create-context nil))
(mf/defc form-input*
[{:keys [name trim] :rest props}]
[{:keys [name] :rest props}]
(let [form (mf/use-ctx context)
input-name name
@@ -33,7 +33,7 @@
(mf/deps input-name)
(fn [event]
(let [value (-> event dom/get-target dom/get-input-value)]
(fm/on-input-change form input-name value trim))))
(fm/on-input-change form input-name value true))))
props
(mf/spread-props props {:on-change on-change
@@ -49,6 +49,7 @@
(mf/defc form-submit*
[{:keys [disabled on-submit] :rest props}]
(let [form (mf/use-ctx context)
disabled? (or (and (some? form)
(or (not (:valid @form))

View File

@@ -21,7 +21,7 @@
(def ^:private schema:properties-row
[:map
[:term :string]
[:detail {:optional true} [:maybe :string]]
[:detail :string]
[:property {:optional true} :string] ;; CSS valid property
[:token {:optional true} :any] ;; resolved token object
[:copiable {:optional true} :boolean]])

View File

@@ -36,6 +36,7 @@
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
;; FIXME: this is a workaround until we export this class on beicon library
@@ -446,24 +447,25 @@
(rx/of default)
(rx/throw cause)))))))
(mf/defc exception-section*
{::mf/private true}
[{:keys [data route] :as props}]
(let [type (get data :type)
report (mf/with-memo [data]
(some-> data ::errors/instance errors/generate-report))
(generate-report data))
props (mf/spread-props props {:report report})]
(mf/with-effect [data route report]
(let [params (:query-params route)
params (u/map->query-string params)]
(st/emit! (ev/event {::ev/name "exception-page"
:type (get data :type :unknown)
:href (rt/get-current-href)
:hint (get data :hint)
:path (get route :path)
:report report
:params params}))))
(st/emit! (ptk/data-event ::ev/event
{::ev/name "exception-page"
:type (get data :type :unknown)
:hint (get data :hint)
:path (get route :path)
:report report
:params params}))))
(case type
:not-found

View File

@@ -78,15 +78,13 @@
(fn []
(close-modals)
;; FIXME: move set-mode to uri?
(st/emit! :interrupt
(dw/set-options-mode :design)
(st/emit! (dw/set-options-mode :design)
(dcm/go-to-dashboard-recent))))
nav-to-project
(mf/use-fn
(mf/deps project-id)
#(st/emit! :interrupt
(dcm/go-to-dashboard-files ::rt/new-window true :project-id project-id)))]
#(st/emit! (dcm/go-to-dashboard-files ::rt/new-window true :project-id project-id)))]
(mf/with-effect [editing?]
(when ^boolean editing?

View File

@@ -401,8 +401,7 @@
(dm/fmt "scale(%)" maybe-zoom))}))]
[:g.text-editor {:clip-path (dm/fmt "url(#%)" clip-id)
:transform (dm/str transform)
:data-testid "text-editor"}
:transform (dm/str transform)}
[:defs
[:clipPath {:id clip-id}
[:rect {:x x :y y :width width :height height}]]]

View File

@@ -119,8 +119,7 @@
[:button {:class (stl/css-case
:toggle-content true
:inverse expanded?)
:data-testid "toggle-content"
:aria-expanded expanded?
:aria-label "Toggle layer"
:on-click on-toggle-collapse}
deprecated-icon/arrow])

View File

@@ -108,7 +108,6 @@
:on-blur accept-edit
:on-key-down on-key-down
:auto-focus true
:id (dm/str "layer-name-" shape-id)
:default-value (d/nilv default-value "")}]
[:*
[:span
@@ -119,7 +118,6 @@
:hidden is-hidden
:type-comp type-comp
:type-frame type-frame)
:id (dm/str "layer-name-" shape-id)
:style {"--depth" depth "--parent-size" parent-size}
:ref ref
:on-double-click start-edit}

View File

@@ -283,7 +283,7 @@
(if (or (string? value) (number? value))
(do
(st/emit! (udw/trigger-bounding-box-cloaking ids))
(st/emit! (udw/update-positions ids {attr value})))
(st/emit! (udw/update-position ids {attr value})))
(st/emit! (udw/trigger-bounding-box-cloaking ids)
(dwta/toggle-token {:token (first value)
:attrs #{attr}

View File

@@ -15,7 +15,6 @@
[app.main.data.workspace.shortcuts :as sc]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
@@ -142,7 +141,7 @@
(dwsh/update-shapes ids #(assoc % :grow-type grow-type)))
(when (features/active-feature? @st/state "render-wasm/v1")
(st/emit! (dwwt/resize-wasm-text-all ids)))
(st/emit! (dwt/resize-wasm-text-all ids)))
;; We asynchronously commit so every sychronous event is resolved first and inside the transaction
(ts/schedule #(st/emit! (dwu/commit-undo-transaction uid))))
(when (some? on-blur)

View File

@@ -309,7 +309,7 @@
on-remove'
(mf/use-fn
(mf/deps index on-remove)
(mf/deps index)
(fn [_]
(when on-remove
(on-remove index))))

View File

@@ -94,7 +94,10 @@
;; This only checks for the currently explicitly selected set
;; id, it is ephimeral and can be nil
;; FIXME: this is a repeated deref for the same `:workspace-tokens` state
selected-token-set-id (mf/deref refs/selected-token-set-id)
selected-token-set-id
(mf/deref refs/selected-token-set-id)
;; If we have not selected any set explicitly we just
;; select the first one from the list of sets
@@ -109,7 +112,6 @@
tokens
(sd/use-resolved-tokens* tokens)
;; Group tokens by their type
tokens-by-type
(mf/with-memo [tokens selected-token-set-tokens]
(let [tokens (reduce-kv (fn [tokens k _]
@@ -127,9 +129,9 @@
;; Filter tokens by their path and return their ids
filter-tokens-by-path-ids
(mf/use-fn
(mf/deps selected-token-set-tokens)
(mf/deps tokens)
(fn [type path]
(->> selected-token-set-tokens
(->> tokens
(filter (fn [token]
(let [[_ token-value] token]
(and (= (:type token-value) type) (str/starts-with? (:name token-value) path)))))
@@ -137,47 +139,12 @@
(let [[_ token-value] token]
(:id token-value)))))))
remaining-tokens-of-type-in-set?
(mf/use-fn
(fn [selected-token-set-tokens tokens-in-path-ids]
(let [token-ids (set tokens-in-path-ids)
remaining-tokens (filter (fn [token]
(not (contains? token-ids (:id token))))
selected-token-set-tokens)
_ (prn "Remaining tokens:" remaining-tokens)]
(seq remaining-tokens))))
delete-token
(mf/with-memo [selected-token-set-tokens selected-token-set-id]
(fn [token]
(let [id (:id token)
type (:type token)
path (:name token)
tokens-by-type (ctob/group-by-type selected-token-set-tokens)
tokens-filtered-by-type (get tokens-by-type type)
tokens-in-path-ids (filter-tokens-by-path-ids type path)
remaining-tokens? (remaining-tokens-of-type-in-set? tokens-filtered-by-type tokens-in-path-ids)]
;; Delete the token
(st/emit! (dwtl/delete-token selected-token-set-id id))
;; Remove from unfolded tree path
(if remaining-tokens?
(st/emit! (dwtl/toggle-token-path (str (name type) "." path)))
(st/emit! (dwtl/toggle-token-path (name type)))))))
delete-node
(mf/with-memo [selected-token-set-tokens selected-token-set-id]
(mf/with-memo [tokens selected-token-set-id]
(fn [node type]
(let [path (:path node)
tokens-by-type (ctob/group-by-type selected-token-set-tokens)
tokens-filtered-by-type (get tokens-by-type type)
tokens-in-path-ids (filter-tokens-by-path-ids type path)
remaining-tokens? (remaining-tokens-of-type-in-set? tokens-filtered-by-type tokens-in-path-ids)]
;; Delete tokens in path
(st/emit! (dwtl/bulk-delete-tokens selected-token-set-id tokens-in-path-ids))
;; Remove from unfolded tree path
(if remaining-tokens?
(st/emit! (dwtl/toggle-token-path (str (name type) "." path)))
(st/emit! (dwtl/toggle-token-path (name type)))))))]
tokens-in-path-ids (filter-tokens-by-path-ids type path)]
(st/emit! (dwtl/bulk-delete-tokens selected-token-set-id tokens-in-path-ids)))))]
(mf/with-effect [tokens-lib selected-token-set-id]
(when (and tokens-lib
@@ -190,7 +157,7 @@
(st/emit! (dwtl/set-selected-token-set-id (ctob/get-id match)))))))
[:*
[:& token-context-menu {:on-delete-token delete-token}]
[:& token-context-menu]
[:> token-node-context-menu* {:on-delete-node delete-node}]
[:> selected-set-info* {:tokens-lib tokens-lib

View File

@@ -9,7 +9,7 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.tokens :as cfo]
[app.common.files.tokens :as cft]
[app.common.types.shape.layout :as ctsl]
[app.common.types.token :as ctt]
[app.main.data.modal :as modal]
@@ -47,9 +47,9 @@
;; Actions ---------------------------------------------------------------------
(defn attribute-actions [token selected-shapes attributes]
(let [ids-by-attributes (cfo/shapes-ids-by-applied-attributes token selected-shapes attributes)
(let [ids-by-attributes (cft/shapes-ids-by-applied-attributes token selected-shapes attributes)
shape-ids (into #{} (map :id selected-shapes))]
{:all-selected? (cfo/shapes-applied-all? ids-by-attributes shape-ids attributes)
{:all-selected? (cft/shapes-applied-all? ids-by-attributes shape-ids attributes)
:shape-ids shape-ids
:selected-pred #(seq (% ids-by-attributes))}))
@@ -316,9 +316,8 @@
(generic-attribute-actions #{:y} "Y" (assoc context-data :on-update-shape dwta/update-shape-position)))
(clean-separators)))}))
(defn default-actions [{:keys [token selected-token-set-id on-delete-token]}]
(let [{:keys [modal]} (dwta/get-token-properties token)
on-duplicate-token #(st/emit! (dwtl/duplicate-token (:id token)))]
(defn default-actions [{:keys [token selected-token-set-id]}]
(let [{:keys [modal]} (dwta/get-token-properties token)]
[{:title (tr "workspace.tokens.edit")
:no-selectable true
:action (fn [event]
@@ -334,10 +333,12 @@
:token token}))))}
{:title (tr "workspace.tokens.duplicate")
:no-selectable true
:action on-duplicate-token}
:action #(st/emit! (dwtl/duplicate-token (:id token)))}
{:title (tr "workspace.tokens.delete")
:no-selectable true
:action #(on-delete-token token)}]))
:action #(st/emit! (dwtl/delete-token
selected-token-set-id
(:id token)))}]))
(defn- allowed-shape-attributes [shapes]
(reduce into #{} (map #(ctt/shape-type->attributes (:type %) (:layout %)) shapes)))
@@ -463,7 +464,7 @@
:selected? selected?}])])))
(mf/defc token-context-menu-tree
[{:keys [width errors on-delete-token] :as mdata}]
[{:keys [width errors] :as mdata}]
(let [objects (mf/deref refs/workspace-page-objects)
selected (mf/deref refs/selected-shapes)
@@ -487,11 +488,10 @@
:errors errors
:selected-token-set-id selected-token-set-id
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:on-delete-token on-delete-token}]]))
:is-selected-inside-layout is-selected-inside-layout}]]))
(mf/defc token-context-menu
[{:keys [on-delete-token]}]
[]
(let [mdata (mf/deref tokens-menu-ref)
is-open? (boolean mdata)
width (mf/use-state 0)
@@ -538,5 +538,5 @@
:left (dm/str left "px")}
:on-context-menu prevent-default}
(when mdata
[:& token-context-menu-tree (assoc mdata :width @width :on-delete-token on-delete-token)])]])
[:& token-context-menu-tree (assoc mdata :width @width)])]])
(dom/get-body)))))

View File

@@ -6,56 +6,48 @@
(ns app.main.ui.workspace.tokens.management.forms.color
(:require
#_[app.common.types.token :as cto]
#_[app.common.types.tokens-lib :as ctob]
#_[app.util.i18n :refer [tr]]
#_[cuerdas.core :as str]
[app.common.files.tokens :as cfo]
[app.common.files.tokens :as cft]
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.main.ui.workspace.tokens.management.forms.controls :as token.controls]
[app.main.ui.workspace.tokens.management.forms.generic-form :as generic]
[app.util.i18n :refer [tr]]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
#_(defn- token-value-error-fn
[{:keys [value]}]
(when (or (str/empty? value)
(str/blank? value))
(tr "workspace.tokens.empty-input")))
(defn- token-value-error-fn
[{:keys [value]}]
(when (or (str/empty? value)
(str/blank? value))
(tr "workspace.tokens.empty-input")))
#_(defn- make-schema
[tokens-tree _]
(sm/schema
[:and
[:map
[:name
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(sm/update-properties cto/schema:token-name assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(not (ctob/token-name-path-exists? % tokens-tree))]]]
(defn- make-schema
[tokens-tree _]
(sm/schema
[:and
[:map
[:name
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(sm/update-properties cto/token-name-ref assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(not (cft/token-name-path-exists? % tokens-tree))]]]
[:value [::sm/text {:error/fn token-value-error-fn}]]
[:value [::sm/text {:error/fn token-value-error-fn}]]
[:color-result {:optional true} ::sm/any]
[:color-result {:optional true} ::sm/any]
[:description {:optional true}
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]]
[:description {:optional true}
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]]
[:fn {:error/field :value
:error/fn #(tr "workspace.tokens.self-reference")}
(fn [{:keys [name value]}]
(when (and name value)
(nil? (cto/token-value-self-reference? name value))))]]))
[:fn {:error/field :value
:error/fn #(tr "workspace.tokens.self-reference")}
(fn [{:keys [name value]}]
(when (and name value)
(nil? (cto/token-value-self-reference? name value))))]]))
(mf/defc form*
[{:keys [token token-type] :as props}]
(let [props (mf/spread-props props {:make-schema #(-> (cfo/make-token-schema %1 token-type)
(sm/dissoc-key :id)
(sm/assoc-key :color-result :string))
:initial {:type token-type
:name (:name token "")
:value (:value token "")
:description (:description token "")
:color-result ""}
[props]
(let [props (mf/spread-props props {:make-schema make-schema
:input-component token.controls/color-input*})]
[:> generic/form* props]))

View File

@@ -11,7 +11,6 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.types.color :as cl]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.main.data.style-dictionary :as sd]
[app.main.data.tinycolor :as tinycolor]
@@ -52,15 +51,12 @@
;; Both variants provide identical color-picker and text-input behavior, but
;; differ in how they persist the value within the forms nested structure.
(defn- resolve-value
[tokens prev-token token-name value]
(let [valid-token-name?
(and (string? token-name)
(re-matches cto/token-name-validation-regex token-name))
token
(let [token
{:value value
:name (if (or (not valid-token-name?) (str/blank? token-name))
:name (if (str/blank? token-name)
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
token-name)}
@@ -156,6 +152,7 @@
color-resolved
(get-in @form [:data :color-result] "")
valid-color (or (tinycolor/valid-color value)
(tinycolor/valid-color color-resolved))

View File

@@ -50,13 +50,9 @@
(defn- resolve-value
[tokens prev-token token-name value]
(let [valid-token-name?
(and (string? token-name)
(re-matches cto/token-name-validation-regex token-name))
token
(let [token
{:value (cto/split-font-family value)
:name (if (or (not valid-token-name?) (str/blank? token-name))
:name (if (str/blank? token-name)
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
token-name)}

View File

@@ -8,7 +8,6 @@
(:require
[app.common.data :as d]
[app.common.files.tokens :as cft]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.main.data.style-dictionary :as sd]
[app.main.data.workspace.tokens.format :as dwtf]
@@ -141,13 +140,9 @@
(defn- resolve-value
[tokens prev-token token-name value]
(let [valid-token-name?
(and (string? token-name)
(re-matches cto/token-name-validation-regex token-name))
token
(let [token
{:value value
:name (if (or (not valid-token-name?) (str/blank? token-name))
:name (if (str/blank? token-name)
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
token-name)}
tokens

View File

@@ -6,8 +6,6 @@
(ns app.main.ui.workspace.tokens.management.forms.font-family
(:require
[app.common.files.tokens :as cfo]
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.main.data.workspace.tokens.errors :as wte]
[app.main.ui.workspace.tokens.management.forms.controls :as token.controls]
@@ -37,11 +35,6 @@
{:type token-type}))
props (mf/spread-props props {:token token
:token-type token-type
:make-schema #(-> (cfo/make-token-schema %1 token-type)
(sm/dissoc-key :id)
;; The value as edited in the form is a simple stirng.
;; It's converted to vector in the validator.
(sm/assoc-key :value cfo/schema:token-value-generic))
:validator validate-font-family-token
:input-component token.controls/fonts-combobox*})]
[:> generic/form* props]))

View File

@@ -7,6 +7,7 @@
(ns app.main.ui.workspace.tokens.management.forms.form-container
(:require
[app.common.data :as d]
[app.common.files.tokens :as cft]
[app.common.types.tokens-lib :as ctob]
[app.main.refs :as refs]
[app.main.ui.workspace.tokens.management.forms.color :as color]
@@ -27,7 +28,7 @@
token-path
(mf/with-memo [token]
(ctob/get-token-path token))
(cft/token-name->path (:name token)))
tokens-tree-in-selected-set
(mf/with-memo [token-path tokens-in-selected-set]

Some files were not shown because too many files have changed in this diff Show More