Compare commits

...

272 Commits

Author SHA1 Message Date
Pablo Alba
700f3e9c10 MR changes 2026-04-24 17:19:41 +02:00
Pablo Alba
debfe5490f 🐛 Fix switching a team nitrate organization lose the background 2026-04-24 17:19:41 +02:00
Andrey Antukh
01d68ec09b Merge remote-tracking branch 'origin/staging' into develop 2026-04-24 14:16:03 +02:00
Andrey Antukh
35f8e1b084 Merge remote-tracking branch 'origin/main-staging' into staging 2026-04-24 14:09:21 +02:00
Andrey Antukh
0b6416e53b Merge remote-tracking branch 'origin/main' into main-staging 2026-04-24 14:09:03 +02:00
Andrey Antukh
d380efdb0c
⬆️ Update devenv dependencies (#9142)
* ⬆️ Update devenv dependencies

*  Fix formatting issues

* 📎 Fix linter issues
2026-04-24 14:07:51 +02:00
moorsecopers99
7e499c5e5f
🐛 Fix Settings/Notifications submit button always active with no changes (#9091)
The "Update Settings" button in Your Account > Settings and Notifications
was always enabled, even when the form had no changes, and clicking it
emitted a success notification despite no data being modified.

Disable the submit button when the current form data equals its initial
state, so it activates only when there are actual changes to persist.

Signed-off-by: moorsecopers99 <patellscott18@gmail.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-24 13:24:55 +02:00
Juan Flores
38d67c8e96
🐛 Fix Help & Learning submenu vertical alignment in account menu (#9138)
The submenu opened by hovering Help & Learning in the user account
menu rendered with a vertical offset, making it appear visually
disconnected from its parent row and aligned instead with the
Community

Signed-off-by: Juan Flores <112629487+juan-flores077@users.noreply.github.com>
2026-04-24 13:17:57 +02:00
Eva Marco
6c4ab8940d
🐛 Fix colorpicker eyedropper on gradients tab (#9125)
* 🐛 Fix colorpicker eyedropper on gradients tab

* 🐛 Fix gradient test deleting opacity input
2026-04-24 12:48:58 +02:00
boskodev790
9ebd17f31f
🐛 Fix PENPOT_OIDC_USER_INFO_SOURCE flag being silently ignored (#9114)
Closes #9108.

The `case` expression in `get-info` (`backend/src/app/auth/oidc.clj`)
dispatched on `:token` and `:userinfo` keywords, but the provider map's
`:user-info-source` value is a string — both from config (the malli
schema in `app.config` pins it to one of `"token"`, `"userinfo"`,
`"auto"`) and from the hard-coded Google / GitHub provider maps (which
already write `"userinfo"`). Strings never equal keywords in Clojure
`case`, so every call fell through to the auto-fallback that prefers
ID-token claims and only hits the UserInfo endpoint when claims are
empty. The net effect: setting `PENPOT_OIDC_USER_INFO_SOURCE=userinfo`
did nothing, and OIDC flows whose IdP requires the UserInfo endpoint
(so claims come back empty/partial) failed with "incomplete user info".

- Extract a pure helper `select-user-info-source` that maps the raw
  config string to a dispatch keyword (`:token`, `:userinfo`, `:auto`),
  falling back to `:auto` for unknown / missing / accidentally-keyword
  values
- Rewrite `get-info`'s `case` to dispatch on the helper's output so
  the arms unambiguously match the normalised keyword
- Add vitest-style deftests in `auth_oidc_test.clj` pinning the three
  valid strings, the nil / "auto" / unknown fallback, and the reverse
  regression (a keyword input must not slip through as if it were the
  matching string)
- Add a CHANGES.md entry under the 2.17.0 Unreleased `🐛 Bugs fixed`
  section linking back to #9108

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-24 12:14:46 +02:00
Alejandro Alonso
89a1ee7813 Merge remote-tracking branch 'origin/main-staging' into staging 2026-04-24 12:06:27 +02:00
Andrey Antukh
29ba336928 Merge remote-tracking branch 'origin/main' into main-staging 2026-04-24 11:58:50 +02:00
Andrey Antukh
cfb076dd61 📚 Update AGENTS.md with common github operations 2026-04-24 11:45:36 +02:00
Eva Marco
4a7140d82d
🐛 Fix theme modal height (#9105)
* 🐛 Fix CI

* 🐛 Fix theme modal height
2026-04-24 11:38:34 +02:00
Pablo Alba
4061673528
Add nitrate api endpoints to get and cancel org invitations (#9124)
*  Add nitrate api endpoints to get and cancel org invitations

*  MR changes
2026-04-24 11:35:53 +02:00
Alejandro Alonso
e05ea1392a
Merge pull request #9140 from penpot/superalex-fix-merge-develop
🐛 Fix text.cljs error from staging merge
2026-04-24 10:57:59 +02:00
Alejandro Alonso
58fae0a04d 🐛 Fix text.cljs error from staging merge 2026-04-24 10:10:00 +02:00
Alejandro Alonso
078663b0fa 🔧 Fix rust linter errors 2026-04-24 09:52:51 +02:00
Eva Marco
5a7ba7ee7e
🐛 Fix multiple selection on shapes with token applied to stroke-color (#9110)
*  Remove the need to navigate to page for deletion operation

* 🐛 Fix multiple selection with applied-tokens on stroke-color

* 🐛 Fix button position on page header

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-24 09:47:44 +02:00
Alejandro Alonso
7532bf411c Merge remote-tracking branch 'origin/develop' into develop 2026-04-24 09:32:35 +02:00
Alejandro Alonso
984d292ab2 Merge remote-tracking branch 'origin/staging' into develop 2026-04-24 09:29:24 +02:00
Full Stack Developer
25e6b939ba
Show detailed messages on file import errors (#9004)
*  Show detailed messages on file import errors

Signed-off-by: jsdevninja <topit89807@gmail.com>

*  Fix test

*  Fix build error

---------

Signed-off-by: jsdevninja <topit89807@gmail.com>
2026-04-24 09:13:46 +02:00
FairyPiggyDev
361c1c574b
🐛 Fix plugin parse-point returning plain map instead of Point record (#9129)
The plugin parser's parse-point returned a plain `{:x … :y …}` map,
but shape interaction schemas (for example schema:open-overlay-interaction)
require the attribute to be a `::gpt/point` record. `(instance? Point {:x 0 :y 0})`
is false, so validation silently rejected plugin `addInteraction` calls
that passed `manualPositionLocation`; only a console warning was produced.

Change parse-point to return a `gpt/point` record via `gpt/point`.
All three call sites (parser.cljs:open-overlay, plugins/page.cljs,
plugins/comments.cljs) continue to work because Point records support
the same `:x`/`:y` access plain maps do.

Add a unit test that covers nil input and verifies the returned value
satisfies `gpt/point?`.

Github #8409

Signed-off-by: FairyPigDev <luislee3108@gmail.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-24 09:12:13 +02:00
Juan Flores
841b2e156e
🐛 Fix typography style creation with tokenized line-height (#9121)
When a text element has a line-height coming from a design token, the value
may be a number (e.g. 1.5) and fails frontend data validation expecting a
string. Normalize line-height before creating the typography style so the
operation succeeds without throwing an assertion error.

Signed-off-by: juan-flores077 <toptalent399@gmail.com>
2026-04-24 09:11:31 +02:00
boskodev790
6c7843f4b6
🐛 Fix obfuscate-email crashing on malformed email or dotless domain (#9120)
The viewer-side `obfuscate-email` helper used by `anonymize-member` when
building share-link bundles called `clojure.string/split` on the raw
email input and then on the extracted domain. Two failure modes:

1. When the stored email had no `@` (legacy data, LDAP-sourced UIDs, direct
   DB inserts, or fixtures that bypassed `::sm/email`), destructuring
   left `domain` bound to `nil` and the follow-up `(str/split nil "." 2)`
   raised `NullPointerException`. Because `obfuscate-email` runs inside
   `get-view-only-bundle`, the exception aborted the whole RPC response
   for share-link viewers, not just the field.

2. When the stored email used a single-label domain (`alice@localhost`),
   `(str/split "localhost" "." 2)` returned `["localhost"]`; destructuring
   bound `rest` to `nil` and the final `(str name "@****." rest)` produced
   a dangling-dot output `"****@****."` (nil coerces to empty in `str`).

Guard both split calls with `(or x "")` so the chain is nil-safe, and
emit the trailing `.<tld>` segment only when `rest` is present. Add three
`deftest` groups covering the happy path, dotless domains, and malformed
inputs (nil / empty / no-`@`), plus a CHANGES.md entry under the 2.17.0
Unreleased bugs-fixed section.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-24 09:09:49 +02:00
Renzo
8aacda2249
Add Shift+Numpad0/1/2 zoom shortcut aliases (#2457) (#9063)
Signed-off-by: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com>
2026-04-24 09:08:31 +02:00
wdeveloper16
50bee5e176
Add clipboard:read/write permissions to plugin system (#6980) (#9053)
*  Add clipboard:read/write permissions to plugin system (#6980)

* 🔧 Fix prettier formatting in clipboard permission files

---------

Co-authored-by: wdeveloper16 <wdeveloer16@protonmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-24 09:07:58 +02:00
Andrey Antukh
20c6a28b52 📎 Add commit agent for opencode 2026-04-24 08:54:01 +02:00
Andrey Antukh
7135782e7d Merge remote-tracking branch 'origin/main-staging' into staging 2026-04-24 08:19:47 +02:00
Andrey Antukh
fd38f5b431 Merge remote-tracking branch 'origin/main' into main-staging 2026-04-24 08:18:55 +02:00
Andrey Antukh
2d5e50f352 ⬆️ Update root repo deps 2026-04-24 08:17:32 +02:00
wdeveloper16
e280168de9
Add read-only preview mode for saved versions (#7622) (#8976)
*  Add read-only preview mode for saved versions (#7622)

* 🔧 Address review feedback on version preview (#7622)

* 🐛 Fix version preview for WASM renderer (#7622)

* 🐛 Fix stylelint color-named and color-function-notation in preview banner (#7622)

* 🐛 Fix invalid-arity call to initialize-workspace in exit-preview (#7622)

* 🐛 Fix unclosed defn paren in exit-preview (#7622)

* ♻️ Refactor version preview/restore flow

Separate enter-preview and enter-restore flows with dedicated dialogs
instead of a persistent banner. Removes preview-banner component in favor
of inline actions dialog. Uses backup/restore pattern for exit-preview
instead of full workspace reinitialization. Adds analytics events for
preview/restore actions.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>

*  Extract on-name-input-focus as namespace-level private function

The callback had no dependencies on component-local state or props,
making it a pure function that can be hoisted to a defn-. This avoids
recreating the same callback identity on every render of version-entry*.

*  Extract extract-id-from-event helper to deduplicate snapshot callbacks

Three callbacks in snapshot-entry* shared the same DOM extraction logic
(get current target, read data-id, parse UUID). Extracted into a private
defn- to remove the duplication and simplify each callback.

*  Extract pure state-update callbacks from versions-toolbox* to namespace level

Eight callbacks that only emit fixed Potok events with no meaningful
deps were hoisted out of the component as defn- functions:

- on-create-version
- on-edit-version
- on-cancel-version-edition
- on-rename-version
- on-delete-version
- on-pin-version
- on-lock-version
- on-unlock-version

These no longer need mf/use-fn wrappers since namespace-level functions
have stable identity across renders, avoiding unnecessary callback
recreation on each render cycle.

*  Rename filter parameter to filter-value in on-change-filter to avoid core shadowing

The parameter name 'filter' shadowed clojure.core/filter within the
function scope. Renamed to 'filter-value' for clarity and to prevent
potential bugs if core/filter were needed in future changes.

* 🔧 Fix linter warnings and errors across version-related namespaces

frontend/src/app/main/ui/workspace.cljs:
- Remove unused requires: app.common.data, app.main.data.notifications,
  app.main.data.workspace.versions

frontend/src/app/main/data/workspace/versions.cljs:
- Remove unused require: app.common.uuid
- Fix duplicate reify type: enter-restore used ::restore-version
  (same as the private restore-version fn), renamed to ::enter-restore
- Remove unused bindings: state in enter-restore, team-id in
  exit-preview and restore-version-from-plugin

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Signed-off-by: wdeveloper16 <wdeveloer16@protonmail.com>
Co-authored-by: wdeveloper16 <wdeveloer16@protonmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-24 08:13:16 +02:00
Renzo
7c1a29ccf7
🐛 Remove corepack dependency from MCP server for Node.js 25+ (#9119)
* 🐛 Remove corepack dependency from MCP server for Node.js 25+

* 🐛 Update
2026-04-23 22:08:11 +02:00
Luis de Dios
cd417443f6
🐛 Fix layer hierarchy to match old and new SCSS (#9126) 2026-04-23 18:00:40 +02:00
Eva Marco
0c60db56a2
🐛 Fix multiselection error with typography texts (#9071)
* 🐛 Ensure typography-ref attrs are always present and fix nil encoding

Add :typography-ref-file and :typography-ref-id (both defaulting to nil)
to default-text-attrs so these keys are always present in text node maps,
whether or not a typography is attached.

Skip nil values in attrs-to-styles (Draft.js style encoder) and in
attrs->styles (v2 CSS custom-property mapper) so nil typography-ref
entries are never serialised to CSS.

Replace when with if/acc in get-styles-from-style-declaration to prevent
the accumulator from being clobbered to nil when a mixed-value entry is
skipped during style decoding.

* 🎉 Add test

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-23 16:08:56 +02:00
Marina López
a3c330d6e7 Add downgrade nitrate to unlimited modal 2026-04-23 12:54:42 +02:00
Elena Torro
96722fde4b 🐛 Support EvenOdd SVG attribute across all path operations 2026-04-23 12:02:40 +02:00
Elena Torro
4a549d0907 Drain GPU queue during pan/zoom to avoid render_from_cache hitch 2026-04-23 11:19:51 +02:00
Eva Marco
d6b341c053
🐛 Fix color token (#9095) 2026-04-23 10:51:30 +02:00
Eva Marco
5c9696e20c
🐛 Fix color dropdown option update (#9100) 2026-04-23 10:51:20 +02:00
Eva Marco
28b33b9acc
🐛 Fix props on text components (#9099) 2026-04-23 10:49:48 +02:00
Andrey Antukh
c6b6b9ce00 📎 Update changelog 2026-04-23 09:59:11 +02:00
Yamila Moreno
5f7de04efe
🚑 Fix email blacklisting (#9122) 2026-04-23 09:42:40 +02:00
Elena Torró
d43d1f431f
Merge pull request #9112 from penpot/superalex-improve-atlas-growth
🎉 Improve atlas growth
2026-04-23 09:22:39 +02:00
Yamila Moreno
dc8073f924 🐳 Add PENPOT_PUBLIC_URI to penpot-frontend 2026-04-23 09:06:10 +02:00
Renzo
5bbb2c5cff
🐛 Fix Copy as SVG for multi-shape selection (#838) (#9066)
Signed-off-by: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com>
2026-04-22 19:46:38 +02:00
Alejandro Alonso
9e990a975a 🎉 Improve atlas growth 2026-04-22 17:21:11 +02:00
Andrey Antukh
ba42cc04b7 ♻️ Derive v-sizing from values instead of passing as prop
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-04-22 14:17:15 +00:00
Luis de Dios
b60695f54a 🐛 Fix indicate that the mcp is disabled if the mcp key has expired
If the mcp key has expired, the switch that indicates the status in the dashboard will appear as disabled, and will show a modal for regenerate the key. It will also appear as disabled in the workspace, not allowing the plugin to connect
2026-04-22 16:00:52 +02:00
Yamila Moreno
3c542a1abc
🐛 Fix email validation (#9037) 2026-04-22 15:59:28 +02:00
Dexterity
3fd976c551
🐛 Fix UI bugs in account settings forms (#8997)
Closes #8977
Closes #8979

Signed-off-by: Dexterity <173429049+Dexterity104@users.noreply.github.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-22 15:21:02 +02:00
Edwin Rivera
7dbd602d1e
🐛 Fix text export with custom fonts across SVG, PNG and JPG (#9094)
* 🐛 Fix text export with custom fonts across SVG, PNG and JPG

Text layers using custom or non-standard fonts were rendered incorrectly
on export regardless of the target format. The exporter was not resolving
the font face correctly before rasterization/serialization, causing the
output to fall back to a default glyph set and producing broken or
misaligned text. This fix ensures font data is resolved and embedded
consistently in the export pipeline for all output formats.

Signed-off-by: Edwin Rivera <bytelogic772@gmail.com>

* 📚 Add entry to CHANGES.md under 2.17.0

Signed-off-by: edwin-rivera-dev <bytelogic772@gmail.com>

---------

Signed-off-by: Edwin Rivera <bytelogic772@gmail.com>
Signed-off-by: edwin-rivera-dev <bytelogic772@gmail.com>
2026-04-22 15:19:58 +02:00
Juanfran
7d4092eeba 🐛 Fix column name mismatch when accepting org invitation 2026-04-22 14:24:03 +02:00
Elena Torro
f673b32567 🐛 Fix image loading callback 2026-04-22 14:00:49 +02:00
Full Stack Developer
d384f47253
🐛 Fix internal error on layer prev/next sibling selection (#9003)
Signed-off-by: jsdevninja <topit89807@gmail.com>
2026-04-22 13:59:42 +02:00
Andrey Antukh
8ad30e14b6 Merge remote-tracking branch 'origin/staging' into develop 2026-04-22 13:34:00 +02:00
Andrey Antukh
b0b2c0d264 📎 Update version on mcp/ module 2026-04-22 13:18:24 +02:00
Andrey Antukh
f00ea8789f 📎 Update version on mcp module 2026-04-22 13:16:34 +02:00
Andrey Antukh
112e81c397 📎 Fix the version reference
Caused by the recent version changes
2026-04-22 13:14:04 +02:00
moorsecopers99
b6487015b8
Add loader feedback while importing and exporting files (#9024)
*  Add loader feedback while importing and exporting files

Show a loader icon with a status label ("Importing files…" /
"Exporting files…") in the import and export dialog footers while the
operation is running, so users get clear in-progress feedback and
cannot retrigger the action by mistake.

Closes #9020

Signed-off-by: moorsecopers99 <patellscott18@gmail.com>

*  Address import/export loader feedback PR review

- Show the loader beside file names in the import dialog while files
  are being imported (previously queued entries kept showing the
  Penpot logo until each one moved into :import-progress).
- Drop the loader from the "Importing files…" / "Exporting files…"
  footer status, leaving just the text styled with the modal title
  color, per the design proposal.

Signed-off-by: moorsecopers99 <patellscott18@gmail.com>

*  Match design proposal for import/export progress feedback

- Move the in-progress label from the modal footer into the modal
  body, under the file rows, styled italic with the modal title
  color.
- Rename the labels to match the design wording: "Uploading file…"
  for import and "Downloading file…" for export.
- Restore the disabled "Accept" button in the import footer during
  the import-progress phase, mirroring the disabled "Close" button
  used by export.

Signed-off-by: moorsecopers99 <patellscott18@gmail.com>

* 🐛 Rename deprecated bodySmallTypography mixin to body-small-typography

Signed-off-by: moorsecopers99 <patellscott18@gmail.com>

---------

Signed-off-by: moorsecopers99 <patellscott18@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-22 13:12:48 +02:00
Andrey Antukh
88008ce16c 📎 Update mcp types yaml file 2026-04-22 13:11:10 +02:00
Andrey Antukh
75d99a0725 🔧 Add missing public uri handling on nginx entrypoint 2026-04-22 13:11:10 +02:00
Andrey Antukh
09637f9794 Allow render entrypoint load alternative config
The render entrypoint is used by exporter
2026-04-22 13:11:10 +02:00
Andrey Antukh
3225319e0c 🐛 Fix frontend tests 2026-04-22 12:54:07 +02:00
Edwin Rivera
2579527e64
🎉 Add get-file-stats RPC command (#9074)
* 🎉 Add get-file-stats RPC command

Introduce a new lightweight RPC query that returns aggregate statistics
for a single file: page count, shape counts by type, component/color/
typography counts, and inbound and outbound library reference counts.
Mirrors the existing get-file-summary permission and decoding pattern.

Useful for plugin authors enforcing per-file budgets, the
@penpot/library npm SDK, and future admin dashboards. Purely additive
— no migrations, no UI, no breaking changes.

Signed-off-by: edwin-rivera-dev <bytelogic772@gmail.com>

* 🐛 Bind *load-fn* around file data walk in get-file-stats

The binding previously wrapped only  — a plain key
lookup that does not realize any pointers — so by the time
 walked  and accessed  on
each page,  was unbound and every PointerMap
dereference threw , failing the three new  tests.

Move  inside the  form so the walk runs
with  available, matching the existing pattern used in
.

Signed-off-by: Edwin Rivera <bytelogic772@gmail.com>

---------

Signed-off-by: edwin-rivera-dev <bytelogic772@gmail.com>
Signed-off-by: Edwin Rivera <bytelogic772@gmail.com>
2026-04-22 12:49:39 +02:00
Eva Marco
09fca1c820
🐛 Fix tooltip appearing two times when nested elements (#9031) 2026-04-22 11:33:43 +02:00
Eva Marco
c02f0a2bc9
🐛 Fix grow options (#9097)
* 🐛 Fix grow options

* 🐛 Fix props when hidden is nil
2026-04-22 11:09:44 +02:00
Yamila Moreno
6de5370a0b 🐛 Fix nginx configuration for mcp 2026-04-22 10:29:52 +02:00
Andrey Antukh
448b5d4786 Merge remote-tracking branch 'origin/main' into main-staging 2026-04-22 09:58:20 +02:00
Andrey Antukh
47b3667248 🐛 Fix exporter renderer URI path construction
Apply consistent path construction across bitmap, PDF, and SVG
renderers in the exporter. Use path join utilities instead of
hardcoding the render.html path, ensuring the path is properly
appended to the public URI base path.

- bitmap.cljs: Use u/ensure-path-slash and u/join for path
- pdf.cljs: Use u/join and ensure-path-slash on base-uri
- svg.cljs: Use u/ensure-path-slash and u/join for path
2026-04-22 09:52:44 +02:00
Andrey Antukh
98e8160875 ♻️ Remove worker URI from global templates and compute from public URI
- Remove penpotWorkerURI from index.mustache and rasterizer.mustache templates
- Remove worker_main entry from the build manifest
- Construct worker URI in config.cljs by joining public-uri with worker path
- Fix global variable casing for plugins-list-uri and templates-uri
- Fix alignment in worker.cljs let bindings
2026-04-22 09:52:44 +02:00
María Valderrama
b67394199b
Add the ability to upload organization profile image
*  Upload org logo

* 📎 Code review

* 📎 Code review 2

* 📎 Code review 3
2026-04-22 09:37:09 +02:00
Yamila Moreno
6ea7a64e01 Add nginx configuration for mcp server 2026-04-22 09:33:58 +02:00
Pablo Alba
534701f04f 🐛 Fix org options space should be hidden when there are no options 2026-04-22 09:33:42 +02:00
Pablo Alba
ad974f4047 💄 Unify naming on nitrate-api 2026-04-22 09:31:09 +02:00
Andrey Antukh
81faa5a728 Replace duplicate on-blur lambda with stable on-text-blur ref
The inline (fn [] (ts/schedule ...)) passed as :on-blur to text-options
was an exact copy of the on-text-blur callback already defined via
mf/use-fn earlier in the same let block. Pass on-text-blur directly
to avoid allocating a new function object on every render.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-04-22 08:55:29 +02:00
Andrey Antukh
7751d9a69b Remove spurious deps from toggle callbacks in text-menu*
toggle-main-menu and toggle-more-options close over a state atom and
need no external deps. The declared deps (main-menu-open? /
more-options-open?) were unused inside the function bodies, causing
each callback to be reallocated on every toggle — self-reinforcing
churn. Drop the mf/deps calls to make both callbacks stable.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-04-22 08:55:29 +02:00
Andrey Antukh
74d1288003 Hoist token-typography-row? flag check to namespace level
(contains? cf/flags :token-typography-row) is a pure constant:
cf/flags is immutable after startup. Define it once as a private
namespace-level var token-typography-row? instead of re-evaluating
the check on every render in text-decoration-options* and text-menu*.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-04-22 08:55:29 +02:00
Andrey Antukh
d28c0ea066 Memoize label computation in text-menu* component
Wrap the (case type (tr ...) ...) expression in mf/with-memo [type]
so the translation is resolved only when the type prop changes
instead of on every render.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-04-22 08:55:29 +02:00
Andrey Antukh
a94a7221fb Memoize options in text-decoration-options* component
Wrap the two radio-button option maps in mf/with-memo [token-applied]
so the vector and its (tr ...) calls are evaluated only when the
token-applied prop changes, not on every render.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-04-22 08:55:29 +02:00
Andrey Antukh
b8aa243c2b Memoize static options in grow-options* component
Wrap the three radio-button option maps in mf/with-memo [] so the
vector and its (tr ...) calls are evaluated once per mount instead
of on every render.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-04-22 08:55:29 +02:00
Andrey Antukh
dfd992aa49 Memoize static options in text-direction-options* component
Wrap the two radio-button option maps in mf/with-memo [] so the
vector and its (tr ...) calls are evaluated once per mount instead
of on every render.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-04-22 08:55:29 +02:00
Andrey Antukh
466f27eb7c Memoize static options in text-align-options* component
Wrap the four radio-button option maps in mf/with-memo [] so the
vector and its (tr ...) calls are evaluated once per mount instead
of on every render.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-04-22 08:55:29 +02:00
Andrey Antukh
6c19c7c0c4 Memoize static options in vertical-align* component
Wrap the radio-button options vector in `mf/with-memo []` so the
vector allocation and `(tr ...)` calls happen once per component
mount instead of on every render.

Also document the translation memoization rule in frontend/AGENTS.md:
`(tr ...)` must never be called at namespace level (locale is
runtime-only), and static option lists should always be wrapped in
`mf/with-memo []`.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-04-22 08:55:29 +02:00
Andrey Antukh
97d234a566 Add 2h min-age threshold to storage/gc_touched task
Skip storage objects touched less than 2 hours ago, matching the pattern
used by upload-session-gc. Update all affected tests to advance the clock
past the threshold using ct/*clock* bindings.
2026-04-22 08:48:04 +02:00
Marina López
11c970a945 Add nitrate trial text 2026-04-22 08:17:28 +02:00
Andrey Antukh
6723e3bbea 🐛 Fix issues after staging merge 2026-04-21 22:52:19 +02:00
Andrey Antukh
c259fbdb5b Merge remote-tracking branch 'origin/staging' into develop 2026-04-21 22:43:46 +02:00
Andrey Antukh
d8340d765a Merge remote-tracking branch 'origin/staging' into develop 2026-04-21 20:28:38 +02:00
moorsecopers99
95b2d7b083
🐛 Add ability to delete uploaded profile avatar (#9068)
Fixes #9067. Adds a delete button that appears on hover over an
uploaded profile photo; clicking it opens a confirm modal and, on
accept, clears the stored photo so the generated fallback avatar is
shown again. A new :delete-profile-photo RPC schedules the old
storage object for garbage collection and sets photo-id to null.

Signed-off-by: moorsecopers99 <patellscott18@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-21 19:19:30 +02:00
Dexterity
bb91c06390
🐛 Show check icon after copying team invitation link (#8996)
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-21 17:32:07 +02:00
Dexterity
e1d3106f61
Add color customization for ruler guides (#8986)
*  Add customizable colors for ruler guides

*  Update CHANGES.md

* 💄 Move guide color menu styles to SCSS

* 💄 Fix trailing whitespace in guides.cljs

---------

Signed-off-by: Dexterity <173429049+Dexterity104@users.noreply.github.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-21 17:31:39 +02:00
Pablo Alba
cd320c0cd6 On profile deletion, remove the user from nitrate too 2026-04-21 15:44:37 +02:00
Yamila Moreno
66e34950b2 🔧 Add main-staging workflow 2026-04-21 15:39:35 +02:00
Eva Marco
f18670ed00
🐛 Fix errors from numeric input design review (#8993)
* 🐛 Fix blur input after enter value

* 🐛 Catch error on invalid maths

* 🐛 Fix race condition

* 🎉 Add tests that cover issues

* 🐛 Fix padding applying only to one side

* 🐛 Fix show broken pill when reference is on not active set
2026-04-21 14:39:06 +02:00
Xaviju
78c48f1953
🐛 Fix broken update library notification link UI (#9070)
* 🐛 Fix broken update library notification link UI

* ♻️ Format and lint
2026-04-21 13:33:38 +02:00
Xaviju
cd9151bf9f
🐛 Fix duplicate modal title (#9064) 2026-04-21 09:54:30 +02:00
Alejandro Alonso
0d17debde7 Merge remote-tracking branch 'origin/staging' into develop 2026-04-21 08:24:29 +02:00
Xaviju
e9105f3670
♻️ Fix linter errors under legacy resources scss (#9035) 2026-04-20 23:58:53 +02:00
Juan de la Cruz
876b8d645d
🎉 Add new page separators feature (#8561)
* 🎉 Add new page separators feature

* 📎 Add PR feedback changes

* 🐛 Fix page sitemap icons

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-20 15:33:42 +02:00
Eva Marco
adea81ceee
♻️ Update icon on typography section and scss files (#9021)
* ♻️ Update icon on new buttons

* ♻️ Update scss on typography section
2026-04-20 15:32:55 +02:00
Eva Marco
003b54421d
🐛 Fix empty warning on login (#9056) 2026-04-20 12:23:47 +02:00
Pablo Alba
73b55ee47e Add nitrate api method get-remove-from-org-summary 2026-04-20 11:18:07 +02:00
Pablo Alba
ae66317d6c Add nitrate api to remove user from org 2026-04-20 11:18:07 +02:00
Pablo Alba
b2c9e08d42 🐛 Fix bad check on leave nitrate org 2026-04-20 11:18:07 +02:00
Dream
42ebee88d6
Add paste to replace (Cmd+Shift+V) (#9033)
Paste clipboard contents in place of the currently selected shape,
inheriting its position, parent, and z-index. The replaced shape
is deleted in the same transaction for a single undo step.

Signed-off-by: eureka0928 <meobius123@gmail.com>
2026-04-20 11:03:50 +02:00
Dream
f0c68fb826
Add search bar to color palette (#8994)
*  Add search bar to color palette

Fixes #7653

Signed-off-by: eureka0928 <meobius123@gmail.com>

* ♻️ Use search icon toggle for color palette search

Address UX feedback: replace always-visible search input with a
search icon that toggles the input on click. Hide the search
functionality when no colors exist. Move CHANGES.md entry to
2.16.0 section.

Signed-off-by: eureka0928 <meobius123@gmail.com>

---------

Signed-off-by: eureka0928 <meobius123@gmail.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-20 11:02:23 +02:00
wdeveloper16
d772632b08
Allow customising the OIDC login button label (#9026)
*  Allow customising the OIDC login button label (#7027)

* 📚 Add CHANGES entry and docs for PENPOT_OIDC_NAME (#7027)

---------

Co-authored-by: wdeveloper16 <wdeveloer16@protonmail.com>
2026-04-17 16:56:29 +02:00
Pablo Alba
c5a2b592a2 Move team to another nitrate organization 2026-04-17 11:38:52 +02:00
Pablo Alba
a206d57443 Add team to a nitrate organization 2026-04-17 11:38:52 +02:00
Yamila Moreno
32d9688c3c
🔧 Add short tag to DocherHub release (#8864) 2026-04-16 18:20:44 +02:00
Eva Marco
7f409eadd4
♻️ Update copy to be more specific (#9028) 2026-04-16 13:49:48 +02:00
Pablo Alba
39f4c13493
Add nitrate remove team from org 2026-04-16 11:46:05 +02:00
Pablo Alba
65a0fcb15b
🐛 Fix on nitrate leave org default org team must be deleted if empty 2026-04-16 11:45:37 +02:00
Pablo Alba
ac472c615a
🐛 Fix nitrate invitations org ux review 2026-04-16 11:18:11 +02:00
aliworksx08
81061013b1
Add openid-attr support and dot notation for OIDC attribute (#8946)
*  Add openid-attr support and dot notation for OIDC attribute paths

* ♻️ Simplify OIDC: add dot-notation for attr paths and retain sub claim

* ♻️ Fix OIDC: fix

* 🐛 Fix OIDC nested attr lookup for dot notation

* ♻️ Remove unused OIDC openid-attr support

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-16 11:12:37 +02:00
Andrey Antukh
b2f173675e 📎 Fix changelog 2026-04-16 10:56:44 +02:00
Dream
78381873eb
Edit ruler guide position by double-clicking the guide pill (#8987)
Drag-and-drop is the only way to move a ruler guide today, which makes
hitting an exact pixel painful. Double-clicking the guide pill now
swaps the position label for a numeric input — Enter commits, Escape
cancels — so users can type a precise value relative to the guide's
frame (or canvas).

Closes #2311

Signed-off-by: eureka0928 <meobius123@gmail.com>
2026-04-16 10:03:28 +02:00
Juanfran
3829443046 🐛 Skip onboarding modal for nitrate entry users 2026-04-15 16:32:58 +02:00
Marina López
c10f945473 Add nitrate subscription expected cancel date 2026-04-15 13:42:04 +02:00
Juanfran
f5591ed22e 🐛 Forward email when adding user to Nitrate organization 2026-04-15 13:28:39 +02:00
Xaviju
431056404c
🎉 Save tokens tree state in local storage (#8922) 2026-04-15 11:24:01 +02:00
Clayton
5dec75fe62
📚 Clarify manifest version 2 for relative plugin asset paths (#8992)
Signed-off-by: Clayton <claytonlin1110@gmail.com>
2026-04-15 10:38:53 +02:00
Eva Marco
b0caa15516
🎉 Add test to bug (#8928) 2026-04-15 09:16:16 +02:00
Marina López
c63b9583a2 Add callback to nitrate billing url 2026-04-15 08:53:28 +02:00
Juanfran
de577a803c 🎉 Add get-org-member-team-counts endpoint to Nitrate API 2026-04-15 08:50:13 +02:00
Andrés Moya
a3ea9fbecb
🔧 Add more validations for components, to avoid some crashes (#7820)
* 🔧 Validate only after propagation in tests

* 💄 Enhance some component sync traces

* 🔧 Add fake uuid generator for debugging

* 🐛 Remove old feature of advancing references when reset changes

Since long time ago, we only allow to reset changes in the top copy
shape. In this case the near and the remote shapes are the same, so
the advance-ref has no effect.

* 🐛 Fix some bugs and add validations, repair and migrations

Also added several utilities to debug and to create scripts that
processes files

* 🐛 Fix misplaced parenthesis passing propagate-fn to wrong function

The :propagate-fn keyword argument was incorrectly placed inside the
ths/get-shape call instead of being passed to tho/reset-overrides.
This caused reset-overrides to never propagate component changes,
making the test not validate what it intended.

* 🐛 Accept and forward :include-deleted? in find-near-match

Callers were passing :include-deleted? true but the parameter was not
in the destructuring, so it was silently ignored and the function
always hardcoded true. This made the API misleading and would cause
incorrect behavior if called with :include-deleted? false.

* 💄 Use set/union alias instead of fully-qualified clojure.set/union

The namespace already requires [clojure.set :as set], so use the alias
for consistency.

* 🐛 Add tests for reset-overrides with and without propagate-fn

Add two focused tests to comp_reset_test to cover the propagate-fn
path in reset-overrides:

- test-reset-with-propagation-updates-copies: verifies that resetting
  an override on a nested copy inside a main and supplying propagate-fn
  causes the canonical color to appear in all downstream copies.

- test-reset-without-propagation-does-not-update-copies: regression
  guard for the misplaced-parenthesis bug; confirms that omitting
  propagate-fn leaves copies with the overridden value because the
  component sync never runs.

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-15 08:42:42 +02:00
Xaviju
909427d442
♻️ Improve import/export warning semantics (#8991) 2026-04-14 22:41:47 +02:00
Dream
dfec9004bf
Add delete and duplicate buttons to typography dialog (#8983)
*  Add delete and duplicate buttons to typography dialog

Add delete and duplicate action buttons to the expanded typography
editing panel, allowing users to quickly manage typographies without
needing to close the panel and use the context menu.

Fixes #5270

* ♻️ Use DS icon-button for typography dialog actions

Address review feedback: replace raw `:button`/`:div` elements and
deprecated-icon usage with the design system `icon-button*` and
non-deprecated icons (`i/add`, `i/delete`, `i/tick`).

* ♻️ Only show typography delete/duplicate buttons in assets sidebar

`typography-entry` is reused from the right sidebar text options
panel, where the delete and duplicate actions don't make sense.
Add an `is-asset?` opt-in prop and gate the `on-delete`/`on-duplicate`
handlers behind it, so the buttons only appear when the entry is
rendered from the assets sidebar.

* ♻️ Move typography delete/duplicate handlers next to their use site

Refine the previous opt-in: instead of plumbing on-delete/on-duplicate
function props through typography-entry, build them directly inside
typography-advanced-options where they're actually rendered. The
component now takes :file-id and :is-asset? and gates the action
buttons on a single `show-actions?` flag.

---------

Signed-off-by: eureka0928 <meobius123@gmail.com>
2026-04-14 21:32:56 +02:00
rockchris099
8cc05d9579
Show alpha percentage in asset library color names (#8975)
When several library colors share the same RGB value but differ only
in opacity, append the alpha percentage (e.g. "#ff0000 50%") next to
the displayed default name and in the color bullet tooltip so users
can tell them apart at a glance.

Closes #6328

Signed-off-by: rockchris99 <chrisleo0721@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-14 21:02:42 +02:00
rockchris099
207cb87d5e
Reorder prototype overlay options (position before relative to) (#8972)
Closes #2910

Signed-off-by: rockchris99 <chrisleo0721@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-14 13:56:26 +02:00
Andrey Antukh
650f725f11 📎 Update changelog 2026-04-14 13:43:41 +02:00
Clayton
39b0e011fc
Differentiate incoming and outgoing interaction link colors (#8923)
*  Color incoming and outgoing interaction links differently

* 🐛 Fix lint

---------

Signed-off-by: Clayton <claytonlin1110@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-14 13:30:22 +02:00
Dexterity
7c3a1a905e
Fix locked elements not selectable in viewer + add guide locking (#8949)
* 🐛 Allow viewers to select locked elements in canvas

*  Add ability to lock guides to prevent accidental movement

---------

Signed-off-by: Dexterity104 <hatanokanjiro@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-14 13:21:51 +02:00
Eva Marco
3469e867ff
🐛 Fix gap input throwing an error (#8984) 2026-04-14 13:02:03 +02:00
James
b211594ce8
🐛 Fix hyphens stripped from export filenames (#8944)
Replace str/slug with a targeted regex that only removes
filesystem-unsafe characters when generating export filenames.
The slug function strips all non-word characters including hyphens,
causing names like "my-board" to become "myboard" on export.

Fixes #8901

Signed-off-by: jamesrayammons <jamesrayammons@outlook.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-14 12:36:26 +02:00
Dream
68595e90eb
Persist asset search query when switching sidebar tabs (#8985)
When users switch between the Layers and Assets sidebar tabs, the
`assets-toolbox*` component unmounts and its local `use-state` is
discarded, so the search query and section filter are lost.

Lift the search term and section filter into a per-file, in-memory
session atom that survives tab switches but doesn't leak across files
or persist across reloads. Ordering and list-style continue to use
localStorage as before.

Closes #2913

Signed-off-by: eureka0928 <meobius123@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-14 12:31:25 +02:00
Andrey Antukh
6788df02ca 🔧 Add minor adjustments on ci workflow related to e2e tests 2026-04-14 11:49:16 +02:00
Dream
8b14de2610
Sort asset subfolders alphabetically (#8952)
Subgroups in asset libraries were rendered in hash-map order because
update-in descends into plain maps instead of sorted ones. Add a
recursive post-process that rebuilds every level as a sorted-map so
subfolders are alphabetical at every nesting depth.

Closes #2572

Signed-off-by: eureka928 <meobius123@gmail.com>
2026-04-14 10:50:46 +02:00
Statxc
d90e7f8164
Add Find & Replace for text content and layer names (#8899)
*  Add Find & Replace for text content and layer names

* 💄 Fix cross-browser styling for Find & Replace radio buttons and action buttons

* 💄 Fix stylelint empty line before declaration in layers.scss

*  Improve match-filters and match-ids efficiency

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-14 10:41:31 +02:00
Dream
19b9c696fc
🐛 Reset account submenu state when profile menu closes (#8953)
Closes #8947

Signed-off-by: eureka928 <meobius123@gmail.com>
2026-04-14 10:33:32 +02:00
Dream
4703fe6e3b
Add visibility toggle for strokes (#8913)
*  Add visibility toggle for strokes

* ♻️ Use single emit! call for stroke visibility toggle

* 💄 Disable stroke controls when hidden, matching shadow/blur pattern

When a stroke is hidden, the alignment/style selects, cap selects, and
cap switch button are now disabled. A .hidden CSS class dims the
options area with reduced opacity. This matches the existing behavior
in shadow_row and blur menu where controls are disabled when the
effect is hidden.

* 💄 Move stroke hide button before remove button

---------

Signed-off-by: eureka928 <meobius123@gmail.com>
2026-04-14 10:08:13 +02:00
Andrey Antukh
9106a994f1 Merge remote-tracking branch 'origin/staging' into develop 2026-04-13 18:31:50 +02:00
Eva Marco
a3f7a1def6
🐛 Fix bugs with multiselection (#8932)
* 🐛 Fix app crash when selecting shapes with one hidden

* 🐛 Fix opacity input when mixed values
2026-04-13 18:29:15 +02:00
Alejandro Alonso
2ccaa3f0c5 Merge remote-tracking branch 'origin/staging' into develop 2026-04-13 16:51:51 +02:00
Alejandro Alonso
dfc5a256b4 Merge remote-tracking branch 'origin/staging' into develop 2026-04-13 16:47:18 +02:00
Marina López
a52831aa8c Show professional card when has nitrate subscription 2026-04-13 16:07:51 +02:00
rockchris099
bbd200f869
🐛 Fix dashboard Recent/Deleted titles overlapped by scrolling content (#8945)
Add z-index to the sticky .nav element in the dashboard so that
section titles (Recent, Deleted) stay above scrolling content
instead of being obscured by project cards and file thumbnails.

Fixes #8577
Signed-off-by: rockchris99 <chrisleo0721@gmail.com>
2026-04-13 15:55:13 +02:00
Juanfran
87179e806f
Add subscribe-nitrate route with post-registration nitrate modal (#8941) 2026-04-13 15:49:22 +02:00
Pablo Alba
d91ce0f9d1 🐛 Fix nitrate go to control center 2026-04-13 12:30:49 +02:00
Pablo Alba
5c761125f3 Add invite-to-org to Nitrate API 2026-04-13 11:49:01 +02:00
Marina López
707cc53ca4
Revert Add can use trial prop in nitrate profile (#8954) 2026-04-13 11:41:32 +02:00
Dream
78a16d99a9
Add clear artboard guides option to context menu (#8936)
*  Add clear artboard guides option to context menu

Adds a "Clear artboard guides" option to the right-click context menu
when one or more frames with guides are selected. Closes #6987

* ♻️ Address review feedback from niwinz

- Replace deprecated dm/assert! with assert
- Replace (map :id) with d/xf:map-id

Signed-off-by: eureka928 <meobius123@gmail.com>
2026-04-13 09:58:23 +02:00
Dream
8dccb2a427
Make links in comments clickable (#8894)
*  Make links in comments clickable

Detect URLs in comment text and render them as clickable links that
open in a new tab. Extends the existing mention parsing to also split
text elements by URL patterns, handling trailing punctuation and
mixed mention+URL content.

Closes #1602

* 📚 Add changelog entry for clickable links in comments

* 🐛 Fix URL elements dropped in comment input initialization

* 🐛 Keep empty text elements in parse-urls to preserve cursor anchors

The remove filter in parse-urls was stripping empty text elements
produced by str/split at URL boundaries. These elements are needed
as cursor anchor spans in the contenteditable input, without them
ESC keydown and visual layout broke.

Signed-off-by: eureka928 <meobius123@gmail.com>
2026-04-13 09:55:53 +02:00
Yamila Moreno
e7e5a19db7
🔧 Prevent draft pr from executing the CI (#8934) 2026-04-10 14:43:29 +02:00
Andrés Moya
3312bfe62c Force current set as active when resolving tokens in sidebar 2026-04-10 13:33:29 +02:00
Dexterity
240e8ce50c
🐛 Use page name for multi-export downloads (#8874)
* 🐛 Use page name for multi-export downloads

* ♻️ Refactor parameter formatting in asset export function

*  Use page name for multi-export ZIP/PDF downloads [Github #8773]

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-04-10 11:21:14 +02:00
Xaviju
9e4c8981be
🎉 Duplicate token group (#8886) 2026-04-10 10:42:35 +02:00
Marek Hrabe
a803bde2ff
🐛 Fix plugin modal dragging bugs (#8871)
* 🐛 Fix plugin modal drag and close interactions

Switch plugin modal dragging to pointer-capture semantics from the header so drag state remains stable when crossing iframe boundaries. Prevent drag start from close-button pointerdown and add regression tests for both non-draggable close-button interaction and close-event dispatch.

Signed-off-by: Marek Hrabe <marekhrabe@me.com>

* 📚 Update changelog for plugin modal drag fix

Document plugin modal drag and close-button interaction fixes in the unreleased changelog.

Signed-off-by: Marek Hrabe <marekhrabe@me.com>

* 🐛 Simplify plugin modal drag CSS selection rules

Keep user-select disabled at the modal wrapper level and keep touch-action scoped to the header drag handle to remove redundant declarations while preserving drag behavior.

Signed-off-by: Marek Hrabe <marekhrabe@me.com>

---------

Signed-off-by: Marek Hrabe <marekhrabe@me.com>
2026-04-09 21:13:10 +02:00
Dexterity
e49b7ce14c
🐛 Fix warnings for unsupported token $type (#8873)
* 🐛 Fix warnings for unsupported token $type

Signed-off-by: Dexterity104 <hatanokanjiro@gmail.com>

* 🐛 Add changelog entry for Github #8790

---------

Signed-off-by: Dexterity104 <hatanokanjiro@gmail.com>
Signed-off-by: Dexterity <173429049+Dexterity104@users.noreply.github.com>
2026-04-09 17:09:19 +02:00
Yamila Moreno
d2050d5331
🔧 Update tests-mcp.yml
Add a more explicit name for a workflow

Signed-off-by: Yamila Moreno <yamila.moreno@kaleidos.net>
2026-04-09 15:38:34 +02:00
Eva Marco
5b78de3594
🐛 Fix selected colors with tokens (#8889) 2026-04-09 14:10:23 +02:00
Xaviju
666313c2c3
🐛 Close expanded tree when switching or creating sets (#8920) 2026-04-09 12:37:29 +02:00
Pablo Alba
d65f3b5396 Add nitrate api endpoints to get an user profile 2026-04-09 12:10:06 +02:00
Pablo Alba
fe2023dde5 Add nitrate api endpoints to get an user profile 2026-04-09 12:10:06 +02:00
Marina López
1c68810521 Add can use trial prop in nitrate profile 2026-04-09 11:15:21 +02:00
Eva Marco
da6bd7509b
🐛 Fix hot reload on color-row text (#8880) 2026-04-09 10:18:21 +02:00
Eva Marco
c1d815f97c
🐛 Fix go to viewer with frame selected (#8878) 2026-04-09 10:05:56 +02:00
Dream
21217c5622
Add per-group add button for typographies (#8895)
*  Add per-group add button for typographies

Add a "+" button to each typography group header, allowing users to
create new typographies directly inside a group instead of only at
the top level. The button only appears for local, editable files.

Closes #5275

* 📚 Add changelog entry for typography group add button

* 🐛 Fix typography group title button layout wrapping

* ♻️ Address review feedback for typography group add button

Signed-off-by: eureka928 <meobius123@gmail.com>
2026-04-09 09:32:56 +02:00
Juanfran
e51e0c7933 Add theme field to nitrate authenticate response 2026-04-09 09:19:36 +02:00
Eva Marco
62b59991a9
🔧 Add guard to apply-token (#8879) 2026-04-09 09:16:28 +02:00
Andrey Antukh
5937a8b0fc Merge remote-tracking branch 'origin/staging' into develop 2026-04-09 09:13:02 +02:00
Alejandro Alonso
27449139ad Merge remote-tracking branch 'origin/staging' into develop 2026-04-09 09:05:02 +02:00
Alejandro Alonso
90fcc9f597
Merge pull request #8903 from penpot/alotor-fix-text-grow-problem
🐛 Fix problem with text auto grow in layouts
2026-04-09 08:26:18 +02:00
Dream
5502fe8df3
📚 Add changelog entry for undo/redo selection state (#8896) 2026-04-08 16:05:53 +02:00
Xaviju
10cfd99525
🐛 Fix lint invalid CSS props (#8907)
* 🐛 Fix lint invalid CSS props

* 🐛 Fix named colors in favor of modern notation or custom properties

* 🐛 Removed multiple combined selectors

* 🐛 Convert alpha value to numeric
2026-04-08 15:44:09 +02:00
Eva Marco
b8be89f231
🐛 Update onboarding image (#8902) 2026-04-08 11:00:59 +02:00
alonso.torres
0b0e193b70 🐛 Fix problem with text auto grow in layouts 2026-04-08 10:21:32 +02:00
Andrey Antukh
40dfeb169c Merge remote-tracking branch 'origin/staging' into develop 2026-04-07 21:37:21 +02:00
Andrey Antukh
0cc5f7c63e Merge remote-tracking branch 'origin/staging' into develop 2026-04-07 19:28:23 +02:00
Dream
0c08dfb13d
Add the ability for save and restore selection state in undo/redo (#8652)
*  Capture selection state before changes are applied

Save current selection IDs in commit-changes so undo entries
can track what was selected before each action.

*  Save and restore selection state in undo/redo

Extend undo entry with selected-before and selected-after fields.
On undo, restore selection to what it was before the action.
On redo, restore selection to what it was after the action.
Handles single entries, stacked entries, accumulated transactions,
and undo groups.

Fixes #6007

* ♻️ Wire selected-before through workspace undo stream

Pass the captured selection state from commit data into
the undo entry so it is stored alongside changes.

* 🐛 Fix unmatched delimiter in changes.cljs

* 🐛 Pass selected-before through commit event to undo entry

selected-before was captured in commit-changes but dropped by the
commit function since it was missing from the destructuring and the
commit map. This caused restore-selection to receive nil on undo.

---------

Signed-off-by: eureka928 <meobius123@gmail.com>
Co-authored-by: Mihai <noreply@github.com>
2026-04-07 16:30:47 +02:00
Eva Marco
48e8c0bc65
🐛 Fix show resolved value instead of value (#8883) 2026-04-07 14:27:26 +02:00
Pablo Alba
3c639f41c4
Add option to leave a nitrate organization 2026-04-07 11:26:57 +02:00
Eva Marco
a5055af538
🐛 Fix hidden on multiple selection (#8854) 2026-04-07 10:58:34 +02:00
Cheonji Kim
d5855f355f
📎 Fix typo in README.md for MCP server description (#8884)
Edited line 7(perfom -> perform)

Signed-off-by: Cheonji Kim <76100119+CheonjiKim@users.noreply.github.com>
2026-04-07 10:45:20 +02:00
Alejandro Alonso
83833896c9 Merge remote-tracking branch 'origin/staging' into develop 2026-04-07 10:18:35 +02:00
Andrey Antukh
650762556f Merge remote-tracking branch 'origin/staging' into develop 2026-04-01 11:30:39 +02:00
Andrey Antukh
c097c4a6da Merge remote-tracking branch 'origin/staging' into develop 2026-04-01 09:26:05 +02:00
Eva Marco
28cefa9cba
🐛 Fix delay tokens on typography row (#8851) 2026-03-31 14:06:50 +02:00
Eva Marco
5f474f9536
🎉 Add typography token row (#8749)
* 🔧 Create flag

*  Add typography type on tokens by input

* 🎉 Add typography token row

* ♻️ Update sub-components to use new style

* 🎉 Add disabled option on radio-buttons* component

* 🎉 Add combobox search in a new component

* 🎉 Divide components

* 🐛 Fix placeholder
2026-03-31 13:48:49 +02:00
Xaviju
27313e6add
🐛 Fix error on path and review UI (#8844) 2026-03-31 13:04:47 +02:00
Alejandro Alonso
56b28b5440 Merge remote-tracking branch 'origin/staging' into develop 2026-03-31 11:29:44 +02:00
Elenzakaleidos
7ecfe77338
Update README.md (#8833)
* 💄 Update README.md

I updated the two images and removed the fest announcement

Signed-off-by: Elenzakaleidos <elena.scilinguo@kaleidos.net>

* ♻️ Improve Markdown

---------

Signed-off-by: Elenzakaleidos <elena.scilinguo@kaleidos.net>
Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
2026-03-30 16:17:39 +02:00
Eva Marco
04f6307c69
🐛 Fix radio-buttons component in the DS (#8820) 2026-03-30 13:35:24 +02:00
Andrey Antukh
87bb1b8e74 Merge remote-tracking branch 'origin/staging' into develop 2026-03-30 12:29:43 +02:00
Pablo Alba
06aec4b3a3
Add is-default to nitrate summary (#8814) 2026-03-30 09:39:35 +02:00
Xaviju
1b68318c6b
🐛 Token tree must be expanded by default (#8799) 2026-03-30 08:14:17 +02:00
Alejandro Alonso
dff381c4fe Merge remote-tracking branch 'origin/staging' into develop 2026-03-27 13:54:38 +01:00
Alejandro Alonso
508c67c930 Merge remote-tracking branch 'origin/staging' into develop 2026-03-27 12:21:30 +01:00
María Valderrama
7f228e58c6 Update delete team modal when in org 2026-03-27 11:36:42 +01:00
Pablo Alba
8cc6c40b87 Update nitrate organizations dropdown visibility 2026-03-27 10:47:47 +01:00
Marina López
1ecfbef6fb ♻️ Refactor forms file 2026-03-27 10:39:47 +01:00
Marina López
abe328973c 💄 Fix focus radio button 2026-03-27 10:39:47 +01:00
Alejandro Alonso
19b1f508d3 Merge remote-tracking branch 'origin/staging' into develop 2026-03-27 10:18:01 +01:00
Alejandro Alonso
9c1f2e9af8 Merge remote-tracking branch 'origin/staging' into develop 2026-03-27 10:06:54 +01:00
Pablo Alba
51b9023640
Show nitrate org name on invitation to join team email (#8802) 2026-03-26 17:25:30 +01:00
Pablo Alba
4b4b99a949
Add response to nitrate request when nitrate is down (#8722) 2026-03-26 17:25:07 +01:00
Pablo Alba
1af2521f64 Add create default team org for nitrate on adding an user to a team 2026-03-26 13:17:05 +01:00
Alejandro Alonso
74af101462 Merge remote-tracking branch 'origin/staging' into develop 2026-03-26 11:42:35 +01:00
Marina López
6fa0c5ceaa Add organization avatar 2026-03-26 10:54:55 +01:00
Xaviju
713ff6190b
🔧 Add SCSS linter (stylelint) (#8592)
* 🔧 Add SCSS linter (stylelint)

*  Fix default standard scss errors with extends - WIP

*  Fix default standard scss errors

*  Update and cleanup

*  Update and cleanup

*  Update and cleanup

* 🐛 Fix broken visual regression tests

* 📎 Add to CHANGES

* ♻️ Remove unused class
2026-03-26 10:09:54 +01:00
Marina López
cd67dc42c4
🐛 Fix dates to avoid show them in english when browser is in auto (#8775) 2026-03-26 09:33:13 +01:00
Andrey Antukh
0a98100536 Merge remote-tracking branch 'origin/staging' into develop 2026-03-25 12:07:27 +01:00
Alejandro Alonso
d361a2ca6e Merge remote-tracking branch 'origin/staging' into develop 2026-03-25 10:42:24 +01:00
Xaviju
a59bd05c4f
🐛 Update visual regression tests (#8730) 2026-03-25 09:51:32 +01:00
Alejandro Alonso
caa25c70fc Merge remote-tracking branch 'origin/staging' into develop 2026-03-25 09:38:06 +01:00
Andrey Antukh
d4bc1d37f2 Merge remote-tracking branch 'origin/staging' into develop 2026-03-24 18:08:23 +01:00
Eva Marco
ccd28140bc
📎 Update changelog (#8744) 2026-03-24 12:03:56 +01:00
Marina López
be437fbfa1 💄 Fix styles from select organization 2026-03-24 10:22:09 +01:00
Alejandro Alonso
51fa5a5773 Merge remote-tracking branch 'origin/staging' into develop 2026-03-24 10:18:51 +01:00
Marina López
65ea27cbac
💄 Fix styles between grid layout inputs (#8673) 2026-03-23 20:05:13 +01:00
Andrey Antukh
1442e4c246 📎 Update changelog 2026-03-23 19:16:48 +01:00
Renzo
852f9ce07f
🎉 Add drag-to-change for numeric inputs (#8536)
Signed-off-by: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com>
2026-03-23 19:01:32 +01:00
Eva Marco
7adac6df40
🐛 Fix review comments (#8708)
* 🐛 Fix focus option only on arrowdown not at open

* 🐛 Fix focus on input when visible focus should be on options

* ♻️ Improve nativation, adding tab control and moving throught options is now cyclic

*  Add selected option when inside cursor is inside option

* 🐛 Dropdown is positioned nex to the input alwais
2026-03-23 16:06:23 +01:00
Pablo Alba
11ed09f431 🐛 Fix link to nitrate create org 2026-03-23 12:32:50 +01:00
Eva Marco
4345cfaec7
🎉 Add natural sort on token names (#8672) 2026-03-23 11:24:59 +01:00
Roland
bfb331d230
🐛 Fix pluings API theme.addSet() crash caused by async state race in token-set proxy (#8700)
When `catalog.addSet()` creates a new token set, `st/emit!` is async —
the set is not yet in `@st/state` when the returned proxy is used.
Calling `theme.addSet(proxy)` immediately after reads `.name` from the
proxy, which calls `locate-token-set` on stale state → returns nil →
`enable-set` conjs nil into the theme's `:sets` → backend rejects with
400 (`:sets #{nil}`) → workspace reloads → plugin disconnects.

Fix: store `initial-name` in the proxy at construction time as a
fallback for the `:name` getter during the async propagation window.
Also add nil guards in `addSet`/`removeSet` as defense-in-depth.

Closes #8698

Signed-off-by: rodo <roland@dolltons.com>
2026-03-23 11:24:29 +01:00
Eva Marco
72fd637ec2
♻️ Refactor small numeric inputs (#8660)
* ♻️ Refactor individual border radius inputs

* ♻️ Refactor layer opacity input

* ♻️ Refactor stroke width inputs and add icon only selects

* ♻️ Fix comments on PR
2026-03-23 11:00:29 +01:00
Andrey Antukh
dc56da9662 Merge remote-tracking branch 'origin/staging' into develop 2026-03-23 10:15:30 +01:00
Pablo Alba
8406b5e9f8
Add nitrate api for notify org deletion (#8697) 2026-03-23 09:59:57 +01:00
Andres Gonzalez
b637f0a917 🐛 Remove wrong lines from changelog 2026-03-23 09:11:09 +01:00
andrés gonzález
35125dfd79
Update changelog (#8703) 2026-03-23 08:48:22 +01:00
Alejandro Alonso
52496243ac Merge remote-tracking branch 'origin/staging' into develop 2026-03-20 17:00:47 +01:00
Alejandro Alonso
c6f3aa4f66
Merge pull request #8710 from penpot/superalex-fix-text-finalize-classic-editor
🐛 Restore correct branches in finalize-editor-state for text
2026-03-20 16:59:53 +01:00
Alejandro Alonso
62b36f0153 🐛 Restore correct branches in finalize-editor-state for text 2026-03-20 16:48:37 +01:00
Juanfran
e53ff6d20b Open create org modal in Nitrate 2026-03-20 16:19:29 +01:00
Alejandro Alonso
02afd805ca Merge remote-tracking branch 'origin/staging' into develop 2026-03-20 16:00:24 +01:00
María Valderrama
9c3fbc59b9
🐛 Fix visibility of go to nitrate cc option 2026-03-20 13:42:45 +01:00
Alejandro Alonso
f068842a6c Merge remote-tracking branch 'origin/staging' into develop 2026-03-20 10:20:43 +01:00
Eva Marco
71b32b97f0
🔧 Activate flag on dev enviroment (#8706) 2026-03-20 10:13:05 +01:00
Eva Marco
fb5ac5cd8b
🐛 Add box shadow to token dropdowns (#8685) 2026-03-20 09:02:27 +01:00
Xaviju
ee1dd80b6e
Copy token name from contextual menu (#8566) 2026-03-19 23:22:44 +01:00
Xaviju
8ad62c6800
🐛 Add export menu to inspect styles tab (#8645)
* 🐛 Add export menu to inspect styles tab

* 📎 Add to CHANGES
2026-03-19 23:20:18 +01:00
Xaviju
f8913c755d
🎉 Rename token group (#8275)
* 🎉 Rename token group

* 📎 Add to CHANGES
2026-03-19 22:54:21 +01:00
Eva Marco
8e7e6ffc2f
♻️ Design review for numeric inputs (#8630)
* ♻️ Update tooltip position on icon buttons

* ♻️ Sort token groups by priority not alphabetically

* ♻️ Add proper padding on text-icon-inputs

* ♻️ Hide detach button when dropdown is open

* 🐛 Fix detach stroke width

* 🐛 Fix strokes applied on all rows

* 🐛 Fix nillable inputs

* 🐛 Fix comments on PR
2026-03-19 16:46:18 +01:00
BitToby
b876417d5b
Add copy and paste for grid layout rows and columns via co… (#8498)
*  Add copy and paste for grid layout rows and columns via context menu

* 🔧 Use grid-id instead of grid in context menu deps

---------

Co-authored-by: bittoby <bittoby@users.noreply.github.com>
2026-03-18 16:19:15 +01:00
Pablo Alba
2a09f30199 Add nitrate endpoint to delete teams keeping your-penpot projects 2026-03-18 15:59:38 +01:00
Andrey Antukh
ca72dcdcbb Merge remote-tracking branch 'origin/staging' into develop 2026-03-18 15:00:40 +01:00
Eva Marco
df8194acf5
🐛 Fix several bugs (#8604)
* 🐛 Fix console warning

* ♻️ Use DS buttons and remove deprecated CSS

* 🐛 Fix copy on update library message

* 🐛 Fix id prop on switch component

* 🐛 Fix tooltip shown after tab change
2026-03-18 12:52:58 +01:00
Pablo Alba
04a3e236fe
Add a callback-url parameter to login (#8655) 2026-03-18 10:15:31 +01:00
Andrey Antukh
5482ee211e
🐛 Fix unexpected corner case between SES hardening and transit (#8663)
* Revert "🐛 Fix plugin sandbox freezing CLJS Proxy constructor breaking Transit encoding"

This reverts commit 27a934dcfd579093b066c78d67eba782ba6229cb.

* 🐛 Fix unexpected corner case between SES hardening and transit

The cause of the issue is a race condition between plugin loading
and the first time js/Date objects are encoded using transit. Transit
encoder populates the prototype of the Date object the first time a
Date instance is encoded, but if SES freezes the Date prototype before
transit, an strange exception will be raised on encoding any object
that contains Date instances.

Example of the exception:

Cannot define property transit$guid$4a57baf3-8824-4930-915a-fa905479a036,
object is not extensible
2026-03-18 09:53:22 +01:00
Dr. Dominik Jain
0f24cf26f6
Reduce instructions transferred at MCP connection to a minimum (#8649)
*  Reduce instructions transferred at MCP connection to a minimum

Force on-demand loading of the 'Penpot High-Level Overview',
which was previously transferred in the MCP server's instructions.

This greatly reduces the number of tokens for users who will
not actually interact with Penpot, allowing the MCP server to
remain enabled for such users without wasting too many tokens.

Resolves #8647

* 📎 Update Serena project
2026-03-17 18:48:06 +01:00
Andrey Antukh
4da332a5e2 Merge remote-tracking branch 'origin/staging' into develop 2026-03-17 18:29:08 +01:00
Pablo Alba
5eecd52743
Add get-teams-summary to nitrate api (#8662) 2026-03-17 18:25:18 +01:00
Andrey Antukh
3c92c98c94 Revert several changes to mcp scripts introduced in previous commits 2026-03-17 15:30:26 +01:00
girafic
d6cc469027
🐛 Fix permission message and update ruler guide proxy name on plugins api (#8632)
- Updated the error message for missing content write permission in the removeRulerGuide function.
- Renamed the ruler guide proxy from "RuleGuideProxy" to "RulerGuideProxy" for consistency.
- Adjusted variable naming in the addRulerGuide function for clarity.

Signed-off-by: Stas Haas <stas@girafic.de>
2026-03-17 15:05:26 +01:00
Andrey Antukh
7480be0bda 🐛 Fix mcp bundle build issue introduced in previous commits 2026-03-17 14:51:51 +01:00
Andrey Antukh
b86898eaf9 Revert "🐛 Fix "Cannot assign to read only property toString" error in plugins runtime"
This reverts commit f796f7ccb9dcf9e4f927450550920ad63f1de08d.
2026-03-17 14:45:18 +01:00
Andrey Antukh
e018253c6b Make mcp plugin always ready to be in multiuser 2026-03-17 14:45:18 +01:00
David Barragán Merino
1b223359d9 🔧 Remove staging-render bundle github workflow 2026-03-17 11:18:51 +01:00
Andrey Antukh
f796f7ccb9 🐛 Fix "Cannot assign to read only property toString" error in plugins runtime
The error "Cannot assign to read only property 'toString' of function"
occurs during React's commit phase after a plugin is loaded. The root
cause is an initialization ordering issue in the SES (Secure EcmaScript)
lockdown sequence.

When loadPlugin() is called, ses.harden(context) runs first, which
transitively freezes everything reachable from the context object —
including Function.prototype and Object.prototype — via prototype chain
traversal of getter functions. Later, createSandbox() calls
ses.hardenIntrinsics(), which attempts to run enablePropertyOverrides()
to convert frozen data properties (like Function.prototype.toString)
into accessor pairs that work around JavaScript's "override mistake".
However, enablePropertyOverrides checks "if (configurable)" before
converting, and since Function.prototype is already frozen (all
properties have configurable: false), the override taming is silently
skipped. This leaves Function.prototype.toString as a frozen
non-writable data property, causing any subsequent code that assigns
.toString to a function instance in strict mode to throw a TypeError.

The fix calls ses.hardenIntrinsics() before ses.harden(context) in
loadPlugin(), ensuring override taming installs the accessor pairs on
prototype properties before they get frozen. The existing
hardenIntrinsics() call in createSandbox() becomes a harmless no-op
thanks to the idempotency guard.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-03-17 10:14:12 +01:00
Andrey Antukh
27a934dcfd 🐛 Fix plugin sandbox freezing CLJS Proxy constructor breaking Transit encoding
When the plugin sandbox calls harden() (SES lockdown) on any proxy object
returned from the penpot.* API, SES traverses the prototype chain up to
Proxy.prototype and freezes the CLJS Proxy constructor function. Transit's
typeTag helper later fails with "object is not extensible" when trying to
set its cache property on that frozen constructor.

Fix by deleting the constructor data property from Proxy.prototype so that
harden never traverses to the CLJS Proxy constructor function.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-03-17 10:01:45 +01:00
Pablo Alba
acc383ba31 Improve nitrate module JSON handling and error management 2026-03-17 09:59:02 +01:00
Andrey Antukh
46f50aab16 Merge remote-tracking branch 'origin/staging' into develop 2026-03-16 16:13:16 +01:00
David Barragán Merino
31696de474 🔧 GitHub Actions worker tasks updated 2026-03-16 15:02:26 +01:00
Marina López
1b8871df8e Update image nitrate modal 2026-03-16 14:23:23 +01:00
Pablo Alba
8cb5c23a29 🐛 Fix nitrate url 2026-03-16 13:35:15 +01:00
Dream
ce04780b6c
🐛 Make collapsible sidebar titles clickable to toggle (#8547)
Fixes #5168
2026-03-16 11:03:49 +01:00
andrés gonzález
98e989d7f3
📚 Adjust MCP presence in changelog (#8642) 2026-03-16 10:51:10 +01:00
Dr. Dominik Jain
f566c1950f
Account for changed interfaces of addToken and addSet (#8614)
Resolves #8613
2026-03-16 10:38:25 +01:00
Pablo Alba
8f35e451e6
Add notification for nitrate when creating a team inside an organization (#8639) 2026-03-16 10:36:32 +01:00
Andrey Antukh
6e19548bac 📎 Update changelog 2026-03-16 09:38:23 +01:00
649 changed files with 23555 additions and 6199 deletions

View File

@ -1,15 +0,0 @@
name: _STAGING RENDER
on:
workflow_dispatch:
schedule:
- cron: '36 5-20 * * 1-5'
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: "staging-render"
build_wasm: "yes"
build_storybook: "yes"

2
.gitignore vendored
View File

@ -50,6 +50,7 @@
/frontend/.storybook/preview-body.html
/frontend/.storybook/preview-head.html
/frontend/playwright-report/
/frontend/playwright/ui/visual-specs/
/frontend/text-editor/src/wasm/
/frontend/dist/
/frontend/npm-debug.log
@ -81,3 +82,4 @@
/**/node_modules
/**/.yarn/*
/.pnpm-store
/.vscode

View File

@ -0,0 +1,27 @@
---
name: commiter
description: Git commit assistant following CONTRIBUTING.md commit rules
mode: primary
---
Role: You are responsible for creating git commits for Penpot and must follow
the repository commit-format rules exactly.
Requirements:
* Read `CONTRIBUTING.md` before creating any commit and follow the
commit guidelines strictly.
* Use commit messages in the form `:emoji: <imperative subject>`.
* Keep the subject capitalized, concise, 70 characters or fewer, and
without a trailing period.
* Keep the description (commit body) with maximum line length of 80
characters. Use manual line breaks to wrap text before it exceeds
this limit.
* Separate the subject from the body with a blank line.
* Write a clear and concise body when needed.
* Use `git commit -s` so the commit includes the required
`Signed-off-by` line.
* Do not guess or hallucinate git author information (Name or
Email). Never include the `--author` flag in git commands unless
specifically instructed by the user for a unique case; assume the
local environment is already configured.

View File

@ -32,6 +32,36 @@ precision while maintaining a strong focus on maintainability and performance.
5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects
`.gitignore` by default.
## GitHub Operations
To obtain the list of repository members/collaborators:
```bash
gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login'
```
To obtain the list of open PRs authored by members:
```bash
MEMBERS=$(gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' | tr '\n' '|' | sed 's/|$//')
gh pr list --state open --limit 200 --json author,title,number | jq -r --arg members "$MEMBERS" '
($members | split("|")) as $m |
.[] | select(.author.login as $a | $m | index($a)) |
"\(.number)\t\(.author.login)\t\(.title)"
'
```
To obtain the list of open PRs from external contributors (non-members):
```bash
MEMBERS=$(gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' | tr '\n' '|' | sed 's/|$//')
gh pr list --state open --limit 200 --json author,title,number | jq -r --arg members "$MEMBERS" '
($members | split("|")) as $m |
.[] | select(.author.login as $a | $m | index($a) | not) |
"\(.number)\t\(.author.login)\t\(.title)"
'
```
## Architecture Overview
Penpot is an open-source design tool composed of several modules:

View File

@ -1,5 +1,91 @@
# CHANGELOG
## 2.17.0 (Unreleased)
### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights
- Add MCP server integration [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112)
### :sparkles: New features & Enhancements
- Show alpha percentage next to library color values to distinguish colors that differ only in opacity (by @rockchris099) [Github #6328](https://github.com/penpot/penpot/issues/6328)
- Add "Clear artboard guides" option to right-click context menu for frames (by @eureka0928) [Github #6987](https://github.com/penpot/penpot/issues/6987)
- Add loader feedback while importing and exporting files [Github #9020](https://github.com/penpot/penpot/issues/9020)
- Allow duplicating color and typography styles (by @MkDev11) [Github #2912](https://github.com/penpot/penpot/issues/2912)
- Add woff2 support on user uploaded fonts (by @Nivl) [Github #8248](https://github.com/penpot/penpot/pull/8248)
- Import Tokens from linked library (by @dfelinto) [Github #8391](https://github.com/penpot/penpot/pull/8391)
- Option to download custom fonts (by @dfelinto) [Github #8320](https://github.com/penpot/penpot/issues/8320)
- Add copy as image to clipboard option to workspace context menu (by @dfelinto) [Github #8313](https://github.com/penpot/penpot/pull/8313)
- Add Tab/Shift+Tab navigation to rename layers sequentially (by @bittoby) [Github #8474](https://github.com/penpot/penpot/pull/8474)
- Copy and paste entire rows in existing table (by @bittoby) [Github #8498](https://github.com/penpot/penpot/pull/8498)
- Rename token group [Taiga #13137](https://tree.taiga.io/project/penpot/us/13137)
- Duplicate token group [Taiga #10653](https://tree.taiga.io/project/penpot/us/10653)
- Copy token name from contextual menu [Taiga #13568](https://tree.taiga.io/project/penpot/issue/13568)
- Add natural sorting on token names [Taiga #13713](https://tree.taiga.io/project/penpot/issue/13713)
- Add drag-to-change for numeric inputs in workspace sidebar [Github #2466](https://github.com/penpot/penpot/issues/2466)
- Add CSS linter [Taiga #13790](https://tree.taiga.io/project/penpot/us/13790)
- Save and restore selection state in undo/redo (by @eureka0928) [Github #6007](https://github.com/penpot/penpot/issues/6007)
- Fix warnings for unsupported token $type (by @Dexterity104) [Github #8790](https://github.com/penpot/penpot/issues/8790)
- Add per-group add button for typographies (by @eureka0928) [Github #5275](https://github.com/penpot/penpot/issues/5275)
- Add Find & Replace for text content and layer names (by @statxc) [Github #7108](https://github.com/penpot/penpot/issues/7108)
- Use page name for multi-export ZIP/PDF downloads (by @Dexterity104) [Github #8773](https://github.com/penpot/penpot/issues/8773)
- Make links in comments clickable (by @eureka0928) [Github #1602](https://github.com/penpot/penpot/issues/1602)
- Add visibility toggle for strokes (by @eureka0928) [Github #7438](https://github.com/penpot/penpot/issues/7438)
- Sort asset library subfolders alphabetically at every nesting level (by @eureka0928) [Github #2572](https://github.com/penpot/penpot/issues/2572)
- Add Paste to replace (Cmd+Shift+V) to replace the selected shape with clipboard contents (by @eureka0928) [Github #4240](https://github.com/penpot/penpot/issues/4240)
- Differentiate incoming and outgoing interaction link colors (by @claytonlin1110) [Github #7794](https://github.com/penpot/penpot/issues/7794)
- Add guide locking and fix locked elements not selectable in viewer (by @Dexterity104) [Github #8358](https://github.com/penpot/penpot/issues/8358)
- Apply styles to selection (by @AzazelN28) [Taiga #13647](https://tree.taiga.io/project/penpot/task/13647)
- Reorder prototyping overlay options to show Position before Relative to (by @rockchris099) [Github #2910](https://github.com/penpot/penpot/issues/2910)
- Add customizable colors for ruler guides (by @Dexterity104) [Github #5199](https://github.com/penpot/penpot/issues/5199)
- Persist asset search query and section filter when switching sidebar tabs (by @eureka0928) [Github #2913](https://github.com/penpot/penpot/issues/2913)
- Add delete and duplicate buttons to typography dialog (by @eureka0928) [Github #5270](https://github.com/penpot/penpot/issues/5270)
- Edit ruler guide position by double-clicking the guide pill (by @eureka0928) [Github #2311](https://github.com/penpot/penpot/issues/2311)
- Add a search bar to filter colors in the color palette toolbar (by @eureka0928) [Github #7653](https://github.com/penpot/penpot/issues/7653)
- Allow customising the OIDC login button label (by @wdeveloper16) [Github #7027](https://github.com/penpot/penpot/issues/7027)
- Add page separators in Workspace [Taiga #13611](https://tree.taiga.io/project/penpot/us/13611?milestone=262806)
- Add Shift+Numpad0/1/2 as aliases to Shift+0/1/2 for zoom shortcuts [Github #2457](https://github.com/penpot/penpot/issues/2457)
### :bug: Bugs fixed
- Fix `PENPOT_OIDC_USER_INFO_SOURCE` flag being silently ignored (`userinfo` / `token`) in the OIDC callback, causing "incomplete user info" failures during registration [Github #9108](https://github.com/penpot/penpot/issues/9108)
- Fix `get-view-only-bundle` crashing when a share-link viewer encounters a team member whose email lacks `@` (NullPointerException in `obfuscate-email`) or whose domain has no `.` (previously produced a dangling-dot `****@****.`); now the viewer-side obfuscation is nil-safe and omits the trailing dot when the domain has no TLD
- Remove `corepack` from the MCP local launcher so it runs on Node.js 25+, where corepack is no longer bundled [Github #8877](https://github.com/penpot/penpot/issues/8877)
- Fix Copy as SVG: emit a single valid SVG document when multiple shapes are selected, and publish `image/svg+xml` to the clipboard so the paste target works in Inkscape and other SVG-native tools [Github #838](https://github.com/penpot/penpot/issues/838)
- Reset profile submenu state when the account menu closes (by @eureka0928) [Github #8947](https://github.com/penpot/penpot/issues/8947)
- Add export panel to inspect styles tab [Taiga #13582](https://tree.taiga.io/project/penpot/issue/13582)
- Fix styles between grid layout inputs [Taiga #13526](https://tree.taiga.io/project/penpot/issue/13526)
- Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534)
- Update copy on penpot update message [Taiga #12924](https://tree.taiga.io/project/penpot/issue/12924)
- Fix scroll on library modal [Taiga #13639](https://tree.taiga.io/project/penpot/issue/13639)
- Fix dates to avoid show them in english when browser is in auto [Taiga #13786](https://tree.taiga.io/project/penpot/issue/13786)
- Fix focus radio button [Taiga #13841](https://tree.taiga.io/project/penpot/issue/13841)
- Token tree should be expanded by default [Taiga #13631](https://tree.taiga.io/project/penpot/issue/13631)
- Fix opacity incorrectly disabled for visible shapes [Taiga #13906](https://tree.taiga.io/project/penpot/issue/13906)
- Update onboarding image [Taiga #13864](https://tree.taiga.io/project/penpot/issue/13864)
- Fix plugin modal drag interactions over iframe and close-button behavior (by @marekhrabe) [Github #8871](https://github.com/penpot/penpot/pull/8871)
- Fix hot update on color-row on texts [Taiga #13923](https://tree.taiga.io/project/penpot/issue/13923)
- Fix selected color tokens [Taiga #13930](https://tree.taiga.io/project/penpot/issue/13930)
- Fix dashboard Recent/Deleted titles overlapped by scrolling content (by @rockchris099) [Github #8577](https://github.com/penpot/penpot/issues/8577)
- Display resolved values of inactive tokens [Taiga #13628](https://tree.taiga.io/project/penpot/issue/13628)
- Fix hyphens stripped from export filenames (by @jamesrayammons) [Github #8901](https://github.com/penpot/penpot/issues/8901)
- Fix app crash when selecting shapes with one hidden [Taiga #13959](https://tree.taiga.io/project/penpot/issue/13959)
- Fix opacity mixed value [Taiga #13960](https://tree.taiga.io/project/penpot/issue/13960)
- Fix gap input throwing an error [Github #8984](https://github.com/penpot/penpot/pull/8984)
- Fix non-functional clear icon in change email modal inputs (by @Dexterity104) [Github #8977](https://github.com/penpot/penpot/issues/8977)
- Disable save button after saving account profile settings (by @Dexterity104) [Github #8979](https://github.com/penpot/penpot/issues/8979)
- Fix copy to be more specific [Taiga #13990](https://tree.taiga.io/project/penpot/issue/13990)
- Allow deleting the profile avatar after uploading [Github #9067](https://github.com/penpot/penpot/issues/9067)
- Fix incorrect rendering when exporting text as SVG, PNG and JPG (by @edwin-rivera-dev) [Github #8516](https://github.com/penpot/penpot/issues/8516)
- Fix Settings and Notifications "Update Settings" button enabled state when form has no changes (by @moorsecopers99) [Github #9090](https://github.com/penpot/penpot/issues/9090)
- Fix "Help & Learning" submenu vertical alignment in account menu (by @juan-flores077) [Github #9137](https://github.com/penpot/penpot/issues/9137)
- Fix plugin `addInteraction` silently rejecting `open-overlay` actions with `manualPositionLocation` [Github #8409](https://github.com/penpot/penpot/issues/8409)
- Fix typography style creation with tokenized line-height (by @juan-flores077) [Github #8479](https://github.com/penpot/penpot/issues/8479)
- Fix colorpicker layout so the eyedropper button is visible again [Taiga #14057](https://tree.taiga.io/project/penpot/issue/14057)
## 2.16.0 (Unreleased)
### :boom: Breaking changes & Deprecations
@ -22,6 +108,8 @@
- Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534)
- Fix dashboard navigation tabs overlap with projects content when scrolling [Taiga #13962](https://tree.taiga.io/project/penpot/issue/13962)
- Fix text editor v1 focus [Taiga #13961](https://tree.taiga.io/project/penpot/issue/13961)
- Fix color dropdown option update [Taiga #14035](https://tree.taiga.io/project/penpot/issue/14035)
- Fix themes modal height [Taiga #14046](https://tree.taiga.io/project/penpot/issue/14046)
## 2.15.0 (Unreleased)
@ -34,6 +122,14 @@
### :bug: Bugs fixed
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
## 2.14.4
### :bug: Bugs fixed
- Fix email validation [Taiga #14006](https://tree.taiga.io/project/penpot/issue/14006)
- Fix email blacklisting [Github #9122](https://github.com/penpot/penpot/pull/9122)
- Fix removeChild errors from unmount race conditions [Github #8927](https://github.com/penpot/penpot/pull/8927)
@ -65,6 +161,7 @@
- Fix wrong `mapcat` call in `collect-main-shapes`
- Fix stale accumulator in `get-children-in-instance` recursion
- Fix typo `:podition` in swap-shapes grid cell
- Fix multiple selection on shapes with token applied to stroke color
## 2.14.2
@ -89,7 +186,6 @@
- Guard delete undo against missing sibling order [Github #8858](https://github.com/penpot/penpot/pull/8858)
- Fix ICounted error on numeric-input token dropdown keyboard nav [Github #8803](https://github.com/penpot/penpot/pull/8803)
## 2.14.1
### :sparkles: New features & Enhancements
@ -113,7 +209,6 @@
- Ensure path content is always PathData when saving
- Fix error when get-parent-with-data encounters non-Element nodes
## 2.14.0
### :boom: Breaking changes & Deprecations
@ -183,6 +278,7 @@
## 2.13.0
### :heart: Community contributions (Thank you!)
- Add 'page' special shapeId to MCP export_shape tool for full-page snapshots [Github #8689](https://github.com/penpot/penpot/issues/8689)
- Fix mask issues with component swap (by @dfelinto) [Github #7675](https://github.com/penpot/penpot/issues/7675)

View File

@ -9,45 +9,39 @@
</picture>
<p align="center">
<a href="https://www.mozilla.org/en-US/MPL/2.0" rel="nofollow"><img alt="License: MPL-2.0" src="https://img.shields.io/badge/MPL-2.0-blue.svg" style="max-width:100%;"></a>
<a href="https://community.penpot.app" rel="nofollow"><img alt="Penpot Community" src="https://img.shields.io/discourse/posts?server=https%3A%2F%2Fcommunity.penpot.app" style="max-width:100%;"></a>
<a href="https://tree.taiga.io/project/penpot/" title="Managed with Taiga.io" rel="nofollow"><img alt="Managed with Taiga.io" src="https://img.shields.io/badge/managed%20with-TAIGA.io-709f14.svg" style="max-width:100%;"></a>
<a href="https://gitpod.io/#https://github.com/penpot/penpot" rel="nofollow"><img alt="Gitpod ready-to-code" src="https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod" style="max-width:100%;"></a>
<a href="https://www.mozilla.org/en-US/MPL/2.0" rel="nofollow"><img alt="License: MPL-2.0" src="https://img.shields.io/badge/MPL-2.0-blue.svg" style="max-width:100%;"></a>
<a href="https://community.penpot.app" rel="nofollow"><img alt="Penpot Community" src="https://img.shields.io/discourse/posts?server=https%3A%2F%2Fcommunity.penpot.app" style="max-width:100%;"></a>
<a href="https://tree.taiga.io/project/penpot/" title="Managed with Taiga.io" rel="nofollow"><img alt="Managed with Taiga.io" src="https://img.shields.io/badge/managed%20with-TAIGA.io-709f14.svg" style="max-width:100%;"></a>
<a href="https://gitpod.io/#https://github.com/penpot/penpot" rel="nofollow"><img alt="Gitpod ready-to-code" src="https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod" style="max-width:100%;"></a>
</p>
<p align="center">
<a href="https://penpot.app/"><b>Website</b></a>
<a href="https://help.penpot.app/user-guide/"><b>User Guide</b></a>
<a href="https://penpot.app/learning-center"><b>Learning Center</b></a>
<a href="https://community.penpot.app/"><b>Community</b></a>
<a href="https://penpot.app/"><b>Website</b></a>
<a href="https://help.penpot.app/user-guide/"><b>User Guide</b></a>
<a href="https://penpot.app/learning-center"><b>Learning Center</b></a>
<a href="https://community.penpot.app/"><b>Community</b></a>
</p>
<p align="center">
<a href="https://www.youtube.com/@Penpot"><b>Youtube</b></a>
<a href="https://peertube.kaleidos.net/a/penpot_app/video-channels"><b>Peertube</b></a>
<a href="https://www.linkedin.com/company/penpot/"><b>Linkedin</b></a>
<a href="https://instagram.com/penpot.app"><b>Instagram</b></a>
<a href="https://fosstodon.org/@penpot/"><b>Mastodon</b></a>
<a href="https://bsky.app/profile/penpot.app"><b>Bluesky</b></a>
<a href="https://twitter.com/penpotapp"><b>X</b></a>
<a href="https://www.youtube.com/@Penpot"><b>Youtube</b></a>
<a href="https://peertube.kaleidos.net/a/penpot_app/video-channels"><b>Peertube</b></a>
<a href="https://www.linkedin.com/company/penpot/"><b>Linkedin</b></a>
<a href="https://instagram.com/penpot.app"><b>Instagram</b></a>
<a href="https://fosstodon.org/@penpot/"><b>Mastodon</b></a>
<a href="https://bsky.app/profile/penpot.app"><b>Bluesky</b></a>
<a href="https://twitter.com/penpotapp"><b>X</b></a>
</p>
<br />
[Penpot video](https://github.com/user-attachments/assets/7c67fd7c-04d3-4c9b-88ec-b6f5e23f8332
)
<br />
[Penpot video](https://github.com/user-attachments/assets/7c67fd7c-04d3-4c9b-88ec-b6f5e23f8332)
Penpot is the first **open-source** design tool for design and code collaboration. Designers can create stunning designs, interactive prototypes, design systems at scale, while developers enjoy ready-to-use code and make their workflow easy and fast. And all of this with no handoff drama.
Available on browser or self-hosted, Penpot works with open standards like SVG, CSS, HTML and JSON, and its free!
The latest updates take Penpot even further. Its the first design tool to integrate native [design tokens](https://penpot.dev/collaboration/design-tokens)—a single source of truth to improve efficiency and collaboration between product design and development.
With the [huge 2.0 release](https://penpot.app/dev-diaries), Penpot took the platform to a whole new level. This update introduces the ground-breaking [CSS Grid Layout feature](https://penpot.app/penpot-2.0), a complete UI redesign, a new Components system, and much more.
For organizations that need extra service for its teams, [get in touch](https://cal.com/team/penpot/talk-to-us)
🎇 Design, code, and Open Source meet at [Penpot Fest](https://penpot.app/penpotfest)! Be part of the 2025 edition in Madrid, Spain, on October 9-10.
With the [huge 2.0 release](https://penpot.app/dev-diaries), Penpot took the platform to a whole new level. This update introduces the ground-breaking [CSS Grid Layout feature](https://penpot.app/penpot-2.0), a complete UI redesign, a new Components system, and much more.
For organizations that need extra service for its teams, [get in touch](https://cal.com/team/penpot/talk-to-us).
## Table of contents ##
@ -63,43 +57,42 @@ For organizations that need extra service for its teams, [get in touch](https://
Penpot expresses designs as code. Designers can do their best work and see it will be beautifully implemented by developers in a two-way collaboration.
### Plugin system ###
[Penpot plugins](https://penpot.app/penpothub/plugins) let you expand the platform's capabilities, give you the flexibility to integrate it with other apps, and design custom solutions.
### Designed for developers ###
Penpot was built to serve both designers and developers and create a fluid design-code process. You have the choice to enjoy real-time collaboration or play "solo".
### Inspect mode ###
Work with ready-to-use code and make your workflow easy and fast. The inspect tab gives instant access to SVG, CSS and HTML code.
### Self host your own instance ###
Provide your team or organization with a completely owned collaborative design tool. Use Penpot's cloud service or deploy your own Penpot server.
### Integrations ###
Penpot offers integration into the development toolchain, thanks to its support for webhooks and an API accessible through access tokens.
### Building Design Systems: design tokens, components and variants ###
### Building Design Systems: design tokens, components and variants ###
Penpot brings design systems to code-minded teams: a single source of truth with native Design Tokens, Components, and Variants for scalable, reusable, and consistent UI across projects and platforms.
<br />
<p align="center">
<img src="https://github.com/user-attachments/assets/cce75ad6-f783-473f-8803-da9eb8255fef">
<img src="https://github.com/user-attachments/assets/cce75ad6-f783-473f-8803-da9eb8255fef">
</p>
<br />
## Getting started ##
Penpot is the only design & prototype platform that is deployment agnostic. You can use it in our [SAAS](https://design.penpot.app) or deploy it anywhere.
Learn how to install it with Docker, Kubernetes, Elestio or other options on [our website](https://penpot.app/self-host).
<br />
<p align="center">
<img src="https://site-assets.plasmic.app/2168cf524dd543caeff32384eb9ea0a1.svg" alt="Open Source" style="width: 65%;">
<img src="https://github.com/user-attachments/assets/93578500-2dbd-4045-a180-e640ea5b3bd5" style="width: 65%;">
</p>
<br />
## Community ##
@ -108,6 +101,7 @@ We love the Open Source software community. Contributing is our passion and if i
If you need help or have any questions; if youd like to share your experience using Penpot or get inspired; if youd rather meet our community of developers and designers, [join our Community](https://community.penpot.app/)!
You will find the following categories:
- [Ask the Community](https://community.penpot.app/c/ask-for-help-using-penpot/6)
- [Troubleshooting](https://community.penpot.app/c/technical/8)
- [Help us Improve Penpot](https://community.penpot.app/c/help-us-improve-penpot/7)
@ -117,45 +111,36 @@ You will find the following categories:
- [Penpot in your language](https://community.penpot.app/c/penpot-in-your-language/12)
- [Design and Code Essentials](https://community.penpot.app/c/design-and-code-essentials/22)
<br />
<p align="center">
<img src="https://github.com/penpot/penpot/assets/5446186/6ac62220-a16c-46c9-ab21-d24ae357ed03" alt="Community" style="width: 65%;">
<img src="https://github.com/user-attachments/assets/7b7d0f6b-a579-4822-a9ae-d3d5a9fc9d19" alt="Community" style="width: 65%;">
</p>
<br />
### Code of Conduct ###
Anyone who contributes to Penpot, whether through code, in the community, or at an event, must adhere to the
[code of conduct](https://help.penpot.app/contributing-guide/coc/) and foster a positive and safe environment.
## Contributing ##
Any contribution will make a difference to improve Penpot. How can you get involved?
Choose your way:
- Create and [share Libraries & Templates](https://penpot.app/libraries-templates.html) that will be helpful for the community
- Invite your [team to join](https://design.penpot.app/#/auth/register)
- Give this repo a star and follow us on Social Media: [Mastodon](https://fosstodon.org/@penpot/), [Youtube](https://www.youtube.com/c/Penpot), [Instagram](https://instagram.com/penpot.app), [Linkedin](https://www.linkedin.com/company/penpotdesign), [Peertube](https://peertube.kaleidos.net/a/penpot_app), [X](https://twitter.com/penpotapp) and [BlueSky](https://bsky.app/profile/penpot.app)
- Create and [share Libraries & Templates](https://penpot.app/libraries-templates.html) that will be helpful for the community.
- Invite your [team to join](https://design.penpot.app/#/auth/register).
- Give this repo a star and follow us on Social Media: [Mastodon](https://fosstodon.org/@penpot/), [Youtube](https://www.youtube.com/c/Penpot), [Instagram](https://instagram.com/penpot.app), [Linkedin](https://www.linkedin.com/company/penpotdesign), [Peertube](https://peertube.kaleidos.net/a/penpot_app), [X](https://twitter.com/penpotapp) and [BlueSky](https://bsky.app/profile/penpot.app).
- Participate in the [Community](https://community.penpot.app/) space by asking and answering questions; reacting to others articles; opening your own conversations and following along on decisions affecting the project.
- Report bugs with our easy [guide for bugs hunting](https://help.penpot.app/contributing-guide/reporting-bugs/) or [GitHub issues](https://github.com/penpot/penpot/issues)
- Become a [translator](https://help.penpot.app/contributing-guide/translations)
- Give feedback: [Email us](mailto:support@penpot.app)
- **Contribute to Penpot's code:** [Watch this video](https://www.youtube.com/watch?v=TpN0osiY-8k) by Alejandro Alonso, CIO and developer at Penpot, where he gives us a hands-on demo of how to use Penpots repository and make changes in both front and back end
- Report bugs with our easy [guide for bugs hunting](https://help.penpot.app/contributing-guide/reporting-bugs/) or [GitHub issues](https://github.com/penpot/penpot/issues).
- Become a [translator](https://help.penpot.app/contributing-guide/translations).
- Give feedback: [Email us](mailto:support@penpot.app).
- **Contribute to Penpot's code:** [Watch this video](https://www.youtube.com/watch?v=TpN0osiY-8k) by Alejandro Alonso, CIO and developer at Penpot, where he gives us a hands-on demo of how to use Penpots repository and make changes in both front and back end.
To find (almost) everything you need to know on how to contribute to Penpot, refer to the [contributing guide](https://help.penpot.app/contributing-guide/).
<br />
<p align="center">
<img src="https://github.com/penpot/penpot/assets/5446186/fea18923-dc06-49be-86ad-c3496a7956e6" alt="Libraries and templates" style="width: 65%;">
</p>
<br />
## Resources ##
You can ask and answer questions, have open-ended conversations, and follow along on decisions affecting the project.
@ -170,14 +155,14 @@ You can ask and answer questions, have open-ended conversations, and follow alon
📚 [Dev Diaries](https://penpot.app/dev-diaries.html)
## License ##
```
```text
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
Copyright (c) KALEIDOS INC
```
Penpot is a Kaleidos [open source project](https://kaleidos.net/)

View File

@ -0,0 +1,264 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-- -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
.mj-column-px-425 {
width: 425px !important;
max-width: 425px;
}
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="background-color:#E5E5E5;">
<div style="background-color:#E5E5E5;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Hi{% if user-name %} {{ user-name|abbreviate:25 }}{% endif %},
</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
<b>{{invited-by|abbreviate:25}}</b> sent you an invitation to join the organization:
</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20" style="display:inline-block;vertical-align:middle;">
<tr>
<td width="20" height="20" align="center" valign="middle"
background="{{org-logo}}"
style="width:20px;height:20px;text-align:center;font-weight:bold;font-size:9px;line-height:20px;color:#ffffff;background-size:cover;background-position:center;background-repeat:no-repeat;border-radius: 50%;color:black">
{{org-initials}}
</td>
</tr>
</table>
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
“{{ organization-name|abbreviate:25 }}”
</span>
</div>
</td>
</tr>
<tr>
<td align="center" vertical-align="middle"
style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#6911d4" role="presentation"
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
valign="middle">
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}"
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
target="_blank"> ACCEPT INVITE </a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Enjoy!</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
The Penpot team.</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
{% include "app/email/includes/footer.html" %}
</div>
</body>
</html>

View File

@ -0,0 +1 @@
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization-name|abbreviate:25 }}”

View File

@ -0,0 +1,10 @@
Hello!
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization-name|abbreviate:25 }}”.
Accept invitation using this link:
{{ public-uri }}/#/auth/verify-token?token={{token}}
Enjoy!
The Penpot team.

View File

@ -186,7 +186,8 @@
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”.</div>
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if organization %}
part of the organization “{{ organization|abbreviate:25 }}”{% endif %}.</div>
</td>
</tr>
<tr>

View File

@ -1,6 +1,6 @@
Hello!
{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”.
{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:25 }}"{% if organization %}, part of the organization "{{ organization|abbreviate:25 }}"{% endif %}.
Accept invitation using this link:

View File

@ -401,8 +401,9 @@
(defn- parse-attr-path
[provider path]
(let [[fitem & items] (str/split path "__")]
(into [(keyword (:type provider) fitem)] (map keyword) items)))
(let [separator (if (str/includes? path "__") "__" ".")
[fitem & items] (str/split path separator)]
(into [(keyword (:type provider) (str/kebab fitem))] (map keyword) items)))
(defn- build-redirect-uri
[]
@ -488,9 +489,9 @@
(let [attr-ph (parse-attr-path provider "nickname")]
(get-in props attr-ph))))]
(let [info (assoc info :provider-id (str (:id provider)))
props (qualify-props provider info)
email (get-email props)]
(let [info (assoc info :provider-id (str (:id provider)))
props (qualify-props provider info)
email (get-email props)]
{:backend (:type provider)
:fullname (or (get-name props) email)
:email email
@ -547,16 +548,29 @@
(def ^:private valid-info?
(sm/validator schema:info))
(defn- select-user-info-source
"Normalise the provider's configured user-info source into a keyword the
dispatch below can match. The raw value comes from config as a string
per the malli schema in `app.config` (`\"token\"`, `\"userinfo\"`, or
`\"auto\"`) and from hard-coded per-provider maps as strings as well;
any unrecognised or missing value falls back to `:auto` (prefer claims,
use userinfo as fallback)."
[source]
(case source
"token" :token
"userinfo" :userinfo
:auto))
(defn- get-info
[cfg provider state code]
(let [tdata (fetch-access-token cfg provider code)
claims (get-id-token-claims provider tdata)
info (case (get provider :user-info-source)
:token (dissoc claims :exp :iss :iat :aud :sub :sid)
info (case (select-user-info-source (get provider :user-info-source))
:token (dissoc claims :exp :iss :iat :aud :sid)
:userinfo (fetch-user-info cfg provider tdata)
(or (some-> claims (dissoc :exp :iss :iat :aud :sub :sid))
(fetch-user-info cfg provider tdata)))
:auto (or (some-> claims (dissoc :exp :iss :iat :aud :sid))
(fetch-user-info cfg provider tdata)))
info (process-user-info provider tdata info)]

View File

@ -40,8 +40,8 @@
[promesa.util :as pu]
[yetti.adapter :as yt])
(:import
com.github.luben.zstd.ZstdIOException
com.github.luben.zstd.ZstdInputStream
com.github.luben.zstd.ZstdIOException
com.github.luben.zstd.ZstdOutputStream
java.io.DataInputStream
java.io.DataOutputStream

View File

@ -36,11 +36,11 @@
java.sql.Connection
java.sql.PreparedStatement
java.sql.Savepoint
org.postgresql.PGConnection
org.postgresql.geometric.PGpoint
org.postgresql.jdbc.PgArray
org.postgresql.largeobject.LargeObject
org.postgresql.largeobject.LargeObjectManager
org.postgresql.PGConnection
org.postgresql.util.PGInterval
org.postgresql.util.PGobject))

View File

@ -22,13 +22,13 @@
[cuerdas.core :as str]
[integrant.core :as ig])
(:import
jakarta.mail.Message$RecipientType
jakarta.mail.Session
jakarta.mail.Transport
jakarta.mail.internet.InternetAddress
jakarta.mail.internet.MimeBodyPart
jakarta.mail.internet.MimeMessage
jakarta.mail.internet.MimeMultipart
jakarta.mail.Message$RecipientType
jakarta.mail.Session
jakarta.mail.Transport
java.util.Properties))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -412,6 +412,21 @@
:id ::invite-to-team
:schema schema:invite-to-team))
(def ^:private schema:invite-to-org
[:map
[:invited-by ::sm/text]
[:organization-name ::sm/text]
[:org-initials ::sm/text]
[:org-logo ::sm/uri]
[:user-name [:maybe ::sm/text]]
[:token ::sm/text]])
(def invite-to-org
"Org member invitation email."
(template-factory
:id ::invite-to-org
:schema schema:invite-to-org))
(def ^:private schema:join-team
[:map
[:invited-by ::sm/text]

View File

@ -36,10 +36,18 @@
:cause cause)))))
(defn contains?
"Check if email is in the blacklist."
"Check if email is in the blacklist. Also matches subdomains: if
'somedomain.com' is blacklisted, 'xxx@foo.somedomain.com' will also
be rejected."
[{:keys [::email/blacklist]} email]
(let [[_ domain] (str/split email "@" 2)]
(c/contains? blacklist (str/lower domain))))
(let [[_ domain] (str/split email "@" 2)
parts (str/split (str/lower domain) #"\.")]
(loop [parts parts]
(if (empty? parts)
false
(if (c/contains? blacklist (str/join "." parts))
true
(recur (rest parts)))))))
(defn enabled?
"Check if the blacklist is enabled"

View File

@ -112,8 +112,9 @@
THEN (c.deleted_at IS NULL OR c.deleted_at >= ?::timestamptz)
END"))
(defn- get-snapshot
"Get snapshot with decoded data"
(defn get-snapshot-data
"Get a fully decoded snapshot for read-only preview or restoration.
Returns the snapshot map with decoded :data field."
[cfg file-id snapshot-id]
(let [now (ct/now)]
(->> (db/get-with-sql cfg [sql:get-snapshot file-id snapshot-id now]
@ -326,7 +327,7 @@
(sto/resolve cfg {::db/reuse-conn true})
snapshot
(get-snapshot cfg file-id snapshot-id)]
(get-snapshot-data cfg file-id snapshot-id)]
(when-not snapshot
(ex/raise :type :not-found

View File

@ -31,8 +31,8 @@
(:import
clojure.lang.XMLHandler
java.io.InputStream
javax.xml.XMLConstants
javax.xml.parsers.SAXParserFactory
javax.xml.XMLConstants
org.apache.commons.io.IOUtils
org.im4java.core.ConvertCmd
org.im4java.core.IMOperation))

View File

@ -15,16 +15,16 @@
io.prometheus.client.CollectorRegistry
io.prometheus.client.Counter
io.prometheus.client.Counter$Child
io.prometheus.client.exporter.common.TextFormat
io.prometheus.client.Gauge
io.prometheus.client.Gauge$Child
io.prometheus.client.Histogram
io.prometheus.client.Histogram$Child
io.prometheus.client.hotspot.DefaultExports
io.prometheus.client.SimpleCollector
io.prometheus.client.Summary
io.prometheus.client.Summary$Builder
io.prometheus.client.Summary$Child
io.prometheus.client.exporter.common.TextFormat
io.prometheus.client.hotspot.DefaultExports
java.io.StringWriter))
(set! *warn-on-reflection* true)

View File

@ -471,6 +471,9 @@
{:name "0146-mod-access-token-table"
:fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")}
{:name "0147-mod-team-invitation-table"
:fn (mg/resource "app/migrations/sql/0147-mod-team-invitation-table.sql")}
{:name "0147-add-upload-session-table"
:fn (mg/resource "app/migrations/sql/0147-add-upload-session-table.sql")}])

View File

@ -0,0 +1,13 @@
ALTER TABLE team_invitation
ADD COLUMN org_id uuid NULL;
ALTER TABLE team_invitation
ALTER COLUMN team_id DROP NOT NULL;
ALTER TABLE team_invitation
ADD CONSTRAINT team_invitation_team_or_org_not_null
CHECK (team_id IS NOT NULL OR org_id IS NOT NULL);
CREATE UNIQUE INDEX team_invitation_org_unique
ON team_invitation (org_id, email_to)
WHERE team_id IS NULL;

View File

@ -1,15 +1,23 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.nitrate
"Module that make calls to the external nitrate aplication"
(:require
[app.common.exceptions :as ex]
[app.common.json :as json]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.time :as ct]
[app.common.types.organization :as cto]
[app.config :as cf]
[app.http.client :as http]
[app.rpc :as-alias rpc]
[app.setup :as-alias setup]
[app.util.json :as json]
[clojure.core :as c]
[integrant.core :as ig]))
@ -18,16 +26,16 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- request-builder
[cfg method uri shared-key profile-id]
[cfg method uri shared-key profile-id request-params]
(fn []
(http/req! cfg {:method method
:headers {"content-type" "application/json"
"accept" "application/json"
"x-shared-key" shared-key
"x-profile-id" (str profile-id)}
:uri uri
:version :http1.1})))
(http/req! cfg (cond-> {:method method
:headers {"content-type" "application/json"
"accept" "application/json"
"x-shared-key" shared-key
"x-profile-id" (str profile-id)}
:uri uri
:version :http1.1}
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))))))
(defn- with-retries
[handler max-retries]
@ -49,20 +57,41 @@
(defn- with-validate [handler uri schema]
(fn []
(let [coercer-http (sm/coercer schema
:type :validation
:hint (str "invalid data received calling " uri))]
(try
(coercer-http (-> (handler) :body json/decode))
(catch Exception e
;; TODO Error handling
(l/error :hint "error validating json response" :cause e)
nil)))))
(let [response (handler)
status (:status response)]
(when-not status
(l/error :hint "could't do the nitrate request, it is probably down"
:uri uri)
;; TODO decide what to do when Nitrate is inaccesible
nil)
(cond
(>= status 400)
;; For error status codes (4xx, 5xx), fail immediately without validation
(do
(when (not= status 404) ;; Don't need to log 404
(l/error :hint "nitrate request failed with error status"
:uri uri
:status status
:body (:body response)))
nil)
(= status 204) ;; 204 doesn't return any body
nil
:else ;; For success status codes, validate the response
(let [coercer-http (sm/coercer schema
:type :validation
:hint (str "invalid data received calling " uri))
data (-> response :body (json/decode :key-fn json/read-kebab-key))]
(try
(coercer-http data)
(catch Exception e
;; TODO Error handling
(l/error :hint "error validating json response" :cause e)
nil)))))))
(defn- request-to-nitrate
[cfg method uri schema {:keys [::rpc/profile-id] :as params}]
[cfg method uri schema {:keys [::rpc/profile-id request-params] :as params}]
(let [shared-key (-> cfg ::setup/shared-keys :nitrate)
full-http-call (-> (request-builder cfg method uri shared-key profile-id)
full-http-call (-> (request-builder cfg method uri shared-key profile-id request-params)
(with-retries 3)
(with-validate uri schema))]
(full-http-call)))
@ -80,11 +109,23 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:organization
(def ^:private schema:org-summary
[:map
[:id ::sm/uuid]
[:name ::sm/text]
[:slug ::sm/text]])
[:owner-id ::sm/uuid]
[:teams
[:vector
[:map
[:id ::sm/uuid]
[:is-your-penpot :boolean]]]]])
(def ^:private schema:profile-org
[:map
[:is-member :boolean]
[:organization-id {:optional true} [:maybe ::sm/uuid]]
[:default-team-id {:optional true} [:maybe ::sm/uuid]]])
;; TODO Unify with schemas on backend/src/app/http/management.clj
(def ^:private schema:timestamp
@ -158,20 +199,136 @@
[:map
[:licenses ::sm/boolean]])
(defn- get-team-org
(defn- get-team-org-api
[cfg {:keys [team-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get (str baseuri "/api/teams/" (str team-id)) schema:organization params)))
(request-to-nitrate cfg :get
(str baseuri
"/api/teams/"
team-id)
cto/schema:team-with-organization params)))
(defn- get-subscription
(defn- get-org-membership-api
[cfg {:keys [profile-id organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/organizations/"
organization-id
"/members/"
profile-id)
schema:profile-org params)))
(defn- get-org-membership-by-team-api
[cfg {:keys [profile-id team-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/teams/"
team-id
"/users/"
profile-id)
schema:profile-org params)))
(defn- get-org-summary-api
[cfg {:keys [organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/organizations/"
organization-id
"/summary")
schema:org-summary params)))
(defn- set-team-org-api
[cfg {:keys [organization-id team-id is-default] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)
params (assoc params :request-params {:team-id team-id
:is-your-penpot (true? is-default)})
team (request-to-nitrate cfg :post
(str baseuri
"/api/organizations/"
organization-id
"/add-team")
cto/schema:team-with-organization params)
custom-photo (when-let [logo-id (get-in team [:organization :logo-id])]
(str (cf/get :public-uri) "/assets/by-id/" logo-id))]
(cond-> team
custom-photo
(assoc-in [:organization :custom-photo] custom-photo))))
(defn- add-profile-to-org-api
[cfg {:keys [profile-id organization-id team-id email] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)
request-params (cond-> {:user-id profile-id :team-id team-id}
(some? email) (assoc :email email))
params (assoc params :request-params request-params)]
(request-to-nitrate cfg :post
(str baseuri
"/api/organizations/"
organization-id
"/add-user")
schema:profile-org params)))
(defn- remove-profile-from-org-api
[cfg {:keys [profile-id organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)
params (assoc params :request-params {:user-id profile-id})]
(request-to-nitrate cfg :post
(str baseuri
"/api/organizations/"
organization-id
"/remove-user")
nil params)))
(defn- remove-profile-from-all-orgs-api
[cfg {:keys [profile-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get (str baseuri "/api/subscriptions/" (str profile-id)) schema:subscription params)))
(request-to-nitrate cfg :post
(str baseuri
"/api/users/"
profile-id
"/remove-organizations")
nil params)))
(defn- get-connectivity
(defn- remove-team-from-org-api
[cfg {:keys [team-id organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)
params (assoc params :request-params {:team-id team-id})]
(request-to-nitrate cfg :post
(str baseuri
"/api/organizations/"
organization-id
"/remove-team")
nil params)))
(defn- delete-team-api
[cfg {:keys [team-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :delete
(str baseuri
"/api/teams/"
team-id)
nil params)))
(defn- get-subscription-api
[cfg {:keys [profile-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/subscriptions/"
profile-id)
schema:subscription params)))
(defn- get-connectivity-api
[cfg params]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get (str baseuri "/api/connectivity") schema:connectivity params)))
(request-to-nitrate cfg :get
(str baseuri
"/api/connectivity")
schema:connectivity params)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INITIALIZATION
@ -180,9 +337,18 @@
(defmethod ig/init-key ::client
[_ cfg]
(when (contains? cf/flags :nitrate)
{:get-team-org (partial get-team-org cfg)
:get-subscription (partial get-subscription cfg)
:connectivity (partial get-connectivity cfg)}))
{:get-team-org (partial get-team-org-api cfg)
:set-team-org (partial set-team-org-api cfg)
:get-org-membership (partial get-org-membership-api cfg)
:get-org-membership-by-team (partial get-org-membership-by-team-api cfg)
:get-org-summary (partial get-org-summary-api cfg)
:add-profile-to-org (partial add-profile-to-org-api cfg)
:remove-profile-from-org (partial remove-profile-from-org-api cfg)
:remove-profile-from-all-orgs (partial remove-profile-from-all-orgs-api cfg)
:delete-team (partial delete-team-api cfg)
:remove-team-from-org (partial remove-team-from-org-api cfg)
:get-subscription (partial get-subscription-api cfg)
:connectivity (partial get-connectivity-api cfg)}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; UTILS
@ -205,18 +371,18 @@
(defn add-org-info-to-team
"Enriches a team map with organization information from Nitrate.
Adds organization-id, organization-name, organization-slug, and your-penpot fields.
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
Returns the original team unchanged if the request fails or org data is nil."
[cfg team params]
(try
(let [params (assoc (or params {}) :team-id (:id team))
org (call cfg :get-team-org params)]
(let [params (assoc (or params {}) :team-id (:id team))
team-with-org (call cfg :get-team-org params)
org (:organization team-with-org)]
(if (some? org)
(assoc team
:organization-id (:id org)
:organization-name (:name org)
:organization-slug (:slug org)
:is-default (or (:is-default team) (true? (:isYourPenpot org))))
(-> (cto/apply-organization team (assoc org :custom-photo
(when-let [logo-id (:logo-id org)]
(str (cf/get :public-uri) "/assets/by-id/" logo-id))))
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org)))))
team))
(catch Throwable cause
(l/error :hint "failed to get team organization info"
@ -224,6 +390,23 @@
:cause cause)
team)))
(defn connectivity
[cfg]
(call cfg :connectivity {}))
(defn set-team-organization
"Associates a team with an organization in Nitrate.
Requires organization-id and is-default in params.
Throws an exception if the request fails."
[cfg team params]
(let [params (assoc (or params {})
:team-id (:id team)
:organization-id (:organization-id params)
:is-default (:is-default params))
result (call cfg :set-team-org params)]
(when (nil? result)
(ex/raise :type :internal
:code :failed-to-set-team-org
:context {:team-id (:id team)
:organization-id (:organization-id params)}))
team))

View File

@ -24,28 +24,28 @@
[integrant.core :as ig])
(:import
clojure.lang.MapEntry
io.lettuce.core.KeyValue
io.lettuce.core.RedisClient
io.lettuce.core.RedisCommandInterruptedException
io.lettuce.core.RedisCommandTimeoutException
io.lettuce.core.RedisException
io.lettuce.core.RedisURI
io.lettuce.core.ScriptOutputType
io.lettuce.core.SetArgs
io.lettuce.core.api.StatefulRedisConnection
io.lettuce.core.api.sync.RedisCommands
io.lettuce.core.api.sync.RedisScriptingCommands
io.lettuce.core.codec.RedisCodec
io.lettuce.core.codec.StringCodec
io.lettuce.core.KeyValue
io.lettuce.core.pubsub.api.sync.RedisPubSubCommands
io.lettuce.core.pubsub.RedisPubSubListener
io.lettuce.core.pubsub.StatefulRedisPubSubConnection
io.lettuce.core.pubsub.api.sync.RedisPubSubCommands
io.lettuce.core.RedisClient
io.lettuce.core.RedisCommandInterruptedException
io.lettuce.core.RedisCommandTimeoutException
io.lettuce.core.RedisException
io.lettuce.core.RedisURI
io.lettuce.core.resource.ClientResources
io.lettuce.core.resource.DefaultClientResources
io.lettuce.core.ScriptOutputType
io.lettuce.core.SetArgs
io.netty.channel.nio.NioEventLoopGroup
io.netty.util.concurrent.EventExecutorGroup
io.netty.util.HashedWheelTimer
io.netty.util.Timer
io.netty.util.concurrent.EventExecutorGroup
java.lang.AutoCloseable
java.time.Duration))

View File

@ -372,9 +372,11 @@
(throw cause))))))
(defn create-profile-rels
[conn {:keys [id] :as profile}]
[{:keys [::db/conn] :as cfg} {:keys [id] :as profile}]
(assert (db/connection-map? cfg)
"expected cfg with valid connection")
(let [features (cfeat/get-enabled-features cf/flags)
team (teams/create-team conn
team (teams/create-team cfg
{:profile-id id
:name "Default"
:features features
@ -429,7 +431,7 @@
(assoc :is-active is-active)
(update :password auth/derive-password))
profile (->> (create-profile cfg params)
(create-profile-rels conn))]
(create-profile-rels cfg))]
(vary-meta profile assoc :created true))))
created? (-> profile meta :created true?)

View File

@ -49,9 +49,9 @@
:deleted-at (ct/in-future (cf/get-deletion-delay))
:password (derive-password password)
:props {}}
profile (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
profile (db/tx-run! cfg (fn [cfg]
(->> (auth/create-profile cfg params)
(auth/create-profile-rels conn))))]
(auth/create-profile-rels cfg))))]
(with-meta {:email email
:password password}
{::audit/profile-id (:id profile)})))

View File

@ -13,6 +13,7 @@
[app.common.features :as cfeat]
[app.common.files.helpers :as cfh]
[app.common.files.migrations :as fmg]
[app.common.files.stats :as cfs]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.schema.desc-js-like :as-alias smdj]
@ -606,6 +607,76 @@
(get-file-summary cfg id))
;; --- COMMAND QUERY: get-file-stats
(def ^:private sql:file-stats-library-counts
"SELECT
(SELECT COUNT(*)
FROM file_library_rel AS flr
JOIN file AS fl ON (fl.id = flr.library_file_id)
WHERE flr.file_id = ?::uuid
AND (fl.deleted_at IS NULL OR fl.deleted_at > now())) AS library_count,
(SELECT COUNT(*)
FROM file_library_rel AS flr
JOIN file AS fl ON (fl.id = flr.file_id)
WHERE flr.library_file_id = ?::uuid
AND (fl.deleted_at IS NULL OR fl.deleted_at > now())) AS referenced_by_count")
(defn- get-file-stats-library-counts
[conn file-id]
(let [row (db/exec-one! conn [sql:file-stats-library-counts file-id file-id])]
{:library-count (or (:library-count row) 0)
:referenced-by-count (or (:referenced-by-count row) 0)}))
(defn- get-file-stats
[{:keys [::db/conn] :as cfg} file-id]
(let [file (bfc/get-file cfg file-id)
base (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
(cfs/calc-file-stats (:data file)))
lib-cnt (get-file-stats-library-counts conn file-id)]
(-> base
(merge lib-cnt)
(assoc :file-id file-id
:revn (:revn file)
:updated-at (:modified-at file)))))
(def ^:private schema:shape-counts
[:map {:title "FileStatsShapeCounts"}
[:total [::sm/int {:min 0}]]
[:by-type [:map-of :keyword [::sm/int {:min 0}]]]])
(def ^:private schema:get-file-stats-result
[:map {:title "FileStats"}
[:file-id ::sm/uuid]
[:page-count [::sm/int {:min 0}]]
[:shape-counts schema:shape-counts]
[:component-count [::sm/int {:min 0}]]
[:deleted-component-count [::sm/int {:min 0}]]
[:color-count [::sm/int {:min 0}]]
[:typography-count [::sm/int {:min 0}]]
[:library-count [::sm/int {:min 0}]]
[:referenced-by-count [::sm/int {:min 0}]]
[:revn [::sm/int {:min 0}]]
[:updated-at ::ct/inst]])
(def ^:private schema:get-file-stats
[:map {:title "get-file-stats"}
[:id ::sm/uuid]])
(sv/defmethod ::get-file-stats
"Return aggregate statistics for a single file: page count, shape
counts by type, component/color/typography counts, and inbound and
outbound library reference counts. Cheap alternative to `get-file`
when only metrics are needed."
{::doc/added "2.17"
::sm/params schema:get-file-stats
::sm/result schema:get-file-stats-result
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id]}]
(check-read-permissions! conn profile-id id)
(get-file-stats cfg id))
;; --- COMMAND QUERY: get-file-libraries
(def ^:private schema:get-file-libraries

View File

@ -8,6 +8,7 @@
(:require
[app.binfile.common :as bfc]
[app.common.exceptions :as ex]
[app.common.features :as-alias cfeat]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.db :as db]
@ -35,6 +36,43 @@
(files/check-read-permissions! conn profile-id file-id)
(fsnap/get-visible-snapshots conn file-id))))
;; --- COMMAND QUERY: get-file-snapshot
(def ^:private schema:get-file-snapshot
[:map {:title "get-file-snapshot"}
[:file-id ::sm/uuid]
[:id ::sm/uuid]
[:features {:optional true} ::cfeat/features]])
(sv/defmethod ::get-file-snapshot
"Retrieve a file bundle with data from a specific snapshot for
read-only preview. Does not modify any database state."
{::doc/added "2.16"
::sm/params schema:get-file-snapshot
::sm/result files/schema:file-with-permissions
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}]
(let [perms (bfc/get-file-permissions conn profile-id file-id)]
(files/check-read-permissions! perms)
(let [snapshot (fsnap/get-snapshot-data cfg file-id id)]
(when-not snapshot
(ex/raise :type :not-found
:code :snapshot-not-found
:hint "unable to find snapshot with the provided id"
:snapshot-id id
:file-id file-id))
;; Load current file metadata only (no data decoding) then overlay
;; the snapshot data so the client receives the same shape as a
;; normal get-file response but with historical page/object content.
(let [base-file (bfc/get-file cfg file-id :load-data? false)]
(-> base-file
(assoc :data (:data snapshot))
(assoc :version (:version snapshot))
(assoc :features (:features snapshot))
(assoc :revn (:revn snapshot))
(assoc :vern (rand-int 100000))
(assoc :permissions perms))))))
(def ^:private schema:create-file-snapshot
[:map
[:file-id ::sm/uuid]

View File

@ -84,5 +84,5 @@
(profile/get-profile-by-email conn))
(->> (assoc info :is-active true :is-demo false)
(auth/create-profile cfg)
(auth/create-profile-rels conn)
(auth/create-profile-rels cfg)
(profile/strip-private-attrs))))))

View File

@ -207,8 +207,7 @@
(update :team-id bfc/lookup-index)
(assoc :created-at timestamp)
(assoc :modified-at timestamp))]
(db/insert! conn :team-profile-rel params
{::db/return-keys false})))
(teams/add-profile-to-team! cfg params {::db/return-keys false})))
;; Duplicate team fonts
(doseq [font fonts]
@ -339,6 +338,21 @@
;; --- COMMAND: Move project
(defn move-project
"Moves a project from one team to another.
Performs comprehensive validation including:
- Permission checks on both source and destination teams
- Team compatibility verification between source and destination
- File features compatibility with destination team
The operation also:
- Updates the project's team assignment
- Cleans up any broken library relations after the move
Throws:
- :cant-move-to-same-team if trying to move project to its current team
- Permission exceptions if user lacks required permissions
- Team compatibility exceptions if teams are incompatible"
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id project-id] :as params}]
(let [project (db/get-by-id conn :project project-id {:columns [:id :team-id]})
pids (->> (db/query conn :project {:team-id (:team-id project)} {:columns [:id]})

View File

@ -255,7 +255,7 @@
[:session-id ::sm/uuid]])
(sv/defmethod ::create-upload-session
{::doc/added "2.16"
{::doc/added "2.17"
::sm/params schema:create-upload-session
::sm/result schema:create-upload-session-result}
[{:keys [::db/pool] :as cfg}
@ -293,7 +293,7 @@
[:index ::sm/int]])
(sv/defmethod ::upload-chunk
{::doc/added "2.16"
{::doc/added "2.17"
::sm/params schema:upload-chunk
::sm/result schema:upload-chunk-result}
[{:keys [::db/pool] :as cfg}
@ -389,7 +389,7 @@
[:id {:optional true} ::sm/uuid]])
(sv/defmethod ::assemble-file-media-object
{::doc/added "2.16"
{::doc/added "2.17"
::sm/params schema:assemble-file-media-object
::climit/id [[:process-image/by-profile ::rpc/profile-id]
[:process-image/global]]}

View File

@ -1,20 +1,283 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.rpc.commands.nitrate
"Nitrate API for Penpot. Provides nitrate-related endpoints to be called
from Penpot frontend."
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.db :as db]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.rpc.notifications :as notifications]
[app.util.services :as sv]))
(defn assert-is-owner [cfg profile-id team-id]
(let [perms (teams/get-permissions cfg profile-id team-id)]
(when-not (:is-owner perms)
(ex/raise :type :validation
:code :insufficient-permissions))))
(defn assert-not-default-team [cfg team-id]
(let [team (teams/get-team-info cfg {:id team-id})]
(when (:is-default team)
(ex/raise :type :validation
:code :cant-move-default-team))))
(defn assert-membership [cfg profile-id organization-id]
(let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id
:organization-id organization-id})]
(when-not (:organization-id membership)
(ex/raise :type :validation
:code :organization-doesnt-exists))
(when-not (:is-member membership)
(ex/raise :type :validation
:code :user-doesnt-belong-organization))))
(def schema:connectivity
[:map {:title "nitrate-connectivity"}
[:licenses ::sm/boolean]])
(sv/defmethod ::get-nitrate-connectivity
{::rpc/auth false
::doc/added "1.18"
{::rpc/auth true
::doc/added "2.14"
::sm/params [:map]
::sm/result schema:connectivity}
[cfg _params]
(nitrate/connectivity cfg))
(nitrate/call cfg :connectivity {}))
(def ^:private sql:prefix-team-name-and-unset-default
"UPDATE team
SET name = ? || name,
is_default = FALSE
WHERE id = ?;")
(def ^:private sql:get-member-teams-info
"SELECT t.id,
t.is_default,
tpr.is_owner,
(SELECT count(*) FROM team_profile_rel WHERE team_id = t.id) AS num_members,
(SELECT array_agg(profile_id) FROM team_profile_rel WHERE team_id = t.id) AS member_ids
FROM team AS t
JOIN team_profile_rel AS tpr ON (tpr.team_id = t.id)
WHERE tpr.profile_id = ?
AND t.id = ANY(?)
AND t.deleted_at IS NULL")
(def ^:private sql:get-team-files-count
"SELECT count(*) AS total
FROM file AS f
JOIN project AS p ON (p.id = f.project_id)
WHERE p.team_id = ?
AND f.deleted_at IS NULL")
(def ^:private schema:leave-org
[:map
[:id ::sm/uuid]
[:name ::sm/text]
[:default-team-id ::sm/uuid]
[:teams-to-delete
[:vector ::sm/uuid]]
[:teams-to-leave
[:vector
[:map
[:id ::sm/uuid]
[:reassign-to {:optional true} ::sm/uuid]]]]])
(defn- get-organization-teams-for-user
[{:keys [::db/conn] :as cfg} org-summary profile-id]
(let [org-team-ids (->> (:teams org-summary)
(map :id))
ids-array (db/create-array conn "uuid" org-team-ids)]
(db/exec! conn [sql:get-member-teams-info profile-id ids-array])))
(defn- calculate-valid-teams
([org-teams default-team-id]
(let [;; valid default team is the one which id is default-team-id
valid-default-team (d/seek #(= default-team-id (:id %)) org-teams)
;; Remove your-penpot for the rest of validations
org-teams (remove #(= default-team-id (:id %)) org-teams)
;; valid teams to delete are those that the user is owner, and only have one member
valid-teams-to-delete-ids (->> org-teams
(filter #(and (:is-owner %)
(= (:num-members %) 1)))
(map :id)
(into #{}))
;; valid teams to transfer are those that the user is owner, and have more than one member
valid-teams-to-transfer (->> org-teams
(filter #(and (:is-owner %)
(> (:num-members %) 1))))
;; valid teams to exit are those that the user isn't owner, and have more than one member
valid-teams-to-exit (->> org-teams
(filter #(and (not (:is-owner %))
(> (:num-members %) 1))))]
{:valid-teams-to-delete-ids valid-teams-to-delete-ids
:valid-teams-to-transfer valid-teams-to-transfer
:valid-teams-to-exit valid-teams-to-exit
:valid-default-team valid-default-team})))
(defn get-valid-teams [cfg organization-id profile-id default-team-id]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
org-teams (get-organization-teams-for-user cfg org-summary profile-id)]
(calculate-valid-teams org-teams default-team-id)))
(defn- assert-valid-teams [cfg profile-id organization-id default-team-id teams-to-delete teams-to-leave]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
org-teams (get-organization-teams-for-user cfg org-summary profile-id)
{:keys [valid-teams-to-delete-ids
valid-teams-to-transfer
valid-teams-to-exit
valid-default-team]} (calculate-valid-teams org-teams default-team-id)
valid-teams-to-exit-ids (->> valid-teams-to-exit (map :id) (into #{}))
valid-teams-to-transfer-ids (->> valid-teams-to-transfer (map :id) (into #{}))
valid-teams-to-leave-ids (into valid-teams-to-transfer-ids valid-teams-to-exit-ids)
valid-default-team-id? (some? valid-default-team)
valid-teams-to-delete? (= valid-teams-to-delete-ids (into #{} teams-to-delete))
;; for every team in teams-to-leave, check that:
;; - if it has a reassign-to, it belongs to valid-teams-to-transfer and
;; the reassign-to is a member of the team and not the current user;
;; - if it hasn't a reassign-to, check that it belongs to valid-teams-to-exit
teams-by-id (d/index-by :id org-teams)
valid-teams-to-leave? (and
(= valid-teams-to-leave-ids (->> teams-to-leave (map :id) (into #{})))
(every? (fn [{:keys [id reassign-to]}]
(if reassign-to
(let [members (db/pgarray->set (:member-ids (get teams-by-id id)))]
(and (contains? valid-teams-to-transfer-ids id)
(not= reassign-to profile-id)
(contains? members reassign-to)))
(contains? valid-teams-to-exit-ids id)))
teams-to-leave))]
;; the org owner cannot leave
(when (= (:owner-id org-summary) profile-id)
(ex/raise :type :validation
:code :org-owner-cannot-leave))
(when (or
(not valid-teams-to-delete?)
(not valid-teams-to-leave?)
(not valid-default-team-id?))
(ex/raise :type :validation
:code :not-valid-teams))))
(defn leave-org
[{:keys [::db/conn] :as cfg} {:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation] :as params}]
(let [org-prefix (str "[" (d/sanitize-string name) "] ")
default-team-files-count (-> (db/exec-one! conn [sql:get-team-files-count default-team-id])
:total)
delete-default-team? (= default-team-files-count 0)]
;; assert that the received teams are valid, checking the different constraints
(when-not skip-validation
(assert-valid-teams cfg profile-id id default-team-id teams-to-delete teams-to-leave))
(assert-membership cfg profile-id id)
;; delete the teams-to-delete
(doseq [id teams-to-delete]
(teams/delete-team cfg {:profile-id profile-id :team-id id}))
;; leave the teams-to-leave
(doseq [{:keys [id reassign-to]} teams-to-leave]
(teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to}))
;; Delete default-team-id if empty; otherwise keep it and prefix the name.
(if delete-default-team?
(do
(db/update! conn :team {:is-default false} {:id default-team-id})
(teams/delete-team cfg {:profile-id profile-id :team-id default-team-id}))
(db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix default-team-id]))
;; Api call to nitrate
(nitrate/call cfg :remove-profile-from-org {:profile-id profile-id :organization-id id})
nil))
(sv/defmethod ::leave-org
{::rpc/auth true
::doc/added "2.15"
::sm/params schema:leave-org
::db/transaction true}
[cfg {:keys [::rpc/profile-id] :as params}]
(leave-org cfg (assoc params :profile-id profile-id)))
(def ^:private schema:remove-team-from-org
[:map
[:team-id ::sm/uuid]
[:organization-id ::sm/uuid]
[:organization-name ::sm/text]])
(sv/defmethod ::remove-team-from-org
{::doc/added "2.17"
::sm/params schema:remove-team-from-org}
[cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}]
(assert-is-owner cfg profile-id team-id)
(assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id)
;; Api call to nitrate
(nitrate/call cfg :remove-team-from-org {:team-id team-id :organization-id organization-id})
;; Notify connected users
(notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-org")
nil)
(def ^:private schema:add-team-to-organization
[:map
[:team-id ::sm/uuid]
[:organization-id ::sm/uuid]])
(sv/defmethod ::add-team-to-organization
{::rpc/auth true
::doc/added "2.17"
::sm/params schema:add-team-to-organization
::db/transaction true}
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
(assert-is-owner cfg profile-id team-id)
(assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id)
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})]
;; Add teammates to the org if needed
(doseq [{member-id :profile-id} team-members
:when (not= member-id profile-id)]
(teams/initialize-user-in-nitrate-org cfg member-id organization-id)))
;; Api call to nitrate
(let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})]
;; Notify connected users
(notifications/notify-team-change cfg team "dashboard.team-belong-org"))
nil)

View File

@ -314,6 +314,25 @@
(climit/invoke! generate-thumbnail file))]
(sto/put-object! storage params)))
;; --- MUTATION: Delete Photo
(sv/defmethod ::delete-profile-photo
{::doc/added "2.17"
::sm/params [:map]
::sm/result :nil
::db/transaction true}
[{:keys [::db/conn ::sto/storage]} {:keys [::rpc/profile-id]}]
(let [profile (get-profile conn profile-id ::db/for-update true)]
(when-let [id (:photo-id profile)]
(sto/touch-object! storage id))
(db/update! conn :profile
{:photo-id nil}
{:id profile-id}
{::db/return-keys false})
nil))
;; --- MUTATION: Request Email Change
(declare ^:private request-email-change!)
@ -462,6 +481,9 @@
{:deleted-at deleted-at}
{:id profile-id})
;; Api call to nitrate
(nitrate/call cfg :remove-profile-from-all-orgs {:profile-id profile-id})
;; Schedule cascade deletion to a worker
(wrk/submit! {::db/conn conn
::wrk/task :delete-object

View File

@ -471,8 +471,8 @@
;; --- COMMAND QUERY: get-team-info
(defn get-team-info
[{:keys [::db/conn] :as cfg} {:keys [id] :as params}]
(-> (db/get* conn :team
[cfg {:keys [id] :as params}]
(-> (db/get* cfg :team
{:id id}
{::sql/columns [:id :is-default :features]})
(decode-row)))
@ -499,7 +499,9 @@
[:map {:title "create-team"}
[:name [:string {:max 250}]]
[:features {:optional true} ::cfeat/features]
[:id {:optional true} ::sm/uuid]])
[:id {:optional true} ::sm/uuid]
[:organization-id {:optional true} ::sm/uuid]
[:is-default {:optional true} :boolean]])
(sv/defmethod ::create-team
{::doc/added "1.17"
@ -520,17 +522,89 @@
(with-meta team
{::audit/props {:id (:id team)}})))
(defn create-default-org-team
[cfg profile-id organization-id]
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
::quotes/profile-id profile-id})
(let [features (-> (cfeat/get-enabled-features cf/flags)
(set/difference cfeat/frontend-only-features)
(set/difference cfeat/no-team-inheritable-features))
params {:profile-id profile-id
:name "Your Penpot"
:features features
:organization-id organization-id
:is-default true}
team (create-team cfg params)]
(select-keys team [:id])))
(defn initialize-user-in-nitrate-org
"If needed, create a default team for the user on the organization,
and notify Nitrate that an user has been added to an org."
([cfg profile-id organization-id]
(initialize-user-in-nitrate-org cfg profile-id organization-id nil))
([cfg profile-id organization-id email]
(assert (db/connection-map? cfg)
"expected cfg with valid connection")
(when (contains? cf/flags :nitrate)
(db/tx-run!
cfg
(fn [{:keys [::db/conn] :as tx-cfg}]
(let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id
:organization-id organization-id})]
;; Only when the user doesn't belong to the organization yet
(when (and
(some? (:organization-id membership)) ;; the organization exists
(not (:is-member membership))) ;; the user is not a member of the org yet
(let [organization-id organization-id
default-team (create-default-org-team (assoc tx-cfg ::db/conn conn) profile-id organization-id)
default-team-id (:id default-team)
result (nitrate/call tx-cfg :add-profile-to-org (cond-> {:profile-id profile-id
:team-id default-team-id
:organization-id organization-id}
(some? email) (assoc :email email)))]
(when (not (:is-member result))
(ex/raise :type :internal
:code :failed-add-profile-org-nitrate
:context {:profile-id profile-id
:organization-id organization-id
:default-team-id default-team-id}))
default-team-id))))))))
(defn add-profile-to-team!
([cfg params]
(add-profile-to-team! cfg params nil))
([{:keys [::db/conn] :as cfg} {:keys [:profile-id :team-id] :as params} options]
(assert (db/connection-map? cfg)
"expected cfg with valid connection")
(when (contains? cf/flags :nitrate)
(let [membership (nitrate/call cfg :get-org-membership-by-team {:profile-id profile-id :team-id team-id})]
;; Only when the team belong to an organization and the user is not a member
(when (and
(some? (:organization-id membership)) ;; the team do belong to an organization
(not (:is-member membership))) ;; the user is not a member of the org yet
(initialize-user-in-nitrate-org cfg profile-id (:organization-id membership)))))
(db/insert! conn :team-profile-rel params options)))
(defn create-team
"This is a complete team creation process, it creates the team
object and all related objects (default role and default project)."
[cfg-or-conn params]
(let [conn (db/get-connection cfg-or-conn)
team (create-team* conn params)
[{:keys [::db/conn] :as cfg} params]
(assert (db/connection-map? cfg)
"expected cfg with valid connection")
(let [team (create-team* conn params)
params (assoc params
:team-id (:id team)
:role :owner)
project (create-team-default-project conn params)]
(create-team-role conn params)
(create-team-role cfg params)
;; Set team organization in Nitrate if organization-id is provided
(when (and (contains? cf/flags :nitrate) (:organization-id params))
(nitrate/set-team-organization cfg team params))
(assoc team :default-project-id (:id project))))
(defn- create-team*
@ -546,11 +620,13 @@
(decode-row team)))
(defn- create-team-role
[conn {:keys [profile-id team-id role] :as params}]
[cfg {:keys [profile-id team-id role] :as params}]
(assert (db/connection-map? cfg)
"expected cfg with valid connection")
(let [params {:team-id team-id
:profile-id profile-id}]
(->> (perms/assign-role-flags params role)
(db/insert! conn :team-profile-rel))))
(add-profile-to-team! cfg))))
(defn- create-team-default-project
[conn {:keys [profile-id team-id] :as params}]
@ -609,7 +685,7 @@
;; --- Mutation: Leave Team
(defn leave-team
[conn {:keys [profile-id id reassign-to]}]
[{:keys [::db/conn ::mbus/msgbus]} {:keys [profile-id id reassign-to]}]
(let [perms (get-permissions conn profile-id id)
members (get-team-members conn id)]
@ -624,7 +700,9 @@
;; if the `reassign-to` is filled and has a different value
;; than the current profile-id, we proceed to reassing the
;; owner role to profile identified by the `reassign-to`.
(and reassign-to (not= reassign-to profile-id))
;; Ignore the reasignation if the current profile is not
;; the owner
(and reassign-to (not= reassign-to profile-id) (:is-owner perms))
(let [member (d/seek #(= reassign-to (:id %)) members)]
(when-not member
(ex/raise :type :not-found :code :member-does-not-exist))
@ -638,7 +716,15 @@
;; assign owner role to new profile
(db/update! conn :team-profile-rel
(get types.team/permissions-for-role :owner)
{:team-id id :profile-id reassign-to}))
{:team-id id :profile-id reassign-to})
;; notify new owner
(mbus/pub! msgbus
:topic reassign-to
:message {:type :team-role-change
:topic reassign-to
:team-id id
:role :owner}))
;; and finally, if all other conditions does not match and the
;; current profile is owner, we dont allow it because there
@ -663,32 +749,44 @@
{::doc/added "1.17"
::sm/params schema:leave-team
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id] :as params}]
(leave-team conn (assoc params :profile-id profile-id)))
[cfg {:keys [::rpc/profile-id] :as params}]
(leave-team cfg (assoc params :profile-id profile-id)))
;; --- Mutation: Delete Team
(defn- delete-team
(defn delete-team
"Mark a team for deletion"
[conn {:keys [id] :as team}]
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id]}]
(let [delay (ldel/get-deletion-delay team)
team (db/update! conn :team
{:deleted-at (ct/in-future delay)}
{:id id}
{::db/return-keys true})]
(let [team (get-team conn :profile-id profile-id :team-id team-id)
perms (get team :permissions)]
(when-not (:is-owner perms)
(ex/raise :type :validation
:code :only-owner-can-delete-team))
(when (:is-default team)
(ex/raise :type :validation
:code :non-deletable-team
:hint "impossible to delete default team"))
(wrk/submit! {::db/conn conn
::wrk/task :delete-object
::wrk/params {:object :team
:deleted-at (:deleted-at team)
:id id}})
team))
(let [delay (ldel/get-deletion-delay team)
team (db/update! conn :team
{:deleted-at (ct/in-future delay)}
{:id team-id}
{::db/return-keys true})]
;; Api call to nitrate
(when (contains? cf/flags :nitrate)
(nitrate/call cfg :delete-team {:profile-id profile-id :team-id team-id}))
(wrk/submit! {::db/conn conn
::wrk/task :delete-object
::wrk/params {:object :team
:deleted-at (:deleted-at team)
:id team-id}})
team)))
(def ^:private schema:delete-team
[:map {:title "delete-team"}
@ -698,16 +796,9 @@
{::doc/added "1.17"
::sm/params schema:delete-team
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id] :as params}]
(let [team (get-team conn :profile-id profile-id :team-id id)
perms (get team :permissions)]
(when-not (:is-owner perms)
(ex/raise :type :validation
:code :only-owner-can-delete-team))
(delete-team conn team)
nil))
[cfg {:keys [::rpc/profile-id id] :as params}]
(delete-team cfg {:team-id id :profile-id profile-id})
nil)
;; --- Mutation: Team Update Role

View File

@ -21,6 +21,7 @@
[app.email :as eml]
[app.loggers.audit :as audit]
[app.main :as-alias main]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.rpc.commands.teams :as teams]
@ -35,20 +36,29 @@
;; --- Mutation: Create Team Invitation
(def sql:upsert-team-invitation
"insert into team_invitation(id, team_id, email_to, created_by, role, valid_until)
values (?, ?, ?, ?, ?, ?)
"insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
values (?, ?, null, ?, ?, ?, ?)
on conflict(team_id, email_to) do
update set role = ?, valid_until = ?, updated_at = now()
returning *")
(def sql:upsert-org-invitation
"insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
values (?, null, ?, ?, ?, ?, ?)
on conflict(org_id, email_to) where team_id is null do
update set role = ?, valid_until = ?, updated_at = now()
returning *")
(defn- create-invitation-token
[cfg {:keys [profile-id valid-until team-id member-id member-email role]}]
[cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}]
(tokens/generate cfg
{:iss :team-invitation
:exp valid-until
:profile-id profile-id
:role role
:team-id team-id
:organization-id organization-id
:organization-name organization-name
:member-email member-email
:member-id member-id}))
@ -74,19 +84,40 @@
[:role types.team/schema:role]
[:email ::sm/email]])
(def ^:private schema:create-org-invitation
[:map {:title "params:create-org-invitation"}
[::rpc/profile-id ::sm/uuid]
[:organization
[:map
[:id ::sm/uuid]
[:name :string]
[:logo ::sm/uri]]]
[:profile
[:map
[:id ::sm/uuid]
[:fullname :string]]]
[:role types.team/schema:role]
[:email ::sm/email]])
(def ^:private check-create-invitation-params
(sm/check-fn schema:create-invitation))
(def ^:private check-create-org-invitation-params
(sm/check-fn schema:create-org-invitation))
(defn- allow-invitation-emails?
[member]
(let [notifications (dm/get-in member [:props :notifications])]
(not= :none (:email-invites notifications))))
(defn- create-invitation
[{:keys [::db/conn] :as cfg} {:keys [team profile role email] :as params}]
[{:keys [::db/conn] :as cfg} {:keys [team organization profile role email] :as params}]
(assert (db/connection? conn) "expected valid connection on cfg parameter")
(assert (check-create-invitation-params params))
(assert (db/connection-map? cfg)
"expected cfg with valid connection")
(if organization
(assert (check-create-org-invitation-params params))
(assert (check-create-invitation-params params)))
(let [email (profile/clean-email email)
member (profile/get-profile-by-email conn email)]
@ -103,9 +134,12 @@
:profile-id (:id member)}
(get types.team/permissions-for-role role))]
;; Insert the invited member to the team
(db/insert! conn :team-profile-rel params
{::db/on-conflict-do-nothing? true})
(if organization
;; Insert the invited member to the org
(when (contains? cf/flags :nitrate)
(teams/initialize-user-in-nitrate-org cfg (:id member) (:id organization) email))
;; Insert the invited member to the team
(teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}))
;; If profile is not yet verified, mark it as verified because
;; accepting an invitation link serves as verification.
@ -122,18 +156,30 @@
(teams/check-email-spam conn email true)
(let [id (uuid/next)
expire (ct/in-future "168h") ;; 7 days
invitation (db/exec-one! conn [sql:upsert-team-invitation id
(:id team) (str/lower email)
(:id profile)
(name role) expire
(name role) expire])
expire (if organization
(ct/in-future "876000h") ;; Organization invitations doesn't expire
(ct/in-future "168h")) ;; 7 days
invitation (db/exec-one! conn (if organization
[sql:upsert-org-invitation id
(:id organization)
(str/lower email)
(:id profile)
(name role) expire
(name role) expire]
[sql:upsert-team-invitation id
(:id team)
(str/lower email)
(:id profile)
(name role) expire
(name role) expire]))
updated? (not= id (:id invitation))
profile-id (:id profile)
tprops {:profile-id profile-id
:invitation-id (:id invitation)
:valid-until expire
:team-id (:id team)
:organization-id (:id organization)
:organization-name (:name organization)
:member-email (:email-to invitation)
:member-id (:id member)
:role role}
@ -145,28 +191,58 @@
(let [props (-> (dissoc tprops :profile-id)
(audit/clean-props))
evname (if updated?
"update-team-invitation"
"create-team-invitation")
evname (cond
(and updated? organization) "update-org-invitation"
updated? "update-team-invitation"
organization "create-org-invitation"
:else "create-team-invitation")
event (-> (audit/event-from-rpc-params params)
(assoc ::audit/name evname)
(assoc ::audit/props props))]
(audit/submit! cfg event))
(when (allow-invitation-emails? member)
(eml/send! {::eml/conn conn
::eml/factory eml/invite-to-team
:public-uri (cf/get :public-uri)
:to email
:invited-by (:fullname profile)
:team (:name team)
:token itoken
:extra-data ptoken}))
(if organization
(when (contains? cf/flags :nitrate)
(eml/send! {::eml/conn conn
::eml/factory eml/invite-to-org
:public-uri (cf/get :public-uri)
:to email
:invited-by (:fullname profile)
:user-name (:fullname member)
:organization-name (:name organization)
:org-logo (:logo organization)
:org-initials (d/get-initials (:name organization))
:token itoken
:extra-data ptoken}))
(let [team (if (contains? cf/flags :nitrate)
(nitrate/add-org-info-to-team cfg team {})
team)]
(eml/send! {::eml/conn conn
::eml/factory eml/invite-to-team
:public-uri (cf/get :public-uri)
:to email
:invited-by (:fullname profile)
:team (:name team)
:organization (:organization-name team)
:token itoken
:extra-data ptoken}))))
itoken)))))
(defn create-org-invitation
[cfg {:keys [::rpc/profile-id id name logo] :as params}]
(let [profile (db/get-by-id cfg :profile profile-id)]
(create-invitation cfg
(assoc params
:organization {:id id :name name :logo logo}
:profile profile
:role :editor))))
(defn- add-member-to-team
[conn profile team role member]
[{:keys [::db/conn] :as cfg} profile team role member]
(assert (db/connection-map? cfg)
"expected cfg with valid connection")
(let [team-id (:id team)
params (merge
@ -186,7 +262,7 @@
::quotes/team-id team-id})
;; Insert the member to the team
(db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true})
(teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})
;; Delete any request
(db/delete! conn :team-access-request
@ -268,7 +344,7 @@
(filter #(contains? invitation-emails (key %)))
(map (fn [[email member]]
(let [role (:role (first (filter #(= (:email %) email) invitation-data)))]
(add-member-to-team conn profile team role member))))
(add-member-to-team cfg profile team role member))))
(doall))
invitations))

View File

@ -16,8 +16,10 @@
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.main :as-alias main]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.rpc.quotes :as quotes]
@ -86,52 +88,74 @@
;; --- Team Invitation
(defn- accept-invitation
[{:keys [::db/conn] :as cfg} {:keys [team-id role member-email] :as claims} invitation member]
[{:keys [::db/conn] :as cfg}
{:keys [team-id organization-id role member-email] :as claims} invitation member]
(let [;; Update the role if there is an invitation
role (or (some-> invitation :role keyword) role)
params (merge
{:team-id team-id
:profile-id (:id member)}
(get types.team/permissions-for-role role))]
id-member (:id member)]
;; Do not allow blocked users accept invitations.
(when (:is-blocked member)
(ex/raise :type :restriction
:code :profile-blocked))
(quotes/check! cfg {::quotes/id ::quotes/profiles-per-team
::quotes/profile-id (:id member)
::quotes/team-id team-id})
(when team-id
(quotes/check! cfg {::quotes/id ::quotes/profiles-per-team
::quotes/profile-id id-member
::quotes/team-id team-id}))
;; Insert the invited member to the team
(db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true})
(let [params (merge
{:team-id team-id
:profile-id id-member}
(get types.team/permissions-for-role role))
;; If profile is not yet verified, mark it as verified because
;; accepting an invitation link serves as verification.
(when-not (:is-active member)
(db/update! conn :profile
{:is-active true}
{:id (:id member)}))
accepted-team-id (if organization-id
;; Insert the invited member to the org
(when (contains? cf/flags :nitrate)
(teams/initialize-user-in-nitrate-org cfg id-member organization-id member-email))
;; Insert the invited member to the team
(do (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})
team-id))]
;; Delete the invitation
(db/delete! conn :team-invitation
{:team-id team-id :email-to member-email})
(when-not accepted-team-id
(ex/raise :type :internal
:code :accept-invitation-failed
:hint "the accept invitation has failed"))
;; Delete any request
(db/delete! conn :team-access-request
{:team-id team-id :requester-id (:id member)})
(assoc member :is-active true)))
;; If profile is not yet verified, mark it as verified because
;; accepting an invitation link serves as verification.
(when-not (:is-active member)
(db/update! conn :profile
{:is-active true}
{:id id-member}))
;; Delete the invitation
(db/delete! conn :team-invitation
(cond-> {:email-to member-email}
team-id (assoc :team-id team-id)
organization-id (assoc :org-id organization-id)))
;; Delete any request (only applicable for team invitations)
(when team-id
(db/delete! conn :team-access-request
{:team-id team-id :requester-id id-member}))
accepted-team-id)))
(def schema:team-invitation-claims
[:map {:title "TeamInvitationClaims"}
[:iss :keyword]
[:exp ::ct/inst]
[:profile-id ::sm/uuid]
[:role types.team/schema:role]
[:team-id ::sm/uuid]
[:member-email ::sm/email]
[:member-id {:optional true} ::sm/uuid]])
[:and
[:map {:title "TeamInvitationClaims"}
[:iss :keyword]
[:exp ::ct/inst]
[:profile-id ::sm/uuid]
[:role types.team/schema:role]
[:team-id {:optional true} ::sm/uuid]
[:organization-id {:optional true} ::sm/uuid]
[:member-email ::sm/email]
[:member-id {:optional true} ::sm/uuid]]
[:fn {:error/message "team-id or organization-id must be present"}
(fn [m] (or (:team-id m) (:organization-id m)))]])
(def valid-team-invitation-claims?
(sm/lazy-validator schema:team-invitation-claims))
@ -139,7 +163,7 @@
(defmethod process-token :team-invitation
[{:keys [::db/conn] :as cfg}
{:keys [::rpc/profile-id token] :as params}
{:keys [member-id team-id member-email] :as claims}]
{:keys [member-id team-id organization-id member-email] :as claims}]
(when-not (valid-team-invitation-claims? claims)
(ex/raise :type :validation
@ -147,19 +171,44 @@
:hint "invitation token contains unexpected data"))
(let [invitation (db/get* conn :team-invitation
{:team-id team-id :email-to member-email})
(cond-> {:email-to member-email}
team-id (assoc :team-id team-id)
organization-id (assoc :org-id organization-id)))
profile (db/get* conn :profile
{:id profile-id}
{:columns [:id :email]})
registration-disabled? (not (contains? cf/flags :registration))]
(when (nil? invitation)
(ex/raise :type :validation
:code :invalid-token
:hint "no invitation associated with the token"))
{:columns [:id :email :default-team-id]})
registration-disabled? (not (contains? cf/flags :registration))
org-invitation? (and (contains? cf/flags :nitrate) organization-id)
membership (when org-invitation?
(nitrate/call cfg :get-org-membership {:profile-id profile-id
:organization-id organization-id}))]
(if profile
(do
(when-not (or (= member-id profile-id)
(= member-email (:email profile)))
(ex/raise :type :validation
:code :invalid-token
:hint "logged-in user does not matches the invitation"))
(when (:is-member membership)
(ex/raise :type :validation
:code :already-an-org-member
:team-id (:default-team-id membership)
:hint "the user is already a member of the organization"))
(when (and org-invitation? (not (:organization-id membership)))
(ex/raise :type :validation
:code :org-not-found
:team-id (:default-team-id profile)
:hint "the organization doesn't exist"))
(when (nil? invitation)
(ex/raise :type :validation
:code :invalid-token
:hint "no invitation associated with the token"))
(if (some? profile)
(if (or (= member-id profile-id)
(= member-email (:email profile)))
;; if we have logged-in user and it matches the invitation we proceed
;; with accepting the invitation and joining the current profile to the
@ -187,17 +236,16 @@
:profile-id (:id profile)
:email (:email profile))))))
(accept-invitation cfg claims invitation profile)
(assoc claims :state :created))
(ex/raise :type :validation
:code :invalid-token
:hint "logged-in user does not matches the invitation"))
(let [accepted-team-id (accept-invitation cfg claims invitation profile)]
(cond-> (assoc claims :state :created)
;; when the invitation is to an org, instead of a team, add the
;; accepted-team-id as :org-team-id
(:organization-id claims)
(assoc :org-team-id accepted-team-id)))))
;; If we have not logged-in user, and invitation comes with member-id we
;; redirect user to login, if no memeber-id is present and in the invitation
;; token and registration is enabled, we redirect user the the register page.
{:invitation-token token
:iss :team-invitation
:redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register)

View File

@ -28,19 +28,25 @@
(update :pages-index select-keys allowed)))
(defn obfuscate-email
"Obfuscate the `email` for share-link members so the viewer only sees a
partially redacted address. Accepts any string shape (including nil,
missing `@`, or a domain with no `.`) and falls back to a fully-masked
result rather than throwing the function is called while building the
view-only bundle for anonymous viewers, so an NPE here would abort the
entire share-link response."
[email]
(let [[name domain]
(str/split email "@" 2)
(str/split (or email "") "@" 2)
[_ rest]
(str/split domain "." 2)
(str/split (or domain "") "." 2)
name
(if (> (count name) 3)
(str (subs name 0 1) (apply str (take (dec (count name)) (repeat "*"))))
"****")]
(str name "@****." rest)))
(str name "@****" (when rest (str "." rest)))))
(defn anonymize-member
[member]

View File

@ -8,22 +8,33 @@
"Internal Nitrate HTTP RPC API. Provides authenticated access to
organization management and token validation endpoints."
(:require
[app.common.features :as cfeat]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.types.organization :refer [schema:team-with-organization]]
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
[app.common.types.team :refer [schema:team]]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.msgbus :as mbus]
[app.media :as media]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.rpc.commands.nitrate :as cnit]
[app.rpc.commands.profile :as profile]
[app.rpc.commands.teams :as teams]
[app.rpc.commands.teams-invitations :as ti]
[app.rpc.doc :as doc]
[app.rpc.quotes :as quotes]
[app.util.services :as sv]
[clojure.set :as set]))
[app.rpc.notifications :as notifications]
[app.storage :as sto]
[app.util.services :as sv]))
(defn- profile-to-map [profile]
{:id (:id profile)
:name (:fullname profile)
:email (:email profile)
:photo-url (files/resolve-public-uri (get profile :photo-id))})
;; ---- API: authenticate
@ -33,11 +44,9 @@
::sm/params [:map]
::sm/result schema:profile}
[cfg {:keys [::rpc/profile-id] :as params}]
(let [profile (profile/get-profile cfg profile-id)]
{:id (get profile :id)
:name (get profile :fullname)
:email (get profile :email)
:photo-url (files/resolve-public-uri (get profile :photo-id))}))
(let [profile (profile/get-profile cfg profile-id)]
(-> (profile-to-map profile)
(assoc :theme (:theme profile)))))
;; ---- API: get-teams
@ -76,29 +85,47 @@
(->> (db/exec! cfg [sql:get-teams current-user-id])
(map #(select-keys % [:id :name])))))
;; ---- API: notify-team-change
;; ---- API: upload-org-logo
(def ^:private schema:notify-team-change
(def ^:private schema:upload-org-logo
[:map
[:id ::sm/uuid]
[:content media/schema:upload]
[:organization-id ::sm/uuid]
[:organization-name ::sm/text]])
[:previous-id {:optional true} ::sm/uuid]])
(def ^:private schema:upload-org-logo-result
[:map [:id ::sm/uuid]])
(sv/defmethod ::upload-org-logo
"Store an organization logo in penpot storage and return its ID.
Accepts an optional previous-id to mark the old logo for garbage
collection when replacing an existing one."
{::doc/added "2.17"
::sm/params schema:upload-org-logo
::sm/result schema:upload-org-logo-result}
[{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
(when previous-id
(sto/touch-object! storage previous-id))
(let [hash (sto/calculate-hash (:path content))
data (-> (sto/content (:path content))
(sto/wrap-with-hash hash))
obj (sto/put-object! storage {::sto/content data
::sto/deduplicate? true
:bucket "organization"
:content-type (:mtype content)
:organization-id organization-id})]
{:id (:id obj)}))
;; ---- API: notify-team-change
(sv/defmethod ::notify-team-change
"Notify to Penpot a team change from nitrate"
{::doc/added "2.14"
::sm/params schema:notify-team-change
::sm/params schema:team-with-organization
::rpc/auth false}
[cfg {:keys [id organization-id organization-name]}]
(let [msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus
;;TODO There is a bug on dashboard with teams notifications.
;;For now we send it to uuid/zero instead of team-id
:topic uuid/zero
:message {:type :team-org-change
:team-id id
:organization-id organization-id
:organization-name organization-name})))
[cfg team]
(notifications/notify-team-change cfg (select-keys team [:id :is-your-penpot :organization]) nil)
nil)
;; ---- API: notify-user-added-to-organization
@ -113,18 +140,8 @@
{::doc/added "2.14"
::sm/params schema:notify-user-added-to-organization
::rpc/auth false}
[cfg {:keys [profile-id]}]
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
::quotes/profile-id profile-id})
(let [features (-> (cfeat/get-enabled-features cf/flags)
(set/difference cfeat/frontend-only-features)
(set/difference cfeat/no-team-inheritable-features))
params {:profile-id profile-id
:name "Default"
:features features}
team (db/tx-run! cfg teams/create-team params)]
(select-keys team [:id])))
[cfg {:keys [profile-id organization-id]}]
(db/tx-run! cfg teams/create-default-org-team profile-id organization-id))
;; ---- API: get-managed-profiles
@ -158,3 +175,358 @@
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(db/exec! cfg [sql:get-managed-profiles current-user-id current-user-id])))
;; ---- API: get-teams-summary
(def ^:private sql:get-teams-summary
"SELECT t.id, t.name, t.is_default
FROM team AS t
WHERE t.id = ANY(?)
AND t.deleted_at IS NULL;")
(def ^:private sql:get-files-count
"SELECT COUNT(f.*) AS count
FROM file AS f
JOIN project AS p ON f.project_id = p.id
JOIN team AS t ON t.id = p.team_id
WHERE p.team_id = ANY(?)
AND t.deleted_at IS NULL
AND p.deleted_at IS NULL
AND f.deleted_at IS NULL;")
(def ^:private schema:get-teams-summary-params
[:map
[:ids [:or ::sm/uuid [:vector ::sm/uuid]]]])
(def ^:private schema:get-teams-summary-result
[:map
[:teams [:vector [:map
[:id ::sm/uuid]
[:name ::sm/text]
[:is-default ::sm/boolean]]]]
[:num-files ::sm/int]])
(sv/defmethod ::get-teams-summary
"Get summary information for a list of teams"
{::doc/added "2.15"
::sm/params schema:get-teams-summary-params
::sm/result schema:get-teams-summary-result}
[cfg {:keys [ids]}]
(let [;; Handle one or multiple params
ids (cond
(uuid? ids)
[ids]
(and (vector? ids) (every? uuid? ids))
ids
:else
[])]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids-array (db/create-array conn "uuid" ids)
teams (db/exec! conn [sql:get-teams-summary ids-array])
files-count (-> (db/exec-one! conn [sql:get-files-count ids-array]) :count)]
{:teams teams
:num-files files-count})))))
;; ---- API: delete-teams-keeping-your-penpot-projects
(def ^:private sql:prefix-teams-name-and-unset-default
"UPDATE team
SET name = ? || name,
is_default = FALSE
WHERE id = ANY(?)
RETURNING id, name;")
(def ^:private schema:notify-org-deletion
[:map
[:organization-name ::sm/text]
[:teams [:vector ::sm/uuid]]])
(sv/defmethod ::notify-org-deletion
"For a list of teams, rename them with the name of the deleted org, and notify
of the deletion to the connected users"
{::doc/added "2.15"
::sm/params schema:notify-org-deletion}
[cfg {:keys [teams organization-name]}]
(when (seq teams)
(let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")]
(db/tx-run!
cfg
(fn [{:keys [::db/conn] :as cfg}]
(let [ids-array (db/create-array conn "uuid" teams)
;; Rename projects
updated-teams (db/exec! conn [sql:prefix-teams-name-and-unset-default org-prefix ids-array])]
;; Notify users
(doseq [team updated-teams]
(notifications/notify-team-change cfg {:id (:id team) :name (:name team) :organization {:name organization-name}} "dashboard.org-deleted"))))))))
;; ---- API: get-profile-by-email
(def ^:private sql:get-profile-by-email
"SELECT DISTINCT id, fullname, email, photo_id
FROM profile
WHERE email = ?
AND deleted_at IS NULL;")
(sv/defmethod ::get-profile-by-email
"Get profile by email"
{::doc/added "2.15"
::sm/params [:map [:email ::sm/email]]
::sm/result schema:profile}
[cfg {:keys [email]}]
(let [profile (db/exec-one! cfg [sql:get-profile-by-email email])]
(when-not profile
(ex/raise :type :not-found
:code :profile-not-found
:hint "profile does not exist"
:email email))
(profile-to-map profile)))
;; ---- API: get-profile-by-id
(def ^:private sql:get-profile-by-id
"SELECT DISTINCT id, fullname, email, photo_id
FROM profile
WHERE id = ?
AND deleted_at IS NULL;")
(sv/defmethod ::get-profile-by-id
"Get profile by email"
{::doc/added "2.15"
::sm/params [:map [:id ::sm/uuid]]
::sm/result schema:profile}
[cfg {:keys [id]}]
(let [profile (db/exec-one! cfg [sql:get-profile-by-id id])]
(when-not profile
(ex/raise :type :not-found
:code :profile-not-found
:hint "profile does not exist"
:id id))
(profile-to-map profile)))
;; ---- API: get-org-member-team-counts
(def ^:private sql:get-org-member-team-counts
"SELECT tpr.profile_id, COUNT(DISTINCT t.id) AS team_count
FROM team_profile_rel AS tpr
JOIN team AS t ON t.id = tpr.team_id
WHERE t.id = ANY(?)
AND t.deleted_at IS NULL
AND t.is_default IS FALSE
GROUP BY tpr.profile_id;")
(def ^:private schema:get-org-member-team-counts-params
[:map [:team-ids [:or ::sm/uuid [:vector ::sm/uuid]]]])
(def ^:private schema:get-org-member-team-counts-result
[:vector [:map
[:profile-id ::sm/uuid]
[:team-count ::sm/int]]])
(sv/defmethod ::get-org-member-team-counts
"Get the number of non-default teams each profile belongs to within a set of teams."
{::doc/added "2.15"
::sm/params schema:get-org-member-team-counts-params
::sm/result schema:get-org-member-team-counts-result
::rpc/auth false}
[cfg {:keys [team-ids]}]
(let [team-ids (cond
(uuid? team-ids)
[team-ids]
(and (vector? team-ids) (every? uuid? team-ids))
team-ids
:else
[])]
(if (empty? team-ids)
[]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids-array (db/create-array conn "uuid" team-ids)]
(db/exec! conn [sql:get-org-member-team-counts ids-array])))))))
;; API: invite-to-org
(sv/defmethod ::invite-to-org
"Invite to organization"
{::doc/added "2.15"
::sm/params [:map
[:email ::sm/email]
[:id ::sm/uuid]
[:name ::sm/text]
[:logo ::sm/uri]]}
[cfg params]
(db/tx-run! cfg ti/create-org-invitation params)
nil)
;; API: get-org-invitations
(def ^:private sql:get-org-invitations
"SELECT DISTINCT ON (email_to)
ti.id,
ti.org_id AS organization_id,
ti.email_to AS email,
ti.created_at AS sent_at,
p.fullname AS name,
p.photo_id
FROM team_invitation AS ti
LEFT JOIN profile AS p
ON p.email = ti.email_to
AND p.deleted_at IS NULL
WHERE ti.valid_until >= now()
AND (ti.org_id = ? OR ti.team_id = ANY(?))
ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;")
(def ^:private schema:get-org-invitations-params
[:map
[:organization-id ::sm/uuid]])
(def ^:private schema:get-org-invitations-result
[:vector
[:map
[:id ::sm/uuid]
[:organization-id {:optional true} [:maybe ::sm/uuid]]
[:email ::sm/email]
[:sent-at ::sm/inst]
[:name {:optional true} [:maybe ::sm/text]]
[:photo-url {:optional true} ::sm/uri]]])
(sv/defmethod ::get-org-invitations
"Get valid invitations for an organization, returning at most one invitation per email."
{::doc/added "2.16"
::sm/params schema:get-org-invitations-params
::sm/result schema:get-org-invitations-result}
[cfg {:keys [organization-id]}]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
team-ids (->> (:teams org-summary)
(map :id)
(filter uuid?)
(into []))]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids-array (db/create-array conn "uuid" team-ids)]
(->> (db/exec! conn [sql:get-org-invitations organization-id ids-array])
(mapv (fn [{:keys [photo-id] :as invitation}]
(cond-> (dissoc invitation :photo-id)
photo-id
(assoc :photo-url (files/resolve-public-uri photo-id)))))))))))
;; API: delete-org-invitations
(def ^:private sql:delete-org-invitations
"DELETE FROM team_invitation AS ti
WHERE ti.email_to = ?
AND (ti.org_id = ? OR ti.team_id = ANY(?));")
(def ^:private schema:delete-org-invitations-params
[:map
[:organization-id ::sm/uuid]
[:email ::sm/email]])
(sv/defmethod ::delete-org-invitations
"Delete all invitations for one email in an organization scope (org + org teams)."
{::doc/added "2.16"
::sm/params schema:delete-org-invitations-params}
[cfg {:keys [organization-id email]}]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
clean-email (profile/clean-email email)
team-ids (->> (:teams org-summary)
(map :id)
(filter uuid?)
(into []))]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids-array (db/create-array conn "uuid" team-ids)]
(db/exec! conn [sql:delete-org-invitations clean-email organization-id ids-array]))))
nil))
;; API: remove-from-org
(def ^:private sql:get-reassign-to
"SELECT tpr.profile_id
FROM team_profile_rel AS tpr
WHERE tpr.team_id = ?
AND tpr.profile_id <> ?
AND tpr.is_owner IS NOT TRUE
ORDER BY CASE
WHEN tpr.is_admin IS TRUE THEN 1
ELSE 2
END,
tpr.created_at,
tpr.profile_id
LIMIT 1;")
(defn add-reassign-to [cfg profile-id team-to-transfer]
(let [reassign-to (-> (db/exec-one! cfg [sql:get-reassign-to (:id team-to-transfer) profile-id])
:profile-id)]
(when-not reassign-to
(ex/raise :type :validation
:code :nobody-to-reassign-team))
(assoc team-to-transfer :reassign-to reassign-to)))
(sv/defmethod ::remove-from-org
"Remove an user from an organization"
{::doc/added "2.17"
::sm/params [:map
[:profile-id ::sm/uuid]
[:organization-id ::sm/uuid]
[:organization-name ::sm/text]
[:default-team-id ::sm/uuid]]
::db/transaction true}
[cfg {:keys [profile-id organization-id organization-name default-team-id] :as params}]
(let [{:keys [valid-teams-to-delete-ids
valid-teams-to-transfer
valid-teams-to-exit]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id)
add-reassign-to (partial add-reassign-to cfg profile-id)
valid-teams-to-leave (into valid-teams-to-exit
(map add-reassign-to valid-teams-to-transfer))]
(cnit/leave-org cfg (assoc params
:id organization-id
:name organization-name
:teams-to-delete valid-teams-to-delete-ids
:teams-to-leave valid-teams-to-leave
:skip-validation true))
(notifications/notify-user-org-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-org")
nil))
;; API: get-remove-from-org-summary
(def ^:private schema:get-remove-from-org-summary-result
[:map
[:teams-to-delete ::sm/int]
[:teams-to-transfer ::sm/int]
[:teams-to-exit ::sm/int]])
(sv/defmethod ::get-remove-from-org-summary
"Get a summary of the teams that would be deleted, transferred, or exited
if the user were removed from the organization"
{::doc/added "2.17"
::sm/params [:map
[:profile-id ::sm/uuid]
[:organization-id ::sm/uuid]
[:default-team-id ::sm/uuid]]
::sm/result schema:get-remove-from-org-summary-result
::db/transaction true}
[cfg {:keys [profile-id organization-id default-team-id]}]
(let [{:keys [valid-teams-to-delete-ids
valid-teams-to-transfer
valid-teams-to-exit
valid-default-team]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id)]
(when-not valid-default-team
(ex/raise :type :validation
:code :not-valid-teams))
{:teams-to-delete (count valid-teams-to-delete-ids)
:teams-to-transfer (count valid-teams-to-transfer)
:teams-to-exit (count valid-teams-to-exit)}))

View File

@ -0,0 +1,33 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.rpc.notifications
(:require
[app.common.uuid :as uuid]
[app.msgbus :as mbus]))
(defn notify-team-change
[cfg team notification]
(let [msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus
;;TODO There is a bug on dashboard with teams notifications.
;;For now we send it to uuid/zero instead of team-id
:topic uuid/zero
:message {:type :team-org-change
:team team
:notification notification})))
(defn notify-user-org-change
[cfg profile-id organization-id organization-name notification]
(let [msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus
:topic profile-id
:message {:type :user-org-change
:topic profile-id
:organization-id organization-id
:organization-name organization-name
:notification notification})))

View File

@ -53,7 +53,7 @@
:or {is-active true}}]
(some-> (get-current-system)
(db/tx-run!
(fn [{:keys [::db/conn] :as system}]
(fn [system]
(let [password (derive-password password)
params {:id (uuid/next)
:email email
@ -62,7 +62,7 @@
:password password
:props {}}]
(->> (cmd.auth/create-profile system params)
(cmd.auth/create-profile-rels conn)))))))
(cmd.auth/create-profile-rels system)))))))
(defmethod exec-command "update-profile"
[{:keys [fullname email password is-active]}]

View File

@ -905,5 +905,4 @@
(let [params (-> rel
(assoc :id (uuid/next))
(assoc :team-id (:id team)))]
(db/insert! conn :team-profile-rel params
{::db/return-keys false}))))))))
(teams/add-profile-to-team! cfg params {::db/return-keys false}))))))))

View File

@ -44,6 +44,7 @@
"file-object-thumbnail"
"file-thumbnail"
"profile"
"organization"
"tempfile"
"file-data"
"file-data-fragment"

View File

@ -166,6 +166,7 @@
"profile" (process-objects! conn has-profile-refs? bucket objects)
"file-data" (process-objects! conn has-file-data-refs? bucket objects)
"tempfile" (process-objects! conn (constantly false) bucket objects)
"organization" (process-objects! conn (constantly false) bucket objects)
(ex/raise :type :internal
:code :unexpected-unknown-reference
:hint (dm/fmt "unknown reference '%'" bucket))))

View File

@ -30,21 +30,18 @@
java.nio.file.Path
java.time.Duration
java.util.Collection
java.util.Optional
java.util.concurrent.atomic.AtomicLong
java.util.Optional
org.reactivestreams.Subscriber
software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider
software.amazon.awssdk.core.ResponseBytes
software.amazon.awssdk.core.async.AsyncRequestBody
software.amazon.awssdk.core.async.AsyncResponseTransformer
software.amazon.awssdk.core.async.BlockingInputStreamAsyncRequestBody
software.amazon.awssdk.core.client.config.ClientAsyncConfiguration
software.amazon.awssdk.core.ResponseBytes
software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient
software.amazon.awssdk.http.nio.netty.SdkEventLoopGroup
software.amazon.awssdk.regions.Region
software.amazon.awssdk.services.s3.S3AsyncClient
software.amazon.awssdk.services.s3.S3AsyncClientBuilder
software.amazon.awssdk.services.s3.S3Configuration
software.amazon.awssdk.services.s3.model.Delete
software.amazon.awssdk.services.s3.model.DeleteObjectRequest
software.amazon.awssdk.services.s3.model.DeleteObjectsRequest
@ -54,9 +51,12 @@
software.amazon.awssdk.services.s3.model.ObjectIdentifier
software.amazon.awssdk.services.s3.model.PutObjectRequest
software.amazon.awssdk.services.s3.model.S3Error
software.amazon.awssdk.services.s3.presigner.S3Presigner
software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest
software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest))
software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest
software.amazon.awssdk.services.s3.presigner.S3Presigner
software.amazon.awssdk.services.s3.S3AsyncClient
software.amazon.awssdk.services.s3.S3AsyncClientBuilder
software.amazon.awssdk.services.s3.S3Configuration))
(def ^:private max-retries
"A maximum number of retries on internal operations"

View File

@ -0,0 +1,55 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns backend-tests.auth-oidc-test
(:require
[app.auth.oidc :as oidc]
[clojure.test :as t]))
(def ^:private oidc-provider
{:id "oidc"
:type "oidc"})
(t/deftest parse-attr-path-supports-dot-and-double-underscore
(t/is
(= [:oidc/resource-access :penpot_roles :roles]
(#'oidc/parse-attr-path oidc-provider "resource_access__penpot_roles__roles")))
(t/is
(= [:oidc/ocs :data :email]
(#'oidc/parse-attr-path oidc-provider "ocs.data.email"))))
(t/deftest process-user-info-supports-dot-notation-nested-attrs
(let [provider (assoc oidc-provider
:email-attr "ocs.data.email"
:name-attr "ocs.data.display-name")
info (#'oidc/process-user-info provider
{}
{:email_verified true
:ocs {:data {:email "nextcloud@example.com"
:display-name "Nextcloud User"}}})]
(t/is (= "nextcloud@example.com" (:email info)))
(t/is (= "Nextcloud User" (:fullname info)))
(t/is (true? (:email-verified info)))))
;; The provider's `:user-info-source` value arrives as a string (enforced by
;; the malli schema in `app.config` and used as-is by the hard-coded Google /
;; GitHub provider maps), so the dispatch must interpret strings — not
;; keywords — to actually honour `PENPOT_OIDC_USER_INFO_SOURCE=userinfo`.
(t/deftest select-user-info-source-interprets-config-strings
(t/testing "explicit string values map to keyword dispatch tokens"
(t/is (= :token (#'oidc/select-user-info-source "token")))
(t/is (= :userinfo (#'oidc/select-user-info-source "userinfo"))))
(t/testing "missing or explicit \"auto\" falls back to auto dispatch"
(t/is (= :auto (#'oidc/select-user-info-source "auto")))
(t/is (= :auto (#'oidc/select-user-info-source nil))))
(t/testing "unknown values fall back to auto dispatch safely"
(t/is (= :auto (#'oidc/select-user-info-source "unknown")))
;; Guards against the reverse regression — a stray keyword value must
;; not silently slip through as if it were the matching string.
(t/is (= :auto (#'oidc/select-user-info-source :token)))
(t/is (= :auto (#'oidc/select-user-info-source :userinfo)))))

View File

@ -0,0 +1,34 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns backend-tests.email-blacklist-test
(:require
[app.email :as-alias email]
[app.email.blacklist :as blacklist]
[clojure.test :as t]))
(def ^:private cfg
{::email/blacklist #{"somedomain.com" "spam.net"}})
(t/deftest test-exact-domain-match
(t/is (true? (blacklist/contains? cfg "user@somedomain.com")))
(t/is (true? (blacklist/contains? cfg "user@spam.net")))
(t/is (false? (blacklist/contains? cfg "user@legit.com"))))
(t/deftest test-subdomain-match
(t/is (true? (blacklist/contains? cfg "user@sub.somedomain.com")))
(t/is (true? (blacklist/contains? cfg "user@a.b.somedomain.com")))
;; A domain that merely contains the blacklisted string but is not a
;; subdomain must NOT be rejected.
(t/is (false? (blacklist/contains? cfg "user@notsomedomain.com"))))
(t/deftest test-case-insensitive
(t/is (true? (blacklist/contains? cfg "user@SOMEDOMAIN.COM")))
(t/is (true? (blacklist/contains? cfg "user@Sub.SomeDomain.Com"))))
(t/deftest test-non-blacklisted-domain
(t/is (false? (blacklist/contains? cfg "user@example.com")))
(t/is (false? (blacklist/contains? cfg "user@sub.legit.com"))))

View File

@ -186,10 +186,10 @@
:is-demo false}
params)]
(db/run! system
(fn [{:keys [::db/conn] :as cfg}]
(fn [cfg]
(->> params
(cmd.auth/create-profile cfg)
(cmd.auth/create-profile-rels conn)))))))
(cmd.auth/create-profile-rels cfg)))))))
(defn create-project*
([i params] (create-project* *system* i params))
@ -234,10 +234,10 @@
(dm/with-open [conn (db/open system)]
(let [id (mk-uuid "team" i)
features (cfeat/get-enabled-features cf/flags)]
(teams/create-team conn {:id id
:profile-id profile-id
:features features
:name (str "team" i)})))))
(teams/create-team {::db/conn conn} {:id id
:profile-id profile-id
:features features
:name (str "team" i)})))))
(defn create-file-media-object*
([params] (create-file-media-object* *system* params))
@ -283,9 +283,10 @@
([params] (create-team-role* *system* params))
([system {:keys [team-id profile-id role] :or {role :owner}}]
(dm/with-open [conn (db/open system)]
(#'teams/create-team-role conn {:team-id team-id
:profile-id profile-id
:role role}))))
(#'teams/create-team-role {::db/conn conn}
{:team-id team-id
:profile-id profile-id
:role role}))))
(defn create-project-role*
([params] (create-project-role* *system* params))
@ -384,6 +385,31 @@
(dissoc ::type)
(assoc :app.rpc/request-at (ct/now)))))))
(defn management-command!
([data]
(management-command! data nil))
([{:keys [::type] :as data} flags-to-add]
(let [flags (reduce conj cf/flags (or flags-to-add []))
resolve-management-methods
(requiring-resolve 'app.rpc/resolve-management-methods)
methods
(with-redefs [cf/flags flags]
(resolve-management-methods *system*))
[_ method-fn]
(get methods type)]
(when-not method-fn
(ex/raise :type :assertion
:code :rpc-method-not-found
:hint (str/ffmt "management rpc method '%' not found" (name type))))
(try-on! (method-fn (-> data
(dissoc ::type)
(assoc :app.rpc/request-at (ct/now))))))))
(defn run-task!
([name]
(run-task! name {}))

View File

@ -2121,3 +2121,92 @@
(t/is (= 1 (count rows)))
(t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z"))
(t/is (nil? (:deleted-at row1))))))))
(t/deftest get-file-stats-empty-file
(let [profile (th/create-profile* 1 {:is-active true})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
out (th/command! {::th/type :get-file-stats
::rpc/profile-id (:id profile)
:id (:id file)})]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= (:id file) (:file-id result)))
(t/is (pos? (:page-count result)))
(t/is (zero? (:component-count result)))
(t/is (zero? (:deleted-component-count result)))
(t/is (zero? (:color-count result)))
(t/is (zero? (:typography-count result)))
(t/is (zero? (:library-count result)))
(t/is (zero? (:referenced-by-count result)))
(t/is (contains? result :shape-counts))
(t/is (zero? (get-in result [:shape-counts :total])))
(t/is (= {} (get-in result [:shape-counts :by-type]))))))
(t/deftest get-file-stats-with-shapes
(let [profile (th/create-profile* 1 {:is-active true})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
page-id (-> file :data :pages first)
rect-id (uuid/random)
frame-id (uuid/random)]
(update-file!
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-obj
:page-id page-id
:id frame-id
:parent-id uuid/zero
:frame-id uuid/zero
:components-v2 true
:obj (cts/setup-shape
{:id frame-id
:name "frame"
:frame-id uuid/zero
:parent-id uuid/zero
:type :frame})}
{:type :add-obj
:page-id page-id
:id rect-id
:parent-id frame-id
:frame-id frame-id
:components-v2 true
:obj (cts/setup-shape
{:id rect-id
:name "rect"
:frame-id frame-id
:parent-id frame-id
:type :rect})}])
(let [out (th/command! {::th/type :get-file-stats
::rpc/profile-id (:id profile)
:id (:id file)})
result (:result out)]
(t/is (nil? (:error out)))
(t/is (= 2 (get-in result [:shape-counts :total])))
(t/is (= 1 (get-in result [:shape-counts :by-type :rect])))
(t/is (= 1 (get-in result [:shape-counts :by-type :frame]))))))
(t/deftest get-file-stats-forbidden
(let [owner (th/create-profile* 1 {:is-active true})
other (th/create-profile* 2 {:is-active true})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:default-project-id owner)
:is-shared false})
out (th/command! {::th/type :get-file-stats
::rpc/profile-id (:id other)
:id (:id file)})]
(t/is (not (nil? (:error out))))
(let [edata (-> out :error ex-data)]
(t/is (= :not-found (:type edata))))))

View File

@ -0,0 +1,800 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns backend-tests.rpc-management-nitrate-test
(:require
[app.common.data :as d]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as-alias db]
[app.email :as email]
[app.msgbus :as mbus]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[backend-tests.helpers :as th]
[clojure.set :as set]
[clojure.test :as t]
[cuerdas.core :as str]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(defn- management-command-with-nitrate!
[data]
(th/management-command! data [:nitrate]))
(t/deftest authenticate-success
(let [profile (th/create-profile* 1 {:is-active true
:fullname "Nitrate User"})
out (management-command-with-nitrate! {::th/type :authenticate
::rpc/profile-id (:id profile)})]
(t/is (th/success? out))
(t/is (= (:id profile) (-> out :result :id)))
(t/is (= "Nitrate User" (-> out :result :name)))
(t/is (= (:email profile) (-> out :result :email)))
(t/is (nil? (-> out :result :photo-url)))))
(t/deftest authenticate-requires-authentication
(let [out (management-command-with-nitrate! {::th/type :authenticate})]
(t/is (not (th/success? out)))
(t/is (= :authentication (th/ex-type (:error out))))
(t/is (= :authentication-required (th/ex-code (:error out))))))
(t/deftest get-penpot-version
(let [profile (th/create-profile* 1 {:is-active true})
out (management-command-with-nitrate! {::th/type :get-penpot-version
::rpc/profile-id (:id profile)})]
(t/is (th/success? out))
(t/is (= cf/version (-> out :result :version)))))
(t/deftest get-teams-returns-only-owned-non-default-non-deleted
(let [profile (th/create-profile* 1 {:is-active true})
other (th/create-profile* 2 {:is-active true})
owned-team (th/create-team* 1 {:profile-id (:id profile)})
deleted-team (th/create-team* 2 {:profile-id (:id profile)})
_ (th/db-update! :team
{:deleted-at (ct/now)}
{:id (:id deleted-team)})
other-team (th/create-team* 3 {:profile-id (:id other)})
_ (th/create-team-role* {:team-id (:id other-team)
:profile-id (:id profile)
:role :editor})
out (management-command-with-nitrate! {::th/type :get-teams
::rpc/profile-id (:id profile)})]
(t/is (th/success? out))
(t/is (= #{(:id owned-team)}
(->> out :result (map :id) set)))
(t/is (= #{(:name owned-team)}
(->> out :result (map :name) set)))))
(t/deftest notify-team-change-publishes-event
(let [team-id (uuid/random)
organization-id (uuid/random)
organization {:id organization-id
:name "Acme Inc"
:slug "acme-inc"
:owner-id (uuid/random)
:avatar-bg-url "http://example.com/avatar.svg"}
calls (atom [])
out (with-redefs [mbus/pub! (fn [_cfg & {:keys [topic message]}]
(swap! calls conj {:topic topic
:message message}))]
(management-command-with-nitrate! {::th/type :notify-team-change
:id team-id
:is-your-penpot false
:organization organization}))]
(t/is (th/success? out))
(t/is (= 1 (count @calls)))
(t/is (= uuid/zero (-> @calls first :topic)))
(let [msg (-> @calls first :message)]
(t/is (= :team-org-change (:type msg)))
(t/is (= nil (:notification msg)))
(t/is (= team-id (-> msg :team :id)))
(t/is (= false (-> msg :team :is-your-penpot)))
(t/is (= (:id organization) (-> msg :team :organization :id)))
(t/is (= (:name organization) (-> msg :team :organization :name)))
(t/is (= (:slug organization) (-> msg :team :organization :slug)))
(t/is (= (:owner-id organization) (-> msg :team :organization :owner-id)))
(t/is (= (:avatar-bg-url organization) (str (-> msg :team :organization :avatar-bg-url)))))))
(t/deftest notify-user-added-to-organization-creates-default-org-team
(let [profile (th/create-profile* 1 {:is-active true})
before-teams (->> (th/db-query :team-profile-rel {:profile-id (:id profile)
:is-owner true})
(map :team-id)
set)
out (management-command-with-nitrate! {::th/type :notify-user-added-to-organization
:profile-id (:id profile)
:organization-id (uuid/random)
:role "owner"})
after-teams (->> (th/db-query :team-profile-rel {:profile-id (:id profile)
:is-owner true})
(map :team-id)
set)
new-team-id (first (set/difference after-teams before-teams))
new-team (th/db-get :team {:id new-team-id})]
(t/is (th/success? out))
(t/is (= 1 (count (set/difference after-teams before-teams))))
(t/is (= "Your Penpot" (:name new-team)))
(t/is (true? (:is-default new-team)))))
(t/deftest get-managed-profiles-returns-unique-members-for-owned-teams
(let [owner (th/create-profile* 1 {:is-active true})
member1 (th/create-profile* 2 {:is-active true})
member2 (th/create-profile* 3 {:is-active true})
team1 (th/create-team* 1 {:profile-id (:id owner)})
team2 (th/create-team* 2 {:profile-id (:id owner)})
_ (th/create-team-role* {:team-id (:id team1)
:profile-id (:id member1)
:role :editor})
_ (th/create-team-role* {:team-id (:id team1)
:profile-id (:id member2)
:role :editor})
_ (th/create-team-role* {:team-id (:id team2)
:profile-id (:id member1)
:role :editor})
out (management-command-with-nitrate! {::th/type :get-managed-profiles
::rpc/profile-id (:id owner)})]
(t/is (th/success? out))
(t/is (= #{(:id member1) (:id member2)}
(->> out :result (map :id) set)))
(t/is (= #{(:email member1) (:email member2)}
(->> out :result (map :email) set)))))
(t/deftest get-teams-summary-returns-teams-and-files-count
(let [profile (th/create-profile* 1 {:is-active true})
team1 (th/create-team* 1 {:profile-id (:id profile)})
team2 (th/create-team* 2 {:profile-id (:id profile)})
proj1 (th/create-project* 1 {:profile-id (:id profile)
:team-id (:id team1)})
proj2 (th/create-project* 2 {:profile-id (:id profile)
:team-id (:id team2)})
_ (th/create-file* 1 {:profile-id (:id profile)
:project-id (:id proj1)})
_ (th/create-file* 2 {:profile-id (:id profile)
:project-id (:id proj2)})
out (management-command-with-nitrate! {::th/type :get-teams-summary
::rpc/profile-id (:id profile)
:ids [(:id team1) (:id team2)]})]
(t/is (th/success? out))
(t/is (= 2 (-> out :result :num-files)))
(t/is (= #{(:id team1) (:id team2)}
(->> out :result :teams (map :id) set)))))
(t/deftest notify-org-deletion-prefixes-teams-and-notifies
(let [profile (th/create-profile* 1 {:is-active true})
extra-team (th/create-team* 1 {:profile-id (:id profile)})
default-team (th/db-get :team {:id (:default-team-id profile)})
teams [(:id default-team) (:id extra-team)]
organization-name "Acme / Design"
expected-start (str "[" (d/sanitize-string organization-name) "] ")
calls (atom [])
out (with-redefs [mbus/pub! (fn [_cfg & {:keys [topic message]}]
(swap! calls conj {:topic topic
:message message}))]
(management-command-with-nitrate! {::th/type :notify-org-deletion
::rpc/profile-id (:id profile)
:teams teams
:organization-name organization-name}))
updated (map #(th/db-get :team {:id %} {::db/remove-deleted false}) teams)]
(t/is (th/success? out))
(t/is (= 2 (count @calls)))
(doseq [team updated]
(t/is (false? (:is-default team)))
(t/is (str/starts-with? (:name team) expected-start)))
(doseq [call @calls]
(t/is (= uuid/zero (:topic call)))
(t/is (= :team-org-change (-> call :message :type)))
(t/is (= organization-name (-> call :message :team :organization :name)))
(t/is (= "dashboard.org-deleted" (-> call :message :notification))))))
(t/deftest get-profile-by-email-success-and-not-found
(let [profile (th/create-profile* 1 {:is-active true
:fullname "Lookup by Email"})
ok-out (management-command-with-nitrate! {::th/type :get-profile-by-email
::rpc/profile-id (:id profile)
:email (:email profile)})
ko-out (management-command-with-nitrate! {::th/type :get-profile-by-email
::rpc/profile-id (:id profile)
:email "not-found@example.com"})]
(t/is (th/success? ok-out))
(t/is (= (:id profile) (-> ok-out :result :id)))
(t/is (= "Lookup by Email" (-> ok-out :result :name)))
(t/is (nil? (-> ok-out :result :photo-url)))
(t/is (not (th/success? ko-out)))
(t/is (= :not-found (th/ex-type (:error ko-out))))
(t/is (= :profile-not-found (th/ex-code (:error ko-out))))))
(t/deftest get-profile-by-id-success-and-not-found
(let [profile (th/create-profile* 1 {:is-active true
:fullname "Lookup by Id"})
ok-out (management-command-with-nitrate! {::th/type :get-profile-by-id
::rpc/profile-id (:id profile)
:id (:id profile)})
ko-out (management-command-with-nitrate! {::th/type :get-profile-by-id
::rpc/profile-id (:id profile)
:id (uuid/random)})]
(t/is (th/success? ok-out))
(t/is (= (:id profile) (-> ok-out :result :id)))
(t/is (= "Lookup by Id" (-> ok-out :result :name)))
(t/is (nil? (-> ok-out :result :photo-url)))
(t/is (not (th/success? ko-out)))
(t/is (= :not-found (th/ex-type (:error ko-out))))
(t/is (= :profile-not-found (th/ex-code (:error ko-out))))))
(t/deftest get-org-invitations-returns-valid-deduped-by-email
(let [profile (th/create-profile* 1 {:is-active true})
team-1 (th/create-team* 1 {:profile-id (:id profile)})
team-2 (th/create-team* 2 {:profile-id (:id profile)})
org-id (uuid/random)
org-summary {:id org-id
:teams [{:id (:id team-1)}
{:id (:id team-2)}]}
params {::th/type :get-org-invitations
::rpc/profile-id (:id profile)
:organization-id org-id}]
;; Same email appears in org and team invitations; only one should be returned.
(th/db-insert! :team-invitation
{:id (uuid/random)
:org-id org-id
:team-id nil
:email-to "dup@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-1)
:org-id nil
:email-to "dup@example.com"
:created-by (:id profile)
:role "admin"
:valid-until (ct/in-future "72h")})
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-2)
:org-id nil
:email-to "valid@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "48h")})
;; Expired invitation should be ignored.
(th/db-insert! :team-invitation
{:id (uuid/random)
:org-id org-id
:team-id nil
:email-to "expired@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-past "1h")})
(let [out (with-redefs [nitrate/call (fn [_cfg method _params]
(case method
:get-org-summary org-summary
nil))]
(management-command-with-nitrate! params))
result (:result out)
emails (->> result (map :email) set)
dedup (->> result
(filter #(= "dup@example.com" (:email %)))
first)]
(t/is (th/success? out))
(t/is (= #{"dup@example.com" "valid@example.com"} emails))
(t/is (= 2 (count result)))
(t/is (some? (:id dedup)))
(t/is (some? (:sent-at dedup)))
(t/is (nil? (:organization-id dedup)))
(t/is (nil? (:team-id dedup)))
(t/is (nil? (:role dedup)))
(t/is (nil? (:valid-until dedup))))))
(t/deftest get-org-invitations-includes-org-level-invitations-when-no-teams
(let [profile (th/create-profile* 1 {:is-active true})
org-id (uuid/random)
org-summary {:id org-id
:teams []}
params {::th/type :get-org-invitations
::rpc/profile-id (:id profile)
:organization-id org-id}]
(th/db-insert! :team-invitation
{:id (uuid/random)
:org-id org-id
:team-id nil
:email-to "org-only@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(let [out (with-redefs [nitrate/call (fn [_cfg method _params]
(case method
:get-org-summary org-summary
nil))]
(management-command-with-nitrate! params))
result (:result out)]
(t/is (th/success? out))
(t/is (= 1 (count result)))
(t/is (= "org-only@example.com" (-> result first :email)))
(t/is (some? (-> result first :sent-at))))))
(t/deftest get-org-invitations-returns-existing-profile-data
(let [profile (th/create-profile* 1 {:is-active true})
invited (th/create-profile* 2 {:is-active true
:fullname "Invited User"})
photo-id (uuid/random)
_ (th/db-insert! :storage-object {:id photo-id
:backend "assets-fs"})
_ (th/db-update! :profile {:photo-id photo-id} {:id (:id invited)})
org-id (uuid/random)
org-summary {:id org-id
:teams []}
params {::th/type :get-org-invitations
::rpc/profile-id (:id profile)
:organization-id org-id}]
(th/db-insert! :team-invitation
{:id (uuid/random)
:org-id org-id
:team-id nil
:email-to (:email invited)
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(let [out (with-redefs [nitrate/call (fn [_cfg method _params]
(case method
:get-org-summary org-summary
nil))]
(management-command-with-nitrate! params))
invitation (-> out :result first)]
(t/is (th/success? out))
(t/is (= "Invited User" (:name invitation)))
(t/is (some? (:sent-at invitation)))
(t/is (str/ends-with? (:photo-url invitation)
(str "/assets/by-id/" photo-id))))))
(t/deftest delete-org-invitations-removes-org-and-org-team-invitations-for-email
(let [profile (th/create-profile* 1 {:is-active true})
team-1 (th/create-team* 1 {:profile-id (:id profile)})
team-2 (th/create-team* 2 {:profile-id (:id profile)})
outside-team (th/create-team* 3 {:profile-id (:id profile)})
org-id (uuid/random)
org-summary {:id org-id
:teams [{:id (:id team-1)}
{:id (:id team-2)}]}
target-email "target@example.com"
params {::th/type :delete-org-invitations
::rpc/profile-id (:id profile)
:organization-id org-id
:email "TARGET@example.com"}]
;; Should be deleted: org-level invitation for same org+email.
(th/db-insert! :team-invitation
{:id (uuid/random)
:org-id org-id
:team-id nil
:email-to target-email
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Should be deleted: team-level invitation for teams belonging to org summary.
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-1)
:org-id nil
:email-to target-email
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-past "1h")})
;; Should remain: different email.
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-2)
:org-id nil
:email-to "other@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Should remain: same email but outside org scope.
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id outside-team)
:org-id nil
:email-to target-email
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(let [out (with-redefs [nitrate/call (fn [_cfg method _params]
(case method
:get-org-summary org-summary
nil))]
(management-command-with-nitrate! params))
remaining-target (th/db-query :team-invitation {:email-to target-email})
remaining-other (th/db-query :team-invitation {:email-to "other@example.com"})]
(t/is (th/success? out))
(t/is (nil? (:result out)))
(t/is (= 1 (count remaining-target)))
(t/is (= (:id outside-team) (:team-id (first remaining-target))))
(t/is (= 1 (count remaining-other))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Tests: remove-from-org
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- make-org-summary
[& {:keys [organization-id organization-name owner-id your-penpot-teams org-teams]
:or {your-penpot-teams [] org-teams []}}]
{:id organization-id
:name organization-name
:owner-id owner-id
:teams (into
(mapv (fn [id] {:id id :is-your-penpot true}) your-penpot-teams)
(mapv (fn [id] {:id id :is-your-penpot false}) org-teams))})
(defn- nitrate-call-mock
[org-summary]
(fn [_cfg method _params]
(case method
:get-org-summary org-summary
:get-org-membership {:organization-id (:id org-summary)
:is-member true}
:remove-profile-from-org nil
nil)))
(t/deftest remove-from-org-happy-path-no-extra-teams
;; User is only in its default team (which has files); it should be
;; kept, renamed and unset as default. A notification must be sent.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
org-team (th/create-team* 1 {:profile-id (:id user)})
project (th/create-project* 1 {:profile-id (:id user)
:team-id (:id org-team)})
_ (th/create-file* 1 {:profile-id (:id user)
:project-id (:id project)})
organization-id (uuid/random)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Acme Org"
:owner-id (:id org-owner)
:your-penpot-teams [(:id org-team)]
:org-teams [])
calls (atom [])
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)
mbus/pub! (fn [_bus & {:keys [topic message]}]
(swap! calls conj {:topic topic :message message}))]
(management-command-with-nitrate!
{::th/type :remove-from-org
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
:organization-name "Acme Org"
:default-team-id (:id org-team)}))]
(t/is (th/success? out))
(t/is (nil? (:result out)))
;; default team preserved, renamed and unset as default
(let [team (th/db-get :team {:id (:id org-team)})]
(t/is (false? (:is-default team)))
(t/is (str/starts-with? (:name team) "[Acme Org] ")))
;; exactly one notification sent to the user
(t/is (= 1 (count @calls)))
(let [msg (-> @calls first :message)]
(t/is (= :user-org-change (:type msg)))
(t/is (= (:id user) (:topic msg)))
(t/is (= organization-id (:organization-id msg)))
(t/is (= "Acme Org" (:organization-name msg)))
(t/is (= "dashboard.user-no-longer-belong-org" (:notification msg))))))
(t/deftest remove-from-org-deletes-empty-default-team
;; When the default team has no files it should be soft-deleted.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
org-team (th/create-team* 2 {:profile-id (:id user)})
organization-id (uuid/random)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Acme Org"
:owner-id (:id org-owner)
:your-penpot-teams [(:id org-team)]
:org-teams [])
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)
mbus/pub! (fn [& _] nil)]
(management-command-with-nitrate!
{::th/type :remove-from-org
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
:organization-name "Acme Org"
:default-team-id (:id org-team)}))]
(t/is (th/success? out))
(let [team (th/db-get :team {:id (:id org-team)} {::db/remove-deleted false})]
(t/is (some? (:deleted-at team))))))
(t/deftest remove-from-org-deletes-sole-owner-team
;; When the user is the sole member of an org team it should be deleted.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
extra-team (th/create-team* 3 {:profile-id (:id user)})
org-team (th/create-team* 99 {:profile-id (:id user)})
organization-id (uuid/random)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Acme Org"
:owner-id (:id org-owner)
:your-penpot-teams [(:id org-team)]
:org-teams [(:id extra-team)])
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)
mbus/pub! (fn [& _] nil)]
(management-command-with-nitrate!
{::th/type :remove-from-org
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
:organization-name "Acme Org"
:default-team-id (:id org-team)}))]
(t/is (th/success? out))
(let [team (th/db-get :team {:id (:id extra-team)} {::db/remove-deleted false})]
(t/is (some? (:deleted-at team))))))
(t/deftest remove-from-org-transfers-ownership-of-multi-member-team
;; When the user owns a team that has another non-owner member, ownership
;; is transferred to that member by the endpoint automatically.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
candidate (th/create-profile* 3 {:is-active true})
extra-team (th/create-team* 4 {:profile-id (:id user)})
_ (th/create-team-role* {:team-id (:id extra-team)
:profile-id (:id candidate)
:role :editor})
org-team (th/create-team* 99 {:profile-id (:id user)})
organization-id (uuid/random)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Acme Org"
:owner-id (:id org-owner)
:your-penpot-teams [(:id org-team)]
:org-teams [(:id extra-team)])
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)
mbus/pub! (fn [& _] nil)]
(management-command-with-nitrate!
{::th/type :remove-from-org
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
:organization-name "Acme Org"
:default-team-id (:id org-team)}))]
(t/is (th/success? out))
;; user no longer in extra-team
(let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})]
(t/is (nil? rel)))
;; candidate promoted to owner
(let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id candidate)})]
(t/is (true? (:is-owner rel))))))
(t/deftest remove-from-org-exits-non-owned-team
;; When the user is a non-owner member of an org team, they simply leave.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
extra-team (th/create-team* 5 {:profile-id (:id org-owner)})
_ (th/create-team-role* {:team-id (:id extra-team)
:profile-id (:id user)
:role :editor})
org-team (th/create-team* 99 {:profile-id (:id user)})
organization-id (uuid/random)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Acme Org"
:owner-id (:id org-owner)
:your-penpot-teams [(:id org-team)]
:org-teams [(:id extra-team)])
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)
mbus/pub! (fn [& _] nil)]
(management-command-with-nitrate!
{::th/type :remove-from-org
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
:organization-name "Acme Org"
:default-team-id (:id org-team)}))]
(t/is (th/success? out))
;; user no longer a member of extra-team
(let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})]
(t/is (nil? rel)))
;; team still exists for the owner
(let [team (th/db-get :team {:id (:id extra-team)})]
(t/is (some? team)))))
(t/deftest remove-from-org-error-nobody-to-reassign
;; When the user owns a multi-member team but every other member is
;; also an owner, the auto-selection query finds nobody and raises.
(let [other-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
extra-team (th/create-team* 6 {:profile-id (:id user)})
;; add other-owner to the team and make them co-owner directly in DB
_ (th/create-team-role* {:team-id (:id extra-team)
:profile-id (:id other-owner)
:role :editor})
_ (th/db-update! :team-profile-rel
{:is-owner true :is-admin false}
{:team-id (:id extra-team) :profile-id (:id other-owner)})
org-team (th/create-team* 99 {:profile-id (:id user)})
organization-id (uuid/random)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Acme Org"
:owner-id (:id other-owner)
:your-penpot-teams [(:id org-team)]
:org-teams [(:id extra-team)])
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)
mbus/pub! (fn [& _] nil)]
(management-command-with-nitrate!
{::th/type :remove-from-org
::rpc/profile-id (:id other-owner)
:profile-id (:id user)
:organization-id organization-id
:organization-name "Acme Org"
:default-team-id (:id org-team)}))]
(t/is (not (th/success? out)))
(t/is (= :validation (th/ex-type (:error out))))
(t/is (= :nobody-to-reassign-team (th/ex-code (:error out))))))
;; Tests: get-remove-from-org-summary
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest get-remove-from-org-summary-no-extra-teams
;; User only has a default team — nothing to delete/transfer/exit.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
org-team (th/create-team* 1 {:profile-id (:id user)})
organization-id (uuid/random)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Acme Org"
:owner-id (:id org-owner)
:your-penpot-teams [(:id org-team)]
:org-teams [])
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(management-command-with-nitrate!
{::th/type :get-remove-from-org-summary
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
:default-team-id (:id org-team)}))]
(t/is (th/success? out))
(t/is (= {:teams-to-delete 0
:teams-to-transfer 0
:teams-to-exit 0}
(:result out)))))
(t/deftest get-remove-from-org-summary-with-teams-to-delete
;; User owns a sole-member extra org team → 1 to delete.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
extra-team (th/create-team* 3 {:profile-id (:id user)})
org-team (th/create-team* 99 {:profile-id (:id user)})
organization-id (uuid/random)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Acme Org"
:owner-id (:id org-owner)
:your-penpot-teams [(:id org-team)]
:org-teams [(:id extra-team)])
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(management-command-with-nitrate!
{::th/type :get-remove-from-org-summary
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
:default-team-id (:id org-team)}))]
(t/is (th/success? out))
(t/is (= {:teams-to-delete 1
:teams-to-transfer 0
:teams-to-exit 0}
(:result out)))))
(t/deftest get-remove-from-org-summary-with-teams-to-transfer
;; User owns a multi-member extra org team → 1 to transfer.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
candidate (th/create-profile* 3 {:is-active true})
extra-team (th/create-team* 4 {:profile-id (:id user)})
_ (th/create-team-role* {:team-id (:id extra-team)
:profile-id (:id candidate)
:role :editor})
org-team (th/create-team* 99 {:profile-id (:id user)})
organization-id (uuid/random)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Acme Org"
:owner-id (:id org-owner)
:your-penpot-teams [(:id org-team)]
:org-teams [(:id extra-team)])
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(management-command-with-nitrate!
{::th/type :get-remove-from-org-summary
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
:default-team-id (:id org-team)}))]
(t/is (th/success? out))
(t/is (= {:teams-to-delete 0
:teams-to-transfer 1
:teams-to-exit 0}
(:result out)))))
(t/deftest get-remove-from-org-summary-with-teams-to-exit
;; User is a non-owner member of an org team → 1 to exit.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
extra-team (th/create-team* 5 {:profile-id (:id org-owner)})
_ (th/create-team-role* {:team-id (:id extra-team)
:profile-id (:id user)
:role :editor})
org-team (th/create-team* 99 {:profile-id (:id user)})
organization-id (uuid/random)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Acme Org"
:owner-id (:id org-owner)
:your-penpot-teams [(:id org-team)]
:org-teams [(:id extra-team)])
out (with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(management-command-with-nitrate!
{::th/type :get-remove-from-org-summary
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
:default-team-id (:id org-team)}))]
(t/is (th/success? out))
(t/is (= {:teams-to-delete 0
:teams-to-transfer 0
:teams-to-exit 1}
(:result out)))))
(t/deftest get-remove-from-org-summary-does-not-mutate
;; Calling the summary endpoint must not modify any teams.
(let [org-owner (th/create-profile* 1 {:is-active true})
user (th/create-profile* 2 {:is-active true})
extra-team (th/create-team* 6 {:profile-id (:id user)})
org-team (th/create-team* 99 {:profile-id (:id user)})
organization-id (uuid/random)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Acme Org"
:owner-id (:id org-owner)
:your-penpot-teams [(:id org-team)]
:org-teams [(:id extra-team)])
_ (with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(management-command-with-nitrate!
{::th/type :get-remove-from-org-summary
::rpc/profile-id (:id org-owner)
:profile-id (:id user)
:organization-id organization-id
:default-team-id (:id org-team)}))]
;; Both teams must still exist and be undeleted
(let [t1 (th/db-get :team {:id (:id org-team)})]
(t/is (some? t1))
(t/is (nil? (:deleted-at t1))))
(let [t2 (th/db-get :team {:id (:id extra-team)})]
(t/is (some? t2))
(t/is (nil? (:deleted-at t2))))
;; User must still be a member of both teams
(let [rel1 (th/db-get :team-profile-rel {:team-id (:id org-team) :profile-id (:id user)})]
(t/is (some? rel1)))
(let [rel2 (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})]
(t/is (some? rel2)))))

View File

@ -0,0 +1,686 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns backend-tests.rpc-nitrate-test
(:require
[app.common.uuid :as uuid]
[app.db :as-alias db]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.commands.nitrate]
[backend-tests.helpers :as th]
[clojure.test :as t]
[cuerdas.core :as str]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Helpers
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- make-org-summary
[& {:keys [organization-id organization-name owner-id your-penpot-teams org-teams]
:or {your-penpot-teams [] org-teams []}}]
{:id organization-id
:name organization-name
:owner-id owner-id
:teams (into
(mapv (fn [id] {:id id :is-your-penpot true}) your-penpot-teams)
(mapv (fn [id] {:id id :is-your-penpot false}) org-teams))})
(defn- nitrate-call-mock
"Creates a mock for nitrate/call that returns the given org-summary for
:get-org-summary, a valid membership for :get-org-membership, and nil for
any other method."
[org-summary]
(fn [_cfg method _params]
(case method
:get-org-summary org-summary
:get-org-membership {:is-member true
:organization-id (:id org-summary)}
nil)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Tests
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest leave-org-happy-path-no-extra-teams
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
project (th/create-project* 99 {:profile-id (:id profile-user)
:team-id (:id org-default-team)})
_ (th/create-file* 99 {:profile-id (:id profile-user)
:project-id (:id project)})
organization-id (uuid/random)
;; The user's personal penpot team in the org context
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [])]
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(let [data {::th/type :leave-org
::rpc/profile-id (:id profile-user)
:id organization-id
:name "Test Org"
:default-team-id your-penpot-id
:teams-to-delete []
:teams-to-leave []}
out (th/command! data)]
;; (th/print-result! out)
(t/is (th/success? out))
(t/is (nil? (:result out)))
;; The personal team must be renamed with the org prefix and
;; unset as a default team.
(let [team (th/db-get :team {:id your-penpot-id})]
(t/is (str/starts-with? (:name team) "[Test Org] "))
(t/is (false? (:is-default team))))))))
(t/deftest leave-org-deletes-org-default-team-when-empty
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
org-default-team (th/create-team* 98 {:profile-id (:id profile-user)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [])]
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(let [data {::th/type :leave-org
::rpc/profile-id (:id profile-user)
:id organization-id
:name "Test Org"
:default-team-id your-penpot-id
:teams-to-delete []
:teams-to-leave []}
out (th/command! data)]
(t/is (th/success? out))
;; Empty org default team should be soft-deleted.
(let [team (th/db-get :team {:id your-penpot-id} {::db/remove-deleted false})]
(t/is (some? (:deleted-at team))))))))
(t/deftest leave-org-keeps-and-renames-org-default-team-when-has-files
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
org-default-team (th/create-team* 97 {:profile-id (:id profile-user)})
project (th/create-project* 97 {:profile-id (:id profile-user)
:team-id (:id org-default-team)})
_ (th/create-file* 97 {:profile-id (:id profile-user)
:project-id (:id project)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [])]
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(let [data {::th/type :leave-org
::rpc/profile-id (:id profile-user)
:id organization-id
:name "Test Org"
:default-team-id your-penpot-id
:teams-to-delete []
:teams-to-leave []}
out (th/command! data)]
(t/is (th/success? out))
;; Non-empty org default team should remain and be renamed.
(let [team (th/db-get :team {:id your-penpot-id})]
(t/is (str/starts-with? (:name team) "[Test Org] "))
(t/is (false? (:is-default team)))
(t/is (nil? (:deleted-at team))))))))
(t/deftest leave-org-with-teams-to-delete
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
;; profile-user is the sole owner/member of team1
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [(:id team1)])]
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(let [data {::th/type :leave-org
::rpc/profile-id (:id profile-user)
:id organization-id
:name "Test Org"
:default-team-id your-penpot-id
:teams-to-delete [(:id team1)]
:teams-to-leave []}
out (th/command! data)]
;; (th/print-result! out)
(t/is (th/success? out))
;; team1 should be scheduled for deletion (deleted-at set)
(let [team (th/db-get :team {:id (:id team1)} {::db/remove-deleted false})]
(t/is (some? (:deleted-at team))))))))
(t/deftest leave-org-with-ownership-transfer
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
;; profile-user owns team1; profile-owner is also a member
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
_ (th/create-team-role* {:team-id (:id team1)
:profile-id (:id profile-owner)
:role :editor})
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [(:id team1)])]
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(let [data {::th/type :leave-org
::rpc/profile-id (:id profile-user)
:id organization-id
:name "Test Org"
:default-team-id your-penpot-id
:teams-to-delete []
:teams-to-leave [{:id (:id team1) :reassign-to (:id profile-owner)}]}
out (th/command! data)]
;; (th/print-result! out)
(t/is (th/success? out))
;; profile-user should no longer be a member of team1
(let [rel (th/db-get :team-profile-rel
{:team-id (:id team1)
:profile-id (:id profile-user)})]
(t/is (nil? rel)))
;; profile-owner should have been promoted to owner
(let [rel (th/db-get :team-profile-rel
{:team-id (:id team1)
:profile-id (:id profile-owner)})]
(t/is (true? (:is-owner rel))))))))
(t/deftest leave-org-exit-as-non-owner
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
;; profile-owner owns team1; profile-user is a non-owner member
team1 (th/create-team* 1 {:profile-id (:id profile-owner)})
_ (th/create-team-role* {:team-id (:id team1)
:profile-id (:id profile-user)
:role :editor})
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [(:id team1)])]
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(let [data {::th/type :leave-org
::rpc/profile-id (:id profile-user)
:id organization-id
:name "Test Org"
:default-team-id your-penpot-id
:teams-to-delete []
:teams-to-leave [{:id (:id team1)}]}
out (th/command! data)]
;; (th/print-result! out)
(t/is (th/success? out))
;; profile-user should no longer be a member of team1
(let [rel (th/db-get :team-profile-rel
{:team-id (:id team1)
:profile-id (:id profile-user)})]
(t/is (nil? rel)))
;; The team itself should still exist
(let [team (th/db-get :team {:id (:id team1)})]
(t/is (nil? (:deleted-at team))))))))
(t/deftest leave-org-error-org-owner-cannot-leave
(let [profile-owner (th/create-profile* 1 {:is-active true})
org-default-team (th/create-team* 99 {:profile-id (:id profile-owner)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
;; profile-owner IS the org owner in the org-summary
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [])]
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(let [data {::th/type :leave-org
::rpc/profile-id (:id profile-owner)
:id organization-id
:name "Test Org"
:default-team-id your-penpot-id
:teams-to-delete []
:teams-to-leave []}
out (th/command! data)]
(t/is (not (th/success? out)))
(t/is (= :validation (th/ex-type (:error out))))
(t/is (= :org-owner-cannot-leave (th/ex-code (:error out))))))))
(t/deftest leave-org-error-invalid-default-team-id
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [])]
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
;; Pass a random UUID that is not in the your-penpot-teams list
(let [data {::th/type :leave-org
::rpc/profile-id (:id profile-user)
:id organization-id
:name "Test Org"
:default-team-id (uuid/random)
:teams-to-delete []
:teams-to-leave []}
out (th/command! data)]
(t/is (not (th/success? out)))
(t/is (= :validation (th/ex-type (:error out))))
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Unit Tests for calculate-valid-teams
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private calculate-valid-teams
(or (ns-resolve 'app.rpc.commands.nitrate 'calculate-valid-teams)
(throw (ex-info "Unable to resolve calculate-valid-teams"
{:ns 'app.rpc.commands.nitrate
:symbol 'calculate-valid-teams}))))
(defn- make-team [id & {:keys [is-owner num-members member-ids]
:or {is-owner false num-members 1 member-ids []}}]
{:id id :is-owner is-owner :num-members num-members :member-ids member-ids})
(t/deftest calculate-valid-teams-no-org-teams
(let [default-id (uuid/random)
default-team (make-team default-id)
result (calculate-valid-teams [default-team] default-id)]
(t/is (= default-team (:valid-default-team result)))
(t/is (empty? (:valid-teams-to-delete-ids result)))
(t/is (empty? (:valid-teams-to-transfer result)))
(t/is (empty? (:valid-teams-to-exit result)))))
(t/deftest calculate-valid-teams-default-not-found
(let [default-id (uuid/random)
other-id (uuid/random)
other-team (make-team other-id)
;; default-id is not in org-teams at all
result (calculate-valid-teams [other-team] default-id)]
(t/is (nil? (:valid-default-team result)))))
(t/deftest calculate-valid-teams-sole-owner-team
(let [default-id (uuid/random)
team-id (uuid/random)
default (make-team default-id)
solo-team (make-team team-id :is-owner true :num-members 1)
result (calculate-valid-teams [default solo-team] default-id)]
(t/is (contains? (:valid-teams-to-delete-ids result) team-id))
(t/is (empty? (:valid-teams-to-transfer result)))
(t/is (empty? (:valid-teams-to-exit result)))))
(t/deftest calculate-valid-teams-owned-multi-member-team
(let [default-id (uuid/random)
team-id (uuid/random)
default (make-team default-id)
;; owner of a team with 3 members — must be transferred
multi-team (make-team team-id :is-owner true :num-members 3)
result (calculate-valid-teams [default multi-team] default-id)]
(t/is (empty? (:valid-teams-to-delete-ids result)))
(t/is (= [team-id] (map :id (:valid-teams-to-transfer result))))
(t/is (empty? (:valid-teams-to-exit result)))))
(t/deftest calculate-valid-teams-non-owner-multi-member-team
(let [default-id (uuid/random)
team-id (uuid/random)
default (make-team default-id)
;; non-owner member of a team with 2 members — can just exit
exit-team (make-team team-id :is-owner false :num-members 2)
result (calculate-valid-teams [default exit-team] default-id)]
(t/is (empty? (:valid-teams-to-delete-ids result)))
(t/is (empty? (:valid-teams-to-transfer result)))
(t/is (= [team-id] (map :id (:valid-teams-to-exit result))))))
(t/deftest calculate-valid-teams-mixed
(let [default-id (uuid/random)
solo-id (uuid/random)
transfer-id (uuid/random)
exit-id (uuid/random)
default (make-team default-id)
solo-team (make-team solo-id :is-owner true :num-members 1)
transfer-team (make-team transfer-id :is-owner true :num-members 2)
exit-team (make-team exit-id :is-owner false :num-members 3)
result (calculate-valid-teams [default solo-team transfer-team exit-team] default-id)]
(t/is (= #{solo-id} (:valid-teams-to-delete-ids result)))
(t/is (= [transfer-id] (map :id (:valid-teams-to-transfer result))))
(t/is (= [exit-id] (map :id (:valid-teams-to-exit result))))
(t/is (= default-id (:id (:valid-default-team result))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Integration: combined delete + leave
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest leave-org-combined-delete-and-leave
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
;; team1: profile-user is sole owner — must delete
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
;; team2: profile-user owns it, profile-owner is also member — must transfer
team2 (th/create-team* 2 {:profile-id (:id profile-user)})
_ (th/create-team-role* {:team-id (:id team2)
:profile-id (:id profile-owner)
:role :editor})
;; team3: profile-owner owns it, profile-user is non-owner member — can exit
team3 (th/create-team* 3 {:profile-id (:id profile-owner)})
_ (th/create-team-role* {:team-id (:id team3)
:profile-id (:id profile-user)
:role :editor})
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [(:id team1) (:id team2) (:id team3)])]
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
(let [data {::th/type :leave-org
::rpc/profile-id (:id profile-user)
:id organization-id
:name "Test Org"
:default-team-id your-penpot-id
:teams-to-delete [(:id team1)]
:teams-to-leave [{:id (:id team2) :reassign-to (:id profile-owner)}
{:id (:id team3)}]}
out (th/command! data)]
(t/is (th/success? out))
;; team1 should be soft-deleted
(let [team (th/db-get :team {:id (:id team1)} {::db/remove-deleted false})]
(t/is (some? (:deleted-at team))))
;; profile-user should no longer be a member of team2
(let [rel (th/db-get :team-profile-rel {:team-id (:id team2) :profile-id (:id profile-user)})]
(t/is (nil? rel)))
;; profile-owner should now own team2
(let [rel (th/db-get :team-profile-rel {:team-id (:id team2) :profile-id (:id profile-owner)})]
(t/is (true? (:is-owner rel))))
;; profile-user should no longer be a member of team3
(let [rel (th/db-get :team-profile-rel {:team-id (:id team3) :profile-id (:id profile-user)})]
(t/is (nil? rel)))
;; team3 itself should still exist (profile-owner is still there)
(let [team (th/db-get :team {:id (:id team3)})]
(t/is (some? team)))))))
(t/deftest leave-org-error-teams-to-delete-incomplete
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
;; profile-user is the sole owner/member of both team1 and team2
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
team2 (th/create-team* 2 {:profile-id (:id profile-user)})
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [(:id team1) (:id team2)])]
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
;; Only team1 is listed; team2 is also a sole-owner team and must be included
(let [data {::th/type :leave-org
::rpc/profile-id (:id profile-user)
:id organization-id
:name "Test Org"
:default-team-id your-penpot-id
:teams-to-delete [(:id team1)]
:teams-to-leave []}
out (th/command! data)]
(t/is (not (th/success? out)))
(t/is (= :validation (th/ex-type (:error out))))
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
(t/deftest leave-org-error-cannot-delete-multi-member-team
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
;; team1 has two members: profile-user (owner) and profile-owner (editor)
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
_ (th/create-team-role* {:team-id (:id team1)
:profile-id (:id profile-owner)
:role :editor})
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [(:id team1)])]
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
;; team1 has 2 members so it is not a valid deletion candidate
(let [data {::th/type :leave-org
::rpc/profile-id (:id profile-user)
:id organization-id
:name "Test Org"
:default-team-id your-penpot-id
:teams-to-delete [(:id team1)]
:teams-to-leave []}
out (th/command! data)]
(t/is (not (th/success? out)))
(t/is (= :validation (th/ex-type (:error out))))
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
(t/deftest leave-org-error-teams-to-leave-incomplete
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
;; profile-user owns team1, which also has profile-owner as editor
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
_ (th/create-team-role* {:team-id (:id team1)
:profile-id (:id profile-owner)
:role :editor})
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [(:id team1)])]
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
;; team1 must be transferred (owner + multiple members) but is absent
(let [data {::th/type :leave-org
::rpc/profile-id (:id profile-user)
:id organization-id
:name "Test Org"
:default-team-id your-penpot-id
:teams-to-delete []
:teams-to-leave []}
out (th/command! data)]
(t/is (not (th/success? out)))
(t/is (= :validation (th/ex-type (:error out))))
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
(t/deftest leave-org-error-reassign-to-self
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
_ (th/create-team-role* {:team-id (:id team1)
:profile-id (:id profile-owner)
:role :editor})
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [(:id team1)])]
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
;; reassign-to points to the profile that is leaving — not allowed
(let [data {::th/type :leave-org
::rpc/profile-id (:id profile-user)
:id organization-id
:name "Test Org"
:default-team-id your-penpot-id
:teams-to-delete []
:teams-to-leave [{:id (:id team1) :reassign-to (:id profile-user)}]}
out (th/command! data)]
(t/is (not (th/success? out)))
(t/is (= :validation (th/ex-type (:error out))))
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
(t/deftest leave-org-error-reassign-to-non-member
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
profile-other (th/create-profile* 3 {:is-active true})
;; team1 has profile-user (owner) and profile-owner (editor) — NOT profile-other
team1 (th/create-team* 1 {:profile-id (:id profile-user)})
_ (th/create-team-role* {:team-id (:id team1)
:profile-id (:id profile-owner)
:role :editor})
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [(:id team1)])]
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
;; profile-other is not a member of team1
(let [data {::th/type :leave-org
::rpc/profile-id (:id profile-user)
:id organization-id
:name "Test Org"
:default-team-id your-penpot-id
:teams-to-delete []
:teams-to-leave [{:id (:id team1) :reassign-to (:id profile-other)}]}
out (th/command! data)]
(t/is (not (th/success? out)))
(t/is (= :validation (th/ex-type (:error out))))
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
(t/deftest leave-org-error-reassign-on-non-owned-team
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
;; profile-owner owns team1; profile-user is just a non-owner member
team1 (th/create-team* 1 {:profile-id (:id profile-owner)})
_ (th/create-team-role* {:team-id (:id team1)
:profile-id (:id profile-user)
:role :editor})
org-default-team (th/create-team* 99 {:profile-id (:id profile-user)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [(:id team1)])]
(with-redefs [nitrate/call (nitrate-call-mock org-summary)]
;; profile-user is not the owner so providing reassign-to is invalid
(let [data {::th/type :leave-org
::rpc/profile-id (:id profile-user)
:id organization-id
:name "Test Org"
:default-team-id your-penpot-id
:teams-to-delete []
:teams-to-leave [{:id (:id team1) :reassign-to (:id profile-owner)}]}
out (th/command! data)]
(t/is (not (th/success? out)))
(t/is (= :validation (th/ex-type (:error out))))
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))

View File

@ -125,7 +125,20 @@
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))))))
(t/is (nil? (:error out)))))
(t/testing "delete photo clears photo-id"
(let [data {::th/type :delete-profile-photo
::rpc/profile-id (:id profile)}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
(let [data {::th/type :get-profile
::rpc/profile-id (:id profile)}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:photo-id (:result out))))))))
(t/deftest profile-deletion-1
(let [prof (th/create-profile* 1)

View File

@ -9,6 +9,7 @@
[app.common.uuid :as uuid]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.viewer :as viewer]
[backend-tests.helpers :as th]
[clojure.test :as t]
[datoteka.fs :as fs]))
@ -16,6 +17,28 @@
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest obfuscate-email-happy-path
(t/is (= "a****@****.com" (viewer/obfuscate-email "alice@example.com")))
(t/is (= "a****@****.example.com" (viewer/obfuscate-email "alice@sub.example.com")))
(t/is (= "****@****.com" (viewer/obfuscate-email "bob@bar.com"))))
(t/deftest obfuscate-email-handles-domain-without-dot
;; `localhost`-style domains have no `.`; the previous implementation produced
;; a dangling-dot output like "a****@****." — now the trailing `.` is only
;; emitted when there actually is a TLD segment to append.
(t/is (= "a****@****" (viewer/obfuscate-email "alice@localhost")))
(t/is (= "****@****" (viewer/obfuscate-email "x@y"))))
(t/deftest obfuscate-email-handles-malformed-input
;; These shapes must not throw — `obfuscate-email` runs while building the
;; view-only bundle for share-link viewers and an NPE here aborts the whole
;; RPC response. The previous implementation called `clojure.string/split`
;; on `nil` for the `no-@` case, raising NullPointerException.
(t/is (= "****@****" (viewer/obfuscate-email nil)))
(t/is (= "****@****" (viewer/obfuscate-email "")))
(t/is (= "r***@****" (viewer/obfuscate-email "root"))) ; no `@`, count > 3
(t/is (= "****@****" (viewer/obfuscate-email "bob")))) ; no `@`, count <= 3
(t/deftest retrieve-bundle
(let [prof (th/create-profile* 1 {:is-active true})
prof2 (th/create-profile* 2 {:is-active true})

View File

@ -1120,6 +1120,71 @@
(when (num? value)
(format-precision value precision)))))
(defn- natural-sort-key
"Splits a string into a sequence of alternating string and number segments,
converting numeric segments to longs/ints so they compare by value rather
than lexicographically. e.g. \"size10b\" => (\"size\" 10 \"b\")"
[s]
(map (fn [part]
(if (re-matches #"\d+" part)
#?(:clj (Long/parseLong part)
:cljs (js/parseInt part))
part))
(re-seq #"\d+|\D+" s)))
(defn- natural-compare
"Comparator that orders strings naturally, sorting numeric segments by value
rather than lexicographically. Returns a negative number, zero, or positive
number when a is before, equal to, or after b respectively.
e.g. \"size2\" < \"size10\" instead of \"size10\" < \"size2\"."
[a b]
(loop [ka (natural-sort-key a)
kb (natural-sort-key b)]
(cond
(and (empty? ka) (empty? kb)) 0
(empty? ka) -1
(empty? kb) 1
:else
(let [pa (first ka)
pb (first kb)
result (cond
(and (number? pa) (number? pb)) (compare pa pb)
(and (string? pa) (string? pb)) (compare pa pb)
(number? pa) -1
:else 1)]
(if (zero? result)
(recur (rest ka) (rest kb))
result)))))
(defn natural-sort-by
"Sorts coll by extracting a string key with keyfn and ordering elements
using natural sort order, where embedded numbers are compared by value
rather than lexicographically.
e.g. (natural-sort-by :name [{:name \"size10\"} {:name \"size2\"}])
=> [{:name \"size2\"} {:name \"size10\"}]"
[key coll]
(sort-by key natural-compare coll))
(defn sanitize-string [s]
(if s
(-> s
str
str/trim
(str/replace #"[^\w\s\-_()]+" "")
(str/replace #"\s+" " ")
str/trim)
""))
(defn get-initials
"Returns up to two uppercase initials extracted from a string.
Non-letter prefixes in each token are ignored."
[name]
(->> (str/split (str/trim (or name "")) #"\s+")
(keep #(first (re-seq #"[a-zA-Z]" %)))
(take 2)
(map str/upper)
(apply str)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Util protocols
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -0,0 +1,115 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.common.files.comp-processors
"Repair, migration or transformation utilities for components."
(:require
[app.common.logging :as log]
[app.common.types.component :as ctk]
[app.common.types.file :as ctf]))
(log/set-level! :warn)
(defn remove-unneeded-objects-in-components
"Some components have an :objects attribute, despite not being deleted. This removes it.
It also adds an empty :objects if it's deleted and does not have it."
[file-data]
(ctf/update-components
file-data
(fn [component]
(if (:deleted component)
(if (nil? (:objects component))
(do
(log/warn :msg "Adding empty :objects to deleted component"
:component-id (:id component)
:component-name (:name component)
:file-id (:id file-data))
(assoc component :objects {}))
component)
(if (contains? component :objects)
(do
(log/warn :msg "Removing :objects from non-deleted component"
:component-id (:id component)
:component-name (:name component)
:file-id (:id file-data))
(dissoc component :objects))
component)))))
(defn fix-missing-swap-slots
"Locate shapes that have been swapped (i.e. their shape-ref does not point to the near match) but
they don't have a swap slot. In this case, add one pointing to the near match."
[file-data libraries]
(ctf/update-all-shapes
file-data
(fn [shape]
(if (ctk/subcopy-head? shape)
(let [container (:container (meta shape))
file {:id (:id file-data) :data file-data}
near-match (ctf/find-near-match file container libraries shape :include-deleted? true :with-context? false)]
(if (and (some? near-match)
(not= (:shape-ref shape) (:id near-match))
(nil? (ctk/get-swap-slot shape)))
(let [updated-shape (ctk/set-swap-slot shape (:id near-match))]
(log/warn :msg "Adding missing swap slot to shape"
:shape-id (:id shape)
:shape-name (:name shape)
:swap-slot (:id near-match)
:file-id (:id file)
:container-id (:id container)
:container-type (:type container))
{:result :update :updated-shape updated-shape})
{:result :keep}))
{:result :keep}))))
(defn sync-component-id-with-ref-shape
"Ensure that all copies heads have the same component id and file as the referenced shape.
There may be bugs that cause them to get out of sync."
[file-data libraries]
(letfn [(sync-one-iteration
[file-data libraries]
(ctf/update-all-shapes
file-data
(fn [shape]
(if (and (ctk/subcopy-head? shape) (nil? (ctk/get-swap-slot shape)))
(let [container (:container (meta shape))
file {:id (:id file-data) :data file-data}
ref-shape (ctf/find-ref-shape file container libraries shape {:include-deleted? true :with-context? true})]
(if (and (some? ref-shape)
(or (not= (:component-id shape) (:component-id ref-shape))
(not= (:component-file shape) (:component-file ref-shape))))
(let [shape' (cond-> shape
(some? (:component-id ref-shape))
(assoc :component-id (:component-id ref-shape))
(nil? (:component-id ref-shape))
(dissoc :component-id)
(some? (:component-file ref-shape))
(assoc :component-file (:component-file ref-shape))
(nil? (:component-file ref-shape))
(dissoc :component-file))]
(log/warn :msg "Syncing component id and file with ref shape"
:shape-id (:id shape)
:shape-name (:name shape)
:component-id (:component-id shape')
:component-file (:component-file shape')
:ref-shape-id (:id ref-shape)
:file-id (:id file)
:container-id (:id container)
:container-type (:type container))
{:result :update :updated-shape shape'})
{:result :keep}))
{:result :keep}))))]
;; If a copy inside a main is updated, we need to repeat the process for the change to be
;; propagated to all copies.
(loop [current-data file-data
iteration 0]
(let [next-data (sync-one-iteration current-data libraries)]
(if (or (= current-data next-data)
(> iteration 20)) ;; safety bound
next-data
(recur next-data (inc iteration)))))))

View File

@ -10,6 +10,7 @@
[app.common.data.macros :as dm]
[app.common.features :as cfeat]
[app.common.files.changes :as cpc]
[app.common.files.comp-processors :as cfcp]
[app.common.files.defaults :as cfd]
[app.common.files.helpers :as cfh]
[app.common.geom.matrix :as gmt]
@ -1786,6 +1787,24 @@
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(defmethod migrate-data "0018-remove-unneeded-objects-from-components"
[data _]
(cfcp/remove-unneeded-objects-in-components data))
(defmethod migrate-data "0019-fix-missing-swap-slots"
[data _]
(let [libraries (if (:libs data)
(deref (:libs data))
{})]
(cfcp/fix-missing-swap-slots data libraries)))
(defmethod migrate-data "0020-sync-component-id-with-near-main"
[data _]
(let [libraries (if (:libs data)
(deref (:libs data))
{})]
(cfcp/sync-component-id-with-ref-shape data libraries)))
(def available-migrations
(into (d/ordered-set)
["legacy-2"
@ -1860,4 +1879,7 @@
"0015-fix-text-attrs-blank-strings"
"0015-clean-shadow-color"
"0016-copy-fills-from-position-data-to-text-node"
"0017-fix-layout-flex-dir"]))
"0017-fix-layout-flex-dir"
"0018-remove-unneeded-objects-from-components"
"0019-fix-missing-swap-slots"
"0020-sync-component-id-with-near-main"]))

View File

@ -334,6 +334,31 @@
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :component-id-mismatch
[_ {:keys [shape page-id args] :as error} file-data _]
(let [repair-shape
(fn [shape]
; Set the component-id and component-file to the ones of the near main
(log/debug :hint (str " -> set component-id to " (:component-id args)))
(log/debug :hint (str " -> set component-file to " (:component-file args)))
(cond-> shape
(some? (:component-id args))
(assoc :component-id (:component-id args))
(nil? (:component-id args))
(dissoc :component-id)
(some? (:component-file args))
(assoc :component-file (:component-file args))
(nil? (:component-file args))
(dissoc :component-file)))]
(log/dbg :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)
(pcb/with-file-data file-data)
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :ref-shape-is-head
[_ {:keys [shape page-id args] :as error} file-data _]
(let [repair-shape
@ -501,7 +526,7 @@
(pcb/update-shapes [(:id shape)] repair-shape))))
(defmethod repair-error :component-nil-objects-not-allowed
[_ {:keys [shape] :as error} file-data _]
[_ {component :shape} file-data _] ; in this error the :shape argument is the component
(let [repair-component
(fn [component]
;; Remove the objects key, or set it to {} if the component is deleted
@ -513,10 +538,26 @@
(log/debug :hint " -> remove :objects")
(dissoc component :objects))))]
(log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id shape) :name (:name shape))
(log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component))
(-> (pcb/empty-changes nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id shape) repair-component))))
(pcb/update-component (:id component) repair-component))))
(defmethod repair-error :non-deleted-component-cannot-have-objects
[_ {component :shape} file-data _] ; in this error the :shape argument is the component
(let [repair-component
(fn [component]
; Remove the :objects field
(if-not (:deleted component)
(do
(log/debug :hint " -> remove :objects")
(dissoc component :objects))
component))]
(log/dbg :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component))
(-> (pcb/empty-changes nil)
(pcb/with-library-data file-data)
(pcb/update-component (:id component) repair-component))))
(defmethod repair-error :invalid-text-touched
[_ {:keys [shape page-id] :as error} file-data _]

View File

@ -0,0 +1,74 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.common.files.stats
"Pure helpers that compute aggregate statistics for a file data map.
Given a decoded file data structure (the value stored under `:data`
on a file row), produces a small map with page/shape/library counts.
Intended to be cheap — a single pass over each page's `:objects`
map, no database access, no side effects."
(:require
[app.common.uuid :as uuid]))
(def empty-shape-counts
{:total 0 :by-type {}})
(defn- inc-type
[by-type shape-type]
(if (nil? shape-type)
by-type
(update by-type shape-type (fnil inc 0))))
(defn count-shapes-by-type
"Walk an `:objects` map of a single page and return
`{:total N :by-type {:rect N :frame N ...}}`. The synthetic root
shape at `uuid/zero` is skipped so it never contributes to totals."
[objects]
(if (empty? objects)
empty-shape-counts
(reduce-kv
(fn [acc id shape]
(if (= id uuid/zero)
acc
(-> acc
(update :total inc)
(update :by-type inc-type (:type shape)))))
empty-shape-counts
objects)))
(defn- merge-shape-counts
[a b]
{:total (+ (:total a) (:total b))
:by-type (merge-with + (:by-type a) (:by-type b))})
(defn- aggregate-shape-counts
[pages-index]
(transduce
(map (comp count-shapes-by-type :objects))
(completing merge-shape-counts)
empty-shape-counts
(vals pages-index)))
(defn calc-file-stats
"Given a decoded file data map with the standard keys
`:pages-index`, `:components`, `:deleted-components`, `:colors`
and `:typographies`, return per-file aggregates.
The result is a plain map suitable for serialization; it never
contains any pointer-map or objects-map instances."
[fdata]
(let [pages-index (get fdata :pages-index)
components (get fdata :components)
deleted-components (get fdata :deleted-components)
colors (get fdata :colors)
typographies (get fdata :typographies)]
{:page-count (count pages-index)
:shape-counts (aggregate-shape-counts pages-index)
:component-count (count components)
:deleted-component-count (count deleted-components)
:color-count (count colors)
:typography-count (count typographies)}))

View File

@ -147,6 +147,27 @@
#(and (some? tokens-tree)
(not (ctob/token-name-path-exists? % tokens-tree)))]])
(defn make-node-token-name-schema
"Dynamically generates a schema to check a token node name, adding translated error messages
and two additional validations:
- Min and max length.
- Checks if other token with a path derived from the name already exists at `tokens-tree`.
e.g. it's not allowed to create a token `foo.bar` if a token `foo` already exists."
[active-tokens tokens-tree node]
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(-> cto/schema:token-node-name
(sm/update-properties assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error"))))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
(fn [name]
(let [current-path (:path node)
current-name (:name node)
new-tokens (ctob/update-tokens-group active-tokens current-path current-name name)]
(and (some? new-tokens)
(some (fn [[token-name _]]
(not (ctob/token-name-path-exists? token-name tokens-tree)))
new-tokens))))]])
(def schema:token-description
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}])
@ -165,6 +186,11 @@
(when (and name value)
(not (cto/token-value-self-reference? name value))))]])
(defn make-node-token-schema
[active-tokens tokens-tree node]
[:map
[:name (make-node-token-name-schema active-tokens tokens-tree node)]])
(defn convert-dtcg-token
"Convert token attributes as they come from a decoded json, with DTCG types, to internal types.
Eg. From this:
@ -288,16 +314,12 @@
{:value parsed-value
:unit unit}))))
;; FIXME: looks very redundant function
(defn token-identifier
[{:keys [name] :as _token}]
name)
(defn attributes-map
"Creats an attributes map using collection of `attributes` for `id`."
"Creates an attributes map using collection of `attributes` for `id`."
[attributes token]
(->> (map (fn [attr] [attr (token-identifier token)]) attributes)
(into {})))
(into {}
(map (fn [attr] [attr (:name token)]))
attributes))
(defn remove-attributes-for-token
"Removes applied tokens with `token-name` for the given `attributes` set from `applied-tokens`."
@ -313,7 +335,7 @@
"Test if `token` is applied to a `shape` on single `token-attribute`."
[token shape token-attribute]
(when-let [id (dm/get-in shape [:applied-tokens token-attribute])]
(= (token-identifier token) id)))
(= (:name token) id)))
(defn token-applied?
"Test if `token` is applied to a `shape` with at least one of the given `token-attributes`."

View File

@ -51,6 +51,7 @@
:ref-shape-is-head
:ref-shape-is-not-head
:shape-ref-in-main
:component-id-mismatch
:root-main-not-allowed
:nested-main-not-allowed
:root-copy-not-allowed
@ -59,6 +60,7 @@
:not-head-copy-not-allowed
:not-component-not-allowed
:component-nil-objects-not-allowed
:non-deleted-component-cannot-have-objects
:instance-head-not-frame
:invalid-text-touched
:misplaced-slot
@ -326,6 +328,20 @@
:component-file (:component-file ref-shape)
:component-id (:component-id ref-shape)))))
(defn- check-ref-component-id
"Validate that if the copy has not been swapped, the component-id and component-file are
the same as in the referenced shape in the near main."
[shape file page libraries]
(when (nil? (ctk/get-swap-slot shape))
(when-let [ref-shape (ctf/find-ref-shape file page libraries shape :include-deleted? true)]
(when (or (not= (:component-id shape) (:component-id ref-shape))
(not= (:component-file shape) (:component-file ref-shape)))
(report-error :component-id-mismatch
"Nested copy component-id and component-file must be the same as the near main"
shape file page
:component-id (:component-id ref-shape)
:component-file (:component-file ref-shape))))))
(defn- check-empty-swap-slot
"Validate that this shape does not have any swap slot."
[shape file page]
@ -350,6 +366,19 @@
"This shape has children with the same swap slot"
shape file page)))
(defn- check-required-swap-slot
"Validate that the shape has swap-slot if it's a subinstance head and the ref shape is not the
matching shape by position in the near main."
[shape file page libraries]
(let [near-match (ctf/find-near-match file page libraries shape :include-deleted? true :with-context? false)]
(when (and (some? near-match)
(not= (:shape-ref shape) (:id near-match))
(nil? (ctk/get-swap-slot shape)))
(report-error :missing-slot
"Shape has been swapped, should have swap slot"
shape file page
:swap-slot (or (ctk/get-swap-slot near-match) (:id near-match))))))
(defn- check-valid-touched
"Validate that the text touched flags are coherent."
[shape file page]
@ -418,6 +447,8 @@
(check-component-not-main-head shape file page libraries)
(check-component-not-root shape file page)
(check-valid-touched shape file page)
(check-ref-component-id shape file page libraries)
(check-required-swap-slot shape file page libraries)
;; We can have situations where the nested copy and the ancestor copy come from different libraries and some of them have been dettached
;; so we only validate the shape-ref if the ancestor is from a valid library
(when library-exists
@ -458,8 +489,7 @@
(defn- check-variant-container
"Shape is a variant container, so:
-all its children should be variants with variant-id equals to the shape-id
-all the components should have the same properties
"
-all the components should have the same properties"
[shape file page]
(let [shape-id (:id shape)
shapes (:shapes shape)
@ -648,6 +678,13 @@
"Component main not allowed inside other component"
main-instance file component-page))))
(defn- check-not-objects
[component file]
(when (d/not-empty? (:objects component))
(report-error :non-deleted-component-cannot-have-objects
"A non-deleted component cannot have shapes inside"
component file nil)))
(defn- check-component
"Validate semantic coherence of a component. Report all errors found."
[component file]
@ -656,7 +693,8 @@
"Objects list cannot be nil"
component file nil))
(when-not (:deleted component)
(check-main-inside-main component file))
(check-main-inside-main component file)
(check-not-objects component file))
(when (:deleted component)
(check-component-duplicate-swap-slot component file)
(check-ref-cycles component file))
@ -674,8 +712,6 @@
;; PUBLIC API: VALIDATION FUNCTIONS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare check-swap-slots)
(defn validate-file
"Validate full referential integrity and semantic coherence on file data.
@ -686,8 +722,6 @@
(doseq [page (filter :id (ctpl/pages-seq data))]
(check-shape uuid/zero file page libraries)
(when (str/includes? (:name file) "check-swap-slot")
(check-swap-slots uuid/zero file page libraries))
(->> (get-orphan-shapes page)
(run! #(check-shape % file page libraries))))
@ -728,40 +762,3 @@
:hint "error on validating file referential integrity"
:file-id (:id file)
:details errors)))
(declare compare-slots)
;; Optional check to look for missing swap slots.
;; Search for copies that do not point the shape-ref to the near component but don't have swap slot
;; (looking for position relative to the parent, in the copy and the main).
;;
;; This check cannot be generally enabled, because files that have been migrated from components v1
;; may have copies with shapes that do not match by position, but have not been swapped. So we enable
;; it for specific files only. To activate the check, you need to add the string "check-swap-slot" to
;; the name of the file.
(defn- check-swap-slots
[shape-id file page libraries]
(let [shape (ctst/get-shape page shape-id)]
(if (and (ctk/instance-root? shape) (ctk/in-component-copy? shape))
(let [ref-shape (ctf/find-ref-shape file page libraries shape :include-deleted? true :with-context? true)
container (:container (meta ref-shape))]
(when (some? ref-shape)
(compare-slots shape ref-shape file page container)))
(doall (for [child-id (:shapes shape)]
(check-swap-slots child-id file page libraries))))))
(defn- compare-slots
[shape-copy shape-main file container-copy container-main]
(if (and (not= (:shape-ref shape-copy) (:id shape-main))
(nil? (ctk/get-swap-slot shape-copy)))
(report-error :missing-slot
"Shape has been swapped, should have swap slot"
shape-copy file container-copy
:swap-slot (or (ctk/get-swap-slot shape-main) (:id shape-main)))
(when (nil? (ctk/get-swap-slot shape-copy))
(let [children-id-pairs (d/zip-all (:shapes shape-copy) (:shapes shape-main))]
(doall (for [[child-copy-id child-main-id] children-id-pairs]
(let [child-copy (ctst/get-shape container-copy child-copy-id)
child-main (ctst/get-shape container-main child-main-id)]
(when (and (some? child-copy) (some? child-main))
(compare-slots child-copy child-main file container-copy container-main)))))))))

View File

@ -128,6 +128,8 @@
:token-shadow
:token-tokenscript
:token-import-from-library
:token-typography-row
;; Only for developtment.
:transit-readable-response
:user-feedback
@ -194,8 +196,7 @@
:enable-inspect-styles
:enable-feature-fdata-objects-map
:enable-feature-render-wasm
;; Temporary deactivated
#_:enable-token-import-from-library])
:enable-token-import-from-library])
(defn parse
[& flags]

View File

@ -17,11 +17,11 @@
java.util.List
linked.map.LinkedMap
linked.set.LinkedSet
org.fressian.handlers.ReadHandler
org.fressian.handlers.WriteHandler
org.fressian.Reader
org.fressian.StreamingWriter
org.fressian.Writer
org.fressian.handlers.ReadHandler
org.fressian.handlers.WriteHandler))
org.fressian.Writer))
(set! *warn-on-reflection* true)

View File

@ -8,11 +8,11 @@
(:refer-clojure :exclude [get])
(:import
java.lang.AutoCloseable
org.apache.commons.pool2.impl.DefaultPooledObject
org.apache.commons.pool2.impl.SoftReferenceObjectPool
org.apache.commons.pool2.ObjectPool
org.apache.commons.pool2.PooledObject
org.apache.commons.pool2.PooledObjectFactory
org.apache.commons.pool2.impl.DefaultPooledObject
org.apache.commons.pool2.impl.SoftReferenceObjectPool))
org.apache.commons.pool2.PooledObjectFactory))
(defn pool?
[o]

View File

@ -333,7 +333,7 @@
(pcb/update-shapes [shape-id] #(do (log/trace :msg " -> promote to root")
(assoc % :component-root true)))
:always
(some? (ctk/get-swap-slot shape))
; First level subinstances of a detached component can't have swap-slot
(pcb/update-shapes [shape-id] #(do (log/trace :msg " -> remove swap-slot")
(ctk/remove-swap-slot %)))
@ -364,7 +364,7 @@
(let [ref-shape (ctf/find-ref-shape file container libraries shape {:include-deleted? true})]
(cond-> changes
(some? (:shape-ref ref-shape))
(pcb/update-shapes [(:id shape)] #(do (log/trace :msg " (advanced)")
(pcb/update-shapes [(:id shape)] #(do (log/trace :msg (str " (advanced to " (:shape-ref ref-shape) ")"))
(assoc % :shape-ref (:shape-ref ref-shape))))
;; When advancing level, the normal touched groups (not swap slots) of the
@ -374,16 +374,18 @@
(pcb/update-shapes
[(:id shape)]
#(do (log/trace :msg " (merge touched)")
(log/trace :msg (str " (ref-shape: " (:id ref-shape) ")"))
(log/trace :msg (str " (ref touched: " (:touched ref-shape) ")"))
(assoc % :touched
(clojure.set/union (:touched shape)
(ctk/normal-touched-groups ref-shape)))))
(set/union (:touched shape)
(ctk/normal-touched-groups ref-shape)))))
;; Swap slot must also be copied if the current shape has not any,
;; except if this is the first level subcopy.
(and (some? (ctk/get-swap-slot ref-shape))
(nil? (ctk/get-swap-slot shape))
(not= (:id shape) shape-id))
(pcb/update-shapes [(:id shape)] #(do (log/trace :msg " (got swap-slot)")
(pcb/update-shapes [(:id shape)] #(do (log/trace :msg (str " (got swap-slot " (ctk/get-swap-slot ref-shape) ")"))
(ctk/set-swap-slot % (ctk/get-swap-slot ref-shape))))
;; If we can't get the ref-shape (e.g. it's in an external library not linked),
@ -771,14 +773,6 @@
;; is different than the one in the near component (Shape-2-2-1)
;; but it's not touched.
(defn- redirect-shaperef ;;Set the :shape-ref of a shape pointing to the :id of its remote-shape
([container libraries shape]
(redirect-shaperef nil nil shape (ctf/find-remote-shape container libraries shape)))
([_ _ shape remote-shape]
(if (some? (:shape-ref shape))
(assoc shape :shape-ref (:id remote-shape))
shape)))
(defn generate-sync-shape-direct
"Generate changes to synchronize one shape that is the root of a component
instance, and all its children, from the given component."
@ -790,18 +784,12 @@
component (ctkl/get-component library (:component-id shape-inst) true)]
(if (and (ctk/in-component-copy? shape-inst)
(or (ctf/direct-copy? shape-inst component container nil libraries) reset?)) ; In a normal sync, we don't want to sync remote mains, only direct/near
(let [redirect-shaperef (partial redirect-shaperef container libraries)
shape-main (when component
(let [shape-main (when component
(if reset?
;; the reset is against the ref-shape, not against the original shape of the component
(ctf/find-ref-shape file container libraries shape-inst)
(ctf/get-ref-shape library component shape-inst)))
shape-inst (if reset?
(redirect-shaperef shape-inst shape-main)
shape-inst)
initial-root? (:component-root shape-inst)
root-inst shape-inst
@ -819,8 +807,8 @@
root-inst
root-main
reset?
initial-root?
redirect-shaperef)
initial-root?)
;; If the component is not found, because the master component has been
;; deleted or the library unlinked, do nothing.
changes))
@ -844,7 +832,7 @@
nil))))))
(defn- generate-sync-shape-direct-recursive
[changes container shape-inst component library file libraries shape-main root-inst root-main reset? initial-root? redirect-shaperef]
[changes container shape-inst component library file libraries shape-main root-inst root-main reset? initial-root?]
(shape-log :debug (:id shape-inst) container
:msg "Sync shape direct recursive"
:shape-inst (str (:name shape-inst) " " (pretty-uuid (:id shape-inst)))
@ -891,9 +879,6 @@
children-inst (vec (ctn/get-direct-children container shape-inst))
children-main (vec (ctn/get-direct-children component-container shape-main))
children-inst (if reset?
(map #(redirect-shaperef %) children-inst) children-inst)
only-inst (fn [changes child-inst]
(shape-log :trace (:id child-inst) container
:msg "Only inst"
@ -942,8 +927,7 @@
root-inst
root-main
reset?
initial-root?
redirect-shaperef))
initial-root?))
swapped (fn [changes child-inst child-main]
(shape-log :trace (:id child-inst) container
@ -1008,16 +992,13 @@
the values in the shape and all its children."
[changes file libraries container shape-id]
(shape-log :debug shape-id container :msg "Sync shape inverse" :shape (str shape-id))
(let [redirect-shaperef (partial redirect-shaperef container libraries)
shape-inst (ctn/get-shape container shape-id)
(let [shape-inst (ctn/get-shape container shape-id)
library (dm/get-in libraries [(:component-file shape-inst) :data])
component (ctkl/get-component library (:component-id shape-inst))
shape-main (when component
(ctf/find-remote-shape container libraries shape-inst))
shape-inst (redirect-shaperef shape-inst shape-main)
initial-root? (:component-root shape-inst)
root-inst shape-inst
@ -1038,12 +1019,11 @@
shape-main
root-inst
root-main
initial-root?
redirect-shaperef)
initial-root?)
changes)))
(defn- generate-sync-shape-inverse-recursive
[changes container shape-inst component library file libraries shape-main root-inst root-main initial-root? redirect-shaperef]
[changes container shape-inst component library file libraries shape-main root-inst root-main initial-root?]
(shape-log :trace (:id shape-inst) container
:msg "Sync shape inverse recursive"
:shape (str (:name shape-inst))
@ -1100,8 +1080,6 @@
children-main (mapv #(ctn/get-shape component-container %)
(:shapes shape-main))
children-inst (map #(redirect-shaperef %) children-inst)
only-inst (fn [changes child-inst]
(add-shape-to-main changes
child-inst
@ -1130,8 +1108,7 @@
child-main
root-inst
root-main
initial-root?
redirect-shaperef))
initial-root?))
swapped (fn [changes child-inst child-main]
(shape-log :trace (:id child-inst) container
@ -1773,6 +1750,23 @@
(pcb/update-shapes changes [(:id dest-shape)] ctk/unhead-shape {:ignore-touched true})
changes))
(defn- check-swapped-main
[changes dest-shape origin-shape]
;; Only for direct updates (from main to copy). Check if the main shape
;; has been swapped. If so, the new component-id and component-file must
;; be put into the copy.
(if (and (= (:shape-ref dest-shape) (:id origin-shape))
(ctk/instance-head? dest-shape)
(ctk/instance-head? origin-shape)
(or (not= (:component-id dest-shape) (:component-id origin-shape))
(not= (:component-file dest-shape) (:component-file origin-shape))))
(pcb/update-shapes changes [(:id dest-shape)]
#(assoc %
:component-id (:component-id origin-shape)
:component-file (:component-file origin-shape))
{:ignore-touched true})
changes))
(defn- update-attrs
"The main function that implements the attribute sync algorithm. Copy
attributes that have changed in the origin shape to the dest shape.
@ -1816,6 +1810,8 @@
:always
(check-detached-main dest-shape origin-shape)
:always
(check-swapped-main dest-shape origin-shape)
:always
(generate-update-tokens container dest-shape origin-shape touched omit-touched? nil))
(let [sync-group

View File

@ -148,16 +148,16 @@ Some naming conventions:
:path 'one'
:depth 0
:leaf nil
:children-fn (fn [] [{:name 'two'
:path 'one.two'
:depth 1
:leaf nil
:children-fn (fn [] [{... :name 'three'} {... :name 'four'}])}
{:name 'five'
:path 'one.five'
:depth 1
:leaf {... :name 'five'}
...}])}]"
:children [{:name 'two'
:path 'one.two'
:depth 1
:leaf nil
:children [{... :name 'three'} {... :name 'four'}]}
{:name 'five'
:path 'one.five'
:depth 1
:leaf {... :name 'five'}
:children nil}]}]"
(defn- sort-by-children
"Sorts segments so that those with children come first."
@ -191,7 +191,7 @@ Some naming conventions:
(into (sorted-map) grouped)))
(defn- build-tree-node
"Builds a single tree node with lazy children."
"Builds a single tree node with computed children."
[segment-name remaining-segments separator parent-path depth]
(let [current-path (if parent-path
(str parent-path "." segment-name)
@ -208,12 +208,11 @@ Some naming conventions:
:path current-path
:depth depth
:leaf leaf-segment
:children-fn (when-not is-leaf?
(fn []
(let [grouped-elements (sort-and-group-segments remaining-segments separator)]
(mapv (fn [[child-segment-name remaining-child-segments]]
(build-tree-node child-segment-name remaining-child-segments separator current-path (inc depth)))
grouped-elements))))}]
:children (when-not is-leaf?
(let [grouped-elements (sort-and-group-segments remaining-segments separator)]
(mapv (fn [[child-segment-name remaining-child-segments]]
(build-tree-node child-segment-name remaining-child-segments separator current-path (inc depth)))
grouped-elements)))}]
node))
(defn build-tree-root

View File

@ -113,12 +113,19 @@
(tgen/fmap keyword)))))
;; --- SPEC: email
;;
;; Regex rules enforced:
;; local part - valid RFC chars, no leading/trailing dot, no consecutive dots
;; domain - labels can't start/end with hyphen, no empty labels
;; TLD - at least 2 alphabetic chars
(def email-re #"[a-zA-Z0-9_.+-\\\\]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+")
(def email-re
#"^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,63}$")
(defn parse-email
[s]
(some->> s (re-seq email-re) first))
(when (and (string? s) (re-matches email-re s))
s))
(letfn [(conformer [v]
(or (parse-email v) ::s/invalid))
@ -126,11 +133,10 @@
(dm/str v))]
(s/def ::email
(s/with-gen (s/conformer conformer unformer)
#(as-> (tgen/let [p1 (s/gen ::not-empty-string)
p2 (s/gen ::not-empty-string)
p3 (tgen/elements ["com" "net"])]
(str p1 "@" p2 "." p3)) $
(tgen/such-that (partial re-matches email-re) $ 50)))))
#(tgen/let [local (tgen/string-alphanumeric 1 20)
label (tgen/string-alphanumeric 2 10)
tld (tgen/elements ["com" "net" "org" "io" "co" "dev"])]
(str local "@" label "." tld)))))
;; -- SPEC: uri

View File

@ -177,8 +177,11 @@
(thc/instantiate-component component-label copy-root-label copy-root-params)))
(defn add-nested-component
[file component1-label main1-root-label main1-child-label component2-label main2-root-label nested-head-label
& {:keys [component1-params root1-params main1-child-params component2-params main2-root-params nested-head-params]}]
[file
component1-label main1-root-label main1-child-label
component2-label main2-root-label nested-head-label
& {:keys [component1-params root1-params main1-child-params
component2-params main2-root-params nested-head-params]}]
;; Generated shape tree:
;; {:main1-root-label} [:name Frame1] # [Component :component1-label]
;; :main1-child-label [:name Rect1]
@ -204,8 +207,13 @@
component2-params)))
(defn add-nested-component-with-copy
[file component1-label main1-root-label main1-child-label component2-label main2-root-label nested-head-label copy2-root-label
& {:keys [component1-params root1-params main1-child-params component2-params main2-root-params nested-head-params copy2-root-params]}]
[file
component1-label main1-root-label main1-child-label
component2-label main2-root-label nested-head-label
copy2-root-label
& {:keys [component1-params root1-params main1-child-params
component2-params main2-root-params nested-head-params
copy2-root-params]}]
;; Generated shape tree:
;; {:main1-root-label} [:name Frame1] # [Component :component1-label]
;; :main1-child-label [:name Rect1]
@ -232,6 +240,102 @@
:nested-head-params nested-head-params)
(thc/instantiate-component component2-label copy2-root-label copy2-root-params)))
(defn add-two-levels-nested-component
[file
component1-label main1-root-label main1-child-label
component2-label main2-root-label nested-head1-label
component3-label main3-root-label nested-head2-label nested-subhead2-label
& {:keys [component1-params root1-params main1-child-params
component2-params main2-root-params nested-head1-params
component3-params main3-root-params nested-head2-params]}]
;; Generated shape tree:
;; {:main1-root-label} [:name Frame1] # [Component :component1-label]
;; :main1-child-label [:name Rect1]
;;
;; {:main2-root-label} [:name Frame2] # [Component :component2-label]
;; :nested-head1-label [:name Frame1] @--> [Component :component1-label] :main1-root-label
;; <no-label> [:name Rect1] ---> :main1-child-label
;;
;; {:main3-root-label} [:name Frame3] # [Component :component3-label]
;; :nested-head2-label [:name Frame2] @--> [Component :component2-label] :main2-root-label
;; :nested-subhead2-label [:name Frame1] @--> [Component :component1-label] :main1-root-label
;; <no-label> [:name Rect1] ---> :main1-child-label
(-> file
(add-simple-component component1-label
main1-root-label
main1-child-label
:component-params component1-params
:root-params root1-params
:child-params main1-child-params)
(add-frame main2-root-label (merge {:name "Frame2"}
main2-root-params))
(thc/instantiate-component component1-label
nested-head1-label
(assoc nested-head1-params
:parent-label main2-root-label))
(thc/make-component component2-label
main2-root-label
component2-params)
(add-frame main3-root-label (merge {:name "Frame3"}
main3-root-params))
(thc/instantiate-component component2-label
nested-head2-label
(assoc nested-head2-params
:parent-label main3-root-label
:children-labels [nested-subhead2-label]))
(thc/make-component component3-label
main3-root-label
component3-params)))
(defn add-two-levels-nested-component-with-copy
[file
component1-label main1-root-label main1-child-label
component2-label main2-root-label nested-head1-label
component3-label main3-root-label nested-head2-label nested-subhead2-label
copy2-root-label
& {:keys [component1-params root1-params main1-child-params
component2-params main2-root-params nested-head1-params
component3-params main3-root-params nested-head2-params
copy2-root-params]}]
;; Generated shape tree:
;; {:main1-root-label} [:name Frame1] # [Component :component1-label]
;; :main1-child-label [:name Rect1]
;;
;; {:main2-root-label} [:name Frame2] # [Component :component2-label]
;; :nested-head1-label [:name Frame1] @--> [Component :component1-label] :main1-root-label
;; <no-label> [:name Rect1] ---> :main1-child-label
;;
;; {:main3-root-label} [:name Frame3] # [Component :component3-label]
;; :nested-head2-label [:name Frame2] @--> [Component :component2-label] :main2-root-label
;; :nested-subhead2-label [:name Frame1] @--> [Component :component1-label] :main1-root-label
;; <no-label> [:name Rect1] ---> :main1-child-label
;;
;; :copy2-label [:name Frame3] #--> [Component :component3-label] :main3-root-label
;; <no-label> [:name Frame2] @--> [Component :component2-label] :nested-head2-label
;; <no-label> [:name Frame1] @--> [Component :component1-label] :nested-subhead2-label
;; <no-label> [:name Rect1] ---> <no-label>
(-> file
(add-two-levels-nested-component component1-label
main1-root-label
main1-child-label
component2-label
main2-root-label
nested-head1-label
component3-label
main3-root-label
nested-head2-label
nested-subhead2-label
:component1-params component1-params
:root1-params root1-params
:main1-child-params main1-child-params
:component2-params component2-params
:main2-root-params main2-root-params
:nested-head1-params nested-head1-params
:component3-params component3-params
:main3-root-params main3-root-params
:nested-head2-params nested-head2-params)
(thc/instantiate-component component3-label copy2-root-label copy2-root-params)))
;; ----- Getters
(defn bottom-shape-by-id
@ -274,15 +378,18 @@
file-id
{file-id file}
file-id))]
(thf/apply-changes file changes)))
(thf/apply-changes file changes :validate? false)))
(defn swap-component
(defn swap-component-
"Swap the specified shape by the component specified by component-tag"
[file shape component-tag & {:keys [page-label propagate-fn keep-touched? new-shape-label]}]
[file shape component-tag & {:keys [page-label propagate-fn keep-touched? new-shape-label library]}]
(let [page (if page-label
(thf/get-page file page-label)
(thf/current-page file))
libraries {(:id file) file}
libraries (cond-> {(:id file) file}
(some? library)
(assoc (:id library) library))
library (or library file)
orig-shapes (when keep-touched? (cfh/get-children-with-self (:objects page) (:id shape)))
@ -290,10 +397,10 @@
(cll/generate-component-swap (pcb/empty-changes)
(:objects page)
shape
(:data file)
(:data library)
page
libraries
(-> (thc/get-component file component-tag)
(-> (thc/get-component library component-tag)
:id)
0
nil
@ -305,26 +412,36 @@
[changes nil])
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(when new-shape-label
(thi/rm-id! (:id new-shape))
(thi/set-id! new-shape-label (:id new-shape)))
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))
(defn swap-component-in-shape [file shape-tag component-tag & {:keys [page-label propagate-fn]}]
(swap-component file (ths/get-shape file shape-tag :page-label page-label) component-tag :page-label page-label :propagate-fn propagate-fn))
(defn swap-component-in-shape
[file shape-tag component-tag & {:keys [page-label propagate-fn keep-touched? new-shape-label library]}]
(swap-component- file (ths/get-shape file shape-tag :page-label page-label)
component-tag
:page-label page-label
:propagate-fn propagate-fn
:keep-touched? keep-touched?
:new-shape-label new-shape-label
:library library))
(defn swap-component-in-first-child [file shape-tag component-tag & {:keys [page-label propagate-fn]}]
(defn swap-component-in-first-child
[file shape-tag component-tag & {:keys [page-label propagate-fn library]}]
(let [first-child-id (->> (ths/get-shape file shape-tag :page-label page-label)
:shapes
first)]
(swap-component file
(ths/get-shape-by-id file first-child-id :page-label page-label)
component-tag
:page-label page-label
:propagate-fn propagate-fn)))
(swap-component- file
(ths/get-shape-by-id file first-child-id :page-label page-label)
component-tag
:page-label page-label
:propagate-fn propagate-fn
:library library)))
(defn update-color
"Update the first fill color for the shape identified by shape-tag"
@ -339,9 +456,10 @@
(assoc shape :fills (ths/sample-fills-color :fill-color color)))
(:objects page)
{})
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))
(defn update-bottom-color
@ -357,9 +475,10 @@
(assoc shape :fills (ths/sample-fills-color :fill-color color)))
(:objects page)
{})
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))
(defn reset-overrides [file shape & {:keys [page-label propagate-fn]}]
@ -374,9 +493,10 @@
{file-id file}
(ctn/make-container container :page)
(:id shape)))
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))
(defn reset-overrides-in-first-child [file shape-tag & {:keys [page-label propagate-fn]}]
@ -398,9 +518,10 @@
#{(-> (ths/get-shape file shape-tag :page-label page-label)
:id)}
{})
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))
(defn duplicate-shape [file shape-tag & {:keys [page-label propagate-fn]}]
@ -419,8 +540,9 @@
(:id file)) ;; file-id
(cll/generate-duplicate-changes-update-indices (:objects page) ;; objects
#{(:id shape)}))
file' (thf/apply-changes file changes)]
file' (thf/apply-changes file changes :validate? (not propagate-fn))]
(if propagate-fn
(propagate-fn file')
(-> (propagate-fn file')
(thf/validate-file!))
file')))

View File

@ -54,12 +54,14 @@
([file] (validate-file! file {}))
([file libraries]
(cfv/validate-file-schema! file)
(cfv/validate-file! file libraries)))
(cfv/validate-file! file libraries)
file))
(defn apply-changes
[file changes]
[file changes & {:keys [validate?] :or {validate? true}}]
(let [file' (ctf/update-file-data file #(cfc/process-changes % (:redo-changes changes) true))]
(validate-file! file')
(when validate?
(validate-file! file'))
file'))
(defn apply-undo-changes

View File

@ -82,6 +82,18 @@
(:id page)
#(ctst/set-shape % (ctn/set-shape-attr shape attr val)))))))
(defn update-shape-by-id
[file shape-id attr val & {:keys [page-label]}]
(let [page (if page-label
(thf/get-page file page-label)
(thf/current-page file))
shape (ctst/get-shape page shape-id)]
(update file :data
(fn [file-data]
(ctpl/update-page file-data
(:id page)
#(ctst/set-shape % (ctn/set-shape-attr shape attr val)))))))
(defn update-shape-text
[file shape-label attr val & {:keys [page-label]}]
(let [page (if page-label

View File

@ -37,7 +37,9 @@
(defn attrs-to-styles
[attrs]
(reduce-kv (fn [res k v]
(conj res (encode-style k v)))
(if (some? v)
(conj res (encode-style k v))
res))
#{}
attrs))

View File

@ -163,11 +163,15 @@
Note that design tokens also are involved, although they go by an alternate
route and thus they are not part of :sync-attrs.
Also when detaching a nested copy it also needs to trigger a synchronization,
even though :shape-ref is not a synced attribute per se"
even though :shape-ref, :component-id or :component-file are not synced
attributes per se."
[attr]
(or (contains? sync-attrs attr)
(= :shape-ref attr)
(= :applied-tokens attr)))
(= :applied-tokens attr)
(= :component-id attr)
(= :component-file attr)
(= :component-root attr)))
(defn instance-root?
"Check if this shape is the head of a top instance."

View File

@ -60,6 +60,9 @@
(some? objects)
(assoc :objects objects)
(nil? objects)
(dissoc :objects)
(some? modified-at)
(assoc :modified-at modified-at)

View File

@ -55,6 +55,10 @@
[page-or-component type]
(assoc page-or-component :type type))
(defn unmake-container
[container]
(dissoc container :type))
(defn page?
[container]
(= (:type container) :page))

View File

@ -204,7 +204,8 @@
(defn update-file-data
[file f]
(update file :data f))
(when file
(update file :data f)))
(defn containers-seq
"Generate a sequence of all pages and all components, wrapped as containers"
@ -225,6 +226,85 @@
(ctpl/update-page file-data (:id container) f)
(ctkl/update-component file-data (:id container) f)))
(defn update-pages
"Update all pages inside the file"
[file-data f]
(update file-data :pages-index d/update-vals
(fn [page]
(-> page
(ctn/make-container :page)
(f)
(ctn/unmake-container)))))
(defn update-components
"Update all components inside the file"
[file-data f]
(d/update-when file-data :components d/update-vals
(fn [component]
(-> component
(ctn/make-container :component)
(f)
(ctn/unmake-container)))))
(defn update-containers
"Update all pages and components inside the file"
[file-data f]
(-> file-data
(update-pages f)
(update-components f)))
(defn update-objects-tree
"Do a depth-first traversal of the shapes in a container, doing different kinds of updates.
The function f receives a shape with a context metadata with the container.
It must return a map with the following keys:
- :result -> :keep, :update or :remove
- :updated-shape -> the updated shape if result is :update"
[container f]
(letfn [(update-shape-recursive
[container shape-id]
(let [shape (ctst/get-shape container shape-id)]
(when (not shape)
(throw (ex-info "Shape not found" {:shape-id shape-id})))
(let [shape (with-meta shape {:container container})
{:keys [result updated-shape]} (f shape)
container'
(case result
:keep
container
:update
(ctst/set-shape container updated-shape)
:remove
(ctst/delete-shape container shape-id true)
(throw (ex-info "Invalid result from update function" {:result result})))]
(if (= result :remove)
container'
(reduce update-shape-recursive
container'
(:shapes shape))))))]
(let [root-id (if (ctn/page? container)
uuid/zero
(:main-instance-id container))]
(if-not (empty? (:objects container))
(update-shape-recursive container root-id)
container))))
(defn update-all-shapes
"Update all shapes in the file data, using the update-objects-tree function for each container"
[file-data f]
(when file-data
(update-containers
file-data
(fn [container]
(update-objects-tree container f)))))
;; Asset helpers
(defn find-component-file
[file libraries component-file]
@ -328,6 +408,27 @@
(get-ref-shape (:data component-file) component shape :with-context? with-context?))))]
(some find-ref-shape-in-head (ctn/get-parent-heads (:objects container) shape))))
(defn find-near-match
"Locate the shape that occupies the same position in the near main component.
This will be the ref-shape except if the shape is a copy subhead that has been
swapped. In this case, the near match will be the ref-shape that was before
the swap."
[file container libraries shape & {:keys [include-deleted? with-context?] :or {include-deleted? false with-context? false}}]
(let [parent-shape (ctst/get-shape container (:parent-id shape))
parent-ref-shape (when parent-shape
(find-ref-shape file container libraries parent-shape :include-deleted? include-deleted? :with-context? true))
ref-container (when parent-ref-shape
(:container (meta parent-ref-shape)))
shape-index (when parent-shape
(d/index-of (:shapes parent-shape) (:id shape)))
near-match-id (when (and parent-ref-shape shape-index)
(get (:shapes parent-ref-shape) shape-index))
near-match (when near-match-id
(cond-> (ctst/get-shape ref-container near-match-id)
with-context?
(with-meta (meta parent-ref-shape))))]
near-match))
(defn advance-shape-ref
"Get the shape-ref of the near main of the shape, recursively repeated as many times
as the given levels."

View File

@ -0,0 +1,47 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.common.types.organization
(:require
[app.common.schema :as sm]))
(def schema:team-with-organization
[:map
[:id ::sm/uuid]
[:is-your-penpot :boolean]
[:organization
[:map
[:id ::sm/uuid]
[:name ::sm/text]
[:slug ::sm/text]
[:owner-id ::sm/uuid]
[:avatar-bg-url ::sm/uri]
[:logo-id {:optional true} [:maybe ::sm/uuid]]]]])
(def organization->team-keys
"Mapping from organization field keys to their corresponding :organization-* team keys."
[[:id :organization-id]
[:name :organization-name]
[:custom-photo :organization-custom-photo]
[:slug :organization-slug]
[:avatar-bg-url :organization-avatar-bg-url]
[:owner-id :organization-owner-id]])
(defn apply-organization
"Updates a team map with organization fields sourced from org.
Associates each org field to the corresponding :organization-* team key when
the value is non-nil; dissociates the key otherwise. This correctly handles
both attaching an org (all values present) and detaching one (org is nil or
all fields absent)."
[team organization]
(let [id (:id organization)]
(reduce (fn [acc [org-k team-k]]
(let [v (get organization org-k)]
(if (and id (some? v))
(assoc acc team-k v)
(dissoc acc team-k))))
team
organization->team-keys)))

View File

@ -34,7 +34,8 @@
[:id ::sm/uuid]
[:axis [::sm/one-of #{:x :y}]]
[:position ::sm/safe-number]
[:frame-id {:optional true} [:maybe ::sm/uuid]]])
[:frame-id {:optional true} [:maybe ::sm/uuid]]
[:color {:optional true} [:maybe ctc/schema:hex-color]]])
(def schema:guides
[:map-of {:gen/max 2} ::sm/uuid schema:guide])

View File

@ -145,7 +145,8 @@
[::sm/one-of stroke-caps]]
[:stroke-color {:optional true} clr/schema:hex-color]
[:stroke-color-gradient {:optional true} clr/schema:gradient]
[:stroke-image {:optional true} clr/schema:image]])
[:stroke-image {:optional true} clr/schema:image]
[:hidden {:optional true} :boolean]])
(def stroke-attrs
"A set of attrs that corresponds to stroke data type"

View File

@ -874,6 +874,42 @@
(duplicate-cells :column index (inc index) ids-map)
(assign-cells objects))))
(defn duplicate-row-at
"Duplicate source row and insert the copy at target-index (0-indexed).
Like `duplicate-row` but inserts at an arbitrary position.
Note: after add-grid-row, if target <= source the source cells shift
by +1, so we must adjust the from-index for duplicate-cells."
[shape objects source-index target-index ids-map]
(let [value (dm/get-in shape [:layout-grid-rows source-index])
;; After inserting at target-index, cells at rows >= (inc target-index)
;; get shifted +1. If target <= source, the source row shifts.
adjusted-source (if (<= target-index source-index)
(inc source-index)
source-index)]
(-> shape
(remove-cell-areas-after :row source-index)
(add-grid-row value target-index)
(duplicate-cells :row adjusted-source target-index ids-map)
(assign-cells objects))))
(defn duplicate-column-at
"Duplicate source column and insert the copy at target-index (0-indexed).
Like `duplicate-column` but inserts at an arbitrary position.
Note: after add-grid-column, if target <= source the source cells shift
by +1, so we must adjust the from-index for duplicate-cells."
[shape objects source-index target-index ids-map]
(let [value (dm/get-in shape [:layout-grid-columns source-index])
;; After inserting at target-index, cells at columns >= (inc target-index)
;; get shifted +1. If target <= source, the source column shifts.
adjusted-source (if (<= target-index source-index)
(inc source-index)
source-index)]
(-> shape
(remove-cell-areas-after :column source-index)
(add-grid-column value target-index)
(duplicate-cells :column adjusted-source target-index ids-map)
(assign-cells objects))))
(defn make-remove-cell
[attr span-attr track-num]
(fn [[_ cell]]

View File

@ -16,8 +16,6 @@
[app.common.types.shape.layout :as ctl]
[app.common.uuid :as uuid]))
;; FIXME: the order of arguments seems arbitrary, container should be a first artgument
(defn add-shape
"Insert a shape in the tree, at the given index below the given parent or frame.
Update the parent as needed."

View File

@ -26,3 +26,4 @@
[:id ::sm/uuid]
[:name :string]])

View File

@ -95,7 +95,9 @@
:text-direction "ltr"})
(def default-text-attrs
{:font-id "sourcesanspro"
{:typography-ref-file nil
:typography-ref-id nil
:font-id "sourcesanspro"
:font-family "sourcesanspro"
:font-variant-id "regular"
:font-size "14"
@ -354,6 +356,32 @@
[k (get attrs k v)]))))
(defn content-has-text?
[content search]
(let [search-lower (str/lower search)]
(->> (node-seq is-text-node? content)
(some #(str/includes? (str/lower (:text %)) search-lower))
(boolean))))
(defn replace-all-case-insensitive
[text search replacement]
(let [text-lower (str/lower text)
search-lower (str/lower search)
search-len (count search)]
(loop [result "" idx 0]
(let [found (str/index-of text-lower search-lower idx)]
(if (nil? found)
(str result (subs text idx))
(recur (str result (subs text idx found) replacement)
(+ found search-len)))))))
(defn replace-text-in-content
[content search replacement]
(transform-nodes
is-text-node?
(fn [node] (update node :text replace-all-case-insensitive search replacement))
content))
(defn content->text
"Given a root node of a text content extracts the texts with its associated styles"
[content]

View File

@ -136,6 +136,9 @@
(def token-name-validation-regex
#"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$")
(def token-node-name-validation-regex
#"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$")
(def schema:token-name
"A token name can contains letters, numbers, underscores the character $ and dots, but
not start with $ or end with a dot. The $ character does not have any special meaning,
@ -153,6 +156,14 @@
:gen/gen sg/text}
token-ref-validation-regex])
(def schema:token-node-name
"A token node name can contains letters, numbers, underscores and the character $, but
not start with $ or a dot, or end with a dot. The $ character does not have any special meaning,
but dots separate token groups (e.g. color.primary.background)."
[:re {:title "TokenNodeName"
:gen/gen sg/text}
token-node-name-validation-regex])
(def schema:token-type
[::sm/one-of {:decode/json (fn [type]
(if (string? type)
@ -521,31 +532,32 @@
(def tokens-by-input
"A map from input name to applicable token for that input."
{:width #{:sizing :dimensions}
:height #{:sizing :dimensions}
:max-width #{:sizing :dimensions}
:max-height #{:sizing :dimensions}
:min-width #{:sizing :dimensions}
:min-height #{:sizing :dimensions}
:x #{:dimensions}
:y #{:dimensions}
:rotation #{:number :rotation}
:border-radius #{:border-radius :dimensions}
:row-gap #{:spacing :dimensions}
:column-gap #{:spacing :dimensions}
:horizontal-padding #{:spacing :dimensions}
:vertical-padding #{:spacing :dimensions}
:sided-paddings #{:spacing :dimensions}
:horizontal-margin #{:spacing :dimensions}
:vertical-margin #{:spacing :dimensions}
:sided-margins #{:spacing :dimensions}
:line-height #{:line-height :number}
:opacity #{:opacity}
:stroke-width #{:stroke-width :dimensions}
:font-size #{:font-size}
:letter-spacing #{:letter-spacing}
:fill #{:color}
:stroke-color #{:color}})
{:width [:sizing :dimensions]
:height [:sizing :dimensions]
:max-width [:sizing :dimensions]
:max-height [:sizing :dimensions]
:min-width [:sizing :dimensions]
:min-height [:sizing :dimensions]
:x [:dimensions]
:y [:dimensions]
:rotation [:rotation :number]
:border-radius [:border-radius :dimensions]
:row-gap [:spacing :dimensions]
:column-gap [:spacing :dimensions]
:horizontal-padding [:spacing :dimensions]
:vertical-padding [:spacing :dimensions]
:sided-paddings [:spacing :dimensions]
:horizontal-margin [:spacing :dimensions]
:vertical-margin [:spacing :dimensions]
:sided-margins [:spacing :dimensions]
:line-height [:line-height :number]
:opacity [:opacity]
:stroke-width [:stroke-width :dimensions]
:font-size [:font-size]
:letter-spacing [:letter-spacing]
:fill [:color]
:stroke-color [:color]
:typography [:typography]})
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS for tokens application

View File

@ -153,6 +153,18 @@
tokens)]
(group-by :type tokens')))
(defn rename-path
"Renames a node or token path segment with a new name.
If token is provided, it renames a token path, otherwise it renames a node path."
([node new-name]
(rename-path node nil new-name))
([node token new-name]
(let [element (if token (:name token) (:path node))
split-path (cpn/split-path element :separator ".")
updated-split-element-name (assoc split-path (:depth node) new-name)
new-element-path (cpn/join-path updated-split-element-name :separator "." :with-spaces? false)]
new-element-path)))
;; === Token Set
(defprotocol ITokenSet
@ -920,6 +932,7 @@ Will return a value that matches this schema:
`:all` All of the nested sets are active
`:partial` Mixed active state of nested sets")
(get-tokens-in-active-sets [_] "set of set names that are active in the the active themes")
(get-tokens-in-active-sets-force [_ force-set-id] "same as above but forcing a set to be active, even if it's not in the active themes")
(get-all-tokens [_] "all tokens in the lib, as a sequence")
(get-all-tokens-map [_] "all tokens in the lib, as a map name -> token")
(get-tokens [_ set-id] "return a map of tokens in the set, indexed by token-name"))
@ -1317,6 +1330,21 @@ Will return a value that matches this schema:
active-set-names)]
tokens))
(get-tokens-in-active-sets-force [this force-set-id]
(let [theme-set-names (get-active-themes-set-names this)
all-set-names (get-set-names this)
force-set (get-set this force-set-id)
active-set-names (cond-> (filter theme-set-names all-set-names)
(some? force-set)
(conj (get-name force-set)))
tokens (reduce (fn [tokens set-name]
(let [set (get-set-by-name this set-name)]
(merge tokens (get-tokens- set))))
(d/ordered-map)
active-set-names)]
tokens))
(get-all-tokens [this]
(mapcat #(vals (get-tokens- %))
(get-sets this)))
@ -1493,6 +1521,30 @@ Will return a value that matches this schema:
(seq)
(boolean)))))
(defn update-tokens-group
"Updates the active tokens path when renaming a group node.
- Filters tokens whose path matches the current path prefix
- Replaces the token name with the new name
- Updates the :path value in the token object
active-tokens: map of token-name to token-object for all active tokens in the set
current-path: the path of the group being renamed, e.g. \"foo.bar\"
current-name: the current name of the group being renamed, e.g. \"bar\"
new-name: the new name for the group being renamed, e.g. \"baz\""
[active-tokens current-path current-name new-name]
(let [path-prefix (str/replace current-path current-name "")]
(mapv (fn [[token-path token-obj]]
(if (str/starts-with? token-path path-prefix)
(let [new-token-path (str/replace token-path current-name new-name)
new-token-obj (-> token-obj
(assoc :name new-token-path)
(cond-> (:path token-obj)
(assoc :path (str/replace (:path token-obj) current-name new-name))))]
[new-token-path new-token-obj])
[token-path token-obj]))
active-tokens)))
;; === Import / Export from JSON format
;; Supported formats:

View File

@ -60,8 +60,9 @@
:cljs (uuid (impl/v4))))
(defn custom
([a] #?(:clj (UUID. 0 a) :cljs (uuid (impl/custom 0 a))))
([b a] #?(:clj (UUID. b a) :cljs (uuid (impl/custom b a)))))
"Generate a uuid using directly the given number (specified as one or two long integers)"
([low] #?(:clj (UUID. 0 low) :cljs (uuid (impl/custom 0 low))))
([high low] #?(:clj (UUID. high low) :cljs (uuid (impl/custom high low)))))
(def zero (uuid "00000000-0000-0000-0000-000000000000"))
@ -137,6 +138,22 @@
(+ (clojure.lang.Murmur3/hashLong a)
(clojure.lang.Murmur3/hashLong b)))))
;; Fake uuids generator
(def ^:private fake-ids (atom 0))
(defn reset-fake!
"Reset the fake uuid counter to 0, for reproducible results across tests."
[]
(reset! fake-ids 0))
(defn next-fake
"When you need predictable uuids, for example when debugging a failing test, wrap the code with
(with-redefs [uuid/next uuid/next-fake]
...tested code...)"
[]
(-> (swap! fake-ids inc)
(custom)))
;; Commented code used for debug
;; #?(:cljs
;; (defn ^:export test-uuid

View File

@ -0,0 +1,151 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns common-tests.attrs-test
(:require
[app.common.attrs :as attrs]
[clojure.test :as t]))
(t/deftest get-attrs-multi-same-value
(t/testing "returns value when all objects have the same attribute value"
(let [objs [{:attr "red"}
{:attr "red"}
{:attr "red"}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {:attr "red"} result))))
(t/testing "returns nil when all objects have nil value"
(let [objs [{:attr nil}
{:attr nil}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {:attr nil} result)))))
(t/deftest get-attrs-multi-different-values
(t/testing "returns :multiple when objects have different concrete values"
(let [objs [{:attr "red"}
{:attr "blue"}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {:attr :multiple} result)))))
(t/deftest get-attrs-multi-missing-key
(t/testing "returns value when one object has the attribute and another doesn't"
(let [objs [{:attr "red"}
{:other "value"}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {:attr "red"} result))))
(t/testing "returns value when one object has UUID and another is missing"
(let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000"
objs [{:attr uuid}
{:other "value"}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {:attr uuid} result))))
(t/testing "returns :multiple when some objects have the key and some don't"
(let [objs [{:attr "red"}
{:other "value"}
{:attr "blue"}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {:attr :multiple} result))))
(t/testing "returns nil when one object has nil and another is missing"
(let [objs [{:attr nil}
{:other "value"}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {:attr nil} result)))))
(t/deftest get-attrs-multi-all-missing
(t/testing "all missing → attribute NOT included in result"
(let [objs [{:other "value"}
{:different "data"}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {} result)
"Attribute should not be in result when all objects are missing")))
(t/testing "all missing with empty maps → attribute NOT included"
(let [objs [{} {}]
result (attrs/get-attrs-multi objs [:attr])]
(t/is (= {} result)
"Attribute should not be in result"))))
(t/deftest get-attrs-multi-multiple-attributes
(t/testing "handles multiple attributes with different merge results"
(let [objs [{:attr1 "red" :attr2 "blue"}
{:attr1 "red" :attr2 "green"}
{:attr1 "red"}] ; :attr2 missing
result (attrs/get-attrs-multi objs [:attr1 :attr2])]
(t/is (= {:attr1 "red" :attr2 :multiple} result))))
(t/testing "handles mixed scenarios: same, different, and missing"
(let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000"
uuid2 #uuid "550e8400-e29b-41d4-a716-446655440001"
objs [{:id :a :ref uuid}
{:id :b :ref uuid2}
{:id :c}] ; :ref missing
result (attrs/get-attrs-multi objs [:id :ref])]
(t/is (= {:id :multiple :ref :multiple} result)))))
(t/deftest get-attrs-multi-typography-ref-id-scenario
(t/testing "the specific bug scenario: typography-ref-id with UUID vs missing"
(let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000"
;; Shape 1 has typography-ref-id with a UUID
shape1 {:id :shape1 :typography-ref-id uuid}
;; Shape 2 does NOT have typography-ref-id at all
shape2 {:id :shape2}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])]
(t/is (= {:typography-ref-id uuid} result))))
(t/testing "both shapes missing → attribute NOT included in result"
(let [shape1 {:id :shape1}
shape2 {:id :shape2}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])]
(t/is (= {} result)
"Expected empty map when all shapes are missing the attribute"))))
(t/deftest get-attrs-multi-bug-missing-vs-present
(t/testing "BUG FIXED: one shape has :typography-ref-id, other does NOT → returns uuid"
(let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000"
shape1 {:id :shape1 :typography-ref-id uuid}
shape2 {:id :shape2}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])]
(t/is (= {:typography-ref-id uuid} result))))
(t/testing "both missing → empty map (attribute not in result)"
(let [shape1 {:id :shape1}
shape2 {:id :shape2}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])]
(t/is (= {} result)
"Expected empty map when all shapes are missing the attribute")))
(t/testing "both equal values → return the value"
(let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000"
shape1 {:id :shape1 :typography-ref-id uuid}
shape2 {:id :shape2 :typography-ref-id uuid}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])]
(t/is (= {:typography-ref-id uuid} result))))
(t/testing "different values → return :multiple"
(let [uuid1 #uuid "550e8400-e29b-41d4-a716-446655440000"
uuid2 #uuid "550e8400-e29b-41d4-a716-446655440001"
shape1 {:id :shape1 :typography-ref-id uuid1}
shape2 {:id :shape2 :typography-ref-id uuid2}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id])]
(t/is (= {:typography-ref-id :multiple} result)))))
(t/deftest get-attrs-multi-default-equal
(t/testing "numbers use close? for equality"
(let [objs [{:value 1.0}
{:value 1.0000001}]
result (attrs/get-attrs-multi objs [:value])]
(t/is (= {:value 1.0} result)
"Numbers within tolerance should be considered equal")))
(t/testing "different floating point positions beyond tolerance are :multiple"
(let [objs [{:x -26}
{:x -153}]
result (attrs/get-attrs-multi objs [:x])]
(t/is (= {:x :multiple} result)
"Different positions should be :multiple"))))

View File

@ -28,6 +28,14 @@
(t/is (not (d/in-range? 5 -1)))
(t/is (not (d/in-range? 0 0))))
(t/deftest get-initials-test
(t/is (= "JD" (d/get-initials "John Doe")))
(t/is (= "A" (d/get-initials "acme")))
(t/is (= "AB" (d/get-initials "123 Alpha ## beta")))
(t/is (= "PD" (d/get-initials " penpot design tool ")))
(t/is (= "" (d/get-initials nil)))
(t/is (= "" (d/get-initials "!!! ???"))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Ordered Data Structures
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -276,6 +284,48 @@
(t/is (= (d/nth-index-of "abc*def*ghi" "*" 2) 7))
(t/is (= (d/nth-index-of "abc*def*ghi" "*" 3) nil)))
(t/deftest natural-sort-by-test
(t/is (= (d/natural-sort-by identity ["10" "2" "1" "11" "3" "30"])
["1" "2" "3" "10" "11" "30"]))
(t/is (= (d/natural-sort-by identity ["banana" "apple" "cherry"])
["apple" "banana" "cherry"]))
(t/is (= (d/natural-sort-by identity ["size10" "size2" "size1" "size20" "size3"])
["size1" "size2" "size3" "size10" "size20"]))
(t/is (= (d/natural-sort-by identity ["b1" "a2" "a10" "a1"])
["a1" "a2" "a10" "b1"]))
(t/is (= (d/natural-sort-by identity []) []))
(t/is (= (d/natural-sort-by identity ["solo"]) ["solo"]))
(t/is (= (d/natural-sort-by identity ["b" "a" "a" "c"])
["a" "a" "b" "c"]))
(t/is (= (d/natural-sort-by :name
[{:name "big"} {:name "small"} {:name "medium"}])
[{:name "big"} {:name "medium"} {:name "small"}]))
(t/is (= (d/natural-sort-by :name
[{:name "size10"} {:name "size2"} {:name "size1"}])
[{:name "size1"} {:name "size2"} {:name "size10"}]))
(t/is (= (d/natural-sort-by :name
[{:name "border-radius-10"}
{:name "border-radius-2"}
{:name "border-radius-1"}])
[{:name "border-radius-1"}
{:name "border-radius-2"}
{:name "border-radius-10"}]))
(t/is (= (d/natural-sort-by :name
[{:name "border-10-radius"}
{:name "border-2-radius"}
{:name "border-1-radius"}])
[{:name "border-1-radius"}
{:name "border-2-radius"}
{:name "border-10-radius"}]))
(t/is (= (d/natural-sort-by :name
[{:name "border-10-radius"}
{:name "border-2-extra"}
{:name "border-2-radius"}
{:name "border-1-radius"}])
[{:name "border-1-radius"}
{:name "border-2-extra"}
{:name "border-2-radius"}
{:name "border-10-radius"}])))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Lazy / sequence helpers
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -0,0 +1,787 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns common-tests.files.comp-processors-test
(:require
[app.common.data :as d]
[app.common.files.comp-processors :as cfcp]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.types.component :as ctk]
[app.common.types.components-list :as ctkl]
[app.common.types.file :as ctf]
[clojure.test :as t]))
(t/deftest test-remove-unneeded-objects-in-components
(t/testing "nil file should return nil"
(let [file nil
file' (ctf/update-file-data file cfcp/remove-unneeded-objects-in-components)]
(t/is (nil? file'))))
(t/testing "empty file should not need any action"
(let [file (thf/sample-file :file1)
file' (ctf/update-file-data file cfcp/remove-unneeded-objects-in-components)]
(t/is (empty? (d/map-diff file file')))))
(t/testing "file without components should not need any action"
(let [file
(-> (thf/sample-file :file1)
(tho/add-frame-with-child :frame1 :shape1))
file' (ctf/update-file-data file cfcp/remove-unneeded-objects-in-components)]
(t/is (empty? (d/map-diff file file')))))
(t/testing "file with non deleted components should not need any action"
(let [file
(-> (thf/sample-file :file1)
(tho/add-simple-component :component1 :frame1 :shape1))
file' (ctf/update-file-data file cfcp/remove-unneeded-objects-in-components)]
(t/is (empty? (d/map-diff file file')))))
(t/testing "file with deleted components should not need any action"
(let [file
(-> (thf/sample-file :file1)
(tho/add-simple-component :component1 :frame1 :shape1)
(tho/delete-shape :frame1))
file' (ctf/update-file-data file cfcp/remove-unneeded-objects-in-components)]
(t/is (empty? (d/map-diff file file')))))
(t/testing "file with non deleted components with :objects nil should remove it"
(let [file
(-> (thf/sample-file :file1)
(tho/add-simple-component :component1 :frame1 :shape1)
(thc/update-component :component1 {:objects nil}))
file' (ctf/update-file-data file cfcp/remove-unneeded-objects-in-components)
diff (d/map-diff file file')
expected-diff {:data
{:components
{(thi/id :component1)
{}}}}]
(t/is (= expected-diff diff))))
(t/testing "file with non deleted components with :objects should remove it"
(let [file
(-> (thf/sample-file :file1)
(tho/add-simple-component :component1 :frame1 :shape1)
(thc/update-component :component1 {:objects {:sample 777}}))
file' (ctf/update-file-data file cfcp/remove-unneeded-objects-in-components)
diff (d/map-diff file file')
expected-diff {:data
{:components
{(thi/id :component1)
{:objects
[{:sample 777} nil]}}}}]
(t/is (= expected-diff diff))))
(t/testing "file with deleted components without :objects should add an empty one"
(let [file
(-> (thf/sample-file :file1)
(tho/add-simple-component :component1 :frame1 :shape1)
(tho/delete-shape :frame1)
(ctf/update-file-data
(fn [file-data]
(ctkl/update-component file-data (thi/id :component1) #(dissoc % :objects)))))
file' (ctf/update-file-data file cfcp/remove-unneeded-objects-in-components)
diff (d/map-diff file file')
expected-diff {:data
{:components
{(thi/id :component1)
{:objects
[nil {}]}}}}]
(t/is (= expected-diff diff)))))
(t/deftest test-fix-missing-swap-slots
(t/testing "nil file should return nil"
(let [file nil
file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {}))]
(t/is (nil? file'))))
(t/testing "empty file should not need any action"
(let [file (thf/sample-file :file1)
file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {}))]
(t/is (empty? (d/map-diff file file')))))
(t/testing "file without components should not need any action"
(let [file
;; :frame1 [:name Frame1]
;; :child1 [:name Rect1]
(-> (thf/sample-file :file1)
(tho/add-frame-with-child :frame1 :shape1))
file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {}))]
(t/is (empty? (d/map-diff file file')))))
(t/testing "file with nested not swapped components should not need any action"
(let [file
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested-head [:name Frame1] @--> [Component :component1] :main1-root
;; <no-label> [:name Rect1] ---> :main1-child
;;
;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root
;; <no-label> [:name Frame1] @--> [Component :component1] :nested-head
;; <no-label> [:name Rect1] ---> <no-label>
(-> (thf/sample-file :file1)
(tho/add-nested-component-with-copy :component1 :main1-root :main1-child
:component2 :main2-root :nested-head
:copy2-root))
file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {}))]
(t/is (empty? (d/map-diff file file')))))
(t/testing "file with a normally swapped copy should not need any action"
(let [file
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested-head [:name Frame1] @--> [Component :component1] :main1-root
;; <no-label> [:name Rect1] ---> :main1-child
;;
;; {:main3-root} [:name Frame3] # [Component :component3]
;; :main3-child [:name Rect3]
;;
;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root
;; :copy2-nested-head [:name Frame3] @--> [Component :component3] :main3-root
;; {swap-slot :nested-head}
;; <no-label> [:name Rect3] ---> :main3-child
(-> (thf/sample-file :file1)
(tho/add-nested-component :component1 :main1-root :main1-child
:component2 :main2-root :nested-head)
(thc/instantiate-component :component2 :copy2-root :children-labels [:copy2-nested-head])
(tho/add-simple-component :component3 :main3-root :main3-child
:root-params {:name "Frame3"}
:child-params {:name "Rect3"})
(tho/swap-component-in-first-child :copy2-root :component3))
file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {}))]
(t/is (empty? (d/map-diff file file')))))
(t/testing "file with a swapped nested copy in a main should not need any action"
(let [file
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main3-root} [:name Frame3] # [Component :component3]
;; :main3-child [:name Rect3]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested-head [:name Frame3] @--> [Component :component3] :main3-root
;; {swap-slot :nested-head}
;; <no-label> [:name Rect3] ---> :main3-child
;;
;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root
;; :copy2-nested-head [:name Frame3] @--> [Component :component3] :nested-head
;; <no-label> [:name Rect3] ---> <no-label>
(-> (thf/sample-file :file1)
(tho/add-nested-component :component1 :main1-root :main1-child
:component2 :main2-root :nested-head)
(thc/instantiate-component :component2 :copy2-root :children-labels [:copy2-nested-head])
(tho/add-simple-component :component3 :main3-root :main3-child
:root-params {:name "Frame3"}
:child-params {:name "Rect3"})
(tho/swap-component-in-shape :nested-head :component3
:propagate-fn #(tho/propagate-component-changes % :component2)))
file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {}))]
(t/is (empty? (d/map-diff file file')))))
(t/testing "file with a swapped copy with broken slot should have it repaired"
(let [file
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested-head [:name Frame1] @--> [Component :component1] :main1-root
;; <no-label> [:name Rect1] ---> :main1-child
;;
;; {:main3-root} [:name Frame3] # [Component :component3]
;; :main3-child [:name Rect3]
;;
;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root
;; :copy2-nested-head [:name Frame3] @--> [Component :component3] :main3-root
;; NO SWAP SLOT
;; <no-label> [:name Rect3] ---> :main3-child
(-> (thf/sample-file :file1)
(tho/add-nested-component :component1 :main1-root :main1-child
:component2 :main2-root :nested-head)
(thc/instantiate-component :component2 :copy2-root :children-labels [:copy2-nested-head])
(tho/add-simple-component :component3 :main3-root :main3-child
:root-params {:name "Frame3"}
:child-params {:name "Rect3"})
(tho/swap-component-in-first-child :copy2-root :component3)
(ths/update-shape :copy2-nested-head :touched nil))
file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {}))
diff (d/map-diff file file')
expected-diff {:data
{:pages-index
{(thf/current-page-id file)
{:objects
{(thi/id :copy2-nested-head)
{:touched
[nil
#{(ctk/build-swap-slot-group (str (thi/id :nested-head)))}]}}}}}}]
(t/is (= expected-diff diff))))
(t/testing "file with a swapped copy inside a main with broken slot has no effect since it cannot be distinguished"
(let [file
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main3-root} [:name Frame3] # [Component :component3]
;; :main3-child [:name Rect3]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested-head [:name Frame3] @--> [Component :component3] :main3-root
;; NO SWAP SLOT
;; <no-label> [:name Rect3] ---> :main3-child
;;
;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root
;; :copy2-nested-head [:name Frame3] @--> [Component :component3] :nested-head
;; <no-label> [:name Rect3] ---> <no-label>
(-> (thf/sample-file :file1)
(tho/add-nested-component :component1 :main1-root :main1-child
:component2 :main2-root :nested-head)
(thc/instantiate-component :component2 :copy2-root :children-labels [:copy2-nested-head])
(tho/add-simple-component :component3 :main3-root :main3-child
:root-params {:name "Frame3"}
:child-params {:name "Rect3"})
(tho/swap-component-in-shape :nested-head :component3
:propagate-fn #(tho/propagate-component-changes % :component2))
(ths/update-shape :nested-head :touched nil))
file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {}))]
(t/is (empty? (d/map-diff file file')))))
(t/testing "file with a two levels nested copy in a main swapped with broken slot should have it repaired"
(let [file
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested-head1 [:name Frame1] @--> [Component :component1] :main1-root
;; <no-label> [:name Rect1] ---> :main1-child
;;
;; {:main4-root} [:name Frame4] # [Component :component4]
;; :main4-child [:name Rect4]
;;
;; {:main3-root} [:name Frame3] # [Component :component3]
;; :nested-head2 [:name Frame2] @--> [Component :component2] :main2-root
;; :nested-subhead2 [:name Frame4] @--> [Component :component4] :main4-root
;; NO SWAP SLOT
;; <no-label> [:name Rect4] ---> :main4-child
;;
;; :copy2-root [:name Frame3] #--> [Component :component3] :main3-root
;; <no-label> [:name Frame2] @--> [Component :component2] :nested-head2
;; <no-label> [:name Frame4] @--> [Component :component4] :nested-subhead2
;; <no-label> [:name Rect4] ---> <no-label>
(-> (thf/sample-file :file1)
(tho/add-two-levels-nested-component-with-copy :component1 :main1-root :main1-child
:component2 :main2-root :nested-head1
:component3 :main3-root :nested-head2 :nested-subhead2
:copy2-root)
(tho/add-simple-component :component4 :main4-root :main4-child
:root-params {:name "Frame4"}
:child-params {:name "Rect4"})
(tho/swap-component-in-shape :nested-subhead2 :component4
:propagate-fn #(tho/propagate-component-changes % :component3))
(ths/update-shape :nested-subhead2 :touched nil))
file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {}))
diff (d/map-diff file file')
expected-diff {:data
{:pages-index
{(thf/current-page-id file)
{:objects
{(thi/id :nested-subhead2)
{:touched
[nil
#{(ctk/build-swap-slot-group (str (thi/id :nested-head1)))}]}}}}}}]
(t/is (= expected-diff diff))))
(t/testing "when components are in external libraries, the fix still works well"
(let [library1
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested2-head [:name Frame1] @--> [Component :component1] :main1-root
;; :nested2-child [:name Rect1] ---> :main1-child
(-> (thf/sample-file :library1)
(tho/add-nested-component :component1 :main1-root :main1-child
:component2 :main2-root :nested2-head
:nested-head-params {:children-labels [:nested2-child]}))
library2
;; {:main3-root} [:name Frame3] # [Component :component3]
;; :main3-child [:name Rect3]
;;
;; {:main4-root} [:name Frame4] # [Component :component4]
;; :nested4-head [:name Frame3] @--> [Component :component1] :main3-root
;; :nested4-child [:name Rect3] ---> :main3-child
(-> (thf/sample-file :library2)
(tho/add-nested-component :component3 :main3-root :main3-child
:component4 :main4-root :nested4-head
:root1-params {:name "Frame3"}
:main1-child-params {:name "Rect3"}
:main2-root-params {:name "Frame4"}
:nested-head-params {:children-labels [:nested4-child]}))
file
;; :copy2 [:name Frame2] #--> [Component :component2] :main2-root
;; :copy2-nested-head [:name Frame4] @--> [Component :component4] :main4-root
;; NO SWAP SLOT
;; <no-label> [:name Frame3] @--> :nested4-head
;; <no-label> [:name Rect3] ---> :nested4-child
(-> (thf/sample-file :file1)
(thc/instantiate-component :component2 :copy2 :children-labels [:copy2-nested-head]
:library library1)
(tho/swap-component-in-first-child :copy2 :component4 :library library2)
(ths/update-shape :copy2-nested-head :touched nil))
libraries {(:id library1) library1
(:id library2) library2}
file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % libraries))
diff (d/map-diff file file')
expected-diff {:data
{:pages-index
{(thf/current-page-id file)
{:objects
{(thi/id :copy2-nested-head)
{:touched
[nil
#{(ctk/build-swap-slot-group (str (thi/id :nested2-head)))}]}}}}}}]
(t/is (= expected-diff diff)))))
(t/deftest test-sync-component-id-with-ref-shape
(t/testing "nil file should return nil"
(let [file nil
file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {}))]
(t/is (nil? file'))))
(t/testing "empty file should not need any action"
(let [file (thf/sample-file :file1)
file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {}))]
(t/is (empty? (d/map-diff file file')))))
(t/testing "file without components should not need any action"
(let [file
;; :frame1 [:name Frame1]
;; :child1 [:name Rect1]
(-> (thf/sample-file :file1)
(tho/add-frame-with-child :frame1 :shape1))
file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {}))]
(t/is (empty? (d/map-diff file file')))))
(t/testing "file with valid normal components should not need any action"
(let [file
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested-head1 [:name Frame1] @--> [Component :component1] :main1-root
;; <no-label> [:name Rect1] ---> :main1-child
;;
;; {:main3-root} [:name Frame3] # [Component :component3]
;; :nested-head2 [:name Frame2] @--> [Component :component2] :main2-root
;; :nested-subhead2 [:name Frame1] @--> [Component :component1] :nested-head1
;; <no-label> [:name Rect1] ---> <no-label>
;;
;; :copy2-root [:name Frame3] #--> [Component :component3] :main3-root
;; <no-label> [:name Frame2] @--> [Component :component2] :nested-head2
;; <no-label> [:name Frame1] @--> [Component :component1] :nested-subhead2
;; <no-label> [:name Rect1] ---> <no-label>
(-> (thf/sample-file :file1)
(tho/add-two-levels-nested-component-with-copy :component1 :main1-root :main1-child
:component2 :main2-root :nested-head1
:component3 :main3-root :nested-head2 :nested-subhead2
:copy2-root))
file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {}))]
#_(thf/dump-file file') ;; Uncomment to debug
(t/is (empty? (d/map-diff file file')))))
(t/testing "file with valid swapped components should not need any action"
(let [file
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested-head [:name Frame1] @--> [Component :component1] :main1-root
;; <no-label> [:name Rect1] ---> :main1-child
;;
;; {:main3-root} [:name Frame3] # [Component :component3]
;; :main3-child [:name Rect3]
;;
;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root
;; <no-label> [:name Frame1] @--> [Component :component1] :nested-head
;; <no-label> [:name Rect1] ---> <no-label>
;;
;; :copy3-root [:name Frame2] #--> [Component :component2] :main2-root
;; :copy3-nested-head [:name Frame3] @--> [Component :component3] :main3-root
;; {swap-slot :nested-head}
;; <no-label> [:name Rect3] ---> :main3-child
(-> (thf/sample-file :file1)
(tho/add-nested-component-with-copy :component1 :main1-root :main1-child
:component2 :main2-root :nested-head
:copy2-root)
(tho/add-simple-component :component3 :main3-root :main3-child
:root-params {:name "Frame3"}
:child-params {:name "Rect3"})
(thc/instantiate-component :component2 :copy3-root :children-labels [:copy3-nested-head])
(tho/swap-component-in-first-child :copy3-root :component3))
file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {}))]
#_(thf/dump-file file') ;; Uncomment to debug
(t/is (empty? (d/map-diff file file')))))
(t/testing "file with a non swapped copy with broken component id/file should have it repaired"
(let [file
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested-head [:name Frame1] @--> [Component :component1] :main1-root
;; <no-label> [:name Rect1] ---> :main1-child
;;
;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root
;; :copy2-nested-head [:name Frame1] @--> [Component <bad>] :nested-head ## <- BAD component-id
;; <no-label> [:name Rect1] ---> <no-label>
;;
;; :copy3-root [:name Frame2] #--> [Component :component2] :main2-root
;; :copy3-nested-head [:name Frame1] @--> [Component <bad>] :nested-head ## <- BAD component-file
;; <no-label> [:name Rect1] ---> <no-label>
(-> (thf/sample-file :file1)
(tho/add-nested-component :component1 :main1-root :main1-child
:component2 :main2-root :nested-head)
(thc/instantiate-component :component2 :copy2-root :children-labels [:copy2-nested-head])
(thc/instantiate-component :component2 :copy3-root :children-labels [:copy3-nested-head])
(ths/update-shape :copy2-nested-head :component-id (thi/new-id! :some-other-id))
(ths/update-shape :copy3-nested-head :component-file (thi/new-id! :some-other-file)))
file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {}))
diff (d/map-diff file file')
expected-diff {:data
{:pages-index
{(thf/current-page-id file)
{:objects
{(thi/id :copy2-nested-head)
{:component-id
[(thi/id :some-other-id) (thi/id :component1)]}
(thi/id :copy3-nested-head)
{:component-file
[(thi/id :some-other-file) (thi/id :file1)]}}}}}}]
#_(ctf/dump-tree file' (thf/current-page-id file') {(:id file') file'} {:show-ids true}) ;; Uncomment to debug
(t/is (= expected-diff diff))))
(t/testing "file with a copy of a swapped main with broken component id/file should have it repaired"
(let [file
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main3-root} [:name Frame3] # [Component :component3]
;; :main3-child [:name Rect3]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested-head [:name Frame3] @--> [Component :component3] :main3-root
;; {swap-slot :nested-head}
;; <no-label> [:name Rect3] ---> :main3-child
;;
;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root
;; :copy2-nested-head [:name Frame3] @--> [Component: <bad>] :nested-head ## <- BAD component-id/file
;; <no-label> [:name Rect3] ---> <no-label>
(-> (thf/sample-file :file1)
(tho/add-nested-component :component1 :main1-root :main1-child
:component2 :main2-root :nested-head)
(thc/instantiate-component :component2 :copy2-root :children-labels [:copy2-nested-head])
(tho/add-simple-component :component3 :main3-root :main3-child
:root-params {:name "Frame3"}
:child-params {:name "Rect3"})
(tho/swap-component-in-shape :nested-head :component3
:propagate-fn #(tho/propagate-component-changes % :component2))
(ths/update-shape :copy2-nested-head :component-id (thi/new-id! :some-other-id))
(ths/update-shape :copy2-nested-head :component-file (thi/new-id! :some-other-file)))
file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {}))
diff (d/map-diff file file')
expected-diff {:data
{:pages-index
{(thf/current-page-id file)
{:objects
{(thi/id :copy2-nested-head)
{:component-id
[(thi/id :some-other-id) (thi/id :component3)]
:component-file
[(thi/id :some-other-file) (thi/id :file1)]}}}}}}]
#_(ctf/dump-tree file' (thf/current-page-id file') {(:id file') file'} {:show-ids true}) ;; Uncomment to debug
(t/is (= expected-diff diff))))
(t/testing "file with multiple copies of same component should sync all"
(let [file
(-> (thf/sample-file :file1)
(tho/add-simple-component :component1 :frame1 :shape1)
(thc/instantiate-component :component1 :copy1-root :children-labels [:copy1-child])
(thc/instantiate-component :component1 :copy2-root :children-labels [:copy2-child])
(ths/update-shape :copy1-child :component-id (thi/new-id! :wrong-id1))
(ths/update-shape :copy2-child :component-id (thi/new-id! :wrong-id2)))
file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {}))
diff (d/map-diff file file')]
;; Both copies should be corrected
(t/is (contains? diff :data))
(t/is (contains? (get-in diff [:data :pages-index]) (thf/current-page-id file)))))
(t/testing "file with a copy root with broken component id/file cannot be repaired. But it's propagated to copies."
(let [file
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested-head [:name Frame1] @--> [Component <bad>] :main1-root ## <- BAD component-id/file
;; <no-label> [:name Rect1] ---> :main1-child
;;
;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root
;; :copy2-nested-head [:name Frame1] @--> [Component :component1] :nested-head
;; <no-label> [:name Rect1] ---> <no-label>
(-> (thf/sample-file :file1)
(tho/add-nested-component :component1 :main1-root :main1-child
:component2 :main2-root :nested-head)
(thc/instantiate-component :component2 :copy2-root :children-labels [:copy2-nested-head])
(ths/update-shape :nested-head :component-id (thi/new-id! :some-other-id))
(ths/update-shape :nested-head :component-file (thi/new-id! :some-other-file)))
file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {}))
diff (d/map-diff file file')
expected-diff {:data
{:pages-index
{(thf/current-page-id file)
{:objects
{(thi/id :copy2-nested-head)
{:component-id
[(thi/id :component1) (thi/id :some-other-id)]
:component-file
[(thi/id :file1) (thi/id :some-other-file)]}}}}}}]
(t/is (= expected-diff diff))))
(t/testing "file with a 2nd nested copy inside a main with broken component/id should have it repaired, and propagated to copies"
(let [file
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested-head1 [:name Frame1] @--> [Component :component1] :main1-root
;; <no-label> [:name Rect1] ---> :main1-child
;;
;; {:main3-root} [:name Frame3] # [Component :component3]
;; :nested-head2 [:name Frame2] @--> [Component :component2] :main2-root
;; :nested-subhead2 [:name Frame1] @--> [Component <bad>] :nested-head1 ## <- BAD component-id/file
;; <no-label> [:name Rect1] ---> <no-label>
;;
;; :copy2-root [:name Frame3] #--> [Component :component3] :main3-root
;; <no-label> [:name Frame2] @--> [Component :component2] :nested-head2
;; <no-label> [:name Frame1] @--> [Component :component1] :nested-subhead2
;; <no-label> [:name Rect1] ---> <no-label>
(-> (thf/sample-file :file1)
(tho/add-two-levels-nested-component-with-copy :component1 :main1-root :main1-child
:component2 :main2-root :nested-head1
:component3 :main3-root :nested-head2 :nested-subhead2
:copy2-root)
(ths/update-shape :nested-subhead2 :component-id (thi/new-id! :some-other-id))
(ths/update-shape :nested-subhead2 :component-file (thi/new-id! :some-other-file)))
copy2-root (ths/get-shape file :copy2-root)
copy2-root-child1 (ths/get-shape-by-id file (first (:shapes copy2-root)))
copy2-root-child2 (ths/get-shape-by-id file (first (:shapes copy2-root-child1)))
file (-> file
(ths/update-shape-by-id (:id copy2-root-child2) :component-id (thi/id :some-other-id))
(ths/update-shape-by-id (:id copy2-root-child2) :component-file (thi/id :some-other-file)))
file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {}))
diff (d/map-diff file file')
expected-diff {:data
{:pages-index
{(thf/current-page-id file)
{:objects
{(thi/id :nested-subhead2)
{:component-id
[(thi/id :some-other-id) (thi/id :component1)]
:component-file
[(thi/id :some-other-file) (thi/id :file1)]}
(:id copy2-root-child2)
{:component-id
[(thi/id :some-other-id) (thi/id :component1)]
:component-file
[(thi/id :some-other-file) (thi/id :file1)]}}}}}}]
#_(ctf/dump-tree file' (thf/current-page-id file') {(:id file') file'} {:show-ids true}) ;; Uncomment to debug
(t/is (= expected-diff diff))))
(t/testing "when components are in external libraries, the fix still works well"
(let [library1
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested2-head [:name Frame4] @--> [Component :component4] :main4-root
;; {swap-slot :nested2-head}
;; :nested4-head [:name Frame3] @--> [Component: component3] :main3-root
;; :nested4-child [:name Rect3] ---> :nested4-child
(-> (thf/sample-file :library1)
(tho/add-nested-component :component1 :main1-root :main1-child
:component2 :main2-root :nested2-head
:nested-head-params {:children-labels [:nested2-child]}))
library2
;; {:main3-root} [:name Frame3] # [Component :component3]
;; :main3-child [:name Rect3]
;;
;; {:main4-root} [:name Frame4] # [Component :component4]
;; :nested4-head [:name Frame3] @--> [Component :component1] :main3-root
;; :nested4-child [:name Rect3] ---> :main3-child
(-> (thf/sample-file :library2)
(tho/add-nested-component :component3 :main3-root :main3-child
:component4 :main4-root :nested4-head
:root1-params {:name "Frame3"}
:main1-child-params {:name "Rect3"}
:main2-root-params {:name "Frame4"}
:nested-head-params {:children-labels [:nested4-child]}))
library1
(tho/swap-component-in-shape library1 :nested2-head :component4 :library library2)
file
;; :copy2 [:name Frame2] #--> [Component :component2] :main2-root
;; :copy2-nested-head [:name Frame4] @--> [Component <bad>] :main4-root ## <- BAD component-id/file
;; <no-label> [:name Frame3] @--> :nested4-head
;; <no-label> [:name Rect3] ---> :nested4-child
(-> (thf/sample-file :file1)
(thc/instantiate-component :component2 :copy2 :children-labels [:copy2-nested-head]
:library library1)
(ths/update-shape :copy2-nested-head :component-id (thi/new-id! :some-other-id))
(ths/update-shape :copy2-nested-head :component-file (thi/new-id! :some-other-file)))
libraries {(:id library1) library1
(:id library2) library2}
file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % libraries))
diff (d/map-diff file file')
expected-diff {:data
{:pages-index
{(thf/current-page-id file)
{:objects
{(thi/id :copy2-nested-head)
{:component-id
[(thi/id :some-other-id) (thi/id :component4)]
:component-file
[(thi/id :some-other-file) (thi/id :library2)]}}}}}}]
#_(thf/dump-file library2) ;; Uncomment to debug
(t/is (= expected-diff diff))))
(t/testing "file with several broken ids should propagate to all copies"
(let [file
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested-head1 [:name Frame1] @--> [Component :component1] :main1-root
;; <no-label> [:name Rect1] ---> :main1-child
;;
;; {:main3-root} [:name Frame3] # [Component :component3]
;; :nested-head2 [:name Frame2] @--> [Component <bad>] :main2-root ## <- BAD component-id
;; :nested-subhead2 [:name Frame1] @--> [Component <bad>] :nested-head1 ## <- BAD component-id
;; <no-label> [:name Rect1] ---> <no-label>
;;
;; :copy2-root [:name Frame3] #--> [Component :component3] :main3-root
;; <no-label> [:name Frame2] @--> [Component :component2] :nested-head2
;; <no-label> [:name Frame1] @--> [Component :component1] :nested-subhead2
;; <no-label> [:name Rect1] ---> <no-label>
(-> (thf/sample-file :file1)
(tho/add-two-levels-nested-component-with-copy :component1 :main1-root :main1-child
:component2 :main2-root :nested-head1
:component3 :main3-root :nested-head2 :nested-subhead2
:copy2-root)
;; Corrupt both levels
(ths/update-shape :nested-head2 :component-id (thi/new-id! :wrong-comp2))
(ths/update-shape :nested-subhead2 :component-id (thi/new-id! :wrong-comp3)))
file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {}))
copy2-root (ths/get-shape file' :copy2-root)
copy2-root-child1 (ths/get-shape-by-id file' (first (:shapes copy2-root)))
copy2-root-child2 (ths/get-shape-by-id file' (first (:shapes copy2-root-child1)))
diff (d/map-diff file file')
expected-diff {:data
{:pages-index
{(thf/current-page-id file)
{:objects
{(:id copy2-root-child1)
{:component-id [(thi/id :component2) (thi/id :wrong-comp2)]}
(:id copy2-root-child2)
{:component-id [(thi/id :component1) (thi/id :wrong-comp3)]}}}}}}]
(thf/dump-file file') ;; Uncomment to debug
(t/is (= expected-diff diff)))))

View File

@ -465,9 +465,10 @@
page
{(:id file) file}
(thi/id :nested-h-ellipse))
file' (-> (thf/apply-changes file changes)
file' (-> (thf/apply-changes file changes :validate? false)
(tho/propagate-component-changes :c-board-with-ellipse)
(tho/propagate-component-changes :c-big-board))
(tho/propagate-component-changes :c-big-board)
(thf/validate-file!))
;; ==== Get
nested2-h-ellipse (ths/get-shape file' :nested-h-ellipse)

View File

@ -349,4 +349,73 @@
(t/is (= (:fill-color fill') "#FFFFFF"))
(t/is (= (:fill-opacity fill') 1))
(t/is (= (:touched copy2-root') nil))
(t/is (= (:touched copy2-child') nil))))
(t/is (= (:touched copy2-child') nil))))
(t/deftest test-reset-with-propagation-updates-copies
;; When a nested copy inside a main component has an override and we
;; reset it passing a propagate-fn, the reset must be propagated to
;; all copies of that component so they reflect the canonical color.
(let [;; ==== Setup
file
(-> (thf/sample-file :file1)
;; component1: main1-root / main1-child (fill "#aabbcc")
;; component2: main2-root contains nested-head (instance of component1)
;; copy2-root: copy of component2
(tho/add-nested-component-with-copy
:component1 :main1-root :main1-child
:component2 :main2-root :nested-head
:copy2-root
:main1-child-params {:fills (ths/sample-fills-color :fill-color "#aabbcc")}
:copy2-root-params {:children-labels [:copy2-nested-head]}))
propagate-fn (fn [f]
(-> f
(tho/propagate-component-changes :component1)
(tho/propagate-component-changes :component2)))
;; ==== Action override the nested-head color, then reset it with propagation
file'
(-> file
(tho/update-bottom-color :nested-head "#fabada" :propagate-fn propagate-fn)
(tho/reset-overrides (ths/get-shape file :nested-head) :propagate-fn propagate-fn))
;; ==== Get
copy2-bottom-color (tho/bottom-fill-color file' :copy2-root)]
;; ==== Check
;; After reset + propagation the copy should mirror the canonical color
(t/is (= copy2-bottom-color "#aabbcc"))))
(t/deftest test-reset-without-propagation-does-not-update-copies
;; This is the regression test for the misplaced-parenthesis bug: when
;; propagate-fn is NOT passed to reset-overrides the copies of the component
;; must still hold the overridden value because the component sync never ran.
(let [;; ==== Setup
file
(-> (thf/sample-file :file1)
(tho/add-nested-component-with-copy
:component1 :main1-root :main1-child
:component2 :main2-root :nested-head
:copy2-root
:main1-child-params {:fills (ths/sample-fills-color :fill-color "#aabbcc")}
:copy2-root-params {:children-labels [:copy2-nested-head]}))
propagate-fn (fn [f]
(-> f
(tho/propagate-component-changes :component1)
(tho/propagate-component-changes :component2)))
;; ==== Action override the nested-head color, then reset WITHOUT propagation
file'
(-> file
(tho/update-bottom-color :nested-head "#fabada" :propagate-fn propagate-fn)
;; Reset without propagate-fn: the component definition is updated but
;; the change is never pushed to the copy.
(tho/reset-overrides (ths/get-shape file :nested-head)))
;; ==== Get
copy2-bottom-color (tho/bottom-fill-color file' :copy2-root)]
;; ==== Check
;; Without propagation the copy still reflects the overridden color
(t/is (= copy2-bottom-color "#fabada"))))

View File

@ -64,9 +64,8 @@
(reset-all-overrides [file]
(-> file
(tho/reset-overrides-in-first-child :frame-board-1 :page-label :page-1)
(tho/reset-overrides-in-first-child :copy-board-1 :page-label :page-2)
(propagate-all-component-changes)))
(tho/reset-overrides-in-first-child :frame-board-1 :page-label :page-1 :propagate-fn propagate-all-component-changes)
(tho/reset-overrides-in-first-child :copy-board-1 :page-label :page-2 :propagate-fn propagate-all-component-changes)))
(fill-colors [file]
[(tho/bottom-fill-color file :frame-ellipse-1 :page-label :page-1)

View File

@ -6,20 +6,11 @@
(ns common-tests.logic.multiple-nesting-levels-test
(:require
[app.common.files.changes :as ch]
[app.common.files.changes-builder :as pcb]
[app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls]
[app.common.pprint :as pp]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
@ -56,10 +47,9 @@
(reset-all-overrides [file]
(-> file
(tho/reset-overrides (ths/get-shape file :copy-simple-1))
(tho/reset-overrides (ths/get-shape file :copy-frame-composed-1))
(tho/reset-overrides (ths/get-shape file :composed-1-composed-2-copy))
(propagate-all-component-changes)))
(tho/reset-overrides (ths/get-shape file :copy-simple-1) :propagate-fn propagate-all-component-changes)
(tho/reset-overrides (ths/get-shape file :copy-frame-composed-1) :propagate-fn propagate-all-component-changes)
(tho/reset-overrides (ths/get-shape file :composed-1-composed-2-copy) :propagate-fn propagate-all-component-changes)))
(fill-colors [file]
[(tho/bottom-fill-color file :frame-simple-1)

View File

@ -6,20 +6,12 @@
(ns common-tests.logic.swap-as-override-test
(:require
[app.common.files.changes :as ch]
[app.common.files.changes-builder :as pcb]
[app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls]
[app.common.pprint :as pp]
[app.common.data :as d]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
@ -27,23 +19,40 @@
(defn- setup []
(-> (thf/sample-file :file1)
(tho/add-simple-component :component-1 :frame-component-1 :child-component-1 :child-params {:name "child-component-1" :type :rect :fills (ths/sample-fills-color :fill-color "#111111")})
(tho/add-simple-component :component-2 :frame-component-2 :child-component-2 :child-params {:name "child-component-2" :type :rect :fills (ths/sample-fills-color :fill-color "#222222")})
(tho/add-simple-component :component-3 :frame-component-3 :child-component-3 :child-params {:name "child-component-3" :type :rect :fills (ths/sample-fills-color :fill-color "#333333")})
(tho/add-simple-component :component-1 :frame-component-1 :child-component-1
:root-params {:name "component-1"}
:child-params {:name "child-component-1"
:type :rect
:fills (ths/sample-fills-color :fill-color "#111111")})
(tho/add-simple-component :component-2 :frame-component-2 :child-component-2
:root-params {:name "component-2"}
:child-params {:name "child-component-2"
:type :rect
:fills (ths/sample-fills-color :fill-color "#222222")})
(tho/add-simple-component :component-3 :frame-component-3 :child-component-3
:root-params {:name "component-3"}
:child-params {:name "child-component-3"
:type :rect
:fills (ths/sample-fills-color :fill-color "#333333")})
(tho/add-frame :frame-icon-and-text)
(thc/instantiate-component :component-1 :copy-component-1 :parent-label :frame-icon-and-text :children-labels [:component-1-icon-and-text])
(tho/add-frame :frame-icon-and-text :name "copy-component-1")
(thc/instantiate-component :component-1 :copy-component-1
:parent-label :frame-icon-and-text
:children-labels [:component-1-icon-and-text])
(ths/add-sample-shape :text
{:type :text
:name "icon+text"
:parent-label :frame-icon-and-text})
(thc/make-component :icon-and-text :frame-icon-and-text)
(tho/add-frame :frame-panel)
(thc/instantiate-component :icon-and-text :copy-icon-and-text :parent-label :frame-panel :children-labels [:icon-and-text-panel])
(tho/add-frame :frame-panel :name "icon-and-text")
(thc/instantiate-component :icon-and-text :copy-icon-and-text
:parent-label :frame-panel
:children-labels [:icon-and-text-panel])
(thc/make-component :panel :frame-panel)
(thc/instantiate-component :panel :copy-panel :children-labels [:copy-icon-and-text-panel])))
(thc/instantiate-component :panel :copy-panel
:children-labels [:copy-icon-and-text-panel])))
(defn- propagate-all-component-changes [file]
(-> file

View File

@ -30,7 +30,7 @@
copy (ths/get-shape file :copy01)
;; ==== Action
file' (tho/swap-component file copy :circle {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :circle {:new-shape-label :copy02 :keep-touched? true})
copy' (ths/get-shape file' :copy02)]
;; Both copies have the same id

View File

@ -35,7 +35,7 @@
copy01 (ths/get-shape file :copy01)
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
copy01' (ths/get-shape file' :copy02)]
(thf/dump-file file :keys [:width])
@ -61,7 +61,7 @@
rect01 (get-in page [:objects (-> copy01 :shapes first)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -100,7 +100,7 @@
copy01 (ths/get-shape file :copy01)
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
copy01' (ths/get-shape file' :copy02)]
(thf/dump-file file :keys [:width])
@ -137,7 +137,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -180,7 +180,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -257,25 +257,19 @@
;; The copy clean has no overrides
copy-clean (ths/get-shape file :copy-clean)
copy-clean-t (ths/get-shape file :copy-clean-t)
;; Override font size on copy-font-size
file (update-attr file :copy-font-size-t font-size-path-0 "25")
copy-font-size (ths/get-shape file :copy-font-size)
copy-font-size-t (ths/get-shape file :copy-font-size-t)
;; Override text on copy-text
file (update-attr file :copy-text-t text-path-0 "text overriden")
copy-text (ths/get-shape file :copy-text)
copy-text-t (ths/get-shape file :copy-text-t)
;; Override both on copy-both
file (update-attr file :copy-both-t font-size-path-0 "25")
file (update-attr file :copy-both-t text-path-0 "text overriden")
copy-both (ths/get-shape file :copy-both)
copy-both-t (ths/get-shape file :copy-both-t)
@ -283,10 +277,10 @@
file' (-> file
(tho/swap-component copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true})
(tho/swap-component copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true})
(tho/swap-component copy-text :c02 {:new-shape-label :copy-text-2 :keep-touched? true})
(tho/swap-component copy-both :c02 {:new-shape-label :copy-both-2 :keep-touched? true}))
(tho/swap-component-in-shape :copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-text :c02 {:new-shape-label :copy-text-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-both :c02 {:new-shape-label :copy-both-2 :keep-touched? true}))
page' (thf/current-page file')
copy-clean' (ths/get-shape file' :copy-clean-2)
copy-clean-t' (get-in page' [:objects (-> copy-clean' :shapes first)])
@ -387,25 +381,19 @@
;; The copy clean has no overrides
copy-clean (ths/get-shape file :copy-clean)
copy-clean-t (ths/get-shape file :copy-clean-t)
;; Override font size on copy-font-size
file (update-attr file :copy-font-size-t font-size-path-0 "25")
copy-font-size (ths/get-shape file :copy-font-size)
copy-font-size-t (ths/get-shape file :copy-font-size-t)
;; Override text on copy-text
file (update-attr file :copy-text-t text-path-0 "text overriden")
copy-text (ths/get-shape file :copy-text)
copy-text-t (ths/get-shape file :copy-text-t)
;; Override both on copy-both
file (update-attr file :copy-both-t font-size-path-0 "25")
file (update-attr file :copy-both-t text-path-0 "text overriden")
copy-both (ths/get-shape file :copy-both)
copy-both-t (ths/get-shape file :copy-both-t)
@ -413,10 +401,10 @@
file' (-> file
(tho/swap-component copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true})
(tho/swap-component copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true})
(tho/swap-component copy-text :c02 {:new-shape-label :copy-text-2 :keep-touched? true})
(tho/swap-component copy-both :c02 {:new-shape-label :copy-both-2 :keep-touched? true}))
(tho/swap-component-in-shape :copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-text :c02 {:new-shape-label :copy-text-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-both :c02 {:new-shape-label :copy-both-2 :keep-touched? true}))
page' (thf/current-page file')
copy-clean' (ths/get-shape file' :copy-clean-2)
copy-clean-t' (get-in page' [:objects (-> copy-clean' :shapes first)])
@ -515,25 +503,19 @@
;; The copy clean has no overrides
copy-clean (ths/get-shape file :copy-clean)
copy-clean-t (ths/get-shape file :copy-clean-t)
;; Override font size on copy-font-size
file (update-attr file :copy-font-size-t font-size-path-0 "25")
copy-font-size (ths/get-shape file :copy-font-size)
copy-font-size-t (ths/get-shape file :copy-font-size-t)
;; Override text on copy-text
file (update-attr file :copy-text-t text-path-0 "text overriden")
copy-text (ths/get-shape file :copy-text)
copy-text-t (ths/get-shape file :copy-text-t)
;; Override both on copy-both
file (update-attr file :copy-both-t font-size-path-0 "25")
file (update-attr file :copy-both-t text-path-0 "text overriden")
copy-both (ths/get-shape file :copy-both)
copy-both-t (ths/get-shape file :copy-both-t)
@ -541,10 +523,10 @@
file' (-> file
(tho/swap-component copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true})
(tho/swap-component copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true})
(tho/swap-component copy-text :c02 {:new-shape-label :copy-text-2 :keep-touched? true})
(tho/swap-component copy-both :c02 {:new-shape-label :copy-both-2 :keep-touched? true}))
(tho/swap-component-in-shape :copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-text :c02 {:new-shape-label :copy-text-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-both :c02 {:new-shape-label :copy-both-2 :keep-touched? true}))
page' (thf/current-page file')
copy-clean' (ths/get-shape file' :copy-clean-2)
copy-clean-t' (get-in page' [:objects (-> copy-clean' :shapes first)])
@ -645,25 +627,19 @@
;; The copy clean has no overrides
copy-clean (ths/get-shape file :copy-clean)
copy-clean-t (ths/get-shape file :copy-clean-t)
;; Override font size on copy-font-size
file (update-attr file :copy-font-size-t font-size-path-0 "25")
copy-font-size (ths/get-shape file :copy-font-size)
copy-font-size-t (ths/get-shape file :copy-font-size-t)
;; Override text on copy-text
file (update-attr file :copy-text-t text-path-0 "text overriden")
copy-text (ths/get-shape file :copy-text)
copy-text-t (ths/get-shape file :copy-text-t)
;; Override both on copy-both
file (update-attr file :copy-both-t font-size-path-0 "25")
file (update-attr file :copy-both-t text-path-0 "text overriden")
copy-both (ths/get-shape file :copy-both)
copy-both-t (ths/get-shape file :copy-both-t)
@ -671,10 +647,10 @@
file' (-> file
(tho/swap-component copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true})
(tho/swap-component copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true})
(tho/swap-component copy-text :c02 {:new-shape-label :copy-text-2 :keep-touched? true})
(tho/swap-component copy-both :c02 {:new-shape-label :copy-both-2 :keep-touched? true}))
(tho/swap-component-in-shape :copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-text :c02 {:new-shape-label :copy-text-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-both :c02 {:new-shape-label :copy-both-2 :keep-touched? true}))
page' (thf/current-page file')
copy-clean' (ths/get-shape file' :copy-clean-2)
copy-clean-t' (get-in page' [:objects (-> copy-clean' :shapes first)])
@ -774,14 +750,12 @@
file (change-structure file :copy-structure-clean-t)
copy-structure-clean (ths/get-shape file :copy-structure-clean)
copy-structure-clean-t (ths/get-shape file :copy-structure-clean-t)
;; Duplicate a text line in copy-structure-clean, updating
;; both lines with the same attrs
file (-> (update-attr file :copy-structure-unif-t font-size-path-0 "25")
(change-structure :copy-structure-unif-t))
copy-structure-unif (ths/get-shape file :copy-structure-unif)
copy-structure-unif-t (ths/get-shape file :copy-structure-unif-t)
;; Duplicate a text line in copy-structure-clean, updating
@ -789,7 +763,6 @@
file (-> (change-structure file :copy-structure-mixed-t)
(update-attr :copy-structure-mixed-t font-size-path-0 "35")
(update-attr :copy-structure-mixed-t font-size-path-1 "40"))
copy-structure-mixed (ths/get-shape file :copy-structure-mixed)
copy-structure-mixed-t (ths/get-shape file :copy-structure-mixed-t)
@ -797,9 +770,9 @@
file' (-> file
(tho/swap-component copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true})
(tho/swap-component copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true})
(tho/swap-component copy-structure-mixed :c02 {:new-shape-label :copy-structure-mixed-2 :keep-touched? true}))
(tho/swap-component-in-shape :copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-structure-mixed :c02 {:new-shape-label :copy-structure-mixed-2 :keep-touched? true}))
page' (thf/current-page file')
copy-structure-clean' (ths/get-shape file' :copy-structure-clean-2)
copy-structure-clean-t' (get-in page' [:objects (-> copy-structure-clean' :shapes first)])
@ -908,14 +881,12 @@
file (change-structure file :copy-structure-clean-t)
copy-structure-clean (ths/get-shape file :copy-structure-clean)
copy-structure-clean-t (ths/get-shape file :copy-structure-clean-t)
;; Duplicate a text line in copy-structure-clean, updating
;; both lines with the same attrs
file (-> (update-attr file :copy-structure-unif-t font-size-path-0 "25")
(change-structure :copy-structure-unif-t))
copy-structure-unif (ths/get-shape file :copy-structure-unif)
copy-structure-unif-t (ths/get-shape file :copy-structure-unif-t)
;; Duplicate a text line in copy-structure-clean, updating
@ -923,7 +894,6 @@
file (-> (change-structure file :copy-structure-mixed-t)
(update-attr :copy-structure-mixed-t font-size-path-0 "35")
(update-attr :copy-structure-mixed-t font-size-path-1 "40"))
copy-structure-mixed (ths/get-shape file :copy-structure-mixed)
copy-structure-mixed-t (ths/get-shape file :copy-structure-mixed-t)
@ -931,9 +901,9 @@
file' (-> file
(tho/swap-component copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true})
(tho/swap-component copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true})
(tho/swap-component copy-structure-mixed :c02 {:new-shape-label :copy-structure-mixed-2 :keep-touched? true}))
(tho/swap-component-in-shape :copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-structure-mixed :c02 {:new-shape-label :copy-structure-mixed-2 :keep-touched? true}))
page' (thf/current-page file')
copy-structure-clean' (ths/get-shape file' :copy-structure-clean-2)
copy-structure-clean-t' (get-in page' [:objects (-> copy-structure-clean' :shapes first)])
@ -1038,14 +1008,12 @@
file (change-structure file :copy-structure-clean-t)
copy-structure-clean (ths/get-shape file :copy-structure-clean)
copy-structure-clean-t (ths/get-shape file :copy-structure-clean-t)
;; Duplicate a text line in copy-structure-clean, updating
;; both lines with the same attrs
file (-> (update-attr file :copy-structure-unif-t font-size-path-0 "25")
(change-structure :copy-structure-unif-t))
copy-structure-unif (ths/get-shape file :copy-structure-unif)
copy-structure-unif-t (ths/get-shape file :copy-structure-unif-t)
;; Duplicate a text line in copy-structure-clean, updating
@ -1053,7 +1021,6 @@
file (-> (change-structure file :copy-structure-mixed-t)
(update-attr :copy-structure-mixed-t font-size-path-0 "35")
(update-attr :copy-structure-mixed-t font-size-path-1 "40"))
copy-structure-mixed (ths/get-shape file :copy-structure-mixed)
copy-structure-mixed-t (ths/get-shape file :copy-structure-mixed-t)
@ -1061,9 +1028,9 @@
file' (-> file
(tho/swap-component copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true})
(tho/swap-component copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true})
(tho/swap-component copy-structure-mixed :c02 {:new-shape-label :copy-structure-mixed-2 :keep-touched? true}))
(tho/swap-component-in-shape :copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-structure-mixed :c02 {:new-shape-label :copy-structure-mixed-2 :keep-touched? true}))
page' (thf/current-page file')
copy-structure-clean' (ths/get-shape file' :copy-structure-clean-2)
copy-structure-clean-t' (get-in page' [:objects (-> copy-structure-clean' :shapes first)])
@ -1169,14 +1136,12 @@
file (change-structure file :copy-structure-clean-t)
copy-structure-clean (ths/get-shape file :copy-structure-clean)
copy-structure-clean-t (ths/get-shape file :copy-structure-clean-t)
;; Duplicate a text line in copy-structure-clean, updating
;; both lines with the same attrs
file (-> (update-attr file :copy-structure-unif-t font-size-path-0 "25")
(change-structure :copy-structure-unif-t))
copy-structure-unif (ths/get-shape file :copy-structure-unif)
copy-structure-unif-t (ths/get-shape file :copy-structure-unif-t)
;; Duplicate a text line in copy-structure-clean, updating
@ -1184,7 +1149,6 @@
file (-> (change-structure file :copy-structure-mixed-t)
(update-attr :copy-structure-mixed-t font-size-path-0 "35")
(update-attr :copy-structure-mixed-t font-size-path-1 "40"))
copy-structure-mixed (ths/get-shape file :copy-structure-mixed)
copy-structure-mixed-t (ths/get-shape file :copy-structure-mixed-t)
@ -1192,9 +1156,9 @@
file' (-> file
(tho/swap-component copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true})
(tho/swap-component copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true})
(tho/swap-component copy-structure-mixed :c02 {:new-shape-label :copy-structure-mixed-2 :keep-touched? true}))
(tho/swap-component-in-shape :copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true})
(tho/swap-component-in-shape :copy-structure-mixed :c02 {:new-shape-label :copy-structure-mixed-2 :keep-touched? true}))
page' (thf/current-page file')
copy-structure-clean' (ths/get-shape file' :copy-structure-clean-2)
copy-structure-clean-t' (get-in page' [:objects (-> copy-structure-clean' :shapes first)])
@ -1290,7 +1254,6 @@
:children-labels [:copy-cp01]))
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
copy-cp01 (ths/get-shape file :copy-cp01)
copy-cp01-rect-id (-> copy-cp01 :shapes first)
@ -1309,7 +1272,7 @@
;; ==== Action
;; Switch :c01 for :c02
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
copy02 (ths/get-shape file' :copy02)
copy-cp02' (ths/get-shape-by-id file' (-> copy02 :shapes first))
copy-cp02-rect' (ths/get-shape-by-id file' (-> copy-cp02' :shapes first))]
@ -1337,17 +1300,16 @@
:children-labels [:copy-cp01]))
copy01 (ths/get-shape file :copy01)
copy-cp01 (ths/get-shape file :copy-cp01)
external02 (thc/get-component file :external02)
;; On :c01, swap the copy of :external01 for a copy of :external02
file (-> file
(tho/swap-component copy-cp01 :external02 {:new-shape-label :copy-cp02 :keep-touched? false}))
(tho/swap-component-in-shape :copy-cp01 :external02 {:new-shape-label :copy-cp02 :keep-touched? false}))
copy-cp02 (ths/get-shape file :copy-cp02)
;; ==== Action
;; Switch :c01 for :c02
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
copy02' (ths/get-shape file' :copy02)
copy-cp02' (ths/get-shape file' :copy-cp02)]
@ -1376,12 +1338,11 @@
page (thf/current-page file)
copy01 (ths/get-shape file :copy01)
copy-cp01 (ths/get-shape file :copy-cp01)
external02 (thc/get-component file :external02)
;; On :c01, swap the copy of :external01 for a copy of :external02
file (-> file
(tho/swap-component copy-cp01 :external02 {:new-shape-label :copy-cp02 :keep-touched? false}))
(tho/swap-component-in-shape :copy-cp01 :external02 {:new-shape-label :copy-cp02 :keep-touched? false}))
copy-cp02 (ths/get-shape file :copy-cp02)
copy-cp02-rect-id (-> copy-cp02 :shapes first)
@ -1396,7 +1357,7 @@
;; ==== Action
;; Switch :c01 for :c02
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
copy02' (ths/get-shape file' :copy02)
copy-cp02' (ths/get-shape file' :copy-cp02)
@ -1463,7 +1424,7 @@
;; ==== Action
file' (tho/swap-component file c01-in-copy :c02 {:new-shape-label :c02-in-copy :keep-touched? true})
file' (tho/swap-component-in-shape file :c01-in-copy :c02 {:new-shape-label :c02-in-copy :keep-touched? true})
page' (thf/current-page file')
c02-in-copy' (ths/get-shape file' :c02-in-copy)
@ -1515,7 +1476,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -1564,7 +1525,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -1613,7 +1574,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -1660,7 +1621,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -1714,7 +1675,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -1763,7 +1724,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -1812,7 +1773,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -1859,7 +1820,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -1910,7 +1871,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -1956,7 +1917,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -2023,7 +1984,7 @@
text01 (get-in page [:objects (:id text01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -2055,7 +2016,7 @@
rect01 (get-in page [:objects (-> copy01 :shapes first)])
;; ==== Action - Try to switch to a component with different shape type
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -2098,7 +2059,7 @@
path01 (get-in page [:objects (:id path01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -2146,7 +2107,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -2190,7 +2151,7 @@
rect01 (get-in page [:objects (-> copy01 :shapes first)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -2243,7 +2204,7 @@
old-position-data (:position-data text01)
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -2306,7 +2267,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -2357,7 +2318,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -2411,7 +2372,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -2468,7 +2429,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -2532,7 +2493,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -2588,7 +2549,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -2653,7 +2614,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)
@ -2710,7 +2671,7 @@
rect01 (get-in page [:objects (:id rect01)])
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
page' (thf/current-page file')
copy02' (ths/get-shape file' :copy02)

View File

@ -8,6 +8,7 @@
(:require
#?(:clj [common-tests.fressian-test])
[clojure.test :as t]
[common-tests.attrs-test]
[common-tests.buffer-test]
[common-tests.colors-test]
[common-tests.data-test]
@ -54,6 +55,7 @@
[common-tests.path-names-test]
[common-tests.record-test]
[common-tests.schema-test]
[common-tests.spec-test]
[common-tests.svg-path-test]
[common-tests.svg-test]
[common-tests.text-test]
@ -85,6 +87,7 @@
(defn -main
[& args]
(t/run-tests
'common-tests.attrs-test
'common-tests.buffer-test
'common-tests.colors-test
'common-tests.data-test
@ -132,6 +135,7 @@
'common-tests.path-names-test
'common-tests.record-test
'common-tests.schema-test
'common-tests.spec-test
'common-tests.svg-path-test
'common-tests.svg-test
'common-tests.text-test

View File

@ -0,0 +1,89 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns common-tests.spec-test
(:require
[app.common.spec :as spec]
[clojure.test :as t]))
(t/deftest valid-emails
(t/testing "accepts well-formed email addresses"
(doseq [email ["user@domain.com"
"user.name@domain.com"
"user+tag@domain.com"
"user-name@domain.com"
"user_name@domain.com"
"user123@domain.com"
"USER@DOMAIN.COM"
"u@domain.io"
"user@sub.domain.com"
"user@domain.co.uk"
"user@domain.dev"
"a@bc.co"]]
(t/is (some? (spec/parse-email email)) (str "should accept: " email)))))
(t/deftest rejects-invalid-local-part
(t/testing "rejects local part starting with a dot"
(t/is (nil? (spec/parse-email ".user@domain.com"))))
(t/testing "rejects local part with consecutive dots"
(t/is (nil? (spec/parse-email "user..name@domain.com"))))
(t/testing "rejects local part with spaces"
(t/is (nil? (spec/parse-email "us er@domain.com"))))
(t/testing "rejects local part with comma"
(t/is (nil? (spec/parse-email "user,name@domain.com")))
(t/is (nil? (spec/parse-email ",user@domain.com"))))
(t/testing "rejects empty local part"
(t/is (nil? (spec/parse-email "@domain.com")))))
(t/deftest rejects-invalid-domain
(t/testing "rejects domain starting with a dot"
(t/is (nil? (spec/parse-email "user@.domain.com"))))
(t/testing "rejects domain part with comma"
(t/is (nil? (spec/parse-email "user@domain,com")))
(t/is (nil? (spec/parse-email "user@,domain.com"))))
(t/testing "rejects domain with consecutive dots"
(t/is (nil? (spec/parse-email "user@sub..domain.com"))))
(t/testing "rejects label starting with hyphen"
(t/is (nil? (spec/parse-email "user@-domain.com"))))
(t/testing "rejects label ending with hyphen"
(t/is (nil? (spec/parse-email "user@domain-.com"))))
(t/testing "rejects TLD shorter than 2 chars"
(t/is (nil? (spec/parse-email "user@domain.c"))))
(t/testing "rejects domain without a dot"
(t/is (nil? (spec/parse-email "user@domain"))))
(t/testing "rejects domain with spaces"
(t/is (nil? (spec/parse-email "user@do main.com"))))
(t/testing "rejects domain ending with a dot"
(t/is (nil? (spec/parse-email "user@domain.")))))
(t/deftest rejects-invalid-structure
(t/testing "rejects nil"
(t/is (nil? (spec/parse-email nil))))
(t/testing "rejects empty string"
(t/is (nil? (spec/parse-email ""))))
(t/testing "rejects string without @"
(t/is (nil? (spec/parse-email "userdomain.com"))))
(t/testing "rejects string with multiple @"
(t/is (nil? (spec/parse-email "user@@domain.com")))
(t/is (nil? (spec/parse-email "us@er@domain.com"))))
(t/testing "rejects empty domain"
(t/is (nil? (spec/parse-email "user@")))))

View File

@ -6,9 +6,13 @@
(ns common-tests.types.components-test
(:require
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.types.component :as ctk]
[app.common.types.file :as ctf]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
@ -39,3 +43,357 @@
(t/is (= (ctk/get-swap-slot s4) #uuid "9cc181fa-5eef-8084-8004-7bb2ab45fd1f"))
(t/is (= (ctk/get-swap-slot s5) #uuid "9cc181fa-5eef-8084-8004-7bb2ab45fd1f"))
(t/is (nil? (ctk/get-swap-slot s6)))))
(t/deftest test-find-near-match
(t/testing "shapes not in a component have no near match"
(let [file
;; :frame1 [:name Frame1]
;; :child1 [:name Rect1]
(-> (thf/sample-file :file1)
(tho/add-frame-with-child :frame1 :shape1))
page (thf/current-page file)
frame1 (ths/get-shape file :frame1)
shape1 (ths/get-shape file :shape1)
near-match1 (ctf/find-near-match file page {} frame1)
near-match2 (ctf/find-near-match file page {} shape1)]
(t/is (nil? near-match1))
(t/is (nil? near-match2))))
(t/testing "shapes in a copy get the ref-shape"
(let [file
;; {:main-root} [:name Frame1] # [Component :component1]
;; :main-child1 [:name Rect1]
;; :main-child2 [:name Rect2]
;; :main-child3 [:name Rect3]
;;
;; :copy-root [:name Frame1] #--> [Component :component1] :main-root
;; <no-label> [:name Rect1] ---> :main-child1
;; <no-label> [:name Rect2] ---> :main-child2
;; <no-label> [:name Rect3] ---> :main-child3
(-> (thf/sample-file :file1)
(tho/add-component-with-many-children-and-copy :component1
:main-root [:main-child1 :main-child2 :main-child3]
:copy-root))
page (thf/current-page file)
main-root (ths/get-shape file :main-root)
main-child1 (ths/get-shape file :main-child1)
main-child2 (ths/get-shape file :main-child2)
main-child3 (ths/get-shape file :main-child3)
copy-root (ths/get-shape file :copy-root)
copy-child1 (ths/get-shape-by-id file (nth (:shapes copy-root) 0))
copy-child2 (ths/get-shape-by-id file (nth (:shapes copy-root) 1))
copy-child3 (ths/get-shape-by-id file (nth (:shapes copy-root) 2))
near-main-root (ctf/find-near-match file page {} main-root)
near-main-child1 (ctf/find-near-match file page {} main-child1)
near-main-child2 (ctf/find-near-match file page {} main-child2)
near-main-child3 (ctf/find-near-match file page {} main-child3)
near-copy-root (ctf/find-near-match file page {} copy-root)
near-copy-child1 (ctf/find-near-match file page {} copy-child1)
near-copy-child2 (ctf/find-near-match file page {} copy-child2)
near-copy-child3 (ctf/find-near-match file page {} copy-child3)]
(t/is (nil? near-main-root))
(t/is (nil? near-main-child1))
(t/is (nil? near-main-child2))
(t/is (nil? near-main-child3))
(t/is (nil? near-copy-root))
(t/is (= (:id near-copy-child1) (thi/id :main-child1)))
(t/is (= (:id near-copy-child2) (thi/id :main-child2)))
(t/is (= (:id near-copy-child3) (thi/id :main-child3)))))
(t/testing "shapes in nested not swapped copies get the ref-shape"
(let [file
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested-head [:name Frame1] @--> [Component :component1] :main1-root
;; :nested-child [:name Rect1] ---> :main1-child
;;
;; :copy2 [:name Frame2] #--> [Component :component2] :main2-root
;; :copy2-nested-head [:name Frame1] @--> [Component :component1] :nested-head
;; :copy2-nested-child [:name Rect1] ---> :nested-child
(-> (thf/sample-file :file1)
(tho/add-nested-component :component1 :main1-root :main1-child
:component2 :main2-root :nested-head
:nested-head-params {:children-labels [:nested-child]})
(thc/instantiate-component :component2 :copy2
:children-labels [:copy2-nested-head :copy2-nested-child]))
page (thf/current-page file)
main1-root (ths/get-shape file :main1-root)
main1-child (ths/get-shape file :main1-child)
main2-root (ths/get-shape file :main2-root)
nested-head (ths/get-shape file :nested-head)
nested-child (ths/get-shape file :nested-child)
copy2 (ths/get-shape file :copy2)
copy2-nested-head (ths/get-shape file :copy2-nested-head)
copy2-nested-child (ths/get-shape file :copy2-nested-child)
near-main1-root (ctf/find-near-match file page {} main1-root)
near-main1-child (ctf/find-near-match file page {} main1-child)
near-main2-root (ctf/find-near-match file page {} main2-root)
near-nested-head (ctf/find-near-match file page {} nested-head)
near-nested-child (ctf/find-near-match file page {} nested-child)
near-copy2 (ctf/find-near-match file page {} copy2)
near-copy2-nested-head (ctf/find-near-match file page {} copy2-nested-head)
near-copy2-nested-child (ctf/find-near-match file page {} copy2-nested-child)]
(t/is (nil? near-main1-root))
(t/is (nil? near-main1-child))
(t/is (nil? near-main2-root))
(t/is (nil? near-nested-head))
(t/is (= (:id near-nested-child) (thi/id :main1-child)))
(t/is (nil? near-copy2))
(t/is (= (:id near-copy2-nested-head) (thi/id :nested-head)))
(t/is (= (:id near-copy2-nested-child) (thi/id :nested-child)))))
(t/testing "shapes in swapped copies get the swap slot"
(let [file
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested-head [:name Frame1] @--> [Component :component1] :main1-root
;; :nested-child [:name Rect1] ---> :main1-child
;;
;; {:main3-root} [:name Frame3] # [Component :component3]
;; :main3-child [:name Rect3]
;;
;; :copy2 [:name Frame2] #--> [Component :component2] :main2-root
;; :copy2-nested-head [:name Frame3] @--> [Component :component3] :main3-root
;; {swap-slot :nested-head}
;; <no-label> [:name Rect3] ---> :main3-child
(-> (thf/sample-file :file1)
(tho/add-nested-component :component1 :main1-root :main1-child
:component2 :main2-root :nested-head
:nested-head-params {:children-labels [:nested-child]})
(thc/instantiate-component :component2 :copy2 :children-labels [:copy2-nested-head])
(tho/add-simple-component :component3 :main3-root :main3-child
:root-params {:name "Frame3"}
:child-params {:name "Rect3"})
(tho/swap-component-in-first-child :copy2 :component3))
page (thf/current-page file)
main1-root (ths/get-shape file :main1-root)
main1-child (ths/get-shape file :main1-child)
main2-root (ths/get-shape file :main2-root)
nested-head (ths/get-shape file :nested-head)
nested-child (ths/get-shape file :nested-child)
copy2 (ths/get-shape file :copy2)
copy2-nested-head (ths/get-shape file :copy2-nested-head)
copy2-nested-child (ths/get-shape-by-id file (first (:shapes copy2-nested-head)))
near-main1-root (ctf/find-near-match file page {} main1-root)
near-main1-child (ctf/find-near-match file page {} main1-child)
near-main2-root (ctf/find-near-match file page {} main2-root)
near-nested-head (ctf/find-near-match file page {} nested-head)
near-nested-child (ctf/find-near-match file page {} nested-child)
near-copy2 (ctf/find-near-match file page {} copy2)
near-copy2-nested-head (ctf/find-near-match file page {} copy2-nested-head)
near-copy2-nested-child (ctf/find-near-match file page {} copy2-nested-child)]
(t/is (nil? near-main1-root))
(t/is (nil? near-main1-child))
(t/is (nil? near-main2-root))
(t/is (nil? near-nested-head))
(t/is (= (:id near-nested-child) (thi/id :main1-child)))
(t/is (nil? near-copy2))
(t/is (= (:id near-copy2-nested-head) (thi/id :nested-head)))
(t/is (= (:id near-copy2-nested-child) (thi/id :main3-child)))))
(t/testing "shapes in second level nested copies under swapped get the shape in the new main"
(let [file
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested2-head [:name Frame1] @--> [Component :component1] :main1-root
;; :nested2-child [:name Rect1] ---> :main1-child
;;
;; {:main3-root} [:name Frame3] # [Component :component3]
;; :main3-child [:name Rect3]
;;
;; {:main4-root} [:name Frame4] # [Component :component4]
;; :nested4-head [:name Frame3] @--> [Component :component1] :main3-root
;; :nested4-child [:name Rect3] ---> :main3-child
;;
;; :copy2 [:name Frame2] #--> [Component :component2] :main2-root
;; :copy2-nested-head [:name Frame4] @--> [Component :component4] :main4-root
;; {swap-slot :nested2-head}
;; <no-label> [:name Frame3] @--> :nested4-head
;; <no-label> [:name Rect3] ---> :nested4-child
(-> (thf/sample-file :file1)
(tho/add-nested-component :component1 :main1-root :main1-child
:component2 :main2-root :nested2-head
:nested-head-params {:children-labels [:nested2-child]})
(thc/instantiate-component :component2 :copy2 :children-labels [:copy2-nested-head])
(tho/add-nested-component :component3 :main3-root :main3-child
:component4 :main4-root :nested4-head
:root1-params {:name "Frame3"}
:main1-child-params {:name "Rect3"}
:main2-root-params {:name "Frame4"}
:nested-head-params {:children-labels [:nested4-child]})
(tho/swap-component-in-first-child :copy2 :component4))
page (thf/current-page file)
main1-root (ths/get-shape file :main1-root)
main1-child (ths/get-shape file :main1-child)
main2-root (ths/get-shape file :main2-root)
nested2-head (ths/get-shape file :nested2-head)
nested2-child (ths/get-shape file :nested2-child)
main3-root (ths/get-shape file :main3-root)
main3-child (ths/get-shape file :main3-child)
main4-root (ths/get-shape file :main4-root)
nested4-head (ths/get-shape file :nested4-head)
nested4-child (ths/get-shape file :nested4-child)
copy2 (ths/get-shape file :copy2)
copy2-nested-head (ths/get-shape file :copy2-nested-head)
copy2-nested4-head (ths/get-shape-by-id file (first (:shapes copy2-nested-head)))
copy2-nested4-child (ths/get-shape-by-id file (first (:shapes copy2-nested4-head)))
near-main1-root (ctf/find-near-match file page {} main1-root)
near-main1-child (ctf/find-near-match file page {} main1-child)
near-main2-root (ctf/find-near-match file page {} main2-root)
near-nested2-head (ctf/find-near-match file page {} nested2-head)
near-nested2-child (ctf/find-near-match file page {} nested2-child)
near-main3-root (ctf/find-near-match file page {} main3-root)
near-main3-child (ctf/find-near-match file page {} main3-child)
near-main4-root (ctf/find-near-match file page {} main4-root)
near-nested4-head (ctf/find-near-match file page {} nested4-head)
near-nested4-child (ctf/find-near-match file page {} nested4-child)
near-copy2 (ctf/find-near-match file page {} copy2)
near-copy2-nested-head (ctf/find-near-match file page {} copy2-nested-head)
near-copy2-nested4-head (ctf/find-near-match file page {} copy2-nested4-head)
near-copy2-nested4-child (ctf/find-near-match file page {} copy2-nested4-child)]
(t/is (nil? near-main1-root))
(t/is (nil? near-main1-child))
(t/is (nil? near-main2-root))
(t/is (nil? near-nested2-head))
(t/is (= (:id near-nested2-child) (thi/id :main1-child)))
(t/is (nil? near-main3-root))
(t/is (nil? near-main3-child))
(t/is (nil? near-main4-root))
(t/is (nil? near-nested4-head))
(t/is (= (:id near-nested4-child) (thi/id :main3-child)))
(t/is (nil? near-copy2))
(t/is (= (:id near-copy2-nested-head) (thi/id :nested2-head)))
(t/is (= (:id near-copy2-nested4-head) (thi/id :nested4-head)))
(t/is (= (:id near-copy2-nested4-child) (thi/id :nested4-child)))))
(t/testing "component in external libraries still work well"
(let [library1
;; {:main1-root} [:name Frame1] # [Component :component1]
;; :main1-child [:name Rect1]
;;
;; {:main2-root} [:name Frame2] # [Component :component2]
;; :nested2-head [:name Frame1] @--> [Component :component1] :main1-root
;; :nested2-child [:name Rect1] ---> :main1-child
(-> (thf/sample-file :library1)
(tho/add-nested-component :component1 :main1-root :main1-child
:component2 :main2-root :nested2-head
:nested-head-params {:children-labels [:nested2-child]}))
library2
;; {:main3-root} [:name Frame3] # [Component :component3]
;; :main3-child [:name Rect3]
;;
;; {:main4-root} [:name Frame4] # [Component :component4]
;; :nested4-head [:name Frame3] @--> [Component :component1] :main3-root
;; :nested4-child [:name Rect3] ---> :main3-child
(-> (thf/sample-file :library2)
(tho/add-nested-component :component3 :main3-root :main3-child
:component4 :main4-root :nested4-head
:root1-params {:name "Frame3"}
:main1-child-params {:name "Rect3"}
:main2-root-params {:name "Frame4"}
:nested-head-params {:children-labels [:nested4-child]}))
file
;; :copy2 [:name Frame2] #--> [Component :component2] :main2-root
;; :copy2-nested-head [:name Frame4] @--> [Component :component4] :main4-root
;; {swap-slot :nested2-head}
;; <no-label> [:name Frame3] @--> :nested4-head
;; <no-label> [:name Rect3] ---> :nested4-child
(-> (thf/sample-file :file1)
(thc/instantiate-component :component2 :copy2 :children-labels [:copy2-nested-head]
:library library1)
(tho/swap-component-in-first-child :copy2 :component4 :library library2))
page-library1 (thf/current-page library1)
page-library2 (thf/current-page library2)
page-file (thf/current-page file)
libraries {(:id library1) library1
(:id library2) library2}
main1-root (ths/get-shape library1 :main1-root)
main1-child (ths/get-shape library1 :main1-child)
main2-root (ths/get-shape library1 :main2-root)
nested2-head (ths/get-shape library1 :nested2-head)
nested2-child (ths/get-shape library1 :nested2-child)
main3-root (ths/get-shape library2 :main3-root)
main3-child (ths/get-shape library2 :main3-child)
main4-root (ths/get-shape library2 :main4-root)
nested4-head (ths/get-shape library2 :nested4-head)
nested4-child (ths/get-shape library2 :nested4-child)
copy2 (ths/get-shape file :copy2)
copy2-nested-head (ths/get-shape file :copy2-nested-head)
copy2-nested4-head (ths/get-shape-by-id file (first (:shapes copy2-nested-head)))
copy2-nested4-child (ths/get-shape-by-id file (first (:shapes copy2-nested4-head)))
near-main1-root (ctf/find-near-match file page-file libraries main1-root)
near-main1-child (ctf/find-near-match file page-file libraries main1-child)
near-main2-root (ctf/find-near-match file page-file libraries main2-root)
near-nested2-head (ctf/find-near-match library1 page-library1 libraries nested2-head)
near-nested2-child (ctf/find-near-match library1 page-library1 libraries nested2-child)
near-main3-root (ctf/find-near-match file page-file libraries main3-root)
near-main3-child (ctf/find-near-match file page-file libraries main3-child)
near-main4-root (ctf/find-near-match file page-file libraries main4-root)
near-nested4-head (ctf/find-near-match library2 page-library2 libraries nested4-head)
near-nested4-child (ctf/find-near-match library2 page-library2 libraries nested4-child)
near-copy2 (ctf/find-near-match file page-file libraries copy2)
near-copy2-nested-head (ctf/find-near-match file page-file libraries copy2-nested-head)
near-copy2-nested4-head (ctf/find-near-match file page-file libraries copy2-nested4-head)
near-copy2-nested4-child (ctf/find-near-match file page-file libraries copy2-nested4-child)]
(thf/dump-file library1 :keys [:name :swap-slot-label] :show-refs? true)
(t/is (some? main1-root))
(t/is (some? main1-child))
(t/is (some? main2-root))
(t/is (some? nested2-head))
(t/is (some? nested2-child))
(t/is (some? main3-root))
(t/is (some? main3-child))
(t/is (some? main4-root))
(t/is (some? nested4-head))
(t/is (some? nested4-child))
(t/is (some? copy2))
(t/is (some? copy2-nested-head))
(t/is (some? copy2-nested4-head))
(t/is (some? copy2-nested4-child))
(t/is (nil? near-main1-root))
(t/is (nil? near-main1-child))
(t/is (nil? near-main2-root))
(t/is (nil? near-nested2-head))
(t/is (= (:id near-nested2-child) (thi/id :main1-child)))
(t/is (nil? near-main3-root))
(t/is (nil? near-main3-child))
(t/is (nil? near-main4-root))
(t/is (nil? near-nested4-head))
(t/is (= (:id near-nested4-child) (thi/id :main3-child)))
(t/is (nil? near-copy2))
(t/is (= (:id near-copy2-nested-head) (thi/id :nested2-head)))
(t/is (= (:id near-copy2-nested4-head) (thi/id :nested4-head)))
(t/is (= (:id near-copy2-nested4-child) (thi/id :nested4-child))))))

View File

@ -32,7 +32,7 @@ RUN set -ex; \
FROM base AS setup-node
ENV NODE_VERSION=v22.22.0 \
ENV NODE_VERSION=v24.15.0 \
PATH=/opt/node/bin:$PATH
RUN set -eux; \
@ -67,7 +67,7 @@ RUN set -eux; \
FROM base AS setup-caddy
ENV CADDY_VERSION=2.10.2
ENV CADDY_VERSION=2.11.2
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
@ -99,18 +99,18 @@ RUN set -eux; \
FROM base AS setup-jvm
# https://clojure.org/releases/tools
ENV CLOJURE_VERSION=1.12.4.1602
ENV CLOJURE_VERSION=1.12.4.1618
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \
ESUM='cc1b459dc442d7422b46a3b5fe52acaea54879fa7913e29a05650cef54687f5f'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_aarch64.tar.gz'; \
;; \
amd64|x86_64) \
ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \
ESUM='7d6663ea8d4298df65de065e32f9f449745ff607d30ba5d13777cb92e9d4613d'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_x64.tar.gz'; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
@ -181,10 +181,10 @@ RUN set -eux; \
FROM base AS setup-utils
ENV CLJKONDO_VERSION=2026.01.19 \
ENV CLJKONDO_VERSION=2026.04.15 \
BABASHKA_VERSION=1.12.208 \
CLJFMT_VERSION=0.15.6 \
PIXI_VERSION=0.63.2
CLJFMT_VERSION=0.16.4 \
PIXI_VERSION=0.67.2
RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \

View File

@ -105,7 +105,7 @@ services:
# - "traefik.http.routers.penpot-https.tls=true"
environment:
<< : [*penpot-flags, *penpot-http-body-size]
<< : [*penpot-flags, *penpot-http-body-size, *penpot-public-uri]
penpot-backend:
image: "penpotapp/backend:${PENPOT_VERSION:-latest}"

View File

@ -1,2 +1,3 @@
// Frontend configuration
//var penpotFlags = "";
//var penpotOIDCName = "";

View File

@ -19,9 +19,22 @@ update_flags() {
-e "s|^//var penpotFlags = .*;|var penpotFlags = \"$PENPOT_FLAGS\";|g" \
"$1")" > "$1"
fi
if [ -n "$PENPOT_PUBLIC_URI" ]; then
echo "var penpotPublicURI = \"$PENPOT_PUBLIC_URI\";" >> "$1";
fi
}
update_oidc_name() {
if [ -n "$PENPOT_OIDC_NAME" ]; then
echo "$(sed \
-e "s|^//var penpotOIDCName = .*;|var penpotOIDCName = \"$PENPOT_OIDC_NAME\";|g" \
"$1")" > "$1"
fi
}
update_flags /var/www/app/js/config.js
update_oidc_name /var/www/app/js/config.js
#########################################
## Nginx Config
@ -30,8 +43,9 @@ update_flags /var/www/app/js/config.js
export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060}
export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061}
export PENPOT_NITRATE_URI=${PENPOT_NITRATE_URI:-http://penpot-nitrate:3000}
export PENPOT_MCP_URI=${PENPOT_MCP_URI:-http://penpot-mcp}
export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE" \
envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_MCP_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE" \
< /tmp/nginx.conf.template > /etc/nginx/nginx.conf
PENPOT_DEFAULT_INTERNAL_RESOLVER="$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf)"

View File

@ -135,6 +135,23 @@ http {
proxy_http_version 1.1;
}
location /mcp/ws {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_pass $PENPOT_MCP_URI:4402;
proxy_http_version 1.1;
}
location /mcp/stream {
proxy_pass $PENPOT_MCP_URI:4401/mcp;
proxy_http_version 1.1;
}
location /mcp/sse {
proxy_pass $PENPOT_MCP_URI:4401/sse;
proxy_http_version 1.1;
}
location /readyz {
access_log off;
proxy_pass $PENPOT_BACKEND_URI$request_uri;

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