mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
✨ Add project and file creation to penpotMgmt
Add penpotMgmt.createProject({name, teamId?}) and
penpotMgmt.createFile({projectId, name}). Both require content:write,
trim the name and require 1 to 250 characters, and return the same
metadata as listProjects and listFiles without changing the current
view. When the new project or file belongs to the current team, the
dashboard state is updated so it shows up right away.
listProjects, listFiles and the creation methods share the summary
builders, so fileCount is now 0 instead of missing for new projects.
Update the plugin types, the MCP API docs and the MCP instructions.
This commit is contained in:
parent
cf1fbb4d7c
commit
a381761aa4
@ -6,13 +6,34 @@
|
||||
|
||||
(ns app.plugins.management
|
||||
(:require
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.dashboard :as dd]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.router :as rt]
|
||||
[app.main.store :as st]
|
||||
[app.util.object :as obj]
|
||||
[beicon.v2.core :as rx]))
|
||||
[beicon.v2.core :as rx]
|
||||
[clojure.set :as set]
|
||||
[cuerdas.core :as str]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(defn- project-summary
|
||||
[project]
|
||||
#js {:id (str (:id project))
|
||||
:teamId (str (:team-id project))
|
||||
:name (:name project)
|
||||
:isDefault (boolean (:is-default project))
|
||||
:fileCount (or (:count project) 0)})
|
||||
|
||||
(defn- file-summary
|
||||
[file]
|
||||
#js {:id (str (:id file))
|
||||
:teamId (str (:team-id file))
|
||||
:projectId (str (:project-id file))
|
||||
:name (:name file)
|
||||
:modifiedAt (.toISOString ^js (:modified-at file))})
|
||||
|
||||
(defn workspace-context
|
||||
[state]
|
||||
@ -83,14 +104,7 @@
|
||||
(fn [resolve reject]
|
||||
(->> (rp/cmd! :get-projects {:team-id team-id})
|
||||
(rx/map (fn [projects]
|
||||
(clj->js
|
||||
(mapv (fn [project]
|
||||
{:id (str (:id project))
|
||||
:teamId (str (:team-id project))
|
||||
:name (:name project)
|
||||
:isDefault (boolean (:is-default project))
|
||||
:fileCount (:count project)})
|
||||
(remove :deleted-at projects)))))
|
||||
(into-array (map project-summary (remove :deleted-at projects)))))
|
||||
(rx/subs! resolve reject)))))))
|
||||
|
||||
(defn list-files
|
||||
@ -102,16 +116,68 @@
|
||||
(fn [resolve reject]
|
||||
(->> (rp/cmd! :get-project-files {:project-id project-id})
|
||||
(rx/map (fn [files]
|
||||
(clj->js
|
||||
(mapv (fn [file]
|
||||
{:id (str (:id file))
|
||||
:teamId (str (:team-id file))
|
||||
:projectId (str (:project-id file))
|
||||
:name (:name file)
|
||||
:modifiedAt (.toISOString ^js (:modified-at file))})
|
||||
files))))
|
||||
(into-array (map file-summary files))))
|
||||
(rx/subs! resolve reject)))))))
|
||||
|
||||
(defn- creation-name
|
||||
[options]
|
||||
(let [name (obj/get options "name")]
|
||||
(when (string? name)
|
||||
(let [name (str/trim name)]
|
||||
(when (<= 1 (count name) 250)
|
||||
name)))))
|
||||
|
||||
(defn create-project
|
||||
[options]
|
||||
(let [team-id (if-let [id (obj/get options "teamId")]
|
||||
(uuid/parse* id)
|
||||
(:current-team-id @st/state))
|
||||
name (creation-name options)]
|
||||
(cond
|
||||
(nil? team-id)
|
||||
(js/Promise.reject (js/Error. "Expected a team UUID"))
|
||||
|
||||
(nil? name)
|
||||
(js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters"))
|
||||
|
||||
:else
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
(->> (rp/cmd! :create-project {:team-id team-id :name name})
|
||||
(rx/tap (fn [project]
|
||||
(st/emit!
|
||||
(fn [state]
|
||||
(if (= (:team-id project) (:current-team-id state))
|
||||
(assoc-in state [:projects (:id project)] (assoc project :count 0))
|
||||
state)))))
|
||||
(rx/map project-summary)
|
||||
(rx/subs! resolve reject)))))))
|
||||
|
||||
(defn create-file
|
||||
[options]
|
||||
(let [project-id (uuid/parse* (obj/get options "projectId"))
|
||||
name (creation-name options)]
|
||||
(cond
|
||||
(nil? project-id)
|
||||
(js/Promise.reject (js/Error. "Expected a project UUID"))
|
||||
|
||||
(nil? name)
|
||||
(js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters"))
|
||||
|
||||
:else
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
(let [features (set/difference (:features @st/state #{}) cfeat/frontend-only-features)]
|
||||
(->> (rp/cmd! :create-file {:project-id project-id :name name :features features})
|
||||
(rx/tap (fn [file]
|
||||
(st/emit!
|
||||
(fn [state]
|
||||
(if (= (:team-id file) (:current-team-id state))
|
||||
(ptk/update (dd/file-created file) state)
|
||||
state)))))
|
||||
(rx/map file-summary)
|
||||
(rx/subs! resolve reject))))))))
|
||||
|
||||
(defn create-context
|
||||
[]
|
||||
(obj/reify {:name "PenpotManagementContext"}
|
||||
@ -119,4 +185,6 @@
|
||||
{:get (fn [] (clj->js (workspace-context @st/state)))}
|
||||
:openFile open-file
|
||||
:listProjects list-projects
|
||||
:listFiles list-files))
|
||||
:listFiles list-files
|
||||
:createProject create-project
|
||||
:createFile create-file))
|
||||
|
||||
@ -152,3 +152,129 @@
|
||||
(mock/with-mocks*
|
||||
{rp/cmd! (mock/stub (fn [_ _] (rx/throw error)))}
|
||||
(t/is (identical? error (await (.then (list-items) (fn [] nil) identity)))))))))
|
||||
|
||||
(t/deftest ^:async create-project-returns-metadata-and-updates-only-the-current-team
|
||||
(let [project-id (uuid/next)
|
||||
other-team-id (uuid/next)]
|
||||
(doseq [[initial options target-team-id]
|
||||
[[dashboard #js {:name " Buttons "} team-id]
|
||||
[ready #js {:name " Buttons " :teamId (str other-team-id)} other-team-id]]]
|
||||
(let [state (atom initial)
|
||||
requests (atom [])
|
||||
project {:id project-id :team-id target-team-id :name "Buttons"
|
||||
:is-default false :is-pinned false}]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state state
|
||||
st/emit! (mock/stub (fn [event] (swap! state event)))
|
||||
rp/cmd! (mock/stub
|
||||
(fn [command params]
|
||||
(swap! requests conj [command params])
|
||||
(->> (rx/of project) (rx/observe-on :async))))}
|
||||
(let [context (management/create-context)
|
||||
result (await (.createProject context options))]
|
||||
(t/is (= {"id" (str project-id) "teamId" (str target-team-id)
|
||||
"name" "Buttons" "isDefault" false "fileCount" 0}
|
||||
(js->clj result)))
|
||||
(t/is (= [[:create-project {:team-id target-team-id :name "Buttons"}]] @requests))
|
||||
(t/is (= (if (= team-id target-team-id)
|
||||
(assoc-in initial [:projects project-id] (assoc project :count 0))
|
||||
initial)
|
||||
@state)))))))))
|
||||
|
||||
(t/deftest ^:async create-file-returns-metadata-and-updates-the-dashboard-without-navigation
|
||||
(let [project-id (uuid/next)
|
||||
created-id (uuid/next)
|
||||
modified-at (js/Date. "2026-01-02T03:04:05Z")]
|
||||
(doseq [initial [dashboard ready]]
|
||||
(let [initial (assoc initial
|
||||
:features #{"components/v2" "styles/v2" "plugins/runtime"}
|
||||
:projects {project-id {:id project-id :team-id team-id :count 2}})
|
||||
state (atom initial)
|
||||
requests (atom [])
|
||||
file {:id created-id :team-id team-id :project-id project-id
|
||||
:name "Login" :modified-at modified-at :data {:pages []}}]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state state
|
||||
st/emit! (mock/stub (fn [event] (swap! state event)))
|
||||
rp/cmd! (mock/stub
|
||||
(fn [command params]
|
||||
(swap! requests conj [command params])
|
||||
(->> (rx/of file) (rx/observe-on :async))))}
|
||||
(let [context (management/create-context)
|
||||
result (await (.createFile context #js {:projectId (str project-id) :name " Login "
|
||||
:id "ignored" :isShared true}))]
|
||||
(t/is (= {"id" (str created-id) "teamId" (str team-id)
|
||||
"projectId" (str project-id) "name" "Login"
|
||||
"modifiedAt" "2026-01-02T03:04:05.000Z"}
|
||||
(js->clj result)))
|
||||
(t/is (= [[:create-file {:project-id project-id :name "Login" :features #{"components/v2"}}]]
|
||||
@requests))
|
||||
(t/is (= (-> initial
|
||||
(assoc-in [:files created-id] (dissoc file :data))
|
||||
(assoc-in [:recent-files created-id] (dissoc file :data))
|
||||
(update-in [:projects project-id :count] inc))
|
||||
@state)))))))))
|
||||
|
||||
(t/deftest ^:async creation-rejects-invalid-input-before-querying
|
||||
(let [project-id (uuid/next)
|
||||
requests (atom [])]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state (atom dashboard)
|
||||
rp/cmd! (mock/stub (fn [command params]
|
||||
(swap! requests conj [command params])
|
||||
(->> (rx/of nil) (rx/observe-on :async))))}
|
||||
(let [context (management/create-context)]
|
||||
(doseq [options [nil #js {} #js {:name nil} #js {:name 1}
|
||||
#js {:name ""} #js {:name " \n "}
|
||||
#js {:name (apply str (repeat 251 "a"))}]]
|
||||
(t/is (= "Expected a name with 1 to 250 characters"
|
||||
(await (.then (.createProject context options) (fn [] nil) #(.-message %)))))
|
||||
(let [options (js/Object.assign #js {:projectId (str project-id)} options)]
|
||||
(t/is (= "Expected a name with 1 to 250 characters"
|
||||
(await (.then (.createFile context options) (fn [] nil) #(.-message %)))))))
|
||||
(doseq [[create options message]
|
||||
[[#(.createProject context %) #js {:name "Buttons" :teamId "invalid"} "Expected a team UUID"]
|
||||
[#(.createFile context %) #js {:name "Login"} "Expected a project UUID"]
|
||||
[#(.createFile context %) #js {:name "Login" :projectId "invalid"} "Expected a project UUID"]]]
|
||||
(t/is (= message (await (.then (create options) (fn [] nil) #(.-message %)))))))
|
||||
(await (a/settle))
|
||||
(t/is (empty? @requests))))))
|
||||
|
||||
(t/deftest ^:async creation-preserves-backend-errors-and-state
|
||||
(doseq [method ["createProject" "createFile"]]
|
||||
(let [state (atom dashboard)
|
||||
error (js/Error. "Access denied")]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state state
|
||||
st/emit! (mock/stub (fn [event] (swap! state event)))
|
||||
rp/cmd! (mock/stub (fn [_ _] (->> (rx/throw error) (rx/observe-on :async))))}
|
||||
(let [context (management/create-context)
|
||||
options #js {:name "Design" :projectId (str (uuid/next))}
|
||||
result (.call (aget context method) context options)]
|
||||
(t/is (identical? error (await (.then result (fn [] nil) identity))))
|
||||
(t/is (= dashboard @state))))))))
|
||||
|
||||
(t/deftest ^:async creation-does-not-add-results-to-a-different-team-after-navigation
|
||||
(doseq [method ["createProject" "createFile"]]
|
||||
(let [state (atom dashboard)
|
||||
response (rx/subject)
|
||||
other (assoc dashboard :current-team-id (uuid/next))
|
||||
item-id (uuid/next)
|
||||
item {:id item-id :team-id team-id :project-id (uuid/next)
|
||||
:name "Design" :modified-at (js/Date.)}]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state state
|
||||
st/emit! (mock/stub (fn [event] (swap! state event)))
|
||||
rp/cmd! (mock/stub (fn [_ _] (->> response (rx/observe-on :async))))}
|
||||
(let [context (management/create-context)
|
||||
result (.call (aget context method) context
|
||||
#js {:name "Design" :projectId (str (:project-id item))})]
|
||||
(reset! state other)
|
||||
(rx/push! response item)
|
||||
(t/is (= (str item-id) (.-id (await result))))
|
||||
(t/is (= other @state))))))))
|
||||
|
||||
@ -5128,9 +5128,9 @@ PenpotMgmt:
|
||||
|
||||
Application management API exposed as `penpotMgmt` to global plugins.
|
||||
|
||||
* Requires `content:read` permission.
|
||||
* Discovery and navigation require `content:read`; creation requires `content:write`.
|
||||
* Available in the dashboard and file workspaces.
|
||||
* Use this API to discover and open files; file operations on `penpot` require a ready workspace.
|
||||
* Use this API to discover, create and open files; file operations on `penpot` require a ready workspace.
|
||||
* Reacquire file, page and shape objects after changing files.
|
||||
|
||||
```
|
||||
@ -5138,6 +5138,8 @@ PenpotMgmt:
|
||||
workspace: WorkspaceContext;
|
||||
listProjects(options?: { teamId?: string }): Promise<ProjectSummary[]>;
|
||||
listFiles(options: { projectId: string }): Promise<FileSummary[]>;
|
||||
createProject(options: { name: string; teamId?: string }): Promise<ProjectSummary>;
|
||||
createFile(options: { name: string; projectId: string }): Promise<FileSummary>;
|
||||
openFile(fileId: string, options?: { teamId?: string }): Promise<void>;
|
||||
on(
|
||||
type: "workspacechange",
|
||||
@ -5199,6 +5201,51 @@ PenpotMgmt:
|
||||
```
|
||||
const files = await penpotMgmt.listFiles({ projectId: project.id });
|
||||
```
|
||||
createProject: |-
|
||||
```
|
||||
createProject(options: { name: string; teamId?: string }): Promise<ProjectSummary>
|
||||
```
|
||||
|
||||
Creates a project and returns its metadata snapshot. Requires `content:write`.
|
||||
|
||||
* `teamId` defaults to the current team.
|
||||
* Trims `name` and requires 1 to 250 characters.
|
||||
* Does not change navigation or open a file.
|
||||
* Rejects invalid input, backend access errors and quota errors.
|
||||
|
||||
Parameters
|
||||
|
||||
* options: { name: string; teamId?: string }
|
||||
|
||||
Returns Promise<ProjectSummary>
|
||||
|
||||
Example
|
||||
```
|
||||
const project = await penpotMgmt.createProject({ name: 'Design system' });
|
||||
```
|
||||
createFile: |-
|
||||
```
|
||||
createFile(options: { name: string; projectId: string }): Promise<FileSummary>
|
||||
```
|
||||
|
||||
Creates a file with an initial page and returns its metadata snapshot. Requires `content:write`.
|
||||
|
||||
* Requires an explicit `projectId`.
|
||||
* Trims `name` and requires 1 to 250 characters.
|
||||
* Does not open the file or change navigation. Use `openFile` to edit it.
|
||||
* Rejects invalid input, backend access errors and quota errors.
|
||||
|
||||
Parameters
|
||||
|
||||
* options: { name: string; projectId: string }
|
||||
|
||||
Returns Promise<FileSummary>
|
||||
|
||||
Example
|
||||
```
|
||||
const file = await penpotMgmt.createFile({ projectId: project.id, name: 'Login' });
|
||||
await penpotMgmt.openFile(file.id, { teamId: file.teamId });
|
||||
```
|
||||
openFile: |-
|
||||
```
|
||||
openFile(fileId: string, options?: { teamId?: string }): Promise<void>
|
||||
@ -5260,7 +5307,9 @@ Context:
|
||||
"workspace"
|
||||
| "openFile"
|
||||
| "listProjects"
|
||||
| "listFiles",
|
||||
| "listFiles"
|
||||
| "createProject"
|
||||
| "createFile",
|
||||
>;
|
||||
version: string;
|
||||
root: Shape | null;
|
||||
@ -5340,7 +5389,9 @@ Context:
|
||||
"workspace"
|
||||
| "openFile"
|
||||
| "listProjects"
|
||||
| "listFiles",
|
||||
| "listFiles"
|
||||
| "createProject"
|
||||
| "createFile",
|
||||
>
|
||||
```
|
||||
version: |-
|
||||
|
||||
@ -435,7 +435,11 @@ For many tasks, it can be critical to visually inspect the design. Remember to u
|
||||
# The `penpotMgmt` Object
|
||||
|
||||
This object (of type `PenpotMgmt`) serves to manage Penpot design projects and files.
|
||||
It allows you to discover and navigate to different design files the user has access to.
|
||||
It allows you to discover, create and navigate to design projects and files the user has access to.
|
||||
Create a project with `await penpotMgmt.createProject({ name: "Design system" })` (optionally specify `teamId`).
|
||||
Create a file with `await penpotMgmt.createFile({ projectId: project.id, name: "Login" })`.
|
||||
Both creation methods require `content:write` and return metadata without changing the current view.
|
||||
To edit the new file, call `await penpotMgmt.openFile(file.id, { teamId: file.teamId })` and reacquire file, page and shape objects.
|
||||
You navigate to different design files only when your task explicitly requires it/you are instructed to do so.
|
||||
|
||||
--
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- **plugin-types, plugins-runtime:** Added `penpotMgmt.createProject` and `penpotMgmt.createFile` to create projects and files from the dashboard or workspace with `content:write` permission.
|
||||
- **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until closed or logged out, with `penpotMgmt` for project and file discovery, file navigation and workspace events.
|
||||
- **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently.
|
||||
- **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs.
|
||||
|
||||
46
plugins/libs/plugin-types/index.d.ts
vendored
46
plugins/libs/plugin-types/index.d.ts
vendored
@ -809,9 +809,9 @@ export interface FileSummary {
|
||||
/**
|
||||
* Application management API exposed as `penpotMgmt` to global plugins.
|
||||
*
|
||||
* - Requires `content:read` permission.
|
||||
* - Discovery and navigation require `content:read`; creation requires `content:write`.
|
||||
* - Available in the dashboard and file workspaces.
|
||||
* - Use this API to discover and open files; file operations on `penpot` require a ready workspace.
|
||||
* - Use this API to discover, create and open files; file operations on `penpot` require a ready workspace.
|
||||
* - Reacquire file, page and shape objects after changing files.
|
||||
*/
|
||||
export interface PenpotMgmt {
|
||||
@ -843,6 +843,41 @@ export interface PenpotMgmt {
|
||||
* ```
|
||||
*/
|
||||
listFiles(options: { projectId: string }): Promise<FileSummary[]>;
|
||||
/**
|
||||
* Creates a project and returns its metadata snapshot. Requires `content:write`.
|
||||
*
|
||||
* - `teamId` defaults to the current team.
|
||||
* - Trims `name` and requires 1 to 250 characters.
|
||||
* - Does not change navigation or open a file.
|
||||
* - Rejects invalid input, backend access errors and quota errors.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const project = await penpotMgmt.createProject({ name: 'Design system' });
|
||||
* ```
|
||||
*/
|
||||
createProject(options: {
|
||||
name: string;
|
||||
teamId?: string;
|
||||
}): Promise<ProjectSummary>;
|
||||
/**
|
||||
* Creates a file with an initial page and returns its metadata snapshot. Requires `content:write`.
|
||||
*
|
||||
* - Requires an explicit `projectId`.
|
||||
* - Trims `name` and requires 1 to 250 characters.
|
||||
* - Does not open the file or change navigation. Use `openFile` to edit it.
|
||||
* - Rejects invalid input, backend access errors and quota errors.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const file = await penpotMgmt.createFile({ projectId: project.id, name: 'Login' });
|
||||
* await penpotMgmt.openFile(file.id, { teamId: file.teamId });
|
||||
* ```
|
||||
*/
|
||||
createFile(options: {
|
||||
name: string;
|
||||
projectId: string;
|
||||
}): Promise<FileSummary>;
|
||||
/**
|
||||
* Opens a file in this tab and resolves once its workspace and page are ready.
|
||||
*
|
||||
@ -869,7 +904,12 @@ export interface PenpotMgmt {
|
||||
export interface Context {
|
||||
readonly management?: Pick<
|
||||
PenpotMgmt,
|
||||
'workspace' | 'openFile' | 'listProjects' | 'listFiles'
|
||||
| 'workspace'
|
||||
| 'openFile'
|
||||
| 'listProjects'
|
||||
| 'listFiles'
|
||||
| 'createProject'
|
||||
| 'createFile'
|
||||
>;
|
||||
/**
|
||||
* Returns the current penpot version.
|
||||
|
||||
@ -427,6 +427,14 @@ export function createApi(
|
||||
checkPermission('content:read', false);
|
||||
return plugin.context.management!.listFiles(options);
|
||||
},
|
||||
createProject(options) {
|
||||
checkPermission('content:write', false);
|
||||
return plugin.context.management!.createProject(options);
|
||||
},
|
||||
createFile(options) {
|
||||
checkPermission('content:write', false);
|
||||
return plugin.context.management!.createFile(options);
|
||||
},
|
||||
openFile(fileId, options) {
|
||||
checkPermission('content:read', false);
|
||||
return plugin.context.management!.openFile(fileId, options);
|
||||
|
||||
@ -108,6 +108,94 @@ describe('Plugin api', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('management creation', () => {
|
||||
it.each(['none', 'loading', 'ready'])(
|
||||
'creates projects and files with workspace status %s',
|
||||
async (status) => {
|
||||
const project = { id: 'project-id', name: 'Buttons' };
|
||||
const file = { id: 'file-id', name: 'Login' };
|
||||
const management = {
|
||||
workspace: { status },
|
||||
createProject: vi.fn().mockResolvedValue(project),
|
||||
createFile: vi.fn().mockResolvedValue(file),
|
||||
};
|
||||
const { penpotMgmt } = createApi({
|
||||
...pluginManager,
|
||||
manifest: {
|
||||
...pluginManager.manifest,
|
||||
permissions: ['content:write'],
|
||||
scope: 'global',
|
||||
},
|
||||
context: { ...pluginManager.context, management },
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
penpotMgmt.createProject({ name: 'Buttons' }),
|
||||
).resolves.toEqual(project);
|
||||
await expect(
|
||||
penpotMgmt.createProject({ name: 'Buttons', teamId: 'team-id' }),
|
||||
).resolves.toEqual(project);
|
||||
expect(management.createProject).toHaveBeenLastCalledWith({
|
||||
name: 'Buttons',
|
||||
teamId: 'team-id',
|
||||
});
|
||||
await expect(
|
||||
penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }),
|
||||
).resolves.toEqual(file);
|
||||
expect(management.createFile).toHaveBeenCalledWith({
|
||||
name: 'Login',
|
||||
projectId: 'project-id',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([{ permissions: [] }, { permissions: ['content:read'] }])(
|
||||
'requires content:write before creating with permissions $permissions',
|
||||
({ permissions }) => {
|
||||
const management = {
|
||||
createProject: vi.fn(),
|
||||
createFile: vi.fn(),
|
||||
};
|
||||
const { penpotMgmt } = createApi({
|
||||
...pluginManager,
|
||||
manifest: { ...pluginManager.manifest, permissions, scope: 'global' },
|
||||
context: { ...pluginManager.context, management },
|
||||
} as any);
|
||||
|
||||
expect(() => penpotMgmt.createProject({ name: 'Buttons' })).toThrow(
|
||||
'Permission content:write is not granted',
|
||||
);
|
||||
expect(() =>
|
||||
penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }),
|
||||
).toThrow('Permission content:write is not granted');
|
||||
expect(management.createProject).not.toHaveBeenCalled();
|
||||
expect(management.createFile).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves backend creation errors', async () => {
|
||||
const error = new Error('Access denied');
|
||||
const { penpotMgmt } = createApi({
|
||||
...pluginManager,
|
||||
manifest: { ...pluginManager.manifest, scope: 'global' },
|
||||
context: {
|
||||
...pluginManager.context,
|
||||
management: {
|
||||
createProject: vi.fn().mockRejectedValue(error),
|
||||
createFile: vi.fn().mockRejectedValue(error),
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
await expect(penpotMgmt.createProject({ name: 'Buttons' })).rejects.toBe(
|
||||
error,
|
||||
);
|
||||
await expect(
|
||||
penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }),
|
||||
).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui', () => {
|
||||
describe.concurrent('permissions', () => {
|
||||
const api = createApi({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user