Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@makeplane/plane-node-sdk",
"version": "0.3.1",
"version": "0.3.2",
"description": "Node SDK for Plane",
"author": "Plane <engineering@plane.so>",
"repository": {
Expand Down
3 changes: 2 additions & 1 deletion scripts/__fixtures__/types-bundle-exports.snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -949,9 +949,10 @@ root WorkItemWorklogShapeParams WorkItemWorklogShapeParams 331380768833d1b4
root WorkLog WorkLog 90f273ede3ad2d88
root WorkLogs WorkLogs$1 222ab67fa4d6bdc6
root WorklogSummaryEntry WorklogSummaryEntry 62a9c9c9b87b025d
root Workspace Workspace$1 7870bade783326a9
root Workspace Workspace$1 060eb8698c3ccbc7
root WorkspaceAsset WorkspaceAsset 158f63c15d06b6c0
root WorkspaceAssetConfirmRequest WorkspaceAssetConfirmRequest a9b006b236b1b790
root WorkspaceAssetDownload WorkspaceAssetDownload b387abb5d86fee04
root WorkspaceAssetField WorkspaceAssetField 827cba08b2a129af
root WorkspaceAssetFieldsParams WorkspaceAssetFieldsParams c6a2feb8ef25577c
root WorkspaceAssetOrderBy WorkspaceAssetOrderBy 8aee13377cf9a336
Expand Down
14 changes: 14 additions & 0 deletions src/api/Workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BaseResource } from "./BaseResource";
import { Configuration } from "../Configuration";
import { WorkspaceMember, ListMembersLiteParams, ProjectRoleDistribution } from "../models/Member";
import { PaginatedResponse } from "../models/common";
import { WorkspaceAssetDownload } from "../models/WorkspaceAsset";
import { UpdateWorkspaceFeatures, WorkspaceFeatures } from "../models/WorkspaceFeatures";

/**
Expand Down Expand Up @@ -41,6 +42,19 @@ export class Workspace extends BaseResource {
return this.get<ProjectRoleDistribution>(`/workspaces/${workspaceSlug}/project-role-distribution/`);
}

/**
* Get a presigned download URL for a workspace asset.
* Covers any uploaded asset in the workspace, including images embedded in work
* item descriptions, which are not listed as work item attachments. The URL is
* time-limited and needs no Plane auth to fetch.
*
* @param workspaceSlug - The workspace slug
* @param assetId - UUID of the asset (the `src` of an `image-component` tag)
*/
async getAsset(workspaceSlug: string, assetId: string): Promise<WorkspaceAssetDownload> {
return this.get<WorkspaceAssetDownload>(`/workspaces/${workspaceSlug}/assets/${assetId}/`);
}

/**
* Retrieve workspace features
*/
Expand Down
13 changes: 13 additions & 0 deletions src/models/WorkspaceAsset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Presigned download details for a workspace asset.
*
* Returned by `client.workspace.getAsset()`. `asset_url` is time-limited and can
* be fetched without Plane authentication. It is `null` when the server could not
* presign the stored file (the endpoint still answers 200 in that case).
*/
export interface WorkspaceAssetDownload {
asset_id: string;
asset_url: string | null;
asset_name?: string | null;
asset_type?: string | null;
}
1 change: 1 addition & 0 deletions src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export * from "./WorkItemPage";
export * from "./WorkItemRelation";
export * from "./WorkItemType";
export * from "./WorkLog";
export * from "./WorkspaceAsset";
export * from "./WorkspaceFeatures";
export * from "./WorkspaceTemplate";
export * from "./WorkspaceProjectLabel";
Expand Down
12 changes: 10 additions & 2 deletions tests/e2e/v2/collections.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ maybe("v2 collections (live)", () => {
const collection = await wiki().collections.create(slug(), {
name: uniqueName("collection-pages"),
});
const page = await suite.projectRow.pages.create({
// A collection only holds workspace wiki pages — a project-scoped page is a 400
// ("Page ... is not a workspace wiki page"), so create this one at the workspace path.
const page = await wiki().pages.create(slug(), {
name: uniqueName("page-for-collection"),
});
try {
Expand All @@ -52,7 +54,13 @@ maybe("v2 collections (live)", () => {
const removed = await wiki().collections.pages.remove(slug(), collection.id, [page.id]);
expect(removed).toContain(page.id);
} finally {
await suite.projectRow.pages.delete(page.id).catch(() => undefined);
// A workspace page must be archived (and unlocked) before it can be deleted.
await wiki()
.pages.update(slug(), page.id, { archived_at: new Date().toISOString(), is_locked: false })
.catch(() => undefined);
await wiki()
.pages.delete(slug(), page.id)
.catch(() => undefined);
await wiki()
.collections.delete(slug(), collection.id)
.catch(() => undefined);
Expand Down
4 changes: 3 additions & 1 deletion tests/e2e/v2/group-sync.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ maybe("GroupSync (v2, live)", () => {
try {
const created = await groupSync().projectMappings.create(slug(), {
idp_group_name: `sdk-e2e-${Date.now()}`,
role_slug: "member",
// Project roles are admin/contributor/commenter/guest — there is no project `member`
// (that slug is a workspace role), so "member" here is a 400 "Unknown project role slug".
role_slug: "contributor",
project_id: suite.projectId,
});
createdId = created.id;
Expand Down
67 changes: 67 additions & 0 deletions tests/unit/workspace-assets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import nock from "nock";
import { PlaneClient } from "../../src/client/plane-client";
import { HttpError } from "../../src/errors";

const BASE = "https://plane.test";
const SLUG = "acme";
const ASSET_ID = "0b1f6d1e-6f0e-4f0a-9d63-2f3c1f6f2a11";
const ROUTE = `/api/v1/workspaces/${SLUG}/assets/${ASSET_ID}/`;

describe("Workspace.getAsset (offline)", () => {
const client = new PlaneClient({ baseUrl: BASE, apiKey: "test-key" });

afterEach(() => nock.cleanAll());

it("returns the download details", async () => {
const body = {
asset_id: ASSET_ID,
asset_url: "https://bucket.s3.example/signed?X-Amz-Signature=abc",
asset_name: "screenshot.png",
asset_type: "image/png",
};
nock(BASE).get(ROUTE).reply(200, body);

const asset = await client.workspace.getAsset(SLUG, ASSET_ID);

expect(asset).toEqual(body);
});

it("hits the workspace asset detail route with the API key header", async () => {
const scope = nock(BASE, { reqheaders: { "x-api-key": "test-key" } })
.get(ROUTE)
.reply(200, { asset_id: ASSET_ID, asset_url: "https://signed.example/x" });

await client.workspace.getAsset(SLUG, ASSET_ID);

expect(scope.isDone()).toBe(true);
});

it("tolerates a null url", async () => {
nock(BASE).get(ROUTE).reply(200, { asset_id: ASSET_ID, asset_url: null });

const asset = await client.workspace.getAsset(SLUG, ASSET_ID);

expect(asset.asset_url).toBeNull();
});

it("leaves optional fields undefined when the server omits them", async () => {
nock(BASE).get(ROUTE).reply(200, { asset_id: ASSET_ID, asset_url: "https://signed.example/x" });

const asset = await client.workspace.getAsset(SLUG, ASSET_ID);

expect(asset.asset_name).toBeUndefined();
expect(asset.asset_type).toBeUndefined();
});

it.each([
[400, "Asset not yet uploaded"],
[404, "Asset not found"],
])("raises HttpError for a %i response", async (status, error) => {
nock(BASE).get(ROUTE).reply(status, { error });

const failure = await client.workspace.getAsset(SLUG, ASSET_ID).catch((e: unknown) => e);

expect(failure).toBeInstanceOf(HttpError);
expect((failure as HttpError).statusCode).toBe(status);
});
});
Loading