0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-04-15 16:31:25 -05:00

Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Alejandro Alonso 2024-06-25 10:18:42 +02:00
commit d48616d510
45 changed files with 1348 additions and 352 deletions

View file

@ -56,6 +56,9 @@
- Workspace-palette items stay hidden when opening with keyboard-shortcut [Taiga #7489](https://tree.taiga.io/project/penpot/issue/7489)
- Fix SVG attrs are not handled correctly when exporting/importing in .zip [Taiga #7920](https://tree.taiga.io/project/penpot/issue/7920)
- Fix validation error when detaching with two nested copies and a swap [Taiga #8095](https://tree.taiga.io/project/penpot/issue/8095)
- Export shapes that are rotated act a bit strange when reimported [Taiga #7585](https://tree.taiga.io/project/penpot/issue/7585)
- Penpot crashes when a new colorpicker is created while uploading an image to another instance [Taiga #8119](https://tree.taiga.io/project/penpot/issue/8119)
- Removing Underline and Strikethrough Affects the Previous Text Object [Taiga #8103](https://tree.taiga.io/project/penpot/issue/8103)
## 2.0.3

View file

@ -32,6 +32,7 @@
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]
[ring.request :as rreq]
[ring.response :as-alias rres]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -470,6 +471,9 @@
(some? (:invitation-token state))
(assoc :invitation-token (:invitation-token state))
(some? (:external-session-id state))
(assoc :external-session-id (:external-session-id state))
;; If state token comes with props, merge them. The state token
;; props can contain pm_ and utm_ prefixed query params.
(map? (:props state))
@ -554,19 +558,22 @@
(redirect-to-register cfg info request))
:else
(let [sxf (session/create-fn cfg (:id profile))
token (or (:invitation-token info)
(tokens/generate (::setup/props cfg)
{:iss :auth
:exp (dt/in-future "15m")
:props (:props info)
:profile-id (:id profile)}))]
(let [sxf (session/create-fn cfg (:id profile))
token (or (:invitation-token info)
(tokens/generate (::setup/props cfg)
{:iss :auth
:exp (dt/in-future "15m")
:props (:props info)
:profile-id (:id profile)}))
props (audit/profile->props profile)
context (d/without-nils {:external-session-id (:external-session-id info)})]
(audit/submit! cfg {::audit/type "command"
::audit/name "login-with-oidc"
::audit/profile-id (:id profile)
::audit/ip-addr (audit/parse-client-ip request)
::audit/props (audit/profile->props profile)})
::audit/props props
::audit/context context})
(->> (redirect-to-verify-token token)
(sxf request))))
@ -588,9 +595,11 @@
(defn- auth-handler
[cfg {:keys [params] :as request}]
(let [props (audit/extract-utm-params params)
esid (rreq/get-header request "x-external-session-id")
state (tokens/generate (::setup/props cfg)
{:iss :oauth
:invitation-token (:invitation-token params)
:external-session-id esid
:props props
:exp (dt/in-future "4h")})
uri (build-auth-uri cfg state)]

View file

@ -86,6 +86,13 @@
(remove #(contains? reserved-props (key %))))
props))
(defn params->context
"Extract default context properties from RPC params object"
[params]
(d/without-nils
{:external-session-id (::rpc/external-session-id params)
:triggered-by (::rpc/handler-name params)}))
;; --- SPECS
@ -140,7 +147,7 @@
(::rpc/profile-id params)
uuid/zero)
session-id (rreq/get-header request "x-external-session-id")
session-id (get params ::rpc/external-session-id)
props (-> (or (::replace-props resultm)
(-> params
(merge (::props resultm))

View file

@ -102,13 +102,13 @@
{::mdef/name "penpot_tasks_timing"
::mdef/help "Background tasks timing (milliseconds)."
::mdef/labels ["name"]
::mdef/type :summary}
::mdef/type :histogram}
:redis-eval-timing
{::mdef/name "penpot_redis_eval_timing"
::mdef/help "Redis EVAL commands execution timings (ms)"
::mdef/labels ["name"]
::mdef/type :summary}
::mdef/type :histogram}
:rpc-climit-queue
{::mdef/name "penpot_rpc_climit_queue"
@ -126,7 +126,7 @@
{::mdef/name "penpot_rpc_climit_timing"
::mdef/help "Summary of the time between queuing and executing on the CLIMIT"
::mdef/labels ["name"]
::mdef/type :summary}
::mdef/type :histogram}
:audit-http-handler-queue-size
{::mdef/name "penpot_audit_http_handler_queue_size"
@ -144,7 +144,7 @@
{::mdef/name "penpot_audit_http_handler_timing"
::mdef/help "Summary of the time between queuing and executing on the audit log http handler"
::mdef/labels []
::mdef/type :summary}
::mdef/type :histogram}
:executors-active-threads
{::mdef/name "penpot_executors_active_threads"

View file

@ -79,8 +79,12 @@
profile-id (or (::session/profile-id request)
(::actoken/profile-id request))
session-id (rreq/get-header request "x-external-session-id")
data (-> params
(assoc ::handler-name handler-name)
(assoc ::request-at (dt/now))
(assoc ::external-session-id session-id)
(assoc ::session/id (::session/id request))
(assoc ::cond/key etag)
(cond-> (uuid? profile-id)
@ -188,10 +192,10 @@
(defn- wrap-all
[cfg f mdata]
(as-> f $
(wrap-metrics cfg $ mdata)
(cond/wrap cfg $ mdata)
(retry/wrap-retry cfg $ mdata)
(climit/wrap cfg $ mdata)
(wrap-metrics cfg $ mdata)
(rlimit/wrap cfg $ mdata)
(wrap-audit cfg $ mdata)
(wrap-spec-conform cfg $ mdata)

View file

@ -321,18 +321,14 @@
(sv/defmethod ::delete-file-object-thumbnail
{::doc/added "1.19"
::doc/module :files
::doc/deprecated "1.20"
::climit/id [[:file-thumbnail-ops/by-profile ::rpc/profile-id]
[:file-thumbnail-ops/global]]
::audit/skip true}
[cfg {:keys [::rpc/profile-id file-id object-id]}]
(files/check-edition-permissions! cfg profile-id file-id)
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-edition-permissions! conn profile-id file-id)
(when-not (db/read-only? conn)
(-> cfg
(update ::sto/storage media/configure-assets-storage conn)
(delete-file-object-thumbnail! file-id object-id))
nil))))
(-> cfg
(update ::sto/storage media/configure-assets-storage conn)
(delete-file-object-thumbnail! file-id object-id))
nil)))
;; --- MUTATION COMMAND: create-file-thumbnail

View file

@ -16,6 +16,7 @@
[app.config :as cf]
[app.db :as db]
[app.http.sse :as sse]
[app.loggers.audit :as audit]
[app.loggers.webhooks :as-alias webhooks]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
@ -397,17 +398,32 @@
;; --- COMMAND: Clone Template
(defn- clone-template
[{:keys [::wrk/executor ::bf.v1/project-id] :as cfg} template]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
[cfg {:keys [project-id ::rpc/profile-id] :as params} template]
(db/tx-run! cfg (fn [{:keys [::db/conn ::wrk/executor] :as cfg}]
;; NOTE: the importation process performs some operations that
;; are not very friendly with virtual threads, and for avoid
;; unexpected blocking of other concurrent operations we
;; dispatch that operation to a dedicated executor.
(let [result (px/submit! executor (partial bf.v1/import-files! cfg template))]
(let [cfg (-> cfg
(assoc ::bf.v1/project-id project-id)
(assoc ::bf.v1/profile-id profile-id))
result (px/invoke! executor (partial bf.v1/import-files! cfg template))]
(db/update! conn :project
{:modified-at (dt/now)}
{:id project-id})
(deref result)))))
(let [props (audit/clean-props params)
context (audit/params->context params)]
(doseq [file-id result]
(audit/submit! cfg
{::audit/type "action"
::audit/name "create-file"
::audit/profile-id profile-id
::audit/props (assoc props :id file-id)
::audit/context context})))
result))))
(def ^:private
schema:clone-template
@ -425,16 +441,14 @@
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id template-id] :as params}]
(let [project (db/get-by-id pool :project project-id {:columns [:id :team-id]})
_ (teams/check-edition-permissions! pool profile-id (:team-id project))
template (tmpl/get-template-stream cfg template-id)
params (-> cfg
(assoc ::bf.v1/project-id (:id project))
(assoc ::bf.v1/profile-id profile-id))]
template (tmpl/get-template-stream cfg template-id)]
(when-not template
(ex/raise :type :not-found
:code :template-not-found
:hint "template not found"))
(sse/response #(clone-template params template))))
(sse/response #(clone-template cfg params template))))
;; --- COMMAND: Get list of builtin templates

View file

@ -763,6 +763,7 @@
{:id (:id member)}))
nil)
(let [id (uuid/next)
expire (dt/in-future "168h") ;; 7 days
invitation (db/exec-one! conn [sql:upsert-team-invitation id
@ -783,14 +784,19 @@
(when (contains? cf/flags :log-invitation-tokens)
(l/info :hint "invitation token" :token itoken))
(audit/submit! cfg
{::audit/type "action"
::audit/name (if updated?
"update-team-invitation"
"create-team-invitation")
::audit/profile-id (:id profile)
::audit/props (-> (dissoc tprops :profile-id)
(d/without-nils))})
(let [props (-> (dissoc tprops :profile-id)
(audit/clean-props))
context (audit/params->context params)]
(audit/submit! cfg
{::audit/type "action"
::audit/name (if updated?
"update-team-invitation"
"create-team-invitation")
::audit/profile-id (:id profile)
::audit/props props
::audit/context context}))
(eml/send! {::eml/conn conn
::eml/factory eml/invite-to-team
@ -850,10 +856,11 @@
;; We don't re-send inviation to already existing members
(remove (partial contains? members))
(map (fn [email]
{:email email
:team team
:profile profile
:role role}))
(-> params
(assoc :email email)
(assoc :team team)
(assoc :profile profile)
(assoc :role role))))
(keep (partial create-invitation cfg)))
emails)]
(with-meta {:total (count invitations)
@ -879,9 +886,11 @@
(let [features (-> (cfeat/get-enabled-features cf/flags)
(cfeat/check-client-features! (:features params)))
params (assoc params
:profile-id profile-id
:features features)
params (-> params
(assoc :profile-id profile-id)
(assoc :features features))
cfg (assoc cfg ::db/conn conn)
team (create-team cfg params)
profile (db/get-by-id conn :profile profile-id)
@ -890,10 +899,11 @@
;; Create invitations for all provided emails.
(->> emails
(map (fn [email]
{:team team
:profile profile
:email email
:role role}))
(-> params
(assoc :team team)
(assoc :profile profile)
(assoc :email email)
(assoc :role role))))
(run! (partial create-invitation cfg)))
(run! (partial quotes/check-quote! conn)

View file

@ -801,15 +801,6 @@
(update :undo-changes conj {:type :del-component
:id id
:main-instance main-instance})))
(defn ignore-remote
[changes]
(letfn [(add-ignore-remote
[change-list]
(->> change-list
(mapv #(assoc % :ignore-remote? true))))]
(-> changes
(update :redo-changes add-ignore-remote)
(update :undo-changes add-ignore-remote))))
(defn reorder-grid-children
[changes ids]

View file

@ -473,9 +473,14 @@
(defn setup-rect
"Initializes the selrect and points for a shape."
[{:keys [selrect points] :as shape}]
(let [selrect (or selrect (gsh/shape->rect shape))
points (or points (grc/rect->points selrect))]
[{:keys [selrect points transform] :as shape}]
(let [selrect (or selrect (gsh/shape->rect shape))
center (grc/rect->center selrect)
transform (or transform (gmt/matrix))
points (or points
(-> selrect
(grc/rect->points)
(gsh/transform-points center transform)))]
(-> shape
(assoc :selrect selrect)
(assoc :points points))))

3
frontend/.gitignore vendored
View file

@ -10,5 +10,4 @@ node_modules/
/playwright-report/
/blob-report/
/playwright/.cache/
visual-dashboard.spec.js-snapshots
/playwright/**/visual-specs/**/*.png

View file

@ -0,0 +1,31 @@
{
"~:id": "~u015fda4f-caa6-8103-8004-862a9e4b4d4b",
"~:file-id": "~u015fda4f-caa6-8103-8004-862a00dd4f31",
"~:created-at": "~m1718718436639",
"~:content": {
"~ue117f7f6-433c-807e-8004-862a38e1823d": {
"~:id": "~ue117f7f6-433c-807e-8004-862a38e1823d",
"~:name": "Button",
"~:path": "",
"~:modified-at": "~m1718718335855",
"~:main-instance-id": "~ue117f7f6-433c-807e-8004-862a38e0099a",
"~:main-instance-page": "~u015fda4f-caa6-8103-8004-862a00ddbe94"
},
"~ue117f7f6-433c-807e-8004-862a51a90ef5": {
"~:id": "~ue117f7f6-433c-807e-8004-862a51a90ef5",
"~:name": "Badge",
"~:path": "",
"~:modified-at": "~m1718718361245",
"~:main-instance-id": "~ue117f7f6-433c-807e-8004-862a51a84a91",
"~:main-instance-page": "~u015fda4f-caa6-8103-8004-862a00ddbe94"
},
"~ue117f7f6-433c-807e-8004-862a9b541a46": {
"~:id": "~ue117f7f6-433c-807e-8004-862a9b541a46",
"~:name": "Avatar",
"~:path": "",
"~:modified-at": "~m1718718436652",
"~:main-instance-id": "~ue117f7f6-433c-807e-8004-862a9b5374b6",
"~:main-instance-page": "~u015fda4f-caa6-8103-8004-862a00ddbe94"
}
}
}

View file

@ -0,0 +1,630 @@
{
"~:id": "~u015fda4f-caa6-8103-8004-862a9e4ad279",
"~:file-id": "~u015fda4f-caa6-8103-8004-862a00dd4f31",
"~:created-at": "~m1718718436639",
"~:content": {
"~:options": {},
"~:objects": {
"~u00000000-0000-0000-0000-000000000000": {
"~#shape": {
"~:y": 0,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:name": "Root Frame",
"~:width": 0.01,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 0,
"~:y": 0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0
}
},
{
"~#point": {
"~:x": 0.01,
"~:y": 0.01
}
},
{
"~#point": {
"~:x": 0,
"~:y": 0.01
}
}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~u00000000-0000-0000-0000-000000000000",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 0,
"~:proportion": 1.0,
"~:selrect": {
"~#rect": {
"~:x": 0,
"~:y": 0,
"~:width": 0.01,
"~:height": 0.01,
"~:x1": 0,
"~:y1": 0,
"~:x2": 0.01,
"~:y2": 0.01
}
},
"~:fills": [
{
"~:fill-color": "#FFFFFF",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 0.01,
"~:flip-y": null,
"~:shapes": [
"~ue117f7f6-433c-807e-8004-862a38e0099a",
"~ue117f7f6-433c-807e-8004-862a51a84a91",
"~ue117f7f6-433c-807e-8004-862a9b5374b6"
]
}
},
"~ue117f7f6-433c-807e-8004-862a18bba46f": {
"~#shape": {
"~:y": 220,
"~:rx": 0,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:hide-in-viewer": false,
"~:name": "Button",
"~:width": 120,
"~:type": "~:rect",
"~:points": [
{
"~#point": {
"~:x": 663,
"~:y": 220
}
},
{
"~#point": {
"~:x": 783,
"~:y": 220
}
},
{
"~#point": {
"~:x": 783,
"~:y": 274
}
},
{
"~#point": {
"~:x": 663,
"~:y": 274
}
}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:constraints-v": "~:scale",
"~:constraints-h": "~:scale",
"~:id": "~ue117f7f6-433c-807e-8004-862a18bba46f",
"~:parent-id": "~ue117f7f6-433c-807e-8004-862a38e0099a",
"~:frame-id": "~ue117f7f6-433c-807e-8004-862a38e0099a",
"~:strokes": [],
"~:x": 663,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 663,
"~:y": 220,
"~:width": 120,
"~:height": 54,
"~:x1": 663,
"~:y1": 220,
"~:x2": 783,
"~:y2": 274
}
},
"~:fills": [
{
"~:fill-color": "#B1B2B5",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:ry": 0,
"~:height": 54,
"~:flip-y": null
}
},
"~ue117f7f6-433c-807e-8004-862a38e0099a": {
"~#shape": {
"~:y": 220,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:hide-in-viewer": true,
"~:name": "Button",
"~:width": 120,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 663,
"~:y": 220
}
},
{
"~#point": {
"~:x": 783,
"~:y": 220
}
},
{
"~#point": {
"~:x": 783,
"~:y": 274
}
},
{
"~#point": {
"~:x": 663,
"~:y": 274
}
}
],
"~:component-root": true,
"~:show-content": true,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~ue117f7f6-433c-807e-8004-862a38e0099a",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:component-id": "~ue117f7f6-433c-807e-8004-862a38e1823d",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 663,
"~:main-instance": true,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 663,
"~:y": 220,
"~:width": 120,
"~:height": 54,
"~:x1": 663,
"~:y1": 220,
"~:x2": 783,
"~:y2": 274
}
},
"~:fills": [],
"~:flip-x": null,
"~:height": 54,
"~:component-file": "~u015fda4f-caa6-8103-8004-862a00dd4f31",
"~:flip-y": null,
"~:shapes": [
"~ue117f7f6-433c-807e-8004-862a18bba46f"
]
}
},
"~ue117f7f6-433c-807e-8004-862a40b7caca": {
"~#shape": {
"~:y": 188,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:hide-in-viewer": false,
"~:name": "Badge",
"~:width": 61,
"~:type": "~:circle",
"~:points": [
{
"~#point": {
"~:x": 860,
"~:y": 188
}
},
{
"~#point": {
"~:x": 921,
"~:y": 188
}
},
{
"~#point": {
"~:x": 921,
"~:y": 247
}
},
{
"~#point": {
"~:x": 860,
"~:y": 247
}
}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:constraints-v": "~:scale",
"~:constraints-h": "~:scale",
"~:id": "~ue117f7f6-433c-807e-8004-862a40b7caca",
"~:parent-id": "~ue117f7f6-433c-807e-8004-862a51a84a91",
"~:frame-id": "~ue117f7f6-433c-807e-8004-862a51a84a91",
"~:strokes": [],
"~:x": 860,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 860,
"~:y": 188,
"~:width": 61,
"~:height": 59,
"~:x1": 860,
"~:y1": 188,
"~:x2": 921,
"~:y2": 247
}
},
"~:fills": [
{
"~:fill-color": "#7798ff",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:height": 59,
"~:flip-y": null
}
},
"~ue117f7f6-433c-807e-8004-862a51a84a91": {
"~#shape": {
"~:y": 188,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:hide-in-viewer": true,
"~:name": "Badge",
"~:width": 61,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 860,
"~:y": 188
}
},
{
"~#point": {
"~:x": 921,
"~:y": 188
}
},
{
"~#point": {
"~:x": 921,
"~:y": 247
}
},
{
"~#point": {
"~:x": 860,
"~:y": 247
}
}
],
"~:component-root": true,
"~:show-content": true,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~ue117f7f6-433c-807e-8004-862a51a84a91",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:component-id": "~ue117f7f6-433c-807e-8004-862a51a90ef5",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 860,
"~:main-instance": true,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 860,
"~:y": 188,
"~:width": 61,
"~:height": 59,
"~:x1": 860,
"~:y1": 188,
"~:x2": 921,
"~:y2": 247
}
},
"~:fills": [],
"~:flip-x": null,
"~:height": 59,
"~:component-file": "~u015fda4f-caa6-8103-8004-862a00dd4f31",
"~:flip-y": null,
"~:shapes": [
"~ue117f7f6-433c-807e-8004-862a40b7caca"
]
}
},
"~ue117f7f6-433c-807e-8004-862a8c166257": {
"~#shape": {
"~:y": 97,
"~:rx": 0,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:grow-type": "~:fixed",
"~:hide-in-viewer": false,
"~:name": "Avatar",
"~:width": 66,
"~:type": "~:rect",
"~:points": [
{
"~#point": {
"~:x": 554,
"~:y": 97
}
},
{
"~#point": {
"~:x": 620,
"~:y": 97
}
},
{
"~#point": {
"~:x": 620,
"~:y": 163
}
},
{
"~#point": {
"~:x": 554,
"~:y": 163
}
}
],
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:constraints-v": "~:scale",
"~:constraints-h": "~:scale",
"~:id": "~ue117f7f6-433c-807e-8004-862a8c166257",
"~:parent-id": "~ue117f7f6-433c-807e-8004-862a9b5374b6",
"~:frame-id": "~ue117f7f6-433c-807e-8004-862a9b5374b6",
"~:strokes": [],
"~:x": 554,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 554,
"~:y": 97,
"~:width": 66,
"~:height": 66,
"~:x1": 554,
"~:y1": 97,
"~:x2": 620,
"~:y2": 163
}
},
"~:fills": [
{
"~:fill-color": "#ff6ffc",
"~:fill-opacity": 1
}
],
"~:flip-x": null,
"~:ry": 0,
"~:height": 66,
"~:flip-y": null
}
},
"~ue117f7f6-433c-807e-8004-862a9b5374b6": {
"~#shape": {
"~:y": 97,
"~:hide-fill-on-export": false,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:rotation": 0,
"~:hide-in-viewer": true,
"~:name": "Avatar",
"~:width": 66,
"~:type": "~:frame",
"~:points": [
{
"~#point": {
"~:x": 554,
"~:y": 97
}
},
{
"~#point": {
"~:x": 620,
"~:y": 97
}
},
{
"~#point": {
"~:x": 620,
"~:y": 163
}
},
{
"~#point": {
"~:x": 554,
"~:y": 163
}
}
],
"~:component-root": true,
"~:show-content": true,
"~:proportion-lock": false,
"~:transform-inverse": {
"~#matrix": {
"~:a": 1.0,
"~:b": 0.0,
"~:c": 0.0,
"~:d": 1.0,
"~:e": 0.0,
"~:f": 0.0
}
},
"~:id": "~ue117f7f6-433c-807e-8004-862a9b5374b6",
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
"~:component-id": "~ue117f7f6-433c-807e-8004-862a9b541a46",
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
"~:strokes": [],
"~:x": 554,
"~:main-instance": true,
"~:proportion": 1,
"~:selrect": {
"~#rect": {
"~:x": 554,
"~:y": 97,
"~:width": 66,
"~:height": 66,
"~:x1": 554,
"~:y1": 97,
"~:x2": 620,
"~:y2": 163
}
},
"~:fills": [],
"~:flip-x": null,
"~:height": 66,
"~:component-file": "~u015fda4f-caa6-8103-8004-862a00dd4f31",
"~:flip-y": null,
"~:shapes": [
"~ue117f7f6-433c-807e-8004-862a8c166257"
]
}
}
},
"~:id": "~u015fda4f-caa6-8103-8004-862a00ddbe94",
"~:name": "Page 1"
}
}

View file

@ -0,0 +1,105 @@
{
"~:features":{
"~#set":[
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:permissions":{
"~:type":"~:membership",
"~:is-owner":true,
"~:is-admin":true,
"~:can-edit":true,
"~:can-read":true,
"~:is-logged":true
},
"~:has-media-trimmed":false,
"~:comment-thread-seqn":0,
"~:name":"Lorem ipsum",
"~:revn":14,
"~:modified-at":"~m1718718464651",
"~:id":"~u015fda4f-caa6-8103-8004-862a00dd4f31",
"~:is-shared":false,
"~:version":49,
"~:project-id":"~u0515a066-e303-8169-8004-73eb401b5d55",
"~:created-at":"~m1718718275492",
"~:data":{
"~:colors":{
"~ue117f7f6-433c-807e-8004-862aa7732f9c":{
"~:path":"",
"~:color":"#ff6ffc",
"~:name":"Rosita",
"~:modified-at":"~m1718718452317",
"~:opacity":1,
"~:id":"~ue117f7f6-433c-807e-8004-862aa7732f9c"
},
"~ue117f7f6-433c-807e-8004-862ab306fa2b":{
"~:path":"",
"~:color":"#7798ff",
"~:name":"#7798ff",
"~:modified-at":"~m1718718461420",
"~:opacity":1,
"~:id":"~ue117f7f6-433c-807e-8004-862ab306fa2b"
}
},
"~:typographies":{
"~ue117f7f6-433c-807e-8004-862ab6ae29d8":{
"~:line-height":"1.2",
"~:font-style":"normal",
"~:text-transform":"none",
"~:font-id":"sourcesanspro",
"~:font-size":"14",
"~:font-weight":"400",
"~:name":"Source Sans Pro Regular",
"~:modified-at":"~m1718718464655",
"~:font-variant-id":"regular",
"~:id":"~ue117f7f6-433c-807e-8004-862ab6ae29d8",
"~:letter-spacing":"0",
"~:font-family":"sourcesanspro"
}
},
"~:pages":[
"~u015fda4f-caa6-8103-8004-862a00ddbe94"
],
"~:components":{
"~#penpot/pointer":[
"~u015fda4f-caa6-8103-8004-862a9e4b4d4b",
{
"~:created-at":"~m1718718436653"
}
]
},
"~:id":"~u015fda4f-caa6-8103-8004-862a00dd4f31",
"~:options":{
"~:components-v2":true
},
"~:recent-colors":[
{
"~:color":"#b5b1b4",
"~:opacity":1
},
{
"~:color":"#ff6ffc",
"~:opacity":1
},
{
"~:color":"#7798ff",
"~:opacity":1
}
],
"~:pages-index":{
"~u015fda4f-caa6-8103-8004-862a00ddbe94":{
"~#penpot/pointer":[
"~u015fda4f-caa6-8103-8004-862a9e4ad279",
{
"~:created-at":"~m1718718436653"
}
]
}
}
}
}

View file

@ -1,10 +1,6 @@
import { BasePage } from "./BasePage";
export class LoginPage extends BasePage {
static async initWithLoggedOutUser(page) {
await BasePage.mockRPC(page, "get-profile", "get-profile-anonymous.json");
}
constructor(page) {
super(page);
this.loginButton = page.getByRole("button", { name: "Login" });
@ -24,6 +20,10 @@ export class LoginPage extends BasePage {
await this.loginButton.click();
}
async initWithLoggedOutUser() {
await this.mockRPC("get-profile", "get-profile-anonymous.json");
}
async setupLoggedInUser() {
await this.mockRPC("get-profile", "logged-in-user/get-profile-logged-in.json");
await this.mockRPC("get-teams", "logged-in-user/get-teams-default.json");

View file

@ -27,6 +27,26 @@ export class ViewerPage extends BaseWebSocketPage {
);
}
async setupFileWithSingleBoard() {
await this.mockRPC(/get\-view\-only\-bundle\?/, "viewer/get-view-only-bundle-single-board.json");
await this.mockRPC("get-comment-threads?file-id=*", "workspace/get-comment-threads-empty.json");
await this.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"viewer/get-file-fragment-single-board.json",
);
};
async setupFileWithComments() {
await this.mockRPC(/get\-view\-only\-bundle\?/, "viewer/get-view-only-bundle-single-board.json");
await this.mockRPC("get-comment-threads?file-id=*", "workspace/get-comment-threads-not-empty.json");
await this.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"viewer/get-file-fragment-single-board.json",
);
await this.mockRPC("get-comments?thread-id=*", "workspace/get-thread-comments.json");
await this.mockRPC("update-comment-thread-status", "workspace/update-comment-thread-status.json");
};
#ws = null;
constructor(page) {
@ -56,5 +76,11 @@ export class ViewerPage extends BaseWebSocketPage {
.filter({ hasText: number.toString() })
.click(clickOptions);
}
async showCode(clickOptions = {}) {
await this.page
.getByRole("button", { name: 'Inspect (G I)' })
.click(clickOptions);
}
}

View file

@ -43,13 +43,14 @@ export class WorkspacePage extends BaseWebSocketPage {
this.presentUserListItems = page.getByTestId("active-users-list").getByAltText("Princesa Leia");
this.viewport = page.getByTestId("viewport");
this.rootShape = page.locator(`[id="shape-00000000-0000-0000-0000-000000000000"]`);
this.toolbarOptions = page.getByTestId("toolbar-options");
this.toolbarOptions = page.getByTestId("toolbar-options");
this.rectShapeButton = page.getByRole("button", { name: "Rectangle (R)" });
this.toggleToolbarButton = page.getByRole("button", { name: "Toggle toolbar" });
this.colorpicker = page.getByTestId("colorpicker");
this.layers = page.getByTestId("layer-tree");
this.palette = page.getByTestId("palette");
this.sidebar = page.getByTestId("left-sidebar");
this.rightSidebar = page.getByTestId("right-sidebar");
this.selectionRect = page.getByTestId("workspace-selection-rect");
this.horizontalScrollbar = page.getByTestId("horizontal-scrollbar");
this.librariesModal = page.getByTestId("libraries-modal");
@ -119,7 +120,7 @@ export class WorkspacePage extends BaseWebSocketPage {
}
async moveSelectionToShape(name) {
await this.page.locator('rect.viewport-selrect').hover();
await this.page.locator("rect.viewport-selrect").hover();
await this.page.mouse.down();
await this.viewport.getByTestId(name).first().hover({ force: true });
await this.page.mouse.up();
@ -170,9 +171,7 @@ export class WorkspacePage extends BaseWebSocketPage {
}
async clickColorPalette(clickOptions = {}) {
await this.palette
.getByRole("button", { name: "Color Palette (Alt+P)" })
.click(clickOptions);
await this.palette.getByRole("button", { name: "Color Palette (Alt+P)" }).click(clickOptions);
}
async clickTogglePalettesVisibility(clickOptions = {}) {

View file

@ -2,7 +2,9 @@ import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/LoginPage";
test.beforeEach(async ({ page }) => {
await LoginPage.initWithLoggedOutUser(page);
const login = new LoginPage(page);
await login.initWithLoggedOutUser();
await page.goto("/#/auth/login");
});

View file

@ -8,21 +8,11 @@ test.beforeEach(async ({ page }) => {
const singleBoardFileId = "dd5cc0bb-91ff-81b9-8004-77df9cd3edb1";
const singleBoardPageId = "dd5cc0bb-91ff-81b9-8004-77df9cd3edb2";
const setupFileWithSingleBoard = async (viewer) => {
await viewer.mockRPC(/get\-view\-only\-bundle\?/, "viewer/get-view-only-bundle-single-board.json");
await viewer.mockRPC("get-comment-threads?file-id=*", "workspace/get-comment-threads-not-empty.json");
await viewer.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"viewer/get-file-fragment-single-board.json",
);
await viewer.mockRPC("get-comments?thread-id=*", "workspace/get-thread-comments.json");
await viewer.mockRPC("update-comment-thread-status", "workspace/update-comment-thread-status.json");
};
test("Comment is shown with scroll and valid position", async ({ page }) => {
const viewer = new ViewerPage(page);
await viewer.setupLoggedInUser();
await setupFileWithSingleBoard(viewer);
await viewer.setupFileWithComments();
await viewer.goToViewer({ fileId: singleBoardFileId, pageId: singleBoardPageId });
await viewer.showComments();

View file

@ -8,15 +8,6 @@ test.beforeEach(async ({ page }) => {
const singleBoardFileId = "dd5cc0bb-91ff-81b9-8004-77df9cd3edb1";
const singleBoardPageId = "dd5cc0bb-91ff-81b9-8004-77df9cd3edb2";
const setupFileWithSingleBoard = async (viewer) => {
await viewer.mockRPC(/get\-view\-only\-bundle\?/, "viewer/get-view-only-bundle-single-board.json");
await viewer.mockRPC("get-comment-threads?file-id=*", "workspace/get-comment-threads-empty.json");
await viewer.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"viewer/get-file-fragment-single-board.json",
);
};
test("Clips link area of the logo", async ({ page }) => {
const viewerPage = new ViewerPage(page);
await viewerPage.setupLoggedInUser();
@ -37,7 +28,7 @@ test("Clips link area of the logo", async ({ page }) => {
test("Updates URL with zoom type", async ({ page }) => {
const viewer = new ViewerPage(page);
await viewer.setupLoggedInUser();
await setupFileWithSingleBoard(viewer);
await viewer.setupFileWithSingleBoard(viewer);
await viewer.goToViewer({ fileId: singleBoardFileId, pageId: singleBoardPageId });

View file

@ -1,10 +0,0 @@
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/LoginPage";
test("Shows login form correctly", async ({ page }) => {
await LoginPage.initWithLoggedOutUser(page);
const loginPage = new LoginPage(page);
await page.goto("/#/auth/login");
await expect(page).toHaveScreenshot();
});

View file

@ -3,11 +3,7 @@ import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in-no-onboarding.json",
);
await DashboardPage.mockRPC(page, "get-profile", "logged-in-user/get-profile-logged-in-no-onboarding.json");
});
test("User goes to an empty dashboard", async ({ page }) => {
@ -123,15 +119,12 @@ test("User goes to an full search page", async ({ page }) => {
await dashboardPage.setupDashboardFull();
await dashboardPage.goToSearch();
await expect(dashboardPage.searchInput).toBeVisible();
await dashboardPage.searchInput.fill("New");
await expect(dashboardPage.searchTitle).toBeVisible();
await expect(dashboardPage.newFileName).toBeVisible();
await expect(dashboardPage.page).toHaveScreenshot();
});
@ -141,9 +134,7 @@ test("User opens user account", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await expect(dashboardPage.userAccount).toBeVisible();
await dashboardPage.goToAccount();
await expect(dashboardPage.page).toHaveScreenshot();
@ -153,11 +144,9 @@ test("User goes to user profile", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await dashboardPage.goToAccount();
await expect(dashboardPage.userAccountTitle).toBeVisible();
await expect(dashboardPage.page).toHaveScreenshot();
});
@ -165,13 +154,11 @@ test("User goes to password management section", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await dashboardPage.goToAccount();
await page.getByText("Password").click();
await expect(page.getByRole("heading", { name: "Change Password" })).toBeVisible();
await expect(dashboardPage.page).toHaveScreenshot();
});
@ -179,91 +166,11 @@ test("User goes to settings section", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await dashboardPage.goToAccount();
await page.getByTestId("settings-profile").click();
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
await expect(dashboardPage.page).toHaveScreenshot();
});
test("User goes to an empty access tokens secction", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await dashboardPage.setupAccessTokensEmpty();
await dashboardPage.goToAccount();
await page.getByText("Access tokens").click();
await expect(page.getByRole("heading", { name: "Personal access tokens" })).toBeVisible();
await expect(dashboardPage.page).toHaveScreenshot();
});
test("User can create an access token", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await dashboardPage.setupAccessTokensEmpty();
await dashboardPage.goToAccount();
await page.getByText("Access tokens").click();
await expect(page.getByRole("heading", { name: "Personal access tokens" })).toBeVisible();
await page.getByRole("button", { name: "Generate New Token" }).click();
await dashboardPage.createAccessToken();
await expect(page.getByPlaceholder("The name can help to know")).toBeVisible();
await page.getByPlaceholder("The name can help to know").fill("New token");
await expect(page.getByRole("button", { name: "Create token" })).not.toBeDisabled();
await page.getByRole("button", { name: "Create token" }).click();
await expect(page.getByRole("button", { name: "Create token" })).not.toBeVisible();
await expect(dashboardPage.page).toHaveScreenshot();
});
test("User goes to a full access tokens secction", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await dashboardPage.setupAccessTokens();
await dashboardPage.goToAccount();
await page.getByText("Access tokens").click();
await expect(page.getByRole("heading", { name: "Personal access tokens" })).toBeVisible();
await expect(page.getByText("new token", { exact: true })).toBeVisible();
await expect(dashboardPage.page).toHaveScreenshot();
});
test("User goes to the feedback secction", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await dashboardPage.goToAccount();
await page.getByText("Give feedback").click();
await expect(page.getByRole("heading", { name: "Email" })).toBeVisible();
await expect(dashboardPage.page).toHaveScreenshot();
});
@ -273,13 +180,11 @@ test("User opens teams selector with only one team", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goToDashboard();
await expect(dashboardPage.titleLabel).toBeVisible();
await dashboardPage.teamDropdown.click();
await expect(page.getByText("Create new team")).toBeVisible();
await expect(dashboardPage.page).toHaveScreenshot();
});
@ -288,30 +193,25 @@ test("User opens teams selector with more than one team", async ({ page }) => {
await dashboardPage.setupDashboardFull();
await dashboardPage.goToDashboard();
await expect(dashboardPage.titleLabel).toBeVisible();
await dashboardPage.teamDropdown.click();
await expect(page.getByText("Second Team")).toBeVisible();
await expect(dashboardPage.page).toHaveScreenshot();
});
test("User goes to second team", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await dashboardPage.goToDashboard();
await dashboardPage.teamDropdown.click();
await expect(page.getByText("Second Team")).toBeVisible();
await page.getByText("Second Team").click();
await expect(page.getByText("Team Up")).toBeVisible();
await expect(dashboardPage.page).toHaveScreenshot();
});
@ -320,13 +220,11 @@ test("User opens team management dropdown", async ({ page }) => {
await dashboardPage.setupDashboardFull();
await dashboardPage.goToSecondTeamDashboard();
await expect(page.getByText("Team Up")).toBeVisible();
await page.getByRole("button", { name: "team-management" }).click();
await expect(page.getByTestId("team-members")).toBeVisible();
await expect(dashboardPage.page).toHaveScreenshot();
});
@ -365,24 +263,20 @@ test("User goes to a complete invitations section", async ({ page }) => {
await expect(dashboardPage.page).toHaveScreenshot();
});
test("User invite people to the team", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await dashboardPage.setupTeamInvitationsEmpty();
await dashboardPage.goToSecondTeamInvitationsSection();
await expect(page.getByTestId("invite-member")).toBeVisible();
await page.getByTestId("invite-member").click();
await expect(page.getByText("Invite with the role")).toBeVisible();
await page.getByPlaceholder('Emails, comma separated').fill("test5@mail.com");
await page.getByPlaceholder("Emails, comma separated").fill("test5@mail.com");
await expect(page.getByText("Send invitation")).not.toBeDisabled();
await expect(dashboardPage.page).toHaveScreenshot();
});
@ -394,7 +288,6 @@ test("User goes to an empty webhook section", async ({ page }) => {
await dashboardPage.goToSecondTeamWebhooksSection();
await expect(page.getByText("No webhooks created so far.")).toBeVisible();
await expect(dashboardPage.page).toHaveScreenshot();
});
@ -406,7 +299,6 @@ test("User goes to a complete webhook section", async ({ page }) => {
await dashboardPage.goToSecondTeamWebhooksSection();
await expect(page.getByText("https://www.google.com")).toBeVisible();
await expect(dashboardPage.page).toHaveScreenshot();
});
@ -418,6 +310,5 @@ test("User goes to the team settings section", async ({ page }) => {
await dashboardPage.goToSecondTeamSettingsSection();
await expect(page.getByText("TEAM INFO")).toBeVisible();
await expect(dashboardPage.page).toHaveScreenshot();
});

View file

@ -0,0 +1,37 @@
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/LoginPage";
test.beforeEach(async ({ page }) => {
const login = new LoginPage(page);
await login.initWithLoggedOutUser();
await login.page.goto("/#/auth/login");
});
test.describe("Login form", () => {
test("Shows the login form correctly", async ({ page }) => {
const login = new LoginPage(page);
await expect(login.page).toHaveScreenshot();
});
test("Shows form error messages correctly ", async ({ page }) => {
const login = new LoginPage(page);
await login.setupLoginSuccess();
await login.fillEmailAndPasswordInputs("foo", "lorenIpsum");
await expect(login.invalidEmailError).toBeVisible();
await expect(login.page).toHaveScreenshot();
});
test("Shows error toasts correctly", async ({ page }) => {
const login = new LoginPage(page);
await login.setupLoginError();
await login.fillEmailAndPasswordInputs("test@example.com", "loremipsum");
await login.clickLoginButton();
await expect(login.invalidCredentialsError).toBeVisible();
await expect(login.page).toHaveURL(/auth\/login$/);
await expect(login.page).toHaveScreenshot();
});
});

View file

@ -0,0 +1,117 @@
import { test, expect } from "@playwright/test";
import { ViewerPage } from "../pages/ViewerPage";
test.beforeEach(async ({ page }) => {
await ViewerPage.init(page);
});
const singleBoardFileId = "dd5cc0bb-91ff-81b9-8004-77df9cd3edb1";
const singleBoardPageId = "dd5cc0bb-91ff-81b9-8004-77df9cd3edb2";
test("User goes to an empty Viewer", async ({ page }) => {
const viewerPage = new ViewerPage(page);
await viewerPage.setupLoggedInUser();
await viewerPage.setupEmptyFile();
await viewerPage.goToViewer();
await expect(viewerPage.page.getByTestId("penpot-logo-link")).toBeVisible();
await expect(viewerPage.page).toHaveScreenshot();
});
test("User goes to the Viewer", async ({ page }) => {
const viewerPage = new ViewerPage(page);
await viewerPage.setupLoggedInUser();
await viewerPage.setupFileWithSingleBoard();
await viewerPage.goToViewer({ fileId: singleBoardFileId, pageId: singleBoardPageId });
await expect(viewerPage.page.getByTestId("penpot-logo-link")).toBeVisible();
await expect(viewerPage.page).toHaveScreenshot();
});
test("User goes to the Viewer and opens zoom modal", async ({ page }) => {
const viewerPage = new ViewerPage(page);
await viewerPage.setupLoggedInUser();
await viewerPage.setupFileWithSingleBoard();
await viewerPage.goToViewer({ fileId: singleBoardFileId, pageId: singleBoardPageId });
await viewerPage.page.getByTitle("Zoom").click();
await expect(viewerPage.page.getByTestId("penpot-logo-link")).toBeVisible();
await expect(viewerPage.page).toHaveScreenshot();
});
test("User goes to the Viewer Comments", async ({ page }) => {
const viewerPage = new ViewerPage(page);
await viewerPage.setupLoggedInUser();
await viewerPage.setupFileWithComments();
await viewerPage.goToViewer({ fileId: singleBoardFileId, pageId: singleBoardPageId });
await viewerPage.showComments();
await viewerPage.showCommentsThread(1);
await expect(viewerPage.page.getByRole("textbox", { name: "Reply" })).toBeVisible();
await expect(viewerPage.page).toHaveScreenshot();
});
test("User opens Viewer comment list", async ({ page }) => {
const viewerPage = new ViewerPage(page);
await viewerPage.setupLoggedInUser();
await viewerPage.setupFileWithComments();
await viewerPage.goToViewer({ fileId: singleBoardFileId, pageId: singleBoardPageId });
await viewerPage.showComments();
await viewerPage.page.getByTestId("viewer-comments-dropdown").click();
await viewerPage.page.getByText("Show comments list").click();
await expect(viewerPage.page.getByRole("button", { name: "Show all comments" })).toBeVisible();
await expect(viewerPage.page).toHaveScreenshot();
});
test("User goes to the Viewer Inspect code", async ({ page }) => {
const viewerPage = new ViewerPage(page);
await viewerPage.setupLoggedInUser();
await viewerPage.setupFileWithComments();
await viewerPage.goToViewer({ fileId: singleBoardFileId, pageId: singleBoardPageId });
await viewerPage.showCode();
await expect(viewerPage.page.getByText("Size and position")).toBeVisible();
await expect(viewerPage.page).toHaveScreenshot();
});
test("User goes to the Viewer Inspect code, code tab", async ({ page }) => {
const viewerPage = new ViewerPage(page);
await viewerPage.setupLoggedInUser();
await viewerPage.setupFileWithComments();
await viewerPage.goToViewer({ fileId: singleBoardFileId, pageId: singleBoardPageId });
await viewerPage.showCode();
await viewerPage.page.getByTestId("code").click();
await expect(viewerPage.page.getByRole("button", { name: "Copy all code" })).toBeVisible();
await expect(viewerPage.page).toHaveScreenshot();
});
test("User opens Share modal", async ({ page }) => {
const viewerPage = new ViewerPage(page);
await viewerPage.setupLoggedInUser();
await viewerPage.setupFileWithSingleBoard();
await viewerPage.goToViewer({ fileId: singleBoardFileId, pageId: singleBoardPageId });
await viewerPage.page.getByRole("button", { name: "Share" }).click();
await expect(viewerPage.page.getByRole("button", { name: "Get link" })).toBeVisible();
await expect(viewerPage.page).toHaveScreenshot();
});

View file

@ -0,0 +1,124 @@
import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../pages/WorkspacePage";
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
});
const setupFileWithAssets = async (workspace) => {
const fileId = "015fda4f-caa6-8103-8004-862a00dd4f31";
const pageId = "015fda4f-caa6-8103-8004-862a00ddbe94";
const fragments = {
"015fda4f-caa6-8103-8004-862a9e4b4d4b": "assets/get-file-fragment-with-assets-components.json",
"015fda4f-caa6-8103-8004-862a9e4ad279": "assets/get-file-fragmnet-with-assets-page.json",
};
await workspace.setupEmptyFile();
await workspace.mockRPC(/get\-file\?/, "assets/get-file-with-assets.json");
for (const [id, fixture] of Object.entries(fragments)) {
await workspace.mockRPC(`get-file-fragment?file-id=*&fragment-id=${id}`, fixture);
}
return { fileId, pageId };
};
test("Shows the workspace correctly for a blank file", async ({ page }) => {
const workspace = new WorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await expect(workspace.page).toHaveScreenshot();
});
test.describe("Design tab", () => {
test("Shows the design tab when selecting a shape", async ({ page }) => {
const workspace = new WorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockRPC(/get\-file\?/, "workspace/get-file-not-empty.json");
await workspace.goToWorkspace({
fileId: "6191cd35-bb1f-81f7-8004-7cc63d087374",
pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375",
});
await workspace.clickLeafLayer("Rectangle");
await expect(workspace.page).toHaveScreenshot();
});
test("Shows expanded sections of the design tab", async ({ page }) => {
const workspace = new WorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockRPC(/get\-file\?/, "workspace/get-file-not-empty.json");
await workspace.goToWorkspace({
fileId: "6191cd35-bb1f-81f7-8004-7cc63d087374",
pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375",
});
await workspace.clickLeafLayer("Rectangle");
await workspace.rightSidebar.getByTestId("add-stroke").click();
await expect(workspace.page).toHaveScreenshot();
});
});
test.describe("Assets tab", () => {
test("Shows the libraries modal correctly", async ({ page }) => {
const workspace = new WorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockRPC("link-file-to-library", "workspace/link-file-to-library.json");
await workspace.mockRPC(
"get-team-shared-files?team-id=*",
"workspace/get-team-shared-libraries-non-empty.json",
);
await workspace.goToWorkspace();
await workspace.clickAssets();
await workspace.openLibrariesModal();
await expect(workspace.page).toHaveScreenshot();
await workspace.clickLibrary("Testing library 1");
await expect(workspace.page).toHaveScreenshot();
});
test("Shows the assets correctly", async ({ page }) => {
const workspace = new WorkspacePage(page);
const { fileId, pageId } = await setupFileWithAssets(workspace);
await workspace.goToWorkspace({ fileId, pageId });
await workspace.clickAssets();
await workspace.sidebar.getByRole("button", { name: "Components" }).click();
await workspace.sidebar.getByRole("button", { name: "Colors" }).click();
await workspace.sidebar.getByRole("button", { name: "Typographies" }).click();
await expect(workspace.page).toHaveScreenshot();
await workspace.sidebar.getByTitle("List view").click();
await expect(workspace.page).toHaveScreenshot();
});
});
test.describe("Palette", () => {
test("Shows the bottom palette expanded and collapsed", async ({ page }) => {
const workspace = new WorkspacePage(page);
const { fileId, pageId } = await setupFileWithAssets(workspace);
await workspace.goToWorkspace({ fileId, pageId });
await expect(workspace.page).toHaveScreenshot();
await workspace.palette.getByRole("button", { name: "Typographies" }).click();
await expect(workspace.palette.getByText("Source Sans Pro Regular")).toBeVisible();
await expect(workspace.page).toHaveScreenshot();
await workspace.palette.getByRole("button", { name: "Color Palette" }).click();
await expect(workspace.palette.getByRole("button", { name: "#7798ff" })).toBeVisible();
});
});

View file

@ -69,7 +69,8 @@
:enable-onboarding-questions
:enable-onboarding-newsletter
:enable-dashboard-templates-section
:enable-google-fonts-provider])
:enable-google-fonts-provider
:enable-component-thumbnails])
(defn- parse-flags
[global]

View file

@ -29,18 +29,24 @@
(def commit?
(ptk/type? ::commit))
(defn update-indexes
(defn- fix-page-id
"For events that modifies the page, page-id does not comes
as a property so we assign it from the `id` property."
[{:keys [id type page] :as change}]
(cond-> change
(and (page-change? type)
(nil? (:page-id change)))
(assoc :page-id (or id (:id page)))))
(defn- update-indexes
"Given a commit, send the changes to the worker for updating the
indexes."
[{:keys [changes] :as commit}]
[commit attr]
(ptk/reify ::update-indexes
ptk/WatchEvent
(watch [_ _ _]
(let [changes (->> changes
(map (fn [{:keys [id type page] :as change}]
(cond-> change
(and (page-change? type) (nil? (:page-id change)))
(assoc :page-id (or id (:id page))))))
(let [changes (->> (get commit attr)
(map fix-page-id)
(filter :page-id)
(group-by :page-id))]
@ -58,6 +64,41 @@
(map (d/getf (:index persistence)))
(not-empty)))
(def ^:private xf:map-page-id
(map :page-id))
(defn- apply-changes-localy
[{:keys [file-id redo-changes] :as commit} pending]
(ptk/reify ::apply-changes-localy
ptk/UpdateEvent
(update [_ state]
(let [current-file-id (get state :current-file-id)
path (if (= file-id current-file-id)
[:workspace-data]
[:workspace-libraries file-id :data])
undo-changes (if pending
(->> pending
(map :undo-changes)
(reverse)
(mapcat identity)
(vec))
nil)
redo-changes (if pending
(into redo-changes
(mapcat :redo-changes)
pending)
redo-changes)]
(d/update-in-when state path
(fn [file]
(let [file (cpc/process-changes file undo-changes false)
file (cpc/process-changes file redo-changes false)
pids (into #{} xf:map-page-id redo-changes)]
(reduce #(ctst/update-object-indices %1 %2) file pids))))))))
(defn commit
"Create a commit event instance"
[{:keys [commit-id redo-changes undo-changes origin save-undo? features
@ -70,6 +111,7 @@
(let [commit-id (or commit-id (uuid/next))
source (d/nilv source :local)
local? (= source :local)
commit {:id commit-id
:created-at (dt/now)
:source source
@ -89,38 +131,20 @@
cljs.core/IDeref
(-deref [_] commit)
ptk/UpdateEvent
(update [_ state]
(let [current-file-id (get state :current-file-id)
path (if (= file-id current-file-id)
[:workspace-data]
[:workspace-libraries file-id :data])
not-local? (not= source :local)
pending (if not-local?
(get-pending-commits state)
nil)
undo-changes (if pending
(->> pending
(map :undo-changes)
(reverse)
(mapcat identity)
(vec))
nil)
redo-changes (if pending
(into redo-changes
(mapcat :redo-changes)
pending)
redo-changes)]
(d/update-in-when state path
(fn [file]
(let [file (cpc/process-changes file undo-changes false)
file (cpc/process-changes file redo-changes false)
pids (into #{} (map :page-id) redo-changes)]
(reduce #(ctst/update-object-indices %1 %2) file pids)))))))))
ptk/WatchEvent
(watch [_ state _]
(let [pending (when-not local?
(get-pending-commits state))]
(rx/concat
(rx/of (apply-changes-localy commit pending))
(if pending
(rx/concat
(->> (rx/from (reverse pending))
(rx/map (fn [commit] (update-indexes commit :undo-changes))))
(rx/of (update-indexes commit :redo-changes))
(->> (rx/from pending)
(rx/map (fn [commit] (update-indexes commit :redo-changes)))))
(rx/of (update-indexes commit :redo-changes)))))))))
(defn- resolve-file-revn
[state file-id]

View file

@ -211,14 +211,6 @@
(update-status :pending)))
(rx/take-until stoper-s))
(->> local-commits-s
(rx/buffer-time 200)
(rx/mapcat merge-commit)
(rx/map dch/update-indexes)
(rx/take-until stoper-s)
(rx/finalize (fn []
(log/debug :hint "finalize persistence: changes watcher [index]"))))
;; Here we watch for local commits, buffer them in a small
;; chunks (very near in time commits) and append them to the
;; persistence queue
@ -237,6 +229,5 @@
(rx/map deref)
(rx/filter #(= :remote (:source %)))
(rx/mapcat (fn [{:keys [file-id file-revn] :as commit}]
(rx/of (update-file-revn file-id file-revn)
(dch/update-indexes commit))))
(rx/of (update-file-revn file-id file-revn))))
(rx/take-until stoper-s)))))))

View file

@ -590,7 +590,7 @@
(update [_ state]
(update state :colorpicker
(fn [state]
(let [type (:type state)
(let [type (:type state)
state (-> state
(update :current-color merge changes)
(update :current-color materialize-color-components)
@ -605,11 +605,12 @@
(-> state
(dissoc :gradient :stops :editing-stop)
(cond-> (not= :image (:type state))
(cond-> (not= :image type)
(assoc :type :color))))))))
ptk/WatchEvent
(watch [_ state _]
(when add-recent?
;; Type can be null, because the colorpicker can be closed while a color image finish its upload
(when (and add-recent? (some? (:type state)))
(let [formated-color (get-color-from-colorpicker-state (:colorpicker state))]
(rx/of (dwl/add-recent-color formated-color)))))))

View file

@ -24,6 +24,7 @@
[app.common.types.shape.layout :as ctl]
[app.common.types.typography :as ctt]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.changes :as dch]
[app.main.data.comments :as dc]
[app.main.data.events :as ev]
@ -1203,7 +1204,7 @@
(rx/debounce 5000)
(rx/tap #(log/trc :hint "buffer initialized")))]
(when components-v2?
(when (and components-v2? (contains? cf/flags :component-thumbnails))
(->> (rx/merge
changes-s

View file

@ -217,40 +217,16 @@
(-deref [_] {:changes changes})
ptk/WatchEvent
(watch [_ state _]
(let [page-id (:current-page-id state)
position-data-operation?
(fn [{:keys [type attr]}]
(and (= :set type)
(= attr :position-data)))
update-position-data
(fn [change]
;; Remove the position data from remote operations. Will be changed localy, otherwise
;; creates a strange "out-of-sync" behaviour.
(cond-> change
(and (= page-id (:page-id change))
(= :mod-obj (:type change)))
(update :operations #(d/removev position-data-operation? %))))
;; We update `position-data` from the incoming message
changes (->> changes
(map update-position-data)
(remove (fn [change]
(and (= page-id (:page-id change))
(:ignore-remote? change))))
(vec))]
;; The commit event is responsible to apply the data localy
;; and update the persistence internal state with the updated
;; file-revn
(rx/of (dch/commit {:file-id file-id
:file-revn revn
:save-undo? false
:source :remote
:redo-changes changes
:undo-changes []}))))))
(watch [_ _ _]
;; The commit event is responsible to apply the data localy
;; and update the persistence internal state with the updated
;; file-revn
(rx/of (dch/commit {:file-id file-id
:file-revn revn
:save-undo? false
:source :remote
:redo-changes (vec changes)
:undo-changes []})))))
(def ^:private
schema:handle-library-change

View file

@ -46,8 +46,8 @@
(defn update-shapes
([ids update-fn] (update-shapes ids update-fn nil))
([ids update-fn {:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id ignore-remote? ignore-touched undo-group with-objects?]
:or {reg-objects? false save-undo? true stack-undo? false ignore-remote? false ignore-touched false with-objects? false}}]
([ids update-fn {:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id ignore-touched undo-group with-objects?]
:or {reg-objects? false save-undo? true stack-undo? false ignore-touched false with-objects? false}}]
(dm/assert!
"expected a valid coll of uuid's"
@ -84,8 +84,7 @@
changes (add-undo-group changes state)]
(rx/concat
(if (seq (:redo-changes changes))
(let [changes (cond-> changes reg-objects? (pcb/resize-parents ids))
changes (cond-> changes ignore-remote? (pcb/ignore-remote))]
(let [changes (cond-> changes reg-objects? (pcb/resize-parents ids))]
(rx/of (dch/commit-changes changes)))
(rx/empty))

View file

@ -486,7 +486,6 @@
(rx/of (dwu/start-undo-transaction undo-id)
(dwsh/update-shapes ids update-fn {:reg-objects? true
:stack-undo? true
:ignore-remote? true
:ignore-touched true})
(ptk/data-event :layout/update {:ids ids})
(dwu/commit-undo-transaction undo-id))))))))
@ -631,7 +630,7 @@
(fn [shape]
(-> shape
(assoc :position-data (get position-data (:id shape)))))
{:stack-undo? true :reg-objects? false :ignore-remote? true}))
{:stack-undo? true :reg-objects? false}))
(rx/of (fn [state]
(dissoc state ::update-position-data-debounce ::update-position-data))))))))

View file

@ -10,7 +10,7 @@
[app.common.files.helpers :as cfh]
[app.common.logging :as l]
[app.common.thumbnails :as thc]
[app.config :as cf]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.persistence :as-alias dps]
[app.main.data.workspace.notifications :as-alias wnt]
@ -19,7 +19,6 @@
[app.main.refs :as refs]
[app.main.render :as render]
[app.main.repo :as rp]
[app.util.http :as http]
[app.util.queue :as q]
[app.util.time :as tp]
[app.util.timers :as tm]
@ -149,34 +148,34 @@
ptk/WatchEvent
(watch [_ state stream]
(l/dbg :hint "update thumbnail" :requester requester :object-id object-id :tag tag)
;; Send the update to the back-end
(->> (request-thumbnail state file-id page-id frame-id tag)
(rx/mapcat (fn [blob]
;; Send the data to backend
(let [params {:file-id file-id
:object-id object-id
:media blob
:tag (or tag "frame")}]
(rp/cmd! :create-file-object-thumbnail params))))
(let [tp (tp/tpoint-ms)]
;; Send the update to the back-end
(->> (request-thumbnail state file-id page-id frame-id tag)
(rx/mapcat (fn [blob]
(let [uri (wapi/create-uri blob)
params {:file-id file-id
:object-id object-id
:media blob
:tag (or tag "frame")}]
(rx/mapcat (fn [{:keys [object-id media-id]}]
(let [uri (cf/resolve-media media-id)]
;; We perform this request just for
;; populate the browser CACHE and avoid
;; unnecesary image flickering
(->> (http/send! {:uri uri :method :get})
(rx/map #(assoc-thumbnail object-id uri))))))
(rx/merge
(rx/of (assoc-thumbnail object-id uri))
(->> (rp/cmd! :create-file-object-thumbnail params)
(rx/catch rx/empty)
(rx/ignore))))))
(rx/catch (fn [cause]
(.error js/console cause)
(rx/empty)))
(rx/catch (fn [cause]
(.error js/console cause)
(rx/empty)))
;; We cancel all the stream if user starts editing while
;; thumbnail is generating
(rx/take-until
(->> stream
(rx/filter (ptk/type? ::clear-thumbnail))
(rx/filter #(= (deref %) object-id)))))))))
(rx/tap #(l/trc :hint "thumbnail updated" :elapsed (dm/str (tp) "ms")))
;; We cancel all the stream if user starts editing while
;; thumbnail is generating
(rx/take-until
(->> stream
(rx/filter (ptk/type? ::clear-thumbnail))
(rx/filter #(= (deref %) object-id))))))))))
(defn- extract-root-frame-changes
"Process a changes set in a commit to extract the frames that are changing"
@ -192,8 +191,8 @@
:mov-objects (->> (:shapes change) (map #(vector page-id %)))
[]))
get-frame-id
(fn [[_ id]]
get-frame-ids
(fn get-frame-ids [id]
(let [old-objects (wsh/lookup-data-objects old-data page-id)
new-objects (wsh/lookup-data-objects new-data page-id)
@ -208,12 +207,21 @@
(conj old-frame-id)
(cfh/root-frame? new-objects new-frame-id)
(conj new-frame-id))))]
(conj new-frame-id)
(and (uuid? (:frame-id old-shape))
(not= uuid/zero (:frame-id old-shape)))
(into (get-frame-ids (:frame-id old-shape)))
(and (uuid? (:frame-id new-shape))
(not= uuid/zero (:frame-id new-shape)))
(into (get-frame-ids (:frame-id new-shape))))))]
(into #{}
(comp (mapcat extract-ids)
(filter (fn [[page-id']] (= page-id page-id')))
(mapcat get-frame-id))
(map (fn [[_ id]] id))
(mapcat get-frame-ids))
changes)))
(defn watch-state-changes

View file

@ -137,6 +137,7 @@
(->> (http/send! {:method :post
:uri uri
:credentials "include"
:headers {"x-external-session-id" (cf/external-session-id)}
:query params})
(rx/map http/conditional-decode-transit)
(rx/mapcat handle-response))))
@ -146,6 +147,7 @@
(->> (http/send! {:method :post
:uri (u/join cf/public-uri "api/export")
:body (http/transit-data (dissoc params :blob?))
:headers {"x-external-session-id" (cf/external-session-id)}
:credentials "include"
:response-type (if blob? :blob :text)})
(rx/map http/conditional-decode-transit)
@ -165,6 +167,7 @@
(->> (http/send! {:method :post
:uri (u/join cf/public-uri "api/rpc/command/" (name id))
:credentials "include"
:headers {"x-external-session-id" (cf/external-session-id)}
:body (http/form-data params)})
(rx/map http/conditional-decode-transit)
(rx/mapcat handle-response)))

View file

@ -105,7 +105,7 @@
{::ev/name "onboarding-step"
:label "team:create-team-and-invite-later"
:team-name name
:step 7})
:step 8})
(ptk/data-event ::ev/event
{::ev/name "onboarding-finish"})))))
@ -122,7 +122,7 @@
:invites (count emails)
:team-name name
:role (:role params)
:step 7})
:step 8})
(ptk/data-event ::ev/event
{::ev/name "onboarding-finish"})))))

View file

@ -65,6 +65,7 @@
(st/emit! (dcm/update-options {:show-sidebar? (not mode)})))))]
[:div {:class (stl/css :view-options)
:data-testid "viewer-comments-dropdown"
:on-click toggle-dropdown}
[:span {:class (stl/css :dropdown-title)} (tr "labels.comments")]
[:span {:class (stl/css :icon-dropdown)} i/arrow]

View file

@ -29,7 +29,8 @@
[promesa.core :as p]
[rumext.v2 :as mf]))
(defn fix-position [shape]
(defn fix-position
[shape]
(if-let [modifiers (:modifiers shape)]
(let [shape' (gsh/transform-shape shape modifiers)

View file

@ -169,6 +169,7 @@
:expanded (> size 276))
:id "right-sidebar-aside"
:data-testid "right-sidebar"
:data-size (str size)
:style #js {"--width" (if can-be-expanded? (dm/str size "px") 276)}}
(when can-be-expanded?

View file

@ -14,6 +14,7 @@
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.config :as cf]
[app.main.data.modal :as modal]
[app.main.data.workspace :as dw]
[app.main.data.workspace.libraries :as dwl]
@ -287,7 +288,7 @@
(when (< @retry 3)
(inc retry))))]
(if (some? thumbnail-uri)
(if (and (some? thumbnail-uri) (contains? cf/flags :component-thumbnails))
[:& component-svg-thumbnail
{:thumbnail-uri thumbnail-uri
:class class

View file

@ -520,9 +520,11 @@
:name "listing-style"}
[:& radio-button {:icon i/view-as-list
:value "list"
:title (tr "workspace.assets.list-view")
:id "opt-list"}]
[:& radio-button {:icon i/flex-grid
:value "grid"
:title (tr "workspace.assets.grid-view")
:id "opt-grid"}]]])
(when (and components-v2 (not read-only?) local?)

View file

@ -32,7 +32,7 @@
(let [{:keys [text-align]} values
handle-change
(mf/use-fn
(mf/deps on-blur)
(mf/deps on-change on-blur)
(fn [value]
(on-change {:text-align value})
(when (some? on-blur) (on-blur))))]
@ -64,7 +64,7 @@
(let [direction (:text-direction values)
handle-change
(mf/use-fn
(mf/deps direction)
(mf/deps on-change on-blur direction)
(fn [value]
(let [dir (if (= value direction)
"none"
@ -93,7 +93,7 @@
vertical-align (or vertical-align "top")
handle-change
(mf/use-fn
(mf/deps on-blur)
(mf/deps on-change on-blur)
(fn [value]
(on-change {:vertical-align value})
(when (some? on-blur) (on-blur))))]
@ -154,7 +154,7 @@
(let [text-decoration (or (:text-decoration values) "none")
handle-change
(mf/use-fn
(mf/deps text-decoration)
(mf/deps on-change on-blur text-decoration)
(fn [value]
(let [decoration (if (= value text-decoration)
"none"

View file

@ -2558,11 +2558,11 @@ msgstr "Prototyping"
#: src/app/main/ui/onboarding/questions.cljs
msgid "onboarding.questions.start-with.ds"
msgstr "Creating Desing Systems"
msgstr "Creating Design Systems"
#: src/app/main/ui/onboarding/questions.cljs
msgid "onboarding.questions.start-with.code"
msgstr "Generating real code designs"
msgstr "Generating real code from designs"
#: src/app/main/ui/onboarding/questions.cljs
msgid "onboarding.questions.step5.title"
@ -3396,6 +3396,14 @@ msgstr "Sort"
msgid "workspace.assets.typography"
msgstr "Typographies"
#: src/app/main/ui/workspace/sidebar/assets/components.cljs, src/app/main/wui/workspace/sidebar/options/menus/component.cljs
msgid "workspace.assets.grid-view"
msgstr "Grid view"
#: src/app/main/ui/workspace/sidebar/assets/components.cljs, src/app/main/wui/workspace/sidebar/options/menus/component.cljs
msgid "workspace.assets.list-view"
msgstr "List view"
#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs
msgid "workspace.assets.typography.font-id"
msgstr "Font"

View file

@ -3465,6 +3465,14 @@ msgstr "Ordenar"
msgid "workspace.assets.typography"
msgstr "Tipografías"
#: src/app/main/ui/workspace/sidebar/assets/components.cljs, src/app/main/wui/workspace/sidebar/options/menus/component.cljs
msgid "workspace.assets.grid-view"
msgstr "Ver como rejilla"
#: src/app/main/ui/workspace/sidebar/assets/components.cljs, src/app/main/wui/workspace/sidebar/options/menus/component.cljs
msgid "workspace.assets.list-view"
msgstr "Ver como lista"
#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs
msgid "workspace.assets.typography.font-id"
msgstr "Fuente"