mirror of
https://github.com/penpot/penpot.git
synced 2026-02-04 19:51:57 -05:00
Compare commits
1 Commits
tokens-api
...
eva-create
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c859fd15a |
45
.github/workflows/tests-mcp.yml
vendored
45
.github/workflows/tests-mcp.yml
vendored
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}}))
|
||||
|
||||
|
||||
@@ -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 _ _]
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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!))
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 );
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,86 @@
|
||||
(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}
|
||||
(def token-name-ref
|
||||
[:re {:title "TokenNameRef" :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 ^: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 +196,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 +216,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 +321,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 +367,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 +390,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 +419,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 +435,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 +500,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 +563,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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}}}}))))
|
||||
|
||||
@@ -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)
|
||||
{}))
|
||||
|
||||
|
||||
@@ -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"))))
|
||||
|
||||
@@ -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"}}}}))))
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -46,11 +46,6 @@ services:
|
||||
- 9090:9090
|
||||
- 9091:9091
|
||||
|
||||
# MCP
|
||||
- 4400:4400
|
||||
- 4401:4401
|
||||
- 4402:4402
|
||||
|
||||
environment:
|
||||
- EXTERNAL_UID=${CURRENT_USER_ID}
|
||||
# SMTP setup
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -130,7 +130,6 @@ http {
|
||||
}
|
||||
|
||||
location /readyz {
|
||||
access_log off;
|
||||
proxy_pass $PENPOT_BACKEND_URI$request_uri;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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({
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
@@ -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}]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))))
|
||||
|
||||
|
||||
@@ -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"))))
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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]])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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}]]]
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.main.ui.workspace.tokens.management.forms.border-radius
|
||||
(:require
|
||||
[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]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
|
||||
(mf/defc form*
|
||||
[{:keys [token token-type] :rest props}]
|
||||
(let [token
|
||||
(mf/with-memo [token]
|
||||
(if token
|
||||
(update token :value cto/join-font-family)
|
||||
{:type token-type}))
|
||||
props (mf/spread-props props {:token token
|
||||
:token-type token-type
|
||||
:input-component token.controls/combobox*})]
|
||||
[:> generic/form* props]))
|
||||
@@ -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]))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.workspace.tokens.management.forms.controls.color-input :as color]
|
||||
[app.main.ui.workspace.tokens.management.forms.controls.combobox :as combobox]
|
||||
[app.main.ui.workspace.tokens.management.forms.controls.fonts-combobox :as fonts]
|
||||
[app.main.ui.workspace.tokens.management.forms.controls.input :as input]
|
||||
[app.main.ui.workspace.tokens.management.forms.controls.select :as select]))
|
||||
@@ -16,4 +17,6 @@
|
||||
(dm/export fonts/fonts-combobox*)
|
||||
(dm/export fonts/composite-fonts-combobox*)
|
||||
|
||||
(dm/export select/select-indexed*)
|
||||
(dm/export select/select-indexed*)
|
||||
|
||||
(dm/export combobox/combobox*)
|
||||
@@ -156,6 +156,7 @@
|
||||
color-resolved
|
||||
(get-in @form [:data :color-result] "")
|
||||
|
||||
|
||||
valid-color (or (tinycolor/valid-color value)
|
||||
(tinycolor/valid-color color-resolved))
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.main.ui.workspace.tokens.management.forms.controls.combobox
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[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.colors :as cl]
|
||||
[app.main.data.workspace.tokens.format :as dwtf]
|
||||
[app.main.ui.context :as muc]
|
||||
[app.main.ui.ds.controls.input :as ds]
|
||||
[app.main.ui.forms :as fc]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.forms :as fm]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[beicon.v2.core :as rx]
|
||||
[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]
|
||||
[rumext.v2.util :as mfu]))
|
||||
|
||||
|
||||
(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
|
||||
{:value value
|
||||
:name (if (or (not valid-token-name?) (str/blank? token-name))
|
||||
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
|
||||
token-name)}
|
||||
tokens
|
||||
(-> tokens
|
||||
;; Remove previous token when renaming a token
|
||||
(dissoc (:name prev-token))
|
||||
(update (:name token) #(ctob/make-token (merge % prev-token token))))]
|
||||
|
||||
(->> tokens
|
||||
(sd/resolve-tokens-interactive)
|
||||
(rx/mapcat
|
||||
(fn [resolved-tokens]
|
||||
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))]
|
||||
(if resolved-value
|
||||
(rx/of {:value resolved-value})
|
||||
(rx/of {:error (first errors)}))))))))
|
||||
|
||||
(defn- token->dropdown-option
|
||||
[token]
|
||||
{:id (str (get token :id))
|
||||
:type :token
|
||||
:resolved-value (get token :resolved-value)
|
||||
:name (get token :name)})
|
||||
|
||||
(defn- generate-dropdown-options
|
||||
[tokens no-sets]
|
||||
(if (empty? tokens)
|
||||
[{:type :empty
|
||||
:label (if no-sets
|
||||
(tr "ds.inputs.numeric-input.no-applicable-tokens")
|
||||
(tr "ds.inputs.numeric-input.no-matches"))}]
|
||||
(->> tokens
|
||||
(map (fn [[type items]]
|
||||
(cons {:group true
|
||||
:type :group
|
||||
:id (dm/str "group-" (name type))
|
||||
:name (name type)}
|
||||
(map token->dropdown-option items))))
|
||||
(interpose [{:separator true
|
||||
:id "separator"
|
||||
:type :separator}])
|
||||
(apply concat)
|
||||
(vec)
|
||||
(not-empty))))
|
||||
|
||||
(defn- extract-partial-brace-text
|
||||
[s]
|
||||
(when-let [start (str/last-index-of s "{")]
|
||||
(subs s (inc start))))
|
||||
|
||||
(defn- filter-token-groups-by-name
|
||||
[tokens filter-text]
|
||||
(let [lc-filter (str/lower filter-text)]
|
||||
(into {}
|
||||
(keep (fn [[group tokens]]
|
||||
(let [filtered (filter #(str/includes? (str/lower (:name %)) lc-filter) tokens)]
|
||||
(when (seq filtered)
|
||||
[group filtered]))))
|
||||
tokens)))
|
||||
|
||||
|
||||
(defn- sort-groups-and-tokens
|
||||
"Sorts both the groups and the tokens inside them alphabetically.
|
||||
|
||||
Input:
|
||||
A map where:
|
||||
- keys are groups (keywords or strings, e.g. :dimensions, :colors)
|
||||
- values are vectors of token maps, each containing at least a :name key
|
||||
|
||||
Example input:
|
||||
{:dimensions [{:name \"tres\"} {:name \"quini\"}]
|
||||
:colors [{:name \"azul\"} {:name \"rojo\"}]}
|
||||
|
||||
Output:
|
||||
A sorted map where:
|
||||
- groups are ordered alphabetically by key
|
||||
- tokens inside each group are sorted alphabetically by :name
|
||||
|
||||
Example output:
|
||||
{:colors [{:name \"azul\"} {:name \"rojo\"}]
|
||||
:dimensions [{:name \"quini\"} {:name \"tres\"}]}"
|
||||
|
||||
[groups->tokens]
|
||||
(into (sorted-map) ;; ensure groups are ordered alphabetically by their key
|
||||
(for [[group tokens] groups->tokens]
|
||||
[group (sort-by :name tokens)])))
|
||||
|
||||
|
||||
(mf/defc combobox*
|
||||
[{:keys [name tokens token] :rest props}]
|
||||
|
||||
(let [form (mf/use-ctx fc/context)
|
||||
tokens2 (if (object? tokens)
|
||||
(mfu/bean tokens)
|
||||
tokens)
|
||||
input-name name
|
||||
token-name (get-in @form [:data :name] nil)
|
||||
|
||||
tokens3 (mf/use-ctx muc/active-tokens-by-type)
|
||||
|
||||
tokens3 (mf/with-memo [tokens3 "boder-radius"]
|
||||
(delay
|
||||
(-> (deref tokens3)
|
||||
(select-keys (get cto/tokens-by-input "boder-radius"))
|
||||
(not-empty))))
|
||||
_ (prn "tokens" @tokens3)
|
||||
|
||||
;; OPTIONS -> duplicated from numeric input
|
||||
filter-id* (mf/use-state "")
|
||||
filter-id (deref filter-id*)
|
||||
|
||||
dropdown-options
|
||||
(mf/with-memo [tokens3 filter-id]
|
||||
(delay
|
||||
(let [tokens (if (delay? tokens3) @tokens3 tokens3)
|
||||
|
||||
sorted-tokens (sort-groups-and-tokens tokens)
|
||||
partial (extract-partial-brace-text filter-id)
|
||||
options (if (seq partial)
|
||||
(filter-token-groups-by-name sorted-tokens partial)
|
||||
sorted-tokens)
|
||||
no-sets? (nil? sorted-tokens)]
|
||||
(generate-dropdown-options options no-sets?))))
|
||||
_ (.log js/console (clj->js @dropdown-options))
|
||||
|
||||
touched?
|
||||
(and (contains? (:data @form) input-name)
|
||||
(get-in @form [:touched input-name]))
|
||||
|
||||
error
|
||||
(get-in @form [:errors input-name])
|
||||
|
||||
value
|
||||
(get-in @form [:data input-name] "")
|
||||
|
||||
resolve-stream
|
||||
(mf/with-memo [token]
|
||||
(if (contains? token :value)
|
||||
(rx/behavior-subject (:value token))
|
||||
(rx/subject)))
|
||||
|
||||
hint*
|
||||
(mf/use-state {})
|
||||
|
||||
hint
|
||||
(deref hint*)
|
||||
|
||||
on-change
|
||||
(mf/use-fn
|
||||
(mf/deps resolve-stream input-name)
|
||||
(fn [event]
|
||||
(let [value (-> event dom/get-target dom/get-input-value)]
|
||||
(fm/on-input-change form input-name value true)
|
||||
(rx/push! resolve-stream value))))
|
||||
|
||||
props
|
||||
(mf/spread-props props {:on-change on-change
|
||||
:default-value value
|
||||
:variant "comfortable"
|
||||
:hint-message (:message hint)
|
||||
:hint-type (:type hint)})
|
||||
props
|
||||
(if (and error touched?)
|
||||
(mf/spread-props props {:hint-type "error"
|
||||
:hint-message (:message error)})
|
||||
props)]
|
||||
|
||||
(mf/with-effect [resolve-stream tokens token input-name token-name]
|
||||
|
||||
(let [subs (->> resolve-stream
|
||||
(rx/debounce 300)
|
||||
(rx/mapcat (partial resolve-value tokens token token-name))
|
||||
(rx/map (fn [result]
|
||||
(d/update-when result :error
|
||||
(fn [error]
|
||||
((:error/fn error) (:error/value error))))))
|
||||
(rx/subs! (fn [{:keys [error value]}]
|
||||
(let [touched? (get-in @form [:touched input-name])]
|
||||
(when touched?
|
||||
(if error
|
||||
(do
|
||||
(swap! form assoc-in [:extra-errors input-name] {:message error})
|
||||
(reset! hint* {:message error :type "error"}))
|
||||
(let [message (tr "workspace.tokens.resolved-value" value)]
|
||||
(swap! form update :extra-errors dissoc input-name)
|
||||
(reset! hint* {:message message :type "hint"}))))))))]
|
||||
|
||||
(fn []
|
||||
(rx/dispose! subs))))
|
||||
|
||||
[:> ds/input* props]))
|
||||
@@ -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]))
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
(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.border-radius :as test]
|
||||
[app.main.ui.workspace.tokens.management.forms.color :as color]
|
||||
[app.main.ui.workspace.tokens.management.forms.font-family :as font-family]
|
||||
[app.main.ui.workspace.tokens.management.forms.generic-form :as generic]
|
||||
@@ -27,7 +29,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]
|
||||
@@ -49,4 +51,5 @@
|
||||
:text-case [:> generic/form* text-case-props]
|
||||
:text-decoration [:> generic/form* text-decoration-props]
|
||||
:font-weight [:> generic/form* font-weight-props]
|
||||
:border-radius [:> test/form* props]
|
||||
[:> generic/form* props])))
|
||||
@@ -7,11 +7,10 @@
|
||||
(ns app.main.ui.workspace.tokens.management.forms.generic-form
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
#_[app.common.types.token :as cto]
|
||||
#_[app.common.uuid :as uuid]
|
||||
[app.common.data :as d]
|
||||
[app.common.files.tokens :as cfo]
|
||||
[app.common.files.tokens :as cft]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.token :as cto]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.main.constants :refer [max-input-length]]
|
||||
[app.main.data.helpers :as dh]
|
||||
@@ -26,6 +25,7 @@
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
||||
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
|
||||
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
|
||||
[app.main.ui.forms :as fc]
|
||||
[app.main.ui.workspace.tokens.management.forms.controls :as token.controls]
|
||||
[app.main.ui.workspace.tokens.management.forms.validators :refer [default-validate-token]]
|
||||
@@ -37,11 +37,12 @@
|
||||
[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 get-value-for-validator
|
||||
[active-tab value value-subfield form-type]
|
||||
@@ -59,28 +60,28 @@
|
||||
|
||||
value))
|
||||
|
||||
#_(defn- default-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- default-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}]]
|
||||
|
||||
[: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)
|
||||
(not (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
|
||||
@@ -97,12 +98,11 @@
|
||||
value-subfield
|
||||
input-value-placeholder] :as props}]
|
||||
|
||||
(let [make-schema (or make-schema #(-> (cfo/make-token-schema % token-type)
|
||||
(sm/dissoc-key :id)))
|
||||
(let [make-schema (or make-schema default-make-schema)
|
||||
input-component (or input-component token.controls/input*)
|
||||
validate-token (or validator default-validate-token)
|
||||
|
||||
active-tab* (mf/use-state #(if (cfo/is-reference? token) :reference :composite))
|
||||
active-tab* (mf/use-state #(if (cft/is-reference? token) :reference :composite))
|
||||
active-tab (deref active-tab*)
|
||||
|
||||
token
|
||||
@@ -135,8 +135,7 @@
|
||||
initial
|
||||
(mf/with-memo [token]
|
||||
(or initial
|
||||
{:type token-type
|
||||
:name (:name token "")
|
||||
{:name (:name token "")
|
||||
:value (:value token "")
|
||||
:description (:description token "")}))
|
||||
|
||||
@@ -144,6 +143,10 @@
|
||||
(fm/use-form :schema schema
|
||||
:initial initial)
|
||||
|
||||
warning-name-change?
|
||||
(not= (get-in @form [:data :name])
|
||||
(:name initial))
|
||||
|
||||
on-toggle-tab
|
||||
(mf/use-fn
|
||||
(mf/deps form)
|
||||
@@ -273,7 +276,12 @@
|
||||
:max-length max-input-length
|
||||
:variant "comfortable"
|
||||
:trim true
|
||||
:auto-focus true}]]
|
||||
:auto-focus true}]
|
||||
|
||||
(when (and warning-name-change? (= action "edit"))
|
||||
[:div {:class (stl/css :warning-name-change-notification-wrapper)}
|
||||
[:> context-notification*
|
||||
{:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])]
|
||||
|
||||
[:div {:class (stl/css :input-row)}
|
||||
(case type
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.files.tokens :as cft]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.token :as cto]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.main.data.workspace.tokens.errors :as wte]
|
||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
||||
[app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]]
|
||||
@@ -51,7 +51,7 @@
|
||||
;; Entering form without a value - show no error just resolve nil
|
||||
(nil? token-value) (rx/of nil)
|
||||
;; Validate refrence string
|
||||
(cto/composite-token-reference? token-value) (default-validate-token params)
|
||||
(cto/shadow-composite-token-reference? token-value) (default-validate-token params)
|
||||
;; Validate composite token
|
||||
:else
|
||||
(let [params (-> params
|
||||
@@ -253,7 +253,6 @@
|
||||
[:> reference-form* {:token token
|
||||
:tokens tokens}])]))
|
||||
|
||||
;; TODO: use cfo/make-schema:token-value and extend it with shadow and reference fields, adding :optional when needed
|
||||
(defn- make-schema
|
||||
[tokens-tree active-tab]
|
||||
(sm/schema
|
||||
@@ -263,10 +262,10 @@
|
||||
[: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
|
||||
(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 (ctob/token-name-path-exists? % tokens-tree))]]]
|
||||
#(not (cft/token-name-path-exists? % tokens-tree))]]]
|
||||
|
||||
[:value
|
||||
[:map
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.files.tokens :as cft]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.token :as cto]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.main.data.workspace.tokens.errors :as wte]
|
||||
[app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
||||
@@ -48,7 +48,7 @@
|
||||
;; Entering form without a value - show no error just resolve nil
|
||||
(nil? token-value) (rx/of nil)
|
||||
;; Validate refrence string
|
||||
(cto/composite-token-reference? token-value) (default-validate-token props)
|
||||
(cto/typography-composite-token-reference? token-value) (default-validate-token props)
|
||||
;; Validate composite token
|
||||
:else
|
||||
(-> props
|
||||
@@ -217,10 +217,10 @@
|
||||
[: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
|
||||
(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 (ctob/token-name-path-exists? % tokens-tree))]]]
|
||||
#(not (cft/token-name-path-exists? % tokens-tree))]]]
|
||||
|
||||
[:value
|
||||
[:map
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
(ns app.main.ui.workspace.tokens.management.forms.validators
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.files.tokens :as cft]
|
||||
[app.common.schema :as sm]
|
||||
[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.errors :as wte]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[beicon.v2.core :as rx]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
@@ -27,8 +29,7 @@
|
||||
;; When creating a new token we dont have a name yet or invalid name,
|
||||
;; but we still want to resolve the value to show in the form.
|
||||
;; So we use a temporary token name that hopefully doesn't clash with any of the users token names
|
||||
(not (sm/valid? cto/schema:token-name (:name token)))
|
||||
(assoc :name "__PENPOT__TOKEN__NAME__PLACEHOLDER__"))
|
||||
(not (sm/valid? cto/token-name-ref (:name token))) (assoc :name "__PENPOT__TOKEN__NAME__PLACEHOLDER__"))
|
||||
tokens' (cond-> tokens
|
||||
;; Remove previous token when renaming a token
|
||||
(not= (:name token) (:name prev-token))
|
||||
@@ -88,3 +89,23 @@
|
||||
[token-name token-vals]
|
||||
(when (some #(cto/token-value-self-reference? token-name %) token-vals)
|
||||
(wte/get-error-code :error.token/direct-self-reference)))
|
||||
|
||||
|
||||
|
||||
;; This is used in plugins
|
||||
|
||||
(defn- make-token-name-schema
|
||||
"Generate a dynamic schema validation to check if a token path derived
|
||||
from the name already exists at `tokens-tree`."
|
||||
[tokens-tree]
|
||||
[: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))]])
|
||||
|
||||
(defn validate-token-name
|
||||
[tokens-tree name]
|
||||
(let [schema (make-token-name-schema tokens-tree)
|
||||
explainer (sm/explainer schema)]
|
||||
(-> name explainer sm/simplify not-empty)))
|
||||
@@ -10,7 +10,7 @@
|
||||
[app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.files.tokens :as cfo]
|
||||
[app.common.files.tokens :as cft]
|
||||
[app.common.path-names :as cpn]
|
||||
[app.common.types.token :as ctt]
|
||||
[app.main.data.workspace.tokens.application :as dwta]
|
||||
@@ -156,9 +156,9 @@
|
||||
|
||||
(defn- applied-all-attributes?
|
||||
[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 #{} xf:map-id selected-shapes)]
|
||||
(cfo/shapes-applied-all? ids-by-attributes shape-ids attributes)))
|
||||
(cft/shapes-applied-all? ids-by-attributes shape-ids attributes)))
|
||||
|
||||
(defn attributes-match-selection?
|
||||
[selected-shapes attrs & {:keys [selected-inside-layout?]}]
|
||||
@@ -178,7 +178,7 @@
|
||||
(let [{:keys [name value errors type]} token
|
||||
|
||||
has-selected? (pos? (count selected-shapes))
|
||||
is-reference? (cfo/is-reference? token)
|
||||
is-reference? (cft/is-reference? token)
|
||||
contains-path? (str/includes? name ".")
|
||||
|
||||
attributes (as-> (get dwta/token-properties type) $
|
||||
@@ -191,7 +191,7 @@
|
||||
|
||||
applied?
|
||||
(if has-selected?
|
||||
(cfo/shapes-token-applied? token selected-shapes attributes)
|
||||
(cft/shapes-token-applied? token selected-shapes attributes)
|
||||
false)
|
||||
|
||||
half-applied?
|
||||
@@ -219,7 +219,7 @@
|
||||
no-valid-value)
|
||||
|
||||
color
|
||||
(when (cfo/color-token? token)
|
||||
(when (cft/color-token? token)
|
||||
(let [theme-token (get active-theme-tokens name)]
|
||||
(or (dwtc/resolved-token-bullet-color theme-token)
|
||||
(dwtc/resolved-token-bullet-color token))))
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(defn- on-select-token-set-click [id]
|
||||
(st/emit! (dwtl/clear-tokens-paths))
|
||||
(st/emit! (dwtl/set-selected-token-set-id id)))
|
||||
|
||||
(defn- on-toggle-token-set-click [name]
|
||||
@@ -63,8 +62,7 @@
|
||||
(st/emit! (dwtl/start-token-set-edition id)))))]
|
||||
|
||||
[:> controlled-sets-list*
|
||||
{:tokens-lib tokens-lib
|
||||
:token-sets token-sets
|
||||
{:token-sets token-sets
|
||||
|
||||
:is-token-set-active token-set-active?
|
||||
:is-token-set-group-active token-set-group-active?
|
||||
@@ -81,6 +79,6 @@
|
||||
|
||||
:on-toggle-token-set on-toggle-token-set-click
|
||||
:on-toggle-token-set-group on-toggle-token-set-group-click
|
||||
:on-update-token-set (partial sets-helpers/on-update-token-set tokens-lib)
|
||||
:on-update-token-set sets-helpers/on-update-token-set
|
||||
:on-update-token-set-group sets-helpers/on-update-token-set-group
|
||||
:on-create-token-set sets-helpers/on-create-token-set}]))
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
(ns app.main.ui.workspace.tokens.sets.helpers
|
||||
(:require
|
||||
[app.common.files.tokens :as cfo]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.data.workspace.tokens.library-edit :as dwtl]
|
||||
[app.main.store :as st]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -15,18 +11,9 @@
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn on-update-token-set
|
||||
[tokens-lib token-set name]
|
||||
(let [name (ctob/normalize-set-name name (ctob/get-name token-set))
|
||||
errors (sm/validation-errors name (cfo/make-token-set-name-schema
|
||||
tokens-lib
|
||||
(ctob/get-id token-set)))]
|
||||
(st/emit! (dwtl/clear-token-set-edition))
|
||||
(if (empty? errors)
|
||||
(st/emit! (dwtl/rename-token-set token-set name))
|
||||
(st/emit! (ntf/show {:content (tr "errors.token-set-already-exists")
|
||||
:type :toast
|
||||
:level :error
|
||||
:timeout 9000})))))
|
||||
[token-set name]
|
||||
(st/emit! (dwtl/clear-token-set-edition)
|
||||
(dwtl/update-token-set token-set name)))
|
||||
|
||||
(defn on-update-token-set-group
|
||||
[path name]
|
||||
@@ -34,15 +21,15 @@
|
||||
(dwtl/rename-token-set-group path name)))
|
||||
|
||||
(defn on-create-token-set
|
||||
[tokens-lib parent-set name]
|
||||
(let [name (ctob/make-child-name parent-set name)
|
||||
errors (sm/validation-errors name (cfo/make-token-set-name-schema tokens-lib nil))]
|
||||
[parent-set name]
|
||||
(let [;; FIXME: this code should be reusable under helper under
|
||||
;; common types namespace
|
||||
name
|
||||
(if-let [parent-path (ctob/get-set-path parent-set)]
|
||||
(->> (concat parent-path (ctob/split-set-name name))
|
||||
(ctob/join-set-path))
|
||||
(ctob/normalize-set-name name))
|
||||
token-set (ctob/make-token-set :name name)]
|
||||
|
||||
(st/emit! (ptk/data-event ::ev/event {::ev/name "create-token-set" :name name})
|
||||
(dwtl/clear-token-set-creation))
|
||||
(if (empty? errors)
|
||||
(let [token-set (ctob/make-token-set :name name)]
|
||||
(st/emit! (dwtl/create-token-set token-set)))
|
||||
(st/emit! (ntf/show {:content (tr "errors.token-set-already-exists")
|
||||
:type :toast
|
||||
:level :error
|
||||
:timeout 9000})))))
|
||||
(dwtl/create-token-set token-set))))
|
||||
|
||||
@@ -321,7 +321,6 @@
|
||||
on-select
|
||||
on-toggle-set
|
||||
on-toggle-set-group
|
||||
tokens-lib
|
||||
token-sets
|
||||
new-path
|
||||
edition-id]}]
|
||||
@@ -409,7 +408,7 @@
|
||||
|
||||
:on-drop on-drop
|
||||
:on-reset-edition on-reset-edition
|
||||
:on-edit-submit (partial sets-helpers/on-create-token-set tokens-lib)}]
|
||||
:on-edit-submit sets-helpers/on-create-token-set}]
|
||||
|
||||
:else
|
||||
[:> sets-tree-set*
|
||||
@@ -435,8 +434,7 @@
|
||||
:on-edit-submit on-edit-submit-set}])))))
|
||||
|
||||
(mf/defc controlled-sets-list*
|
||||
[{:keys [tokens-lib
|
||||
token-sets
|
||||
[{:keys [token-sets
|
||||
selected
|
||||
on-update-token-set
|
||||
on-update-token-set-group
|
||||
@@ -488,7 +486,6 @@
|
||||
{:is-draggable draggable?
|
||||
:new-path new-path
|
||||
:edition-id edition-id
|
||||
:tokens-lib tokens-lib
|
||||
:token-sets token-sets
|
||||
:selected selected
|
||||
:on-select on-select
|
||||
|
||||
@@ -7,11 +7,8 @@
|
||||
(ns app.main.ui.workspace.tokens.themes.create-modal
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.files.tokens :as cfo]
|
||||
[app.common.logic.tokens :as clt]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.main.constants :refer [max-input-length]]
|
||||
[app.main.data.event :as ev]
|
||||
@@ -33,9 +30,32 @@
|
||||
[app.util.i18n :refer [tr]]
|
||||
[app.util.keyboard :as k]
|
||||
[cuerdas.core :as str]
|
||||
[malli.core :as m]
|
||||
[malli.error :as me]
|
||||
[potok.v2.core :as ptk]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
;; Schemas ---------------------------------------------------------------------
|
||||
|
||||
(defn- theme-name-schema
|
||||
"Generate a dynamic schema validation to check if a theme path derived from the name already exists at `tokens-tree`."
|
||||
[{:keys [group theme-id tokens-lib]}]
|
||||
(m/-simple-schema
|
||||
{:type :token/name-exists
|
||||
:pred (fn [name]
|
||||
(if tokens-lib
|
||||
(let [theme (ctob/get-theme-by-name tokens-lib group name)]
|
||||
(or (nil? theme)
|
||||
(= (ctob/get-id theme) theme-id)))
|
||||
true)) ;; if still no library exists, cannot be duplicate
|
||||
:type-properties {:error/fn #(tr "workspace.tokens.theme-name-already-exists")}}))
|
||||
|
||||
(defn validate-theme-name
|
||||
[tokens-lib group theme-id name]
|
||||
(let [schema (theme-name-schema {:tokens-lib tokens-lib :theme-id theme-id :group group})
|
||||
validation (m/explain schema (str/trim name))]
|
||||
(me/humanize validation)))
|
||||
|
||||
;; Form Component --------------------------------------------------------------
|
||||
|
||||
(mf/defc empty-themes
|
||||
@@ -161,43 +181,26 @@
|
||||
theme-groups)
|
||||
current-group* (mf/use-state (:group theme))
|
||||
current-group (deref current-group*)
|
||||
current-name* (mf/use-state (:name theme))
|
||||
current-name (deref current-name*)
|
||||
group-errors* (mf/use-state nil)
|
||||
group-errors (deref group-errors*)
|
||||
name-errors* (mf/use-state nil)
|
||||
name-errors (deref name-errors*)
|
||||
|
||||
on-update-group
|
||||
(mf/use-fn
|
||||
(mf/deps on-change-field tokens-lib current-name)
|
||||
(mf/deps on-change-field)
|
||||
(fn [value]
|
||||
(let [errors (sm/validation-errors value (cfo/make-token-theme-group-schema
|
||||
tokens-lib
|
||||
current-name
|
||||
(ctob/get-id theme)))]
|
||||
(reset! group-errors* errors)
|
||||
(if (empty? errors)
|
||||
(do
|
||||
(reset! current-group* value)
|
||||
(on-change-field :group value))
|
||||
(on-change-field :group "")))))
|
||||
(reset! current-group* value)
|
||||
(on-change-field :group value)))
|
||||
|
||||
on-update-name
|
||||
(mf/use-fn
|
||||
(mf/deps on-change-field tokens-lib current-group)
|
||||
(fn [event]
|
||||
(let [value (-> event dom/get-target dom/get-value)
|
||||
errors (sm/validation-errors value (cfo/make-token-theme-name-schema
|
||||
tokens-lib
|
||||
current-group
|
||||
(ctob/get-id theme)))]
|
||||
errors (validate-theme-name tokens-lib current-group (ctob/get-id theme) value)]
|
||||
(reset! name-errors* errors)
|
||||
(mf/set-ref-val! theme-name-ref value)
|
||||
(if (empty? errors)
|
||||
(do
|
||||
(reset! current-name* value)
|
||||
(on-change-field :name value))
|
||||
(on-change-field :name value)
|
||||
(on-change-field :name "")))))]
|
||||
|
||||
[:div {:class (stl/css :edit-theme-inputs-wrapper)}
|
||||
@@ -207,7 +210,6 @@
|
||||
:placeholder (tr "workspace.tokens.label.group-placeholder")
|
||||
:default-selected (:group theme)
|
||||
:options (clj->js options)
|
||||
:has-error (d/not-empty? group-errors)
|
||||
:on-change on-update-group}]]
|
||||
|
||||
[:div {:class (stl/css :group-input-wrapper)}
|
||||
@@ -260,7 +262,6 @@
|
||||
(mf/defc edit-create-theme*
|
||||
[{:keys [change-view theme on-save is-editing has-prev-view]}]
|
||||
(let [ordered-token-sets (mf/deref refs/workspace-ordered-token-sets)
|
||||
tokens-lib (mf/deref refs/tokens-lib)
|
||||
token-sets (mf/deref refs/workspace-token-sets-tree)
|
||||
|
||||
current-theme* (mf/use-state theme)
|
||||
@@ -362,8 +363,7 @@
|
||||
[:div {:class (stl/css :sets-list-wrapper)}
|
||||
|
||||
[:> wts/controlled-sets-list*
|
||||
{:tokens-lib tokens-lib
|
||||
:token-sets token-sets
|
||||
{:token-sets token-sets
|
||||
:is-token-set-active token-set-active?
|
||||
:is-token-set-group-active token-set-group-active?
|
||||
:on-select on-click-token-set
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.plugins :as ctp]
|
||||
[app.common.uri :as u]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
@@ -51,13 +50,7 @@
|
||||
(contains? permissions "comment:write")
|
||||
(conj "comment:read"))
|
||||
|
||||
plugin-url
|
||||
(u/uri plugin-url)
|
||||
|
||||
origin
|
||||
(-> plugin-url
|
||||
(u/join ".")
|
||||
(str))
|
||||
origin (obj/get (js/URL. plugin-url) "origin")
|
||||
|
||||
prev-plugin
|
||||
(->> (:data @registry)
|
||||
@@ -66,13 +59,12 @@
|
||||
(and (= name (:name plugin))
|
||||
(= origin (:host plugin))))))
|
||||
|
||||
plugin-id
|
||||
(d/nilv (:plugin-id prev-plugin) (str (uuid/next)))
|
||||
plugin-id (d/nilv (:plugin-id prev-plugin) (str (uuid/next)))
|
||||
|
||||
manifest
|
||||
(d/without-nils
|
||||
{:plugin-id plugin-id
|
||||
:url (str plugin-url)
|
||||
:url plugin-url
|
||||
:name name
|
||||
:description desc
|
||||
:host origin
|
||||
|
||||
@@ -7,41 +7,33 @@
|
||||
(ns app.plugins.tokens
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.files.tokens :as cfo]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.token :as cto]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.style-dictionary :as sd]
|
||||
[app.main.data.workspace.tokens.application :as dwta]
|
||||
[app.main.data.workspace.tokens.library-edit :as dwtl]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.shape :as shape]
|
||||
[app.main.ui.workspace.tokens.management.forms.validators :as form-validator]
|
||||
[app.main.ui.workspace.tokens.themes.create-modal :as theme-form]
|
||||
[app.plugins.utils :as u]
|
||||
[app.util.object :as obj]
|
||||
[beicon.v2.core :as rx]
|
||||
[clojure.datafy :refer [datafy]]))
|
||||
|
||||
;; === Token
|
||||
|
||||
(defn- apply-token-to-shapes
|
||||
[file-id set-id id shape-ids attrs]
|
||||
(let [token (u/locate-token file-id set-id id)]
|
||||
(if (some #(not (cto/token-attr? %)) attrs)
|
||||
(let [token (u/locate-token file-id set-id id)
|
||||
kw-attrs (into #{} (map keyword attrs))]
|
||||
(if (some #(not (cto/token-attr? %)) kw-attrs)
|
||||
(u/display-not-valid :applyToSelected attrs)
|
||||
(st/emit!
|
||||
(dwta/toggle-token {:token token
|
||||
:attrs attrs
|
||||
:attrs kw-attrs
|
||||
:shape-ids shape-ids
|
||||
:expand-with-children false})))))
|
||||
|
||||
(defn token-proxy? [p]
|
||||
(obj/type-of? p "TokenProxy"))
|
||||
|
||||
(defn token-proxy
|
||||
[plugin-id file-id set-id id]
|
||||
(obj/reify {:name "TokenProxy"
|
||||
:wrap u/wrap-errors}
|
||||
(obj/reify {:name "TokenSetProxy"}
|
||||
:$plugin {:enumerable false :get (constantly plugin-id)}
|
||||
:$file-id {:enumerable false :get (constantly file-id)}
|
||||
:$set-id {:enumerable false :get (constantly set-id)}
|
||||
@@ -56,12 +48,18 @@
|
||||
(fn [_]
|
||||
(let [token (u/locate-token file-id set-id id)]
|
||||
(ctob/get-name token)))
|
||||
:schema (cfo/make-token-name-schema
|
||||
(-> (u/locate-tokens-lib file-id)
|
||||
(ctob/get-tokens set-id)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(st/emit! (dwtl/update-token set-id id {:name value})))}
|
||||
(let [tokens-lib (u/locate-tokens-lib file-id)
|
||||
errors (form-validator/validate-token-name
|
||||
(ctob/get-tokens tokens-lib set-id)
|
||||
value)]
|
||||
(cond
|
||||
(some? errors)
|
||||
(u/display-not-valid :name (first errors))
|
||||
|
||||
:else
|
||||
(st/emit! (dwtl/update-token set-id id {:name value})))))}
|
||||
|
||||
:type
|
||||
{:this true
|
||||
@@ -75,31 +73,17 @@
|
||||
:get
|
||||
(fn [_]
|
||||
(let [token (u/locate-token file-id set-id id)]
|
||||
(:value token)))
|
||||
:schema (let [token (u/locate-token file-id set-id id)]
|
||||
(cfo/make-token-value-schema (:type token)))
|
||||
:set
|
||||
(fn [_ value]
|
||||
(st/emit! (dwtl/update-token set-id id {:value value})))}
|
||||
(:value token)))}
|
||||
|
||||
:description
|
||||
{:this true
|
||||
:get
|
||||
(fn [_]
|
||||
(let [token (u/locate-token file-id set-id id)]
|
||||
(ctob/get-description token)))
|
||||
:schema cfo/schema:token-description
|
||||
:set
|
||||
(fn [_ value]
|
||||
(st/emit! (dwtl/update-token set-id id {:description value})))}
|
||||
(ctob/get-description token)))}
|
||||
|
||||
:duplicate
|
||||
(fn []
|
||||
;; TODO:
|
||||
;; - add function duplicate-token in tokens-lib, that allows to specify the new id
|
||||
;; - use this function in dwtl/duplicate-token
|
||||
;; - return the new token proxy using the locally forced id
|
||||
;; - do the same with sets and themes
|
||||
(let [token (u/locate-token file-id set-id id)
|
||||
token' (ctob/make-token (-> (datafy token)
|
||||
(dissoc :id
|
||||
@@ -112,27 +96,17 @@
|
||||
(st/emit! (dwtl/delete-token set-id id)))
|
||||
|
||||
:applyToShapes
|
||||
{:schema [:tuple
|
||||
[:vector [:fn shape/shape-proxy?]]
|
||||
[:maybe [:set ::sm/keyword]]]
|
||||
:fn (fn [shapes attrs]
|
||||
(apply-token-to-shapes file-id set-id id (map :id shapes) attrs))}
|
||||
(fn [shapes attrs]
|
||||
(apply-token-to-shapes file-id set-id id (map :id shapes) attrs))
|
||||
|
||||
:applyToSelected
|
||||
{:schema [:tuple [:maybe [:set ::sm/keyword]]]
|
||||
:fn (fn [attrs]
|
||||
(let [selected (get-in @st/state [:workspace-local :selected])]
|
||||
(apply-token-to-shapes file-id set-id id selected attrs)))}))
|
||||
|
||||
;; === Token Set
|
||||
|
||||
(defn token-set-proxy? [p]
|
||||
(obj/type-of? p "TokenSetProxy"))
|
||||
(fn [attrs]
|
||||
(let [selected (get-in @st/state [:workspace-local :selected])]
|
||||
(apply-token-to-shapes file-id set-id id selected attrs)))))
|
||||
|
||||
(defn token-set-proxy
|
||||
[plugin-id file-id id]
|
||||
(obj/reify {:name "TokenSetProxy"
|
||||
:wrap u/wrap-errors}
|
||||
(obj/reify {:name "TokenSetProxy"}
|
||||
:$plugin {:enumerable false :get (constantly plugin-id)}
|
||||
:$file-id {:enumerable false :get (constantly file-id)}
|
||||
:$id {:enumerable false :get (constantly id)}
|
||||
@@ -146,13 +120,15 @@
|
||||
(fn [_]
|
||||
(let [set (u/locate-token-set file-id id)]
|
||||
(ctob/get-name set)))
|
||||
:schema (cfo/make-token-set-name-schema
|
||||
(u/locate-tokens-lib file-id)
|
||||
id)
|
||||
:set
|
||||
(fn [_ name]
|
||||
(fn [_ value]
|
||||
(let [set (u/locate-token-set file-id id)]
|
||||
(st/emit! (dwtl/rename-token-set set name))))}
|
||||
(cond
|
||||
(not (string? value))
|
||||
(u/display-not-valid :name value)
|
||||
|
||||
:else
|
||||
(st/emit! (dwtl/update-token-set set value)))))}
|
||||
|
||||
:active
|
||||
{:this true
|
||||
@@ -162,7 +138,6 @@
|
||||
(let [tokens-lib (u/locate-tokens-lib file-id)
|
||||
set (u/locate-token-set file-id id)]
|
||||
(ctob/token-set-active? tokens-lib (ctob/get-name set))))
|
||||
:schema ::sm/boolean
|
||||
:set
|
||||
(fn [_ value]
|
||||
(let [set (u/locate-token-set file-id id)]
|
||||
@@ -178,7 +153,8 @@
|
||||
:enumerable false
|
||||
:get
|
||||
(fn [_]
|
||||
(let [tokens-lib (u/locate-tokens-lib file-id)]
|
||||
(let [file (u/locate-file file-id)
|
||||
tokens-lib (->> file :data :tokens-lib)]
|
||||
(->> (ctob/get-tokens tokens-lib id)
|
||||
(vals)
|
||||
(map #(token-proxy plugin-id file-id id (:id %)))
|
||||
@@ -189,7 +165,8 @@
|
||||
:enumerable false
|
||||
:get
|
||||
(fn [_]
|
||||
(let [tokens-lib (u/locate-tokens-lib file-id)
|
||||
(let [file (u/locate-file file-id)
|
||||
tokens-lib (->> file :data :tokens-lib)
|
||||
tokens (ctob/get-tokens tokens-lib id)]
|
||||
(->> tokens
|
||||
(vals)
|
||||
@@ -204,56 +181,55 @@
|
||||
(apply array))))}
|
||||
|
||||
:getTokenById
|
||||
{:schema [:tuple ::sm/uuid]
|
||||
:fn (fn [token-id]
|
||||
(let [token (u/locate-token file-id id token-id)]
|
||||
(when (some? token)
|
||||
(token-proxy plugin-id file-id id token-id))))}
|
||||
(fn [token-id]
|
||||
(cond
|
||||
(not (string? token-id))
|
||||
(u/display-not-valid :getTokenById token-id)
|
||||
|
||||
:else
|
||||
(let [token-id (uuid/parse token-id)
|
||||
token (u/locate-token file-id id token-id)]
|
||||
(when (some? token)
|
||||
(token-proxy plugin-id file-id id token-id)))))
|
||||
|
||||
:addToken
|
||||
{:schema (fn [args]
|
||||
[:tuple (-> (cfo/make-token-schema
|
||||
(-> (u/locate-tokens-lib file-id) (ctob/get-tokens id))
|
||||
(cto/dtcg-token-type->token-type (-> args (first) (get "type"))))
|
||||
;; Don't allow plugins to set the id
|
||||
(sm/dissoc-key :id)
|
||||
;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below)
|
||||
;; and set a converter that changes DTCG types to internal types (:decode/json).
|
||||
;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width
|
||||
(sm/update-properties assoc :decode/json cfo/convert-dtcg-token))])
|
||||
:decode/options {:key-fn identity}
|
||||
:fn (fn [attrs]
|
||||
(let [tokens-lib (u/locate-tokens-lib file-id)
|
||||
tokens-tree (ctob/get-tokens-in-active-sets tokens-lib)
|
||||
token (ctob/make-token attrs)]
|
||||
(->> (assoc tokens-tree (:name token) token)
|
||||
(sd/resolve-tokens-interactive)
|
||||
(rx/subs!
|
||||
(fn [resolved-tokens]
|
||||
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))]
|
||||
(if resolved-value
|
||||
(st/emit! (dwtl/create-token id token))
|
||||
(u/display-not-valid :addToken (str errors)))))))
|
||||
;; TODO: as the addToken function is synchronous, we must return the newly created
|
||||
;; token even if the validator will throw it away if the resolution fails.
|
||||
;; This will be solved with the TokenScript resolver, that is syncronous.
|
||||
(token-proxy plugin-id file-id id (:id token))))}
|
||||
(fn [type-str name value]
|
||||
(let [type (cto/dtcg-token-type->token-type type-str)
|
||||
value (case type
|
||||
:font-family (ctob/convert-dtcg-font-family (js->clj value))
|
||||
:typography (ctob/convert-dtcg-typography-composite (js->clj value))
|
||||
:shadow (ctob/convert-dtcg-shadow-composite (js->clj value))
|
||||
(js->clj value))]
|
||||
(cond
|
||||
(nil? type)
|
||||
(u/display-not-valid :addTokenType type-str)
|
||||
|
||||
(not (string? name))
|
||||
(u/display-not-valid :addTokenName name)
|
||||
|
||||
:else
|
||||
(let [token (ctob/make-token {:type type
|
||||
:name name
|
||||
:value value})]
|
||||
(st/emit! (dwtl/create-token id token))
|
||||
(token-proxy plugin-id file-id (:id set) (:id token))))))
|
||||
|
||||
:duplicate
|
||||
(fn []
|
||||
(st/emit! (dwtl/duplicate-token-set id)))
|
||||
(let [set (u/locate-token-set file-id id)
|
||||
set' (ctob/make-token-set (-> (datafy set)
|
||||
(dissoc :id
|
||||
:modified-at)))]
|
||||
(st/emit! (dwtl/create-token-set set'))
|
||||
(token-set-proxy plugin-id file-id (:id set'))))
|
||||
|
||||
:remove
|
||||
(fn []
|
||||
(st/emit! (dwtl/delete-token-set id)))))
|
||||
|
||||
(defn token-theme-proxy? [p]
|
||||
(obj/type-of? p "TokenThemeProxy"))
|
||||
|
||||
(defn token-theme-proxy
|
||||
[plugin-id file-id id]
|
||||
(obj/reify {:name "TokenThemeProxy"
|
||||
:wrap u/wrap-errors}
|
||||
(obj/reify {:name "TokenThemeProxy"}
|
||||
:$plugin {:enumerable false :get (constantly plugin-id)}
|
||||
:$file-id {:enumerable false :get (constantly file-id)}
|
||||
:$id {:enumerable false :get (constantly id)}
|
||||
@@ -274,15 +250,15 @@
|
||||
(fn [_]
|
||||
(let [theme (u/locate-token-theme file-id id)]
|
||||
(:group theme)))
|
||||
:schema (let [theme (u/locate-token-theme file-id id)]
|
||||
(cfo/make-token-theme-group-schema
|
||||
(u/locate-tokens-lib file-id)
|
||||
(:name theme)
|
||||
(:id theme)))
|
||||
:set
|
||||
(fn [_ group]
|
||||
(fn [_ value]
|
||||
(let [theme (u/locate-token-theme file-id id)]
|
||||
(st/emit! (dwtl/update-token-theme id (assoc theme :group group)))))}
|
||||
(cond
|
||||
(not (string? value))
|
||||
(u/display-not-valid :group value)
|
||||
|
||||
:else
|
||||
(st/emit! (dwtl/update-token-theme id (assoc theme :group value))))))}
|
||||
|
||||
:name
|
||||
{:this true
|
||||
@@ -290,16 +266,20 @@
|
||||
(fn [_]
|
||||
(let [theme (u/locate-token-theme file-id id)]
|
||||
(:name theme)))
|
||||
:schema (let [theme (u/locate-token-theme file-id id)]
|
||||
(cfo/make-token-theme-name-schema
|
||||
(u/locate-tokens-lib file-id)
|
||||
(:id theme)
|
||||
(:group theme)))
|
||||
:set
|
||||
(fn [_ name]
|
||||
(let [theme (u/locate-token-theme file-id id)]
|
||||
(when name
|
||||
(st/emit! (dwtl/update-token-theme id (assoc theme :name name))))))}
|
||||
(fn [_ value]
|
||||
(let [theme (u/locate-token-theme file-id id)
|
||||
errors (theme-form/validate-theme-name
|
||||
(u/locate-tokens-lib file-id)
|
||||
(:group theme)
|
||||
id
|
||||
value)]
|
||||
(cond
|
||||
(some? errors)
|
||||
(u/display-not-valid :name (first errors))
|
||||
|
||||
:else
|
||||
(st/emit! (dwtl/update-token-theme id (assoc theme :name value))))))}
|
||||
|
||||
:active
|
||||
{:this true
|
||||
@@ -308,7 +288,6 @@
|
||||
(fn [_]
|
||||
(let [tokens-lib (u/locate-tokens-lib file-id)]
|
||||
(ctob/theme-active? tokens-lib id)))
|
||||
:schema ::sm/boolean
|
||||
:set
|
||||
(fn [_ value]
|
||||
(st/emit! (dwtl/set-token-theme-active id value)))}
|
||||
@@ -321,16 +300,14 @@
|
||||
{:this true :get (fn [_])}
|
||||
|
||||
:addSet
|
||||
{:schema [:tuple [:fn token-set-proxy?]]
|
||||
:fn (fn [tokenSet]
|
||||
(let [theme (u/locate-token-theme file-id id)]
|
||||
(st/emit! (dwtl/update-token-theme id (ctob/enable-set theme (obj/get tokenSet :name))))))}
|
||||
(fn [tokenSet]
|
||||
(let [theme (u/locate-token-theme file-id id)]
|
||||
(st/emit! (dwtl/update-token-theme id (ctob/enable-set theme (obj/get tokenSet :name))))))
|
||||
|
||||
:removeSet
|
||||
{:schema [:tuple [:fn token-set-proxy?]]
|
||||
:fn (fn [tokenSet]
|
||||
(let [theme (u/locate-token-theme file-id id)]
|
||||
(st/emit! (dwtl/update-token-theme id (ctob/disable-set theme (obj/get tokenSet :name))))))}
|
||||
(fn [tokenSet]
|
||||
(let [theme (u/locate-token-theme file-id id)]
|
||||
(st/emit! (dwtl/update-token-theme id (ctob/disable-set theme (obj/get tokenSet :name))))))
|
||||
|
||||
:duplicate
|
||||
(fn []
|
||||
@@ -347,8 +324,7 @@
|
||||
|
||||
(defn tokens-catalog
|
||||
[plugin-id file-id]
|
||||
(obj/reify {:name "TokensCatalog"
|
||||
:wrap u/wrap-errors}
|
||||
(obj/reify {:name "TokensCatalog"}
|
||||
:$plugin {:enumerable false :get (constantly plugin-id)}
|
||||
:$id {:enumerable false :get (constantly file-id)}
|
||||
|
||||
@@ -357,10 +333,10 @@
|
||||
:enumerable false
|
||||
:get
|
||||
(fn [_]
|
||||
(let [tokens-lib (u/locate-tokens-lib file-id)
|
||||
themes (when tokens-lib
|
||||
(->> (ctob/get-themes tokens-lib)
|
||||
(remove #(= (:id %) uuid/zero))))]
|
||||
(let [file (u/locate-file file-id)
|
||||
tokens-lib (->> file :data :tokens-lib)
|
||||
themes (->> (ctob/get-themes tokens-lib)
|
||||
(remove #(= (:id %) uuid/zero)))]
|
||||
(apply array (map #(token-theme-proxy plugin-id file-id (ctob/get-id %)) themes))))}
|
||||
|
||||
:sets
|
||||
@@ -368,47 +344,58 @@
|
||||
:enumerable false
|
||||
:get
|
||||
(fn [_]
|
||||
(let [tokens-lib (u/locate-tokens-lib file-id)
|
||||
sets (when tokens-lib
|
||||
(ctob/get-sets tokens-lib))]
|
||||
(let [file (u/locate-file file-id)
|
||||
tokens-lib (->> file :data :tokens-lib)
|
||||
sets (ctob/get-sets tokens-lib)]
|
||||
(apply array (map #(token-set-proxy plugin-id file-id (ctob/get-id %)) sets))))}
|
||||
|
||||
:addTheme
|
||||
{:schema (fn [attrs]
|
||||
[:tuple (-> (sm/schema (cfo/make-token-theme-schema
|
||||
(u/locate-tokens-lib file-id)
|
||||
(or (obj/get attrs "group") "")
|
||||
(or (obj/get attrs "name") "")
|
||||
nil))
|
||||
(sm/dissoc-key :id))]) ;; We don't allow plugins to set the id
|
||||
:fn (fn [attrs]
|
||||
(let [theme (ctob/make-token-theme attrs)]
|
||||
(st/emit! (dwtl/create-token-theme theme))
|
||||
(token-theme-proxy plugin-id file-id (:id theme))))}
|
||||
(fn [group name]
|
||||
(cond
|
||||
(not (string? group))
|
||||
(u/display-not-valid :addThemeGroup group)
|
||||
|
||||
(not (string? name))
|
||||
(u/display-not-valid :addThemeName name)
|
||||
|
||||
:else
|
||||
(let [theme (ctob/make-token-theme {:group group
|
||||
:name name})]
|
||||
(st/emit! (dwtl/create-token-theme theme))
|
||||
(token-theme-proxy plugin-id file-id (:id theme)))))
|
||||
|
||||
:addSet
|
||||
{:schema [:tuple (-> (sm/schema (cfo/make-token-set-schema
|
||||
(u/locate-tokens-lib file-id)
|
||||
nil))
|
||||
(sm/dissoc-key :id))] ;; We don't allow plugins to set the id
|
||||
(fn [name]
|
||||
(cond
|
||||
(not (string? name))
|
||||
(u/display-not-valid :addSetName name)
|
||||
|
||||
:fn (fn [attrs]
|
||||
(let [attrs (update attrs :name ctob/normalize-set-name)
|
||||
set (ctob/make-token-set attrs)]
|
||||
(st/emit! (dwtl/create-token-set set))
|
||||
(token-set-proxy plugin-id file-id (ctob/get-id set))))}
|
||||
:else
|
||||
(let [set (ctob/make-token-set {:name name})]
|
||||
(st/emit! (dwtl/create-token-set set))
|
||||
(token-set-proxy plugin-id file-id (:id set)))))
|
||||
|
||||
:getThemeById
|
||||
{:schema [:tuple ::sm/uuid]
|
||||
:fn (fn [theme-id]
|
||||
(let [theme (u/locate-token-theme file-id theme-id)]
|
||||
(when (some? theme)
|
||||
(token-theme-proxy plugin-id file-id theme-id))))}
|
||||
(fn [theme-id]
|
||||
(cond
|
||||
(not (string? theme-id))
|
||||
(u/display-not-valid :getThemeById theme-id)
|
||||
|
||||
:else
|
||||
(let [theme-id (uuid/parse theme-id)
|
||||
theme (u/locate-token-theme file-id theme-id)]
|
||||
(when (some? theme)
|
||||
(token-theme-proxy plugin-id file-id theme-id)))))
|
||||
|
||||
:getSetById
|
||||
{:schema [:tuple ::sm/uuid]
|
||||
:fn (fn [set-id]
|
||||
(let [set (u/locate-token-set file-id set-id)]
|
||||
(when (some? set)
|
||||
(token-set-proxy plugin-id file-id set-id))))}))
|
||||
(fn [set-id]
|
||||
(cond
|
||||
(not (string? set-id))
|
||||
(u/display-not-valid :getSetById set-id)
|
||||
|
||||
:else
|
||||
(let [set-id (uuid/parse set-id)
|
||||
set (u/locate-token-set file-id set-id)]
|
||||
(when (some? set)
|
||||
(token-set-proxy plugin-id file-id set-id)))))))
|
||||
|
||||
|
||||
@@ -9,15 +9,12 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.json :as json]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.container :as ctn]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.store :as st]
|
||||
[app.util.object :as obj]
|
||||
[cuerdas.core :as str]))
|
||||
[app.util.object :as obj]))
|
||||
|
||||
(defn locate-file
|
||||
[id]
|
||||
@@ -64,20 +61,17 @@
|
||||
(defn locate-token-theme
|
||||
[file-id id]
|
||||
(let [tokens-lib (locate-tokens-lib file-id)]
|
||||
(when (some? tokens-lib)
|
||||
(ctob/get-theme tokens-lib id))))
|
||||
(ctob/get-theme tokens-lib id)))
|
||||
|
||||
(defn locate-token-set
|
||||
[file-id set-id]
|
||||
(let [tokens-lib (locate-tokens-lib file-id)]
|
||||
(when (some? tokens-lib)
|
||||
(ctob/get-set tokens-lib set-id))))
|
||||
(ctob/get-set tokens-lib set-id)))
|
||||
|
||||
(defn locate-token
|
||||
[file-id set-id token-id]
|
||||
(let [tokens-lib (locate-tokens-lib file-id)]
|
||||
(when (some? tokens-lib)
|
||||
(ctob/get-token tokens-lib set-id token-id))))
|
||||
(ctob/get-token tokens-lib set-id token-id)))
|
||||
|
||||
(defn locate-presence
|
||||
[session-id]
|
||||
@@ -224,8 +218,7 @@
|
||||
|
||||
(defn display-not-valid
|
||||
[code value]
|
||||
(.error js/console (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code))
|
||||
nil)
|
||||
(.error js/console (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)))
|
||||
|
||||
(defn reject-not-valid
|
||||
[reject code value]
|
||||
@@ -233,44 +226,7 @@
|
||||
(.error js/console msg)
|
||||
(reject msg)))
|
||||
|
||||
(defn coerce
|
||||
"Decodes a javascript object into clj and check against schema. If schema validation fails,
|
||||
displays a not-valid message with the code and hint provided and returns nil."
|
||||
[attrs schema code hint]
|
||||
(let [decoder (sm/decoder schema sm/json-transformer)
|
||||
explainer (sm/explainer schema)
|
||||
attrs (-> attrs json/->clj decoder)]
|
||||
(if-let [explain (explainer attrs)]
|
||||
(display-not-valid code (str hint " " (sm/humanize-explain explain)))
|
||||
attrs)))
|
||||
|
||||
(defn coerce-1
|
||||
"Checks a single javascript value against schema. If schema validation fails,
|
||||
displays a not-valid message with the code and hint provided and returns nil."
|
||||
[value schema code hint]
|
||||
(let [errors (sm/validation-errors value schema)]
|
||||
(if (d/not-empty? errors)
|
||||
(display-not-valid code (str hint " " (str/join ", " errors)))
|
||||
value)))
|
||||
|
||||
(defn mixed-value
|
||||
[values]
|
||||
(let [s (set values)]
|
||||
(if (= (count s) 1) (first s) "mixed")))
|
||||
|
||||
(defn wrap-errors
|
||||
"Function wrapper to be used in plugin proxies methods to handle errors.
|
||||
When an exception is thrown, a readable error message is output to the console
|
||||
and the exception is captured."
|
||||
[f]
|
||||
(fn []
|
||||
(let [args (js-arguments)]
|
||||
(try
|
||||
(.apply f nil args)
|
||||
(catch :default cause
|
||||
(display-not-valid (ex-message cause) (obj/stringify args))
|
||||
(if-let [explain (-> cause ex-data ::sm/explain)]
|
||||
(println (sm/humanize-explain explain))
|
||||
(js/console.log (ex-data cause)))
|
||||
(js/console.log (.-stack cause))
|
||||
nil)))))
|
||||
@@ -8,7 +8,6 @@
|
||||
"A i18n foundation."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.i18n]
|
||||
[app.common.logging :as log]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
@@ -211,7 +210,3 @@
|
||||
(fn [_ _ pv cv]
|
||||
(when (not= pv cv)
|
||||
(ct/set-default-locale cv))))
|
||||
|
||||
;; We set the real translation function in the common i18n namespace,
|
||||
;; so that when common code calls (tr ...) it uses this function.
|
||||
(set! app.common.i18n/tr tr)
|
||||
|
||||
@@ -106,11 +106,6 @@
|
||||
(identical? (.getPrototypeOf js/Object o)
|
||||
(.-prototype js/Object)))))
|
||||
|
||||
#?(:cljs
|
||||
(defn stringify
|
||||
[obj]
|
||||
(js/JSON.stringify obj)))
|
||||
|
||||
;; EXPERIMENTAL: unsafe, does not checks and not validates the input,
|
||||
;; should be improved over time, for now it works for define a class
|
||||
;; extending js/Error that is more than enought for a first, quick and
|
||||
@@ -168,14 +163,14 @@
|
||||
bindings
|
||||
(->> properties
|
||||
(mapcat (fn [params]
|
||||
(let [pname (c/get params :name)
|
||||
get-expr (c/get params :get)
|
||||
set-expr (c/get params :set)
|
||||
fn-expr (c/get params :fn)
|
||||
schema-n (c/get params :schema)
|
||||
wrap (c/get params :wrap)
|
||||
schema-1 (c/get params :schema-1)
|
||||
this? (c/get params :this false)
|
||||
(let [pname (c/get params :name)
|
||||
get-expr (c/get params :get)
|
||||
set-expr (c/get params :set)
|
||||
fn-expr (c/get params :fn)
|
||||
schema-n (c/get params :schema)
|
||||
wrap (c/get params :wrap)
|
||||
schema-1 (c/get params :schema-1)
|
||||
this? (c/get params :this false)
|
||||
|
||||
decode-expr
|
||||
(c/get params :decode/fn)
|
||||
@@ -258,11 +253,7 @@
|
||||
`(~decode-sym ~val-sym)
|
||||
`(~decode-sym ~val-sym ~decode-options))
|
||||
val-sym `(~coercer-sym ~val-sym)]
|
||||
[])
|
||||
|
||||
~fn-sym ~(if wrap
|
||||
`(~wrap-sym ~fn-sym)
|
||||
fn-sym)]
|
||||
[])]
|
||||
|
||||
~(if this?
|
||||
`(.call ~fn-sym ~this-sym ~this-sym ~val-sym)
|
||||
|
||||
@@ -391,7 +391,7 @@
|
||||
(group-by :code)
|
||||
(clj->js))
|
||||
(catch :default cause
|
||||
(ex/print-throwable cause))))))
|
||||
(errors/print-error! cause))))))
|
||||
|
||||
(defn ^:export validate-schema
|
||||
[]
|
||||
@@ -399,7 +399,7 @@
|
||||
(let [file (dsh/lookup-file @st/state)]
|
||||
(cfv/validate-file-schema! file))
|
||||
(catch :default cause
|
||||
(ex/print-throwable cause))))
|
||||
(errors/print-error! cause))))
|
||||
|
||||
(defn ^:export repair
|
||||
[reload?]
|
||||
@@ -431,7 +431,7 @@
|
||||
(when reload?
|
||||
(dom/reload-current-window)))
|
||||
(fn [cause]
|
||||
(ex/print-throwable cause)))))))))
|
||||
(errors/print-error! cause)))))))))
|
||||
|
||||
(defn ^:export fix-orphan-shapes
|
||||
[]
|
||||
|
||||
@@ -1,37 +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 frontend-tests.logic.update-position-test
|
||||
(:require
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.test-helpers.compositions :as ctho]
|
||||
[app.common.test-helpers.files :as cthf]
|
||||
[app.common.test-helpers.shapes :as cths]
|
||||
[app.main.data.workspace :as dw]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.state :as ths]))
|
||||
|
||||
(t/deftest test-update-positions-multiple-ids
|
||||
(t/async
|
||||
done
|
||||
(let [file (-> (cthf/sample-file :file1)
|
||||
(ctho/add-rect :rect1 :x 10 :y 20 :width 10 :height 10)
|
||||
(ctho/add-rect :rect2 :x 30 :y 40 :width 10 :height 10))
|
||||
store (ths/setup-store file)
|
||||
rect1 (cths/get-shape file :rect1)
|
||||
rect2 (cths/get-shape file :rect2)
|
||||
ids [(:id rect1) (:id rect2)]
|
||||
events [(dw/update-positions ids {:x 123.45})]]
|
||||
(ths/run-store
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [file' (ths/get-file-from-state new-state)
|
||||
rect1' (cths/get-shape file' :rect1)
|
||||
rect2' (cths/get-shape file' :rect2)
|
||||
x1 (-> rect1' :points grc/points->rect :x)
|
||||
x2 (-> rect2' :points grc/points->rect :x)]
|
||||
(t/is (= 123.45 x1))
|
||||
(t/is (= 123.45 x2))))))))
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
(ns frontend-tests.tokens.helpers.tokens
|
||||
(:require
|
||||
[app.common.files.tokens :as cfo]
|
||||
[app.common.files.tokens :as cft]
|
||||
[app.common.test-helpers.ids-map :as thi]
|
||||
[app.common.types.tokens-lib :as ctob]))
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
(let [first-page-id (get-in file [:data :pages 0])
|
||||
shape-id (thi/id shape-label)
|
||||
token (get-token file token-label)
|
||||
applied-attributes (cfo/attributes-map attributes token)]
|
||||
applied-attributes (cft/attributes-map attributes token)]
|
||||
(update-in file [:data
|
||||
:pages-index first-page-id
|
||||
:objects shape-id
|
||||
|
||||
@@ -57,7 +57,8 @@
|
||||
store (ths/setup-store file)
|
||||
tokens-lib (toht/get-tokens-lib file)
|
||||
set-a (ctob/get-set-by-name tokens-lib "Set A")
|
||||
events [(dwtl/rename-token-set set-a "Set A updated")]]
|
||||
events [(dwtl/update-token-set (ctob/rename set-a "Set A updated")
|
||||
"Set A updated")]]
|
||||
|
||||
(tohs/run-store-async
|
||||
store done events
|
||||
|
||||
@@ -1467,7 +1467,7 @@ msgid "errors.generic"
|
||||
msgstr "Something wrong has happened."
|
||||
|
||||
msgid "errors.unexpected-exception"
|
||||
msgstr "Unexpected error: %s"
|
||||
msgstr "Unexpected exception: %s"
|
||||
|
||||
#: src/app/main/errors.cljs:200
|
||||
msgid "errors.internal-assertion-error"
|
||||
|
||||
40
manage.sh
40
manage.sh
@@ -215,23 +215,6 @@ function build-frontend-bundle {
|
||||
echo ">> bundle frontend end";
|
||||
}
|
||||
|
||||
function build-mcp-bundle {
|
||||
echo ">> bundle mcp start";
|
||||
|
||||
mkdir -p ./bundles
|
||||
local version=$(print-current-version);
|
||||
local bundle_dir="./bundles/mcp";
|
||||
|
||||
build "mcp";
|
||||
|
||||
rm -rf $bundle_dir;
|
||||
mv ./mcp/dist $bundle_dir;
|
||||
echo $version > $bundle_dir/version.txt;
|
||||
put-license-file $bundle_dir;
|
||||
echo ">> bundle mcp end";
|
||||
}
|
||||
|
||||
|
||||
function build-backend-bundle {
|
||||
echo ">> bundle backend start";
|
||||
|
||||
@@ -326,16 +309,6 @@ function build-exporter-docker-image {
|
||||
popd;
|
||||
}
|
||||
|
||||
function build-mcp-docker-image {
|
||||
rsync -avr --delete ./bundles/mcp/ ./docker/images/bundle-mcp/;
|
||||
pushd ./docker/images;
|
||||
docker build \
|
||||
-t penpotapp/mcp:$CURRENT_BRANCH -t penpotapp/mcp:latest \
|
||||
--build-arg BUNDLE_PATH="./bundle-mcp/" \
|
||||
-f Dockerfile.mcp .;
|
||||
popd;
|
||||
}
|
||||
|
||||
function build-storybook-docker-image {
|
||||
rsync -avr --delete ./bundles/storybook/ ./docker/images/bundle-storybook/;
|
||||
pushd ./docker/images;
|
||||
@@ -373,7 +346,6 @@ function usage {
|
||||
echo "- build-frontend-docker-image Build frontend docker images."
|
||||
echo "- build-backend-docker-image Build backend docker images."
|
||||
echo "- build-exporter-docker-image Build exporter docker images."
|
||||
echo "- build-mcp-docker-image Build exporter docker images."
|
||||
echo "- build-storybook-docker-image Build storybook docker images."
|
||||
echo ""
|
||||
echo "- version Show penpot's version."
|
||||
@@ -425,7 +397,6 @@ case $1 in
|
||||
## production builds
|
||||
build-bundle)
|
||||
build-frontend-bundle;
|
||||
build-mcp-bundle;
|
||||
build-backend-bundle;
|
||||
build-exporter-bundle;
|
||||
build-storybook-bundle;
|
||||
@@ -435,10 +406,6 @@ case $1 in
|
||||
build-frontend-bundle;
|
||||
;;
|
||||
|
||||
build-mcp-bundle)
|
||||
build-mcp-bundle;
|
||||
;;
|
||||
|
||||
build-backend-bundle)
|
||||
build-backend-bundle;
|
||||
;;
|
||||
@@ -464,7 +431,6 @@ case $1 in
|
||||
build-frontend-docker-image
|
||||
build-backend-docker-image
|
||||
build-exporter-docker-image
|
||||
build-mcp-docker-image
|
||||
build-storybook-docker-image
|
||||
;;
|
||||
|
||||
@@ -479,11 +445,7 @@ case $1 in
|
||||
build-exporter-docker-image)
|
||||
build-exporter-docker-image
|
||||
;;
|
||||
|
||||
build-mcp-docker-image)
|
||||
build-mcp-docker-image
|
||||
;;
|
||||
|
||||
|
||||
build-storybook-docker-image)
|
||||
build-storybook-docker-image
|
||||
;;
|
||||
|
||||
11
mcp/.gitignore
vendored
11
mcp/.gitignore
vendored
@@ -1,11 +0,0 @@
|
||||
.idea
|
||||
node_modules
|
||||
dist
|
||||
*.bak
|
||||
*.orig
|
||||
temp
|
||||
*.tsbuildinfo
|
||||
|
||||
# Log files
|
||||
logs/
|
||||
*.log
|
||||
@@ -1,7 +0,0 @@
|
||||
*.md
|
||||
*.json
|
||||
python-scripts/
|
||||
.serena/
|
||||
|
||||
# auto-generated files
|
||||
mcp-server/data/api_types.yml
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.yml",
|
||||
"options": {
|
||||
"tabWidth": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"quoteProps": "as-needed",
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"printWidth": 120,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
1
mcp/.serena/.gitignore
vendored
1
mcp/.serena/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
/cache
|
||||
@@ -1,25 +0,0 @@
|
||||
# Code Style and Conventions
|
||||
|
||||
## General Principles
|
||||
- **Object-Oriented Design**: VERY IMPORTANT: Use idiomatic, object-oriented style with explicit abstractions
|
||||
- **Strategy Pattern**: Prefer explicitly typed interfaces over bare functions for non-trivial functionality
|
||||
- **Clean Architecture**: Tools implement a common interface for consistent registration and execution
|
||||
|
||||
## TypeScript Configuration
|
||||
- **Strict Mode**: All strict TypeScript options enabled
|
||||
- **Target**: ES2022
|
||||
- **Module System**: CommonJS
|
||||
- **Declaration Files**: Generated with source maps
|
||||
|
||||
## Naming Conventions
|
||||
- **Classes**: PascalCase (e.g., `ExeceuteCodeTool`, `PenpotMcpServer`)
|
||||
- **Interfaces**: PascalCase (e.g., `Tool`)
|
||||
- **Methods**: camelCase (e.g., `execute`, `registerTools`)
|
||||
- **Constants**: camelCase for readonly properties (e.g., `definition`)
|
||||
- **Files**: PascalCase for classes (e.g., `ExecuteCodeTool.ts`)
|
||||
|
||||
## Documentation Style
|
||||
- **JSDoc**: Use comprehensive JSDoc comments for classes, methods, and interfaces
|
||||
- **Description Format**: Initial elliptical phrase that defines *what* it is, followed by details
|
||||
- **Comment Style**: VERY IMPORTANT: Start with lowercase for comments of code blocks (unless lengthy explanation with multiple sentences)
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# Penpot MCP Project Overview - Updated
|
||||
|
||||
## Purpose
|
||||
This project is a Model Context Protocol (MCP) server for Penpot integration. It provides a TypeScript-based server that can be used to extend Penpot's functionality through custom tools with bidirectional WebSocket communication.
|
||||
|
||||
## Tech Stack
|
||||
- **Language**: TypeScript
|
||||
- **Runtime**: Node.js
|
||||
- **Framework**: MCP SDK (@modelcontextprotocol/sdk)
|
||||
- **Build Tool**: TypeScript Compiler (tsc) + esbuild
|
||||
- **Package Manager**: pnpm
|
||||
- **WebSocket**: ws library for real-time communication
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
penpot-mcp/
|
||||
├── common/ # Shared type definitions
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Exports for shared types
|
||||
│ │ └── types.ts # PluginTaskResult, request/response interfaces
|
||||
│ └── package.json # @penpot-mcp/common package
|
||||
├── mcp-server/ # Main MCP server implementation
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Main server entry point
|
||||
│ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation
|
||||
│ │ ├── PluginTask.ts # Now supports result promises
|
||||
│ │ ├── tasks/ # PluginTask implementations
|
||||
│ │ └── tools/ # Tool implementations
|
||||
│ └── package.json # Includes @penpot-mcp/common dependency
|
||||
├── penpot-plugin/ # Penpot plugin with response capability
|
||||
│ ├── src/
|
||||
│ │ ├── main.ts # Enhanced WebSocket handling with response forwarding
|
||||
│ │ └── plugin.ts # Now sends task responses back to server
|
||||
│ └── package.json # Includes @penpot-mcp/common dependency
|
||||
└── prepare-api-docs # Python project for the generation of API docs
|
||||
```
|
||||
|
||||
## Key Tasks
|
||||
|
||||
### Adding a new Tool
|
||||
|
||||
1. Implement the tool class in `mcp-server/src/tools/` following the `Tool` interface.
|
||||
IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally.
|
||||
2. Register the tool in `PenpotMcpServer`.
|
||||
|
||||
Look at `PrintTextTool` as an example.
|
||||
|
||||
Many tools are linked to tasks that are handled in the plugin, i.e. they have an associated `PluginTask` implementation in `mcp-server/src/tasks/`.
|
||||
|
||||
### Adding a new PluginTask
|
||||
|
||||
1. Implement the input data interface for the task in `common/src/types.ts`.
|
||||
2. Implement the `PluginTask` class in `mcp-server/src/tasks/`.
|
||||
3. Implement the corresponding task handler class in the plugin (`penpot-plugin/src/task-handlers/`).
|
||||
* In the success case, call `task.sendSuccess`.
|
||||
* In the failure case, just throw an exception, which will be handled centrally!
|
||||
* Look at `PrintTextTaskHandler` as an example.
|
||||
4. Register the task handler in `penpot-plugin/src/plugin.ts` in the `taskHandlers` list.
|
||||
|
||||
|
||||
## Key Components
|
||||
|
||||
### Enhanced WebSocket Protocol
|
||||
- **Request Format**: `{id: string, task: string, params: any}`
|
||||
- **Response Format**: `{id: string, result: {success: boolean, error?: string, data?: any}}`
|
||||
- **Request/Response Correlation**: Using unique UUIDs for task tracking
|
||||
- **Timeout Handling**: 30-second timeout with automatic cleanup
|
||||
- **Type Safety**: Shared definitions via @penpot-mcp/common package
|
||||
|
||||
### Core Classes
|
||||
- **PenpotMcpServer**: Enhanced with pending task tracking and response handling
|
||||
- **PluginTask**: Now creates result promises that resolve when plugin responds
|
||||
- **Tool implementations**: Now properly await task completion and report results
|
||||
- **Plugin handlers**: Send structured responses back to server
|
||||
|
||||
### New Features
|
||||
1. **Bidirectional Communication**: Plugin now responds with success/failure status
|
||||
2. **Task Result Promises**: Every executePluginTask() sets and returns a promise
|
||||
3. **Error Reporting**: Failed tasks properly report error messages to tools
|
||||
4. **Shared Type Safety**: Common package ensures consistency across projects
|
||||
5. **Timeout Protection**: Tasks don't hang indefinitely (30s limit)
|
||||
6. **Request Correlation**: Unique IDs match requests to responses
|
||||
|
||||
## Task Flow
|
||||
|
||||
```
|
||||
LLM Tool Call → MCP Server → WebSocket (Request) → Plugin → Penpot API
|
||||
↑ ↓
|
||||
Tool Response ← MCP Server ← WebSocket (Response) ← Plugin Result
|
||||
```
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# Suggested Commands
|
||||
|
||||
## Development Commands
|
||||
```bash
|
||||
# Navigate to MCP server directory
|
||||
cd penpot/mcp/server
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Build the TypeScript project
|
||||
pnpm run build
|
||||
|
||||
# Start the server (production)
|
||||
pnpm run start
|
||||
|
||||
# Start the server in development mode
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
## Testing and Development
|
||||
```bash
|
||||
# Run TypeScript compiler in watch mode
|
||||
pnpx tsc --watch
|
||||
|
||||
# Check TypeScript compilation without emitting files
|
||||
pnpx tsc --noEmit
|
||||
```
|
||||
|
||||
## Windows-Specific Commands
|
||||
```cmd
|
||||
# Directory navigation
|
||||
cd penpot/mcp/server
|
||||
dir # List directory contents
|
||||
type package.json # Display file contents
|
||||
|
||||
# Git operations
|
||||
git status
|
||||
git add .
|
||||
git commit -m "message"
|
||||
git push
|
||||
|
||||
# File operations
|
||||
copy src\file.ts backup\file.ts # Copy files
|
||||
del dist\* # Delete files
|
||||
mkdir new-directory # Create directory
|
||||
rmdir /s directory # Remove directory recursively
|
||||
```
|
||||
|
||||
## Project Structure Navigation
|
||||
```bash
|
||||
# Key directories
|
||||
cd penpot/mcp/server/src # Source code
|
||||
cd penpot/mcp/server/src/tools # Tool implementations
|
||||
cd penpot/mcp/server/src/interfaces # Type definitions
|
||||
cd penpot/mcp/server/dist # Compiled output
|
||||
```
|
||||
|
||||
## Common Utilities
|
||||
```cmd
|
||||
# Search for text in files
|
||||
findstr /s /i "HelloWorld" *.ts
|
||||
|
||||
# Find files by name
|
||||
dir /s /b *Tool.ts
|
||||
|
||||
# Process management
|
||||
tasklist | findstr node # Find Node.js processes
|
||||
taskkill /f /im node.exe # Kill Node.js processes
|
||||
```
|
||||
@@ -1,56 +0,0 @@
|
||||
# Task Completion Guidelines
|
||||
|
||||
## After Making Code Changes
|
||||
|
||||
### 1. Build and Test
|
||||
```bash
|
||||
cd mcp-server
|
||||
npm run build:full # or npm run build for faster bundling only
|
||||
```
|
||||
|
||||
### 2. Verify TypeScript Compilation
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
### 3. Test the Server
|
||||
```bash
|
||||
# Start in development mode to test changes
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 4. Code Quality Checks
|
||||
- Ensure all code follows the established conventions
|
||||
- Verify JSDoc comments are complete and accurate
|
||||
- Check that error handling is appropriate
|
||||
- Use clean imports WITHOUT file extensions (esbuild handles resolution)
|
||||
- Validate that tool interfaces are properly implemented
|
||||
|
||||
### 5. Integration Testing
|
||||
- Test tool registration in the main server
|
||||
- Verify MCP protocol compliance
|
||||
- Ensure tool definitions match implementation
|
||||
|
||||
## Before Committing Changes
|
||||
1. **Build Successfully**: `npm run build:full` completes without errors
|
||||
2. **No TypeScript Errors**: `npx tsc --noEmit` passes
|
||||
3. **Documentation Updated**: JSDoc comments reflect changes
|
||||
4. **Tool Registry Updated**: New tools added to `registerTools()` method
|
||||
5. **Interface Compliance**: All tools implement the `Tool` interface correctly
|
||||
|
||||
## File Organization
|
||||
- Place new tools in `src/tools/` directory
|
||||
- Update main server registration in `src/index.ts`
|
||||
- Follow existing naming conventions
|
||||
|
||||
## Common Patterns
|
||||
- All tools must implement the `Tool` interface
|
||||
- Use readonly properties for tool definitions
|
||||
- Include comprehensive error handling
|
||||
- Follow the established documentation style
|
||||
- Import WITHOUT file extensions (esbuild resolves them automatically)
|
||||
|
||||
## Build System
|
||||
- Uses esbuild for fast bundling and TypeScript for declarations
|
||||
- Import statements should omit file extensions entirely
|
||||
- IDE refactoring is safe - no extension-related build failures
|
||||
@@ -1,130 +0,0 @@
|
||||
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: |
|
||||
IMPORTANT: You use an idiomatic, object-oriented style.
|
||||
In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions
|
||||
rather than mere functions (i.e. use the strategy pattern, for example).
|
||||
|
||||
Comments:
|
||||
When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase
|
||||
clearly defines *what* it is. Any details then follow in subsequent sentences.
|
||||
|
||||
When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless
|
||||
the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is
|
||||
required for sentences).
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "penpot-mcp"
|
||||
|
||||
# list of mode names to that are always to be included in the set of active modes
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this setting overrides the global configuration.
|
||||
# Set this to [] to disable base modes for this project.
|
||||
# Set this to a list of mode names to always include the respective modes for this project.
|
||||
base_modes:
|
||||
|
||||
# list of mode names that are to be activated by default.
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
default_modes:
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
fixed_tools: []
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: utf-8
|
||||
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp
|
||||
# csharp_omnisharp dart elixir elm erlang
|
||||
# fortran fsharp go groovy haskell
|
||||
# java julia kotlin lua markdown
|
||||
# matlab nix pascal perl php
|
||||
# powershell python python_jedi r rego
|
||||
# ruby ruby_solargraph rust scala swift
|
||||
# terraform toml typescript typescript_vts vue
|
||||
# yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- typescript
|
||||
291
mcp/README.md
291
mcp/README.md
@@ -1,291 +0,0 @@
|
||||

|
||||
|
||||
# Penpot's Official MCP Server
|
||||
|
||||
Penpot integrates a LLM layer built on the Model Context Protocol
|
||||
(MCP) via Penpot's Plugin API to interact with a Penpot design
|
||||
file. Penpot's MCP server enables LLMs to perfom data queries,
|
||||
transformation and creation operations.
|
||||
|
||||
Penpot's MCP Server is unlike any other you've seen. You get
|
||||
design-to- design, code-to-design and design-code supercharged
|
||||
workflows.
|
||||
|
||||
|
||||
[](https://www.youtube.com/playlist?list=PLgcCPfOv5v57SKMuw1NmS0-lkAXevpn10)
|
||||
|
||||
|
||||
## Architecture
|
||||
|
||||
The **Penpot MCP Server** exposes tools to AI clients (LLMs), which
|
||||
support the retrieval of design data as well as the modification and
|
||||
creation of design elements. The MCP server communicates with Penpot
|
||||
via the dedicated **Penpot MCP Plugin**,
|
||||
which connects to the MCP server via WebSocket.
|
||||
This enables the LLM to carry out tasks in the context of a design file by
|
||||
executing code that leverages the Penpot Plugin API.
|
||||
The LLM is free to write and execute arbitrary code snippets
|
||||
within the Penpot Plugin environment to accomplish its tasks.
|
||||
|
||||

|
||||
|
||||
This repository thus contains not only the MCP server implementation itself
|
||||
but also the supporting Penpot MCP Plugin
|
||||
(see section [Repository Structure](#repository-structure) below).
|
||||
|
||||
## Demonstration
|
||||
|
||||
[](https://v32155.1blu.de/penpot/PenpotFest2025.mp4)
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
To use the Penpot MCP server, you must
|
||||
* run the MCP server and connect your AI client to it,
|
||||
* run the web server providing the Penpot MCP plugin, and
|
||||
* open the Penpot MCP plugin in Penpot and connect it to the MCP server.
|
||||
|
||||
Follow the steps below to enable the integration.
|
||||
|
||||
|
||||
### Prerequisites
|
||||
|
||||
The project requires [Node.js](https://nodejs.org/) (tested with v22.x
|
||||
with corepack).
|
||||
|
||||
Following the installation of Node.js, the tools `pnpm` and `npx`
|
||||
should be available in your terminal. For ensure corepack installed
|
||||
and enabled correctly, just execute the `./scripts/setup`.
|
||||
|
||||
It is also required to have `caddy` executeable in the path, it is
|
||||
used for start a local server for generate types documentation from
|
||||
the current branch. If you want to run it outside devenv where all
|
||||
dependencies are already provided, please download caddy from
|
||||
[here](https://caddyserver.com/download).
|
||||
|
||||
You should probably be using penpot devenv, where all this
|
||||
dependencies are already present and correctly setup. But nothing
|
||||
prevents you execute this outside of devenv if you satisfy the
|
||||
specified dependencies.
|
||||
|
||||
|
||||
### 1. Build & Launch the MCP Server and the Plugin Server
|
||||
|
||||
If it's your first execution, install the required dependencies:
|
||||
|
||||
```shell
|
||||
cd mcp/
|
||||
./scripts/setup
|
||||
```
|
||||
|
||||
Then build all components and start the two servers:
|
||||
|
||||
```shell
|
||||
pnpm run bootstrap
|
||||
```
|
||||
|
||||
This bootstrap command will:
|
||||
|
||||
* install dependencies for all components (`pnpm -r run install`)
|
||||
* build all components (`pnpm -r run build`)
|
||||
* start all components (`pnpm -r --parallel run start`)
|
||||
|
||||
If you want to have types scrapped from a remote repository, the best
|
||||
approach is executing the following:
|
||||
|
||||
```shell
|
||||
PENPOT_PLUGINS_API_DOC_URL=https://doc.plugins.penpot.app pnpm run build:types
|
||||
pnpm run bootstrap
|
||||
```
|
||||
|
||||
Or this, if you want skip build step bacause you have already have all
|
||||
build artifacts ready (per example from previous `bootstrap` command):
|
||||
|
||||
```
|
||||
PENPOT_PLUGINS_API_DOC_URL=https://doc.plugins.penpot.app pnpm run build:types
|
||||
pnpm run start
|
||||
```
|
||||
|
||||
If you want just to update the types definitions with the plugins api doc from the
|
||||
current branch:
|
||||
|
||||
```shell
|
||||
pnpm run build:types
|
||||
```
|
||||
|
||||
(That command will build plugins doc locally and will generate the types yaml from
|
||||
the locally build documentation)
|
||||
|
||||
### 2. Load the Plugin in Penpot and Establish the Connection
|
||||
|
||||
> [!NOTE]
|
||||
> **Browser Connectivity Restrictions**
|
||||
>
|
||||
> Starting with Chromium version 142, the private network access (PNA) restrictions have been hardened,
|
||||
> and when connecting to `localhost` from a web application served from a different origin
|
||||
> (such as https://design.penpot.app), the connection must explicitly be allowed.
|
||||
>
|
||||
> Most Chromium-based browsers (e.g. Chrome, Vivaldi) will display a popup requesting permission
|
||||
> to access the local network. Be sure to approve the request to allow the connection.
|
||||
>
|
||||
> Some browsers take additional security measures, and you may need to disable them.
|
||||
> For example, in Brave, disable the "Shield" for the Penpot website to allow local network access.
|
||||
>
|
||||
> If your browser refuses to connect to the locally served plugin, check its configuration or
|
||||
> try a different browser (e.g. Firefox) that does not enforce these restrictions.
|
||||
|
||||
1. Open Penpot in your browser
|
||||
2. Navigate to a design file
|
||||
3. Open the Plugins menu
|
||||
4. Load the plugin using the development URL (`http://localhost:4400/manifest.json` by default)
|
||||
5. Open the plugin UI
|
||||
6. In the plugin UI, click "Connect to MCP server".
|
||||
The connection status should change from "Not connected" to "Connected to MCP server".
|
||||
(Check the browser's developer console for WebSocket connection logs.
|
||||
Check the MCP server terminal for WebSocket connection messages.)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Do not close the plugin's UI while using the MCP server, as this will close the connection.
|
||||
|
||||
### 3. Connect an MCP Client
|
||||
|
||||
By default, the server runs on port 4401 and provides:
|
||||
|
||||
- **Modern Streamable HTTP endpoint**: `http://localhost:4401/mcp`
|
||||
- **Legacy SSE endpoint**: `http://localhost:4401/sse`
|
||||
|
||||
These endpoints can be used directly by MCP clients that support them.
|
||||
Simply configure the client to connect the MCP server by providing the respective URL.
|
||||
|
||||
When using a client that only supports stdio transport,
|
||||
a proxy like `mcp-remote` is required.
|
||||
|
||||
#### Using a Proxy for stdio Transport
|
||||
|
||||
NOTE: only relevant if you are executing this outside of devenv
|
||||
|
||||
The `mcp-remote` package can proxy stdio transport to HTTP/SSE,
|
||||
allowing clients that support only stdio to connect to the MCP server indirectly.
|
||||
|
||||
1. Install `mcp-remote` globally if you haven't already:
|
||||
|
||||
npm install -g mcp-remote
|
||||
|
||||
2. Use `mcp-remote` to provide the launch command for your MCP client:
|
||||
|
||||
npx -y mcp-remote http://localhost:4401/sse --allow-http
|
||||
|
||||
#### Example: Claude Desktop
|
||||
|
||||
For Windows and macOS, there is the official [Claude Desktop app](https://claude.ai/download), which you can use as an MCP client.
|
||||
For Linux, there is an [unofficial community version](https://github.com/aaddrick/claude-desktop-debian).
|
||||
|
||||
Since Claude Desktop natively supports only stdio transport, you will need to use a proxy like `mcp-remote`.
|
||||
Install it as described above.
|
||||
|
||||
To add the server to Claude Desktop's configuration, locate the configuration file (or find it via Menu / File / Settings / Developer):
|
||||
|
||||
- **Windows**: `%APPDATA%/Claude/claude_desktop_config.json`
|
||||
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
- **Linux**: `~/.config/Claude/claude_desktop_config.json`
|
||||
|
||||
Add a `penpot` entry under `mcpServers` with the following content:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"penpot": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-remote", "http://localhost:4401/sse", "--allow-http"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After updating the configuration file, restart Claude Desktop completely for the changes to take effect.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Be sure to fully quit the app for the changes to take effect; closing the window is *not* sufficient.
|
||||
> To fully terminate the app, choose Menu / File / Quit.
|
||||
|
||||
After the restart, you should see the MCP server listed when clicking on the "Search and tools" icon at the bottom
|
||||
of the prompt input area.
|
||||
|
||||
#### Example: Claude Code
|
||||
|
||||
To add the Penpot MCP server to a Claude Code project, issue the command
|
||||
|
||||
claude mcp add penpot -t http http://localhost:4401/mcp
|
||||
|
||||
## Repository Structure
|
||||
|
||||
This repository is a monorepo containing four main components:
|
||||
|
||||
1. **Common Types** (`common/`):
|
||||
- Shared TypeScript definitions for request/response protocol
|
||||
- Ensures type safety across server and plugin components
|
||||
|
||||
2. **Penpot MCP Server** (`mcp-server/`):
|
||||
- Provides MCP tools to LLMs for Penpot interaction
|
||||
- Runs a WebSocket server accepting connections from the Penpot MCP plugin
|
||||
- Implements request/response correlation with unique task IDs
|
||||
- Handles task timeouts and proper error reporting
|
||||
|
||||
3. **Penpot MCP Plugin** (`penpot-plugin/`):
|
||||
- Connects to the MCP server via WebSocket
|
||||
- Executes tasks in Penpot using the Plugin API
|
||||
- Sends structured responses back to the server#
|
||||
|
||||
4. **Helper Scripts** (`python-scripts/`):
|
||||
- Python scripts that prepare data for the MCP server (development use)
|
||||
|
||||
The core components are written in TypeScript, rendering interactions with the
|
||||
Penpot Plugin API both natural and type-safe.
|
||||
|
||||
## Configuration
|
||||
|
||||
The Penpot MCP server can be configured using environment variables. All configuration
|
||||
options use the `PENPOT_MCP_` prefix for consistency.
|
||||
|
||||
### Server Configuration
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
|------------------------------------|----------------------------------------------------------------------------|--------------|
|
||||
| `PENPOT_MCP_SERVER_LISTEN_ADDRESS` | Address on which the MCP server listens (binds to) | `localhost` |
|
||||
| `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` |
|
||||
| `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` |
|
||||
| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` |
|
||||
| `PENPOT_MCP_SERVER_ADDRESS` | Hostname or IP address via which clients can reach the MCP server | `localhost` |
|
||||
| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` |
|
||||
|
||||
### Logging Configuration
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
|------------------------|------------------------------------------------------|----------|
|
||||
| `PENPOT_MCP_LOG_LEVEL` | Log level: `trace`, `debug`, `info`, `warn`, `error` | `info` |
|
||||
| `PENPOT_MCP_LOG_DIR` | Directory for log files | `logs` |
|
||||
|
||||
### Plugin Server Configuration
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
|-------------------------------------------|-----------------------------------------------------------------------------------------|--------------|
|
||||
| `PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS` | Address on which the plugin web server listens (single address or comma-separated list) | (local only) |
|
||||
|
||||
## Beyond Local Execution
|
||||
|
||||
The above instructions describe how to run the MCP server and plugin server locally.
|
||||
We are working on enabling remote deployments of the MCP server, particularly
|
||||
in [multi-user mode](docs/multi-user-mode.md), where multiple Penpot users will
|
||||
be able to connect to the same MCP server instance.
|
||||
|
||||
To run the server remotely (even for a single user),
|
||||
you may set the following environment variables to configure the two servers
|
||||
(MCP server & plugin server) appropriately:
|
||||
* `PENPOT_MCP_REMOTE_MODE=true`: This ensures that the MCP server is operating
|
||||
in remote mode, with local file system access disabled.
|
||||
* `PENPOT_MCP_SERVER_LISTEN_ADDRESS` and `PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS`:
|
||||
Set these according to your requirements for remote connectivity.
|
||||
To bind all interfaces, use `0.0.0.0` (use caution in untrusted networks).
|
||||
* `PENPOT_MCP_SERVER_ADDRESS=<your-address>`: This sets the hostname or IP address
|
||||
where the MCP server can be reached. The Penpot MCP Plugin uses this to construct
|
||||
the WebSocket URL as `ws://<your-address>:<port>` (default port: `4402`).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user