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

This commit is contained in:
Andrey Antukh 2025-11-27 16:11:36 +01:00
commit 0f88253dd5
22 changed files with 1821 additions and 540 deletions

View File

@ -8,8 +8,6 @@ on:
pull_request:
types:
- opened
- edited
- reopened
- synchronize
push:
branches:
@ -91,6 +89,30 @@ jobs:
run: |
yarn run lint:scss;
test-render-wasm:
name: "Render WASM Tests"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Format
working-directory: ./render-wasm
run: |
cargo fmt --check
- name: Lint
working-directory: ./render-wasm
run: |
./lint
- name: Test
working-directory: ./render-wasm
run: |
./test
test-backend:
name: "Backend Tests"
runs-on: ubuntu-24.04

View File

@ -1,7 +1,8 @@
From: {{profile.fullname}} <{{profile.email}}> / {{profile.id}}
Subject: {{feedback-subject}}
Type: {{feedback-type}}
{%- if feedback-error-href %}
{% if feedback-error-href %}
HREF: {{feedback-error-href}}
{% endif -%}

View File

@ -57,7 +57,7 @@
:uid uuid/zero})
body (t/encode {:events events})
headers {"content-type" "application/transit+json"
"origin" (cf/get :public-uri)
"origin" (str (cf/get :public-uri))
"cookie" (u/map->query-string {:auth-token token})}
params {:uri uri
:timeout 12000

View File

@ -9,7 +9,7 @@
[app.common.exceptions :as ex]
[selmer.parser :as sp]))
(sp/cache-off!)
;; (sp/cache-off!)
(defn render
[path context]

View File

@ -318,3 +318,35 @@
;; check that we have all no objects
(let [rows (th/db-exec! ["select * from storage_object where deleted_at is null"])]
(t/is (= 0 (count rows))))))
(t/deftest tempfile-bucket-test
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
content1 (sto/content "content1")
now (ct/now)
object1 (sto/put-object! storage {::sto/content content1
::sto/touched-at (ct/plus now {:minutes 1})
:bucket "tempfile"
:content-type "text/plain"})]
(binding [ct/*clock* (clock/fixed now)]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 0 (:delete res)))))
(binding [ct/*clock* (clock/fixed (ct/plus now {:minutes 1}))]
(let [res (th/run-task! :storage-gc-touched {})]
(t/is (= 0 (:freeze res)))
(t/is (= 1 (:delete res)))))
(binding [ct/*clock* (clock/fixed (ct/plus now {:hours 1}))]
(let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 0 (:deleted res)))))
(binding [ct/*clock* (clock/fixed (ct/plus now {:hours 2}))]
(let [res (th/run-task! :storage-gc-deleted {})]
(t/is (= 0 (:deleted res)))))))

View File

@ -17,7 +17,7 @@
org.slf4j/slf4j-api {:mvn/version "2.0.17"}
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.40"}
selmer/selmer {:mvn/version "1.12.62"}
selmer/selmer {:mvn/version "1.12.69"}
criterium/criterium {:mvn/version "0.4.6"}
metosin/jsonista {:mvn/version "0.3.13"}
@ -48,12 +48,8 @@
com.sun.mail/jakarta.mail {:mvn/version "2.0.2"}
org.la4j/la4j {:mvn/version "0.6.0"}
;; exception printing
fipp/fipp {:mvn/version "0.6.29"}
me.flowthing/pp {:mvn/version "2024-11-13.77"}
io.aviso/pretty {:mvn/version "1.4.4"}
environ/environ {:mvn/version "1.2.0"}}
:paths ["src" "vendor" "target/classes"]

View File

@ -9,10 +9,10 @@
(:refer-clojure :exclude [get-in select-keys str with-open max])
#?(:cljs (:require-macros [app.common.data.macros]))
(:require
#?(:clj [cljs.analyzer.api :as aapi])
#?(:clj [clojure.core :as c]
:cljs [cljs.core :as c])
[app.common.data :as d]
[cljs.analyzer.api :as aapi]
[cuerdas.core :as str]))
(defmacro select-keys
@ -44,42 +44,43 @@
[& params]
`(str/concat ~@params))
(defmacro export
"A helper macro that allows reexport a var in a current namespace."
[v]
(if (boolean (:ns &env))
#?(:clj
(defmacro export
"A helper macro that allows reexport a var in a current namespace."
[v]
(if (boolean (:ns &env))
;; Code for ClojureScript
(let [mdata (aapi/resolve &env v)
arglists (second (get-in mdata [:meta :arglists]))
sym (symbol (c/name v))
andsym (symbol "&")
procarg #(if (= % andsym) % (gensym "param"))]
(if (pos? (count arglists))
`(def
~(with-meta sym (:meta mdata))
(fn ~@(for [args arglists]
(let [args (map procarg args)]
(if (some #(= andsym %) args)
(let [[sargs dargs] (split-with #(not= andsym %) args)]
`([~@sargs ~@dargs] (apply ~v ~@sargs ~@(rest dargs))))
`([~@args] (~v ~@args)))))))
`(def ~(with-meta sym (:meta mdata)) ~v)))
;; Code for ClojureScript
(let [mdata (aapi/resolve &env v)
arglists (second (get-in mdata [:meta :arglists]))
sym (symbol (c/name v))
andsym (symbol "&")
procarg #(if (= % andsym) % (gensym "param"))]
(if (pos? (count arglists))
`(def
~(with-meta sym (:meta mdata))
(fn ~@(for [args arglists]
(let [args (map procarg args)]
(if (some #(= andsym %) args)
(let [[sargs dargs] (split-with #(not= andsym %) args)]
`([~@sargs ~@dargs] (apply ~v ~@sargs ~@(rest dargs))))
`([~@args] (~v ~@args)))))))
`(def ~(with-meta sym (:meta mdata)) ~v)))
;; Code for Clojure
(let [vr (resolve v)
m (meta vr)
n (:name m)
n (with-meta n
(cond-> {}
(:dynamic m) (assoc :dynamic true)
(:protocol m) (assoc :protocol (:protocol m))))]
`(let [m# (meta ~vr)]
(def ~n (deref ~vr))
(alter-meta! (var ~n) merge (dissoc m# :name))
;; (when (:macro m#)
;; (.setMacro (var ~n)))
~vr))))
;; Code for Clojure
(let [vr (resolve v)
m (meta vr)
n (:name m)
n (with-meta n
(cond-> {}
(:dynamic m) (assoc :dynamic true)
(:protocol m) (assoc :protocol (:protocol m))))]
`(let [m# (meta ~vr)]
(def ~n (deref ~vr))
(alter-meta! (var ~n) merge (dissoc m# :name))
;; (when (:macro m#)
;; (.setMacro (var ~n)))
~vr)))))
(defmacro fmt
"String interpolation helper. Can only be used with strings known at

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -107,6 +107,25 @@ desc: Streamline your design workflow with Penpot's Components guide! Learn to c
<li>Select the variant copy, press right-click, and select the menu option <strong>Restore variant</strong> (will show if the main component still exists).</li>
</ul>
<h3 id="component-variants-toggle">Toggle for boolean variants</h3>
<p>When a variant property represents a boolean state, Penpot can display it as a toggle instead of a dropdown. This offers a quicker and more visual way to switch between two opposite values when working with copies.</p>
<p>The toggle appears in place of the property values dropdown, <strong>only when a copy is selected</strong>.</p>
<figure>
<img src="/img/variants/07-variants-boolean.webp" alt="Boolean variant option" />
</figure>
<h4>Accepted value pairs</h4>
<p>For Penpot to recognize the property as a boolean and display the toggle, the property must be defined with exactly two opposing values. These can be any of the following pairs:</p>
<ul>
<li><code>true</code> / <code>false</code></li>
<li><code>on</code> / <code>off</code></li>
<li><code>yes</code> / <code>no</code></li>
</ul>
<p>The order of the values does not matter. Penpot automatically maps them to ON and OFF states:</p>
<ul>
<li><strong>ON state:</strong> <code>true</code>, <code>yes</code>, <code>on</code></li>
<li><strong>OFF state:</strong> <code>false</code>, <code>no</code>, <code>off</code></li>
</ul>
<h3 id="component-use-variants">Use variants</h3>
<p>Once you have created your variants, you can place a copy of a component with variants into your design and then switch between its different versions.</p>

View File

@ -47,7 +47,7 @@
"watch:app:libs": "node ./scripts/build-libs.js --watch",
"watch:app:main": "clojure -M:dev:shadow-cljs watch main storybook",
"clear:shadow-cache": "rm -rf .shadow-cljs",
"watch:app": "yarn run clear:shadow-cache && concurrently \"yarn run build:app:worker\" \"yarn run watch:app:main\" \"yarn run watch:app:libs\"",
"watch:app": "yarn run clear:shadow-cache && yarn run build:app:worker && concurrently \"yarn run watch:app:main\" \"yarn run watch:app:libs\"",
"watch": "yarn run watch:app:assets",
"watch:storybook": "yarn run build:storybook:assets && concurrently \"storybook dev -p 6006 --no-open\" \"yarn run watch:storybook:assets\"",
"watch:storybook:assets": "node ./scripts/watch-storybook.js"

File diff suppressed because it is too large Load Diff

View File

@ -42,7 +42,7 @@ export class WasmWorkspacePage extends WorkspacePage {
}
async waitForFirstRenderWithoutUI() {
await waitForFirstRender();
await this.waitForFirstRender();
await this.hideUI();
}

View File

@ -258,6 +258,22 @@ test("Renders a file with nested frames with inherited blur", async ({
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders a file with nested clipping frames", async ({ page }) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile(
"render-wasm/get-file-frame-nested-clipping.json",
);
await workspace.goToWorkspace({
id: "44471494-966a-8178-8006-c5bd93f0fe72",
pageId: "44471494-966a-8178-8006-c5bd93f0fe73",
});
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders a clipped frame with a large blur drop shadow", async ({
page,
}) => {

View File

@ -9,403 +9,399 @@ test.beforeEach(async ({ page }) => {
]);
});
test.describe("Subscriptions: dashboard", () => {
test("Team with unlimited subscription has specific icon in menu", async ({
test("Team with unlimited subscription has specific icon in menu", async ({
page,
}) => {
await DashboardPage.mockRPC(
page,
}) => {
await DashboardPage.mockRPC(
page,
"get-profile",
"subscription/get-profile-unlimited-subscription.json",
);
"get-profile",
"subscription/get-profile-unlimited-subscription.json",
);
await DashboardPage.mockRPC(
page,
"get-subscription-usage",
"subscription/get-subscription-usage.json",
);
await DashboardPage.mockRPC(
page,
"get-team-info",
"subscription/get-team-info-subscriptions.json",
);
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await DashboardPage.mockRPC(
page,
"get-teams",
"subscription/get-teams-unlimited-subscription-owner.json",
);
await DashboardPage.mockRPC(
page,
"get-projects?team-id=*",
"dashboard/get-projects-second-team.json",
);
await dashboardPage.mockRPC(
"push-audit-events",
"workspace/audit-event-empty.json",
);
await dashboardPage.goToSecondTeamDashboard();
await expect(page.getByTestId("subscription-icon")).toBeVisible();
});
test("The Unlimited subscription has its name in the sidebar dropdown", async ({
await DashboardPage.mockRPC(
page,
}) => {
await DashboardPage.mockRPC(
page,
"get-profile",
"subscription/get-profile-unlimited-subscription.json",
);
"get-subscription-usage",
"subscription/get-subscription-usage.json",
);
await DashboardPage.mockRPC(
page,
"get-subscription-usage",
"subscription/get-subscription-usage-one-editor.json",
);
await DashboardPage.mockRPC(
page,
"get-team-info",
"subscription/get-team-info-subscriptions.json",
);
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await DashboardPage.mockRPC(
page,
"get-teams",
"subscription/get-teams-unlimited-subscription-owner.json",
);
await dashboardPage.mockRPC(
"push-audit-events",
"workspace/audit-event-empty.json",
);
await dashboardPage.goToDashboard();
await expect(page.getByTestId("subscription-name")).toHaveText(
"Unlimited plan (trial)",
);
});
test("When the subscription status is unpaid, the sidebar dropdown displays the name Professional for the Unlimited subscription", async ({
await DashboardPage.mockRPC(
page,
}) => {
await DashboardPage.mockRPC(
page,
"get-profile",
"subscription/get-profile-unlimited-unpaid-subscription.json",
);
"get-team-info",
"subscription/get-team-info-subscriptions.json",
);
await DashboardPage.mockRPC(
page,
"get-subscription-usage",
"subscription/get-subscription-usage.json",
);
await DashboardPage.mockRPC(
page,
"get-team-info",
"subscription/get-team-info-subscriptions.json",
);
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await DashboardPage.mockRPC(
page,
"get-teams",
"subscription/get-teams-unlimited-subscription-owner.json",
);
await dashboardPage.mockRPC(
"push-audit-events",
"workspace/audit-event-empty.json",
);
await dashboardPage.goToDashboard();
await expect(page.getByTestId("subscription-name")).toHaveText(
"Professional plan",
);
});
test("When the subscription status is canceled, the sidebar dropdown displays the name Professional for the Enterprise subscription", async ({
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await DashboardPage.mockRPC(
page,
}) => {
await DashboardPage.mockRPC(
page,
"get-profile",
"subscription/get-profile-enterprise-canceled-subscription.json",
);
"get-teams",
"subscription/get-teams-unlimited-subscription-owner.json",
);
await DashboardPage.mockRPC(
page,
"get-subscription-usage",
"subscription/get-subscription-usage.json",
);
await DashboardPage.mockRPC(
page,
"get-team-info",
"subscription/get-team-info-subscriptions.json",
);
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await DashboardPage.mockRPC(
page,
"get-teams",
"subscription/get-teams-unlimited-subscription-owner.json",
);
await dashboardPage.mockRPC(
"push-audit-events",
"workspace/audit-event-empty.json",
);
await dashboardPage.goToDashboard();
await expect(page.getByTestId("subscription-name")).toHaveText(
"Professional plan",
);
});
await DashboardPage.mockRPC(
page,
"get-projects?team-id=*",
"dashboard/get-projects-second-team.json",
);
await dashboardPage.mockRPC(
"push-audit-events",
"workspace/audit-event-empty.json",
);
await dashboardPage.goToSecondTeamDashboard();
await expect(page.getByTestId("subscription-icon")).toBeVisible();
});
test.describe("Subscriptions: team members and invitations", () => {
test("Team settings has susbscription name and no manage subscription link when is member", async ({
test("The Unlimited subscription has its name in the sidebar dropdown", async ({
page,
}) => {
await DashboardPage.mockRPC(
page,
}) => {
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in.json",
);
"get-profile",
"subscription/get-profile-unlimited-subscription.json",
);
await DashboardPage.mockRPC(
page,
"get-subscription-usage",
"subscription/get-subscription-usage.json",
);
await DashboardPage.mockRPC(
page,
"get-team-info",
"subscription/get-team-info-subscriptions.json",
);
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await DashboardPage.mockRPC(
page,
"get-teams",
"subscription/get-teams-unlimited-subscription-member.json",
);
await DashboardPage.mockRPC(
page,
"get-projects?team-id=*",
"dashboard/get-projects-second-team.json",
);
await DashboardPage.mockRPC(
page,
"get-team-members?team-id=*",
"subscription/get-team-members-subscription-member.json",
);
await DashboardPage.mockRPC(
page,
"get-team-stats?team-id=*",
"dashboard/get-team-stats.json",
);
await dashboardPage.mockRPC(
"push-audit-events",
"workspace/audit-event-empty.json",
);
await dashboardPage.goToSecondTeamSettingsSection();
await expect(page.getByText("Unlimited (trial)")).toBeVisible();
await expect(
page.getByRole("button", { name: "Manage your subscription" }),
).not.toBeVisible();
});
test("Team settings has susbscription name and manage subscription link when is owner", async ({
await DashboardPage.mockRPC(
page,
}) => {
await DashboardPage.mockRPC(
page,
"get-profile",
"subscription/get-profile-unlimited-subscription.json",
);
"get-subscription-usage",
"subscription/get-subscription-usage-one-editor.json",
);
await DashboardPage.mockRPC(
page,
"get-subscription-usage",
"subscription/get-subscription-usage.json",
);
await DashboardPage.mockRPC(
page,
"get-team-info",
"subscription/get-team-info-subscriptions.json",
);
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await DashboardPage.mockRPC(
page,
"get-teams",
"subscription/get-teams-unlimited-subscription-owner.json",
);
await DashboardPage.mockRPC(
page,
"get-projects?team-id=*",
"dashboard/get-projects-second-team.json",
);
await DashboardPage.mockRPC(
page,
"get-team-members?team-id=*",
"subscription/get-team-members-subscription-owner.json",
);
await DashboardPage.mockRPC(
page,
"get-team-stats?team-id=*",
"dashboard/get-team-stats.json",
);
await dashboardPage.mockRPC(
"push-audit-events",
"workspace/audit-event-empty.json",
);
await dashboardPage.goToSecondTeamSettingsSection();
await expect(page.getByText("Unlimited (trial)")).toBeVisible();
await expect(
page.getByRole("button", { name: "Manage your subscription" }),
).toBeVisible();
});
test("Members tab has warning message when user has more seats than editors.", async ({
await DashboardPage.mockRPC(
page,
}) => {
await DashboardPage.mockRPC(
page,
"get-profile",
"subscription/get-profile-unlimited-subscription.json",
);
"get-team-info",
"subscription/get-team-info-subscriptions.json",
);
await DashboardPage.mockRPC(
page,
"get-subscription-usage",
"subscription/get-subscription-usage.json",
);
await DashboardPage.mockRPC(
page,
"get-team-info",
"subscription/get-team-info-subscriptions.json",
);
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await DashboardPage.mockRPC(
page,
"get-teams",
"subscription/get-teams-unlimited-subscription-owner.json",
);
await DashboardPage.mockRPC(
page,
"get-projects?team-id=*",
"dashboard/get-projects-second-team.json",
);
await DashboardPage.mockRPC(
page,
"get-team-members?team-id=*",
"subscription/get-team-members-subscription-eight-member.json",
);
await dashboardPage.mockRPC(
"push-audit-events",
"workspace/audit-event-empty.json",
);
await dashboardPage.goToSecondTeamMembersSection();
const ctas = page.getByTestId("cta");
await expect(ctas).toHaveCount(2);
await expect(
page.getByText("Inviting people while on the unlimited plan"),
).toBeVisible();
});
test("Invitations tab has warning message when user has more seats than editors.", async ({
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await DashboardPage.mockRPC(
page,
}) => {
await DashboardPage.mockRPC(
page,
"get-profile",
"subscription/get-profile-unlimited-subscription.json",
);
"get-teams",
"subscription/get-teams-unlimited-subscription-owner.json",
);
await DashboardPage.mockRPC(
page,
"get-subscription-usage",
"subscription/get-subscription-usage.json",
);
await dashboardPage.mockRPC(
"push-audit-events",
"workspace/audit-event-empty.json",
);
await dashboardPage.goToDashboard();
await DashboardPage.mockRPC(
page,
"get-team-info",
"subscription/get-team-info-subscriptions.json",
);
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await DashboardPage.mockRPC(
page,
"get-teams",
"subscription/get-teams-unlimited-subscription-owner.json",
);
await DashboardPage.mockRPC(
page,
"get-projects?team-id=*",
"dashboard/get-projects-second-team.json",
);
await DashboardPage.mockRPC(
page,
"get-team-members?team-id=*",
"subscription/get-team-members-subscription-eight-member.json",
);
await DashboardPage.mockRPC(
page,
"get-team-invitations?team-id=*",
"subscription/get-team-invitations.json",
);
await dashboardPage.mockRPC(
"push-audit-events",
"workspace/audit-event-empty.json",
);
await dashboardPage.goToSecondTeamInvitationsSection();
const ctas = page.getByTestId("cta");
await expect(ctas).toHaveCount(2);
await expect(
page.getByText("Inviting people while on the unlimited plan"),
).toBeVisible();
});
await expect(page.getByTestId("subscription-name")).toHaveText(
"Unlimited plan (trial)",
);
});
test("The sidebar dropdown displays the correct subscription name when status is Unpaid", async ({
page,
}) => {
await DashboardPage.mockRPC(
page,
"get-profile",
"subscription/get-profile-unlimited-unpaid-subscription.json",
);
await DashboardPage.mockRPC(
page,
"get-subscription-usage",
"subscription/get-subscription-usage.json",
);
await DashboardPage.mockRPC(
page,
"get-team-info",
"subscription/get-team-info-subscriptions.json",
);
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await DashboardPage.mockRPC(
page,
"get-teams",
"subscription/get-teams-unlimited-subscription-owner.json",
);
await dashboardPage.mockRPC(
"push-audit-events",
"workspace/audit-event-empty.json",
);
await dashboardPage.goToDashboard();
await expect(page.getByTestId("subscription-name")).toHaveText(
"Professional plan",
);
});
test("The sidebar dropdown displays the correct subscription name when status is cancelled", async ({
page,
}) => {
await DashboardPage.mockRPC(
page,
"get-profile",
"subscription/get-profile-enterprise-canceled-subscription.json",
);
await DashboardPage.mockRPC(
page,
"get-subscription-usage",
"subscription/get-subscription-usage.json",
);
await DashboardPage.mockRPC(
page,
"get-team-info",
"subscription/get-team-info-subscriptions.json",
);
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await DashboardPage.mockRPC(
page,
"get-teams",
"subscription/get-teams-unlimited-subscription-owner.json",
);
await dashboardPage.mockRPC(
"push-audit-events",
"workspace/audit-event-empty.json",
);
await dashboardPage.goToDashboard();
await expect(page.getByTestId("subscription-name")).toHaveText(
"Professional plan",
);
});
test("Team settings has susbscription name and no manage subscription link when is member", async ({
page,
}) => {
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in.json",
);
await DashboardPage.mockRPC(
page,
"get-subscription-usage",
"subscription/get-subscription-usage.json",
);
await DashboardPage.mockRPC(
page,
"get-team-info",
"subscription/get-team-info-subscriptions.json",
);
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await DashboardPage.mockRPC(
page,
"get-teams",
"subscription/get-teams-unlimited-subscription-member.json",
);
await DashboardPage.mockRPC(
page,
"get-projects?team-id=*",
"dashboard/get-projects-second-team.json",
);
await DashboardPage.mockRPC(
page,
"get-team-members?team-id=*",
"subscription/get-team-members-subscription-member.json",
);
await DashboardPage.mockRPC(
page,
"get-team-stats?team-id=*",
"dashboard/get-team-stats.json",
);
await dashboardPage.mockRPC(
"push-audit-events",
"workspace/audit-event-empty.json",
);
await dashboardPage.goToSecondTeamSettingsSection();
await expect(page.getByText("Unlimited (trial)")).toBeVisible();
await expect(
page.getByRole("button", { name: "Manage your subscription" }),
).not.toBeVisible();
});
test("Team settings has susbscription name and manage subscription link when is owner", async ({
page,
}) => {
await DashboardPage.mockRPC(
page,
"get-profile",
"subscription/get-profile-unlimited-subscription.json",
);
await DashboardPage.mockRPC(
page,
"get-subscription-usage",
"subscription/get-subscription-usage.json",
);
await DashboardPage.mockRPC(
page,
"get-team-info",
"subscription/get-team-info-subscriptions.json",
);
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await DashboardPage.mockRPC(
page,
"get-teams",
"subscription/get-teams-unlimited-subscription-owner.json",
);
await DashboardPage.mockRPC(
page,
"get-projects?team-id=*",
"dashboard/get-projects-second-team.json",
);
await DashboardPage.mockRPC(
page,
"get-team-members?team-id=*",
"subscription/get-team-members-subscription-owner.json",
);
await DashboardPage.mockRPC(
page,
"get-team-stats?team-id=*",
"dashboard/get-team-stats.json",
);
await dashboardPage.mockRPC(
"push-audit-events",
"workspace/audit-event-empty.json",
);
await dashboardPage.goToSecondTeamSettingsSection();
await expect(page.getByText("Unlimited (trial)")).toBeVisible();
await expect(
page.getByRole("button", { name: "Manage your subscription" }),
).toBeVisible();
});
test("Members tab has warning message when user has more seats than editors", async ({
page,
}) => {
await DashboardPage.mockRPC(
page,
"get-profile",
"subscription/get-profile-unlimited-subscription.json",
);
await DashboardPage.mockRPC(
page,
"get-subscription-usage",
"subscription/get-subscription-usage.json",
);
await DashboardPage.mockRPC(
page,
"get-team-info",
"subscription/get-team-info-subscriptions.json",
);
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await DashboardPage.mockRPC(
page,
"get-teams",
"subscription/get-teams-unlimited-subscription-owner.json",
);
await DashboardPage.mockRPC(
page,
"get-projects?team-id=*",
"dashboard/get-projects-second-team.json",
);
await DashboardPage.mockRPC(
page,
"get-team-members?team-id=*",
"subscription/get-team-members-subscription-eight-member.json",
);
await dashboardPage.mockRPC(
"push-audit-events",
"workspace/audit-event-empty.json",
);
await dashboardPage.goToSecondTeamMembersSection();
const ctas = page.getByTestId("cta");
await expect(ctas).toHaveCount(2);
await expect(
page.getByText("Inviting people while on the unlimited plan"),
).toBeVisible();
});
test("Invitations tab has warning message when user has more seats than editors", async ({
page,
}) => {
await DashboardPage.mockRPC(
page,
"get-profile",
"subscription/get-profile-unlimited-subscription.json",
);
await DashboardPage.mockRPC(
page,
"get-subscription-usage",
"subscription/get-subscription-usage.json",
);
await DashboardPage.mockRPC(
page,
"get-team-info",
"subscription/get-team-info-subscriptions.json",
);
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await DashboardPage.mockRPC(
page,
"get-teams",
"subscription/get-teams-unlimited-subscription-owner.json",
);
await DashboardPage.mockRPC(
page,
"get-projects?team-id=*",
"dashboard/get-projects-second-team.json",
);
await DashboardPage.mockRPC(
page,
"get-team-members?team-id=*",
"subscription/get-team-members-subscription-eight-member.json",
);
await DashboardPage.mockRPC(
page,
"get-team-invitations?team-id=*",
"subscription/get-team-invitations.json",
);
await dashboardPage.mockRPC(
"push-audit-events",
"workspace/audit-event-empty.json",
);
await dashboardPage.goToSecondTeamInvitationsSection();
const ctas = page.getByTestId("cta");
await expect(ctas).toHaveCount(2);
await expect(
page.getByText("Inviting people while on the unlimited plan"),
).toBeVisible();
});

View File

@ -317,7 +317,7 @@
max-height (max height selrect-height)
valign (-> shape :content :vertical-align)
y (:y selrect)
y (if (> height selrect-height)
y (if (and valign (> height selrect-height))
(case valign
"bottom" (- y (- height selrect-height))
"center" (- y (/ (- height selrect-height) 2))

View File

@ -38,30 +38,18 @@
(features/use-feature "render-wasm/v1")
has-invalid-shapes?
(if render-wasm-enabled?
false
(some (fn [shape]
(or (cfh/frame-shape? shape)
(cfh/text-shape? shape)))
shapes-with-children))
(some (if render-wasm-enabled?
cfh/frame-shape?
#(or (cfh/frame-shape? %) (cfh/text-shape? %)))
shapes-with-children)
head-not-group-like?
(and (= 1 total-selected)
(not is-group?)
(not is-bool?))
disabled-bool-btns
(if render-wasm-enabled?
false
(or (zero? total-selected)
has-invalid-shapes?
head-not-group-like?))
disabled-flatten
(if render-wasm-enabled?
false
(or (zero? total-selected)
has-invalid-shapes?))
disabled-bool-btns (or (zero? total-selected) has-invalid-shapes? head-not-group-like?)
disabled-flatten (or (zero? total-selected) has-invalid-shapes?)
on-change
(mf/use-fn

View File

@ -475,9 +475,9 @@
(dissoc :style)
(merge style)
(select-keys allowed-keys))
fill-rule (or (-> attrs :fill-rule sr/translate-fill-rule) (-> attrs :fillRule sr/translate-fill-rule))
stroke-linecap (or (-> attrs :stroke-linecap sr/translate-stroke-linecap) (-> attrs :strokeLinecap sr/translate-stroke-linecap))
stroke-linejoin (or (-> attrs :stroke-linejoin sr/translate-stroke-linejoin) (-> attrs :strokeLinejoin sr/translate-stroke-linejoin))
fill-rule (-> (or (:fill-rule attrs) (:fillRule attrs)) sr/translate-fill-rule)
stroke-linecap (-> (or (:stroke-linecap attrs) (:strokeLinecap attrs)) sr/translate-stroke-linecap)
stroke-linejoin (-> (or (:stroke-linejoin attrs) (:strokeLinejoin attrs)) sr/translate-stroke-linejoin)
fill-none (= "none" (-> attrs :fill))]
(h/call wasm/internal-module "_set_shape_svg_attrs" fill-rule stroke-linecap stroke-linejoin fill-none)))

View File

@ -67,15 +67,17 @@ function filterAllowedTypes(options) {
* @param {string} type
* @returns {boolean}
*/
return function filter(type) {
function filter(type) {
if (
(!("allowHTMLPaste" in options) || !options["allowHTMLPaste"]) &&
type === "text/html"
type === "text/html"
) {
return false;
}
return allowedTypes.includes(type);
};
return filter;
}
/**
@ -85,19 +87,22 @@ function filterAllowedTypes(options) {
* @returns {Function<AllowedTypesFilterFunction>}
*/
function filterAllowedItems(options) {
/**
* @param {DataTransferItem}
* @returns {boolean}
*/
return function filter(item) {
function filter(item) {
if (
(!("allowHTMLPaste" in options) || !options["allowHTMLPaste"]) &&
item.type === "text/html"
item.type === "text/html"
) {
return false;
}
return allowedTypes.includes(item.type);
};
return filter;
}
/**

View File

@ -38,12 +38,14 @@ const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 1;
const MAX_BLOCKING_TIME_MS: i32 = 32;
const NODE_BATCH_THRESHOLD: i32 = 10;
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
pub struct NodeRenderState {
pub id: Uuid,
// We use this bool to keep that we've traversed all the children inside this node.
visited_children: bool,
// This is used to clip the content of frames.
clip_bounds: Option<(Rect, Option<Corners>, Matrix)>,
clip_bounds: Option<ClipStack>,
// This is a flag to indicate that we've already drawn the mask of a masked group.
visited_mask: bool,
// This bool indicates that we're drawing the mask shape.
@ -68,13 +70,26 @@ impl NodeRenderState {
/// the clipping region to compensate for coordinate system transformations.
/// This is useful for nested coordinate systems or when elements are grouped
/// and need relative positioning adjustments.
fn append_clip(
clip_stack: Option<ClipStack>,
clip: (Rect, Option<Corners>, Matrix),
) -> Option<ClipStack> {
match clip_stack {
Some(mut stack) => {
stack.push(clip);
Some(stack)
}
None => Some(vec![clip]),
}
}
pub fn get_children_clip_bounds(
&self,
element: &Shape,
offset: Option<(f32, f32)>,
) -> Option<(Rect, Option<Corners>, Matrix)> {
) -> Option<ClipStack> {
if self.id.is_nil() || !element.clip() {
return self.clip_bounds;
return self.clip_bounds.clone();
}
let mut bounds = element.selrect();
@ -95,7 +110,7 @@ impl NodeRenderState {
_ => None,
};
Some((bounds, corners, transform))
Self::append_clip(self.clip_bounds.clone(), (bounds, corners, transform))
}
/// Calculates the clip bounds for shadow rendering of a given shape.
@ -113,9 +128,9 @@ impl NodeRenderState {
&self,
element: &Shape,
shadow: &Shadow,
) -> Option<(Rect, Option<Corners>, Matrix)> {
) -> Option<ClipStack> {
if self.id.is_nil() {
return self.clip_bounds;
return self.clip_bounds.clone();
}
// Assert that the shape is either a Frame or Group
@ -136,9 +151,9 @@ impl NodeRenderState {
_ => None,
};
Some((bounds, corners, transform))
Self::append_clip(self.clip_bounds.clone(), (bounds, corners, transform))
}
_ => self.clip_bounds,
_ => self.clip_bounds.clone(),
}
}
}
@ -368,6 +383,15 @@ impl RenderState {
Self::blur_from_variance(total)
}
fn frame_clip_layer_blur(shape: &Shape) -> Option<Blur> {
match shape.shape_type {
Type::Frame(_) if shape.clip() => shape.blur.filter(|blur| {
!blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.
}),
_ => None,
}
}
/// Runs `f` with `ignore_nested_blurs` temporarily forced to `true`.
/// Certain off-screen passes (e.g. shadow masks) must render shapes without
/// inheriting ancestor blur. This helper guarantees the flag is restored.
@ -554,7 +578,7 @@ impl RenderState {
pub fn render_shape(
&mut self,
shape: &Shape,
clip_bounds: Option<(Rect, Option<Corners>, Matrix)>,
clip_bounds: Option<ClipStack>,
fills_surface_id: SurfaceId,
strokes_surface_id: SurfaceId,
innershadows_surface_id: SurfaceId,
@ -574,49 +598,59 @@ impl RenderState {
let antialias = shape.should_use_antialias(self.get_scale());
// set clipping
if let Some((bounds, corners, transform)) = clip_bounds {
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().concat(&transform);
});
if let Some(clips) = clip_bounds.as_ref() {
for (bounds, corners, transform) in clips.iter() {
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().concat(transform);
});
if let Some(corners) = corners {
let rrect = RRect::new_rect_radii(*bounds, corners);
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas()
.clip_rrect(rrect, skia::ClipOp::Intersect, antialias);
});
} else {
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas()
.clip_rect(*bounds, skia::ClipOp::Intersect, antialias);
});
}
// This renders a red line around clipped
// shapes (frames).
if self.options.is_debug_visible() {
let mut paint = skia::Paint::default();
paint.set_style(skia::PaintStyle::Stroke);
paint.set_color(skia::Color::from_argb(255, 255, 0, 0));
paint.set_stroke_width(4.);
self.surfaces
.canvas(fills_surface_id)
.draw_rect(*bounds, &paint);
}
if let Some(corners) = corners {
let rrect = RRect::new_rect_radii(bounds, &corners);
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas()
.clip_rrect(rrect, skia::ClipOp::Intersect, antialias);
});
} else {
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas()
.clip_rect(bounds, skia::ClipOp::Intersect, antialias);
.concat(&transform.invert().unwrap_or(Matrix::default()));
});
}
// This renders a red line around clipped
// shapes (frames).
if self.options.is_debug_visible() {
let mut paint = skia::Paint::default();
paint.set_style(skia::PaintStyle::Stroke);
paint.set_color(skia::Color::from_argb(255, 255, 0, 0));
paint.set_stroke_width(4.);
self.surfaces
.canvas(fills_surface_id)
.draw_rect(bounds, &paint);
}
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas()
.concat(&transform.invert().unwrap_or(Matrix::default()));
});
}
// We don't want to change the value in the global state
let mut shape: Cow<Shape> = Cow::Borrowed(shape);
let frame_has_blur = Self::frame_clip_layer_blur(&shape).is_some();
let shape_has_blur = shape.blur.is_some();
if !self.ignore_nested_blurs {
if self.ignore_nested_blurs {
if frame_has_blur && shape_has_blur {
shape.to_mut().set_blur(None);
}
} else if !frame_has_blur {
if let Some(blur) = self.combined_layer_blur(shape.blur) {
shape.to_mut().set_blur(Some(blur));
}
} else if shape_has_blur {
shape.to_mut().set_blur(None);
}
let center = shape.center();
@ -1064,6 +1098,14 @@ impl RenderState {
paint.set_blend_mode(element.blend_mode().into());
paint.set_alpha_f(element.opacity());
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
let scale = self.get_scale();
let sigma = frame_blur.value * scale;
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
paint.set_image_filter(filter);
}
}
// When we're rendering the mask shape we need to set a special blend mode
// called 'destination-in' that keeps the drawn content within the mask.
// @see https://skia.org/docs/user/api/skblendmode_overview/
@ -1228,7 +1270,7 @@ impl RenderState {
shape: &Shape,
shape_bounds: &Rect,
shadow: &Shadow,
clip_bounds: Option<(Rect, Option<Corners>, Matrix)>,
clip_bounds: Option<ClipStack>,
scale: f32,
translation: (f32, f32),
extra_layer_blur: Option<Blur>,
@ -1373,13 +1415,11 @@ impl RenderState {
let mut is_empty = true;
while let Some(node_render_state) = self.pending_nodes.pop() {
let NodeRenderState {
id: node_id,
visited_children,
clip_bounds,
visited_mask,
mask,
} = node_render_state;
let node_id = node_render_state.id;
let visited_children = node_render_state.visited_children;
let visited_mask = node_render_state.visited_mask;
let mask = node_render_state.mask;
let clip_bounds = node_render_state.clip_bounds.clone();
is_empty = false;
@ -1462,7 +1502,7 @@ impl RenderState {
element,
&element.extrect(tree, scale),
shadow,
clip_bounds,
clip_bounds.clone(),
scale,
translation,
None,
@ -1550,37 +1590,40 @@ impl RenderState {
}
}
if let Some((bounds, corners, transform)) = clip_bounds.as_ref() {
if let Some(clips) = clip_bounds.as_ref() {
let antialias = element.should_use_antialias(scale);
let mut total_matrix = Matrix::new_identity();
total_matrix.pre_scale((scale, scale), None);
total_matrix.pre_translate((translation.0, translation.1));
total_matrix.pre_concat(transform);
self.surfaces.canvas(SurfaceId::Current).save();
self.surfaces
.canvas(SurfaceId::Current)
.concat(&total_matrix);
for (bounds, corners, transform) in clips.iter() {
let mut total_matrix = Matrix::new_identity();
total_matrix.pre_scale((scale, scale), None);
total_matrix.pre_translate((translation.0, translation.1));
total_matrix.pre_concat(transform);
if let Some(corners) = corners {
let rrect = RRect::new_rect_radii(*bounds, corners);
self.surfaces.canvas(SurfaceId::Current).clip_rrect(
rrect,
skia::ClipOp::Intersect,
antialias,
);
} else {
self.surfaces.canvas(SurfaceId::Current).clip_rect(
*bounds,
skia::ClipOp::Intersect,
antialias,
);
self.surfaces
.canvas(SurfaceId::Current)
.concat(&total_matrix);
if let Some(corners) = corners {
let rrect = RRect::new_rect_radii(*bounds, corners);
self.surfaces.canvas(SurfaceId::Current).clip_rrect(
rrect,
skia::ClipOp::Intersect,
antialias,
);
} else {
self.surfaces.canvas(SurfaceId::Current).clip_rect(
*bounds,
skia::ClipOp::Intersect,
antialias,
);
}
self.surfaces
.canvas(SurfaceId::Current)
.concat(&total_matrix.invert().unwrap_or_default());
}
self.surfaces
.canvas(SurfaceId::Current)
.concat(&total_matrix.invert().unwrap_or_default());
self.surfaces
.draw_into(SurfaceId::DropShadows, SurfaceId::Current, None);
@ -1596,7 +1639,7 @@ impl RenderState {
self.render_shape(
element,
clip_bounds,
clip_bounds.clone(),
SurfaceId::Fills,
SurfaceId::Strokes,
SurfaceId::InnerShadows,
@ -1614,6 +1657,9 @@ impl RenderState {
}
match element.shape_type {
Type::Frame(_) if Self::frame_clip_layer_blur(element).is_some() => {
self.nested_blurs.push(None);
}
Type::Frame(_) | Type::Group(_) => {
self.nested_blurs.push(element.blur);
}
@ -1624,7 +1670,7 @@ impl RenderState {
self.pending_nodes.push(NodeRenderState {
id: node_id,
visited_children: true,
clip_bounds,
clip_bounds: clip_bounds.clone(),
visited_mask: false,
mask,
});
@ -1651,7 +1697,7 @@ impl RenderState {
self.pending_nodes.push(NodeRenderState {
id: **child_id,
visited_children: false,
clip_bounds: children_clip_bounds,
clip_bounds: children_clip_bounds.clone(),
visited_mask: false,
mask: false,
});

View File

@ -885,19 +885,50 @@ impl Shape {
scale: f32,
) -> Bounds {
let mut rect = bounds.to_rect();
let include_children = match self.shape_type {
Type::Group(_) => true,
Type::Frame(_) => !self.clip_content,
_ => false,
};
if include_children {
for child_id in self.children_ids_iter(false) {
if let Some(child_shape) = shapes_pool.get(child_id) {
let child_extrect = child_shape.calculate_extrect(shapes_pool, scale);
rect.join(child_extrect);
match self.shape_type {
Type::Group(Group { masked: true }) => {
let mut mask_rect: Option<math::Rect> = None;
let mut content_rect: Option<math::Rect> = None;
for (index, child_id) in self.children.iter().enumerate() {
if let Some(child_shape) = shapes_pool.get(child_id) {
let child_extrect = child_shape.calculate_extrect(shapes_pool, scale);
if index == 0 {
mask_rect = Some(child_extrect);
} else {
match content_rect.as_mut() {
Some(r) => r.join(child_extrect),
None => content_rect = Some(child_extrect),
}
}
}
}
match (mask_rect, content_rect) {
(Some(mut mask), Some(content)) => {
if mask.intersect(content) {
rect.join(mask);
}
}
(Some(mask), None) | (None, Some(mask)) => {
rect.join(mask);
}
(None, None) => {}
}
}
Type::Group(_) | Type::Frame(_) if !self.clip_content => {
for child_id in self.children_ids_iter(false) {
if let Some(child_shape) = shapes_pool.get(child_id) {
let child_extrect = child_shape.calculate_extrect(shapes_pool, scale);
rect.join(child_extrect);
}
}
}
_ => {}
}
Bounds::from_rect(&rect)
@ -1426,6 +1457,7 @@ impl Shape {
#[cfg(test)]
mod tests {
use super::*;
use crate::state::ShapesPool;
fn any_shape() -> Shape {
Shape::new(Uuid::nil())
@ -1485,4 +1517,42 @@ mod tests {
assert_eq!(shape.selrect().width(), 20.0);
assert_eq!(shape.selrect().height(), 20.0);
}
#[test]
fn masked_group_extrect_matches_mask_intersection() {
let mut pool = ShapesPool::new();
pool.initialize(3);
let group_id = Uuid::new_v4();
let mask_id = Uuid::new_v4();
let content_id = Uuid::new_v4();
{
let group = pool.add_shape(group_id);
group.set_shape_type(Type::Group(Group { masked: true }));
group.children = vec![mask_id, content_id];
}
{
let mask = pool.add_shape(mask_id);
mask.set_shape_type(Type::Rect(Rect::default()));
mask.set_selrect(0.0, 0.0, 50.0, 50.0);
mask.set_parent(group_id);
}
{
let content = pool.add_shape(content_id);
content.set_shape_type(Type::Rect(Rect::default()));
content.set_selrect(-10.0, -10.0, 110.0, 110.0);
content.set_parent(group_id);
}
let group = pool.get(&group_id).expect("group should exist");
let extrect = group.calculate_extrect(&pool, 1.0);
assert_eq!(extrect.left, 0.0);
assert_eq!(extrect.top, 0.0);
assert_eq!(extrect.right, 50.0);
assert_eq!(extrect.bottom, 50.0);
}
}

View File

@ -292,7 +292,7 @@ pub extern "C" fn set_shape_text_content() {
with_current_shape_mut!(state, |shape: &mut Shape| {
let raw_text_data = RawParagraph::try_from(&bytes).unwrap();
if let Err(_) = shape.add_paragraph(raw_text_data.into()) {
if shape.add_paragraph(raw_text_data.into()).is_err() {
println!("Error with set_shape_text_content on {:?}", shape.id);
}
});