diff --git a/src/api/types/project.ts b/src/api/types/project.ts
index 49b0a6628..08af727dd 100644
--- a/src/api/types/project.ts
+++ b/src/api/types/project.ts
@@ -9,6 +9,5 @@ export interface Project {
updated_at: string;
edit_access: boolean;
add_remove_access: boolean;
- // TODO: Add 'pinned' field on the server
pinned?: boolean;
}
diff --git a/src/common/Dropdown2.svelte b/src/common/Dropdown2.svelte
index ed8ba20d0..c3f2812cf 100644
--- a/src/common/Dropdown2.svelte
+++ b/src/common/Dropdown2.svelte
@@ -173,10 +173,10 @@
position: absolute;
}
.dropdown.top {
- bottom: 100%;
+ bottom: calc(100% + var(--offset, 0px));
}
.dropdown.bottom {
- top: 100%;
+ top: calc(100% + var(--offset, 0px));
}
.dropdown.left {
left: 0;
diff --git a/src/common/icons/Pin.svelte b/src/common/icons/Pin.svelte
index 969d2e7c3..28e3f06ec 100644
--- a/src/common/icons/Pin.svelte
+++ b/src/common/icons/Pin.svelte
@@ -16,6 +16,5 @@
svg {
display: block;
transform: rotate(-45deg);
- fill: var(--fill, var(--orange, #ec7b6b));
}
diff --git a/src/langs/json/en.json b/src/langs/json/en.json
index 2a82688b7..1e368daa6 100644
--- a/src/langs/json/en.json
+++ b/src/langs/json/en.json
@@ -7,7 +7,9 @@
"new": "New",
"learnMore": "Learn more",
"or": "or",
- "explore": "Explore"
+ "explore": "Explore",
+ "more": "More",
+ "add": "Add"
},
"homeTemplate": {
"signedIn": "Signed in as {name}",
@@ -682,7 +684,7 @@
"createProject": "Create your first project by clicking “New Project” above.",
"pinsEmpty": "Pinned projects will appear here for quick access",
"private": "Private Project",
- "public": "Public Project",
+ "public": "Public Projects",
"loading": "Loading project documents…",
"empty": "This project is empty",
"error": "Error loading project documents",
@@ -690,7 +692,57 @@
"yours": "Your Projects",
"shared": "Shared with you",
"none": "No projects found",
- "create": "Create project"
+ "create": "Create project",
+ "edit": "Edit project metadata",
+ "share": "Share & embed this project",
+ "users": "Manage Collaborators",
+ "add": "Add {n, plural, one {this document} other {# documents}} to a project",
+ "delete": {
+ "action": "Delete project",
+ "confirm": "Confirm delete",
+ "really": "Are you sure you want to delete this project ({project})?"
+ },
+ "placeholder": {
+ "projects": "Search projects",
+ "documents": "Search documents"
+ },
+ "collaborators": {
+ "title": "Collaborators",
+ "manage": "Change access",
+ "empty": "No collaborators",
+ "add": "Invite users to this project"
+ },
+ "fields": {
+ "title": "Title",
+ "description": "Description",
+ "private": "Private Project",
+ "pinned": "Pin project"
+ }
+ },
+ "collaborators": {
+ "remove": {
+ "label": "Remove user",
+ "confirm": "Confirm remove user",
+ "message": "Proceeding will remove {name} from {title}. Do you wish to continue?"
+ },
+ "add": "Add",
+ "addCollaborators": "Add Collaborators",
+ "invite": "Put in the email of an existing DocumentCloud user below. If they don't have an account, have them register here for free, and then ask them to log in to DocumentCloud at least once.",
+ "admin": "Admin",
+ "view": "View",
+ "edit": "Edit",
+ "help": {
+ "admin": "Collaborators can edit this project and its documents",
+ "edit": "Collaborators can edit documents in this project",
+ "view": "Collaborators can view documents in this project",
+ "remove": "Remove this user from this project"
+ },
+ "empty": "You have not yet added any collaborators to this project. Invite collaborators to grant other users access to the documents shared in this project. You can control whether collaborators have access to view/edit the project’s documents or be an admin with permissions to invite other users and edit the project itself.",
+ "manage": "Manage Collaborators",
+ "name": "Name",
+ "access": "Access",
+ "you": "(you)",
+ "change": "Change access"
},
"organizations": {
"sameOrgUsers": "Users in organization"
diff --git a/src/lib/api/collaborators.ts b/src/lib/api/collaborators.ts
new file mode 100644
index 000000000..6f6b43de1
--- /dev/null
+++ b/src/lib/api/collaborators.ts
@@ -0,0 +1,104 @@
+// manage users in a project
+import type { ProjectAccess, ProjectUser } from "./types";
+
+import { APP_URL, BASE_API_URL, CSRF_HEADER_NAME } from "@/config/config.js";
+import { getAll, isErrorCode } from "$lib/utils/api";
+
+export async function list(project_id: number, fetch = globalThis.fetch) {
+ const endpoint = new URL(
+ `projects/${project_id}/users/?expand=user`,
+ BASE_API_URL,
+ );
+
+ return getAll(endpoint, undefined, fetch);
+}
+
+export async function add(
+ project_id: number,
+ user: { email: string; access: ProjectAccess },
+ csrf_token: string,
+ fetch = globalThis.fetch,
+) {
+ const endpoint = new URL(`projects/${project_id}/users/`, BASE_API_URL);
+
+ const resp = await fetch(endpoint, {
+ body: JSON.stringify(user),
+ credentials: "include",
+ headers: {
+ "Content-type": "application/json",
+ [CSRF_HEADER_NAME]: csrf_token,
+ Referer: APP_URL,
+ },
+ method: "POST",
+ }).catch(console.error);
+
+ if (!resp) {
+ throw new Error("API unavailable");
+ }
+
+ if (isErrorCode(resp.status)) {
+ const data = await resp.json();
+ console.error(data);
+ throw new Error(resp.statusText);
+ }
+
+ return resp.json();
+}
+
+export async function update(
+ project_id: number,
+ user_id: number,
+ access: ProjectAccess,
+ csrf_token: string,
+ fetch = globalThis.fetch,
+) {
+ const endpoint = new URL(
+ `projects/${project_id}/users/${user_id}/`,
+ BASE_API_URL,
+ );
+
+ const resp = await fetch(endpoint, {
+ body: JSON.stringify({ user: user_id, access }),
+ credentials: "include",
+ headers: {
+ "Content-type": "application/json",
+ [CSRF_HEADER_NAME]: csrf_token,
+ Referer: APP_URL,
+ },
+ method: "PATCH",
+ }).catch(console.error);
+
+ if (!resp) {
+ throw new Error("API unavailable");
+ }
+
+ if (isErrorCode(resp.status)) {
+ const data = await resp.json();
+ console.error(data);
+ throw new Error(resp.statusText);
+ }
+
+ return resp.json();
+}
+
+export async function remove(
+ project_id: number,
+ user_id: number,
+ csrf_token: string,
+ fetch = globalThis.fetch,
+) {
+ const endpoint = new URL(
+ `projects/${project_id}/users/${user_id}/`,
+ BASE_API_URL,
+ );
+
+ return fetch(endpoint, {
+ credentials: "include",
+ headers: {
+ "Content-type": "application/json",
+ [CSRF_HEADER_NAME]: csrf_token,
+ Referer: APP_URL,
+ },
+ method: "DELETE",
+ });
+}
diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts
index 379c7d7ff..7fdcaefe1 100644
--- a/src/lib/api/projects.ts
+++ b/src/lib/api/projects.ts
@@ -1,19 +1,20 @@
// api methods for projects
import type { Page } from "@/api/types";
-import type { Project, ProjectResults, Document } from "./types";
+import type {
+ APIError,
+ Document,
+ Project,
+ ProjectMembershipItem,
+ ProjectResults,
+} from "./types";
-import { error, type NumericRange } from "@sveltejs/kit";
-
-import { BASE_API_URL, CSRF_HEADER_NAME } from "@/config/config.js";
+import { APP_URL, BASE_API_URL, CSRF_HEADER_NAME } from "@/config/config.js";
import { getAll, isErrorCode } from "$lib/utils/api";
/**
* Get a single project
*
* @export
- * @param {number} id
- * @param {globalThis.fetch} fetch
- * @returns {Promise}
*/
export async function get(
id: number,
@@ -21,28 +22,28 @@ export async function get(
): Promise {
const endpoint = new URL(`projects/${id}/`, BASE_API_URL);
- const res = await fetch(endpoint, { credentials: "include" }).catch((e) => {
- error(500, { message: e });
- });
+ const resp = await fetch(endpoint, { credentials: "include" }).catch(
+ console.error,
+ );
+
+ if (!resp) {
+ throw new Error("API error");
+ }
- if (isErrorCode(res.status)) {
- error(res.status, {
- message: res.statusText,
- });
+ if (isErrorCode(resp.status)) {
+ throw new Error(resp.statusText);
}
- return res.json();
+ return resp.json();
}
/**
* Get a page of projects
*
* @export
- * @param {any} params filter params
- * @param {globalThis.fetch} fetch
*/
export async function list(
- params: any = {},
+ params: Record = {},
fetch = globalThis.fetch,
): Promise {
const endpoint = new URL("projects/", BASE_API_URL);
@@ -51,33 +52,43 @@ export async function list(
endpoint.searchParams.set(k, String(v));
}
- const res = await fetch(endpoint, { credentials: "include" }).catch((e) => {
- error(500, { message: e });
- });
+ const resp = await fetch(endpoint, { credentials: "include" }).catch(
+ console.error,
+ );
+
+ if (!resp) {
+ throw new Error("API error");
+ }
- if (isErrorCode(res.status)) {
- error(res.status, {
- message: res.statusText,
- });
+ if (isErrorCode(resp.status)) {
+ throw new Error(resp.statusText);
}
- return res.json();
+ return resp.json();
}
-/**
- * Get a list of all projects owned by the user
- */
-export async function getOwned(
+export async function getForUser(
userId: number,
query?: string,
fetch = globalThis.fetch,
-): Promise {
+) {
const endpoint = new URL("projects/", BASE_API_URL);
endpoint.searchParams.set("user", String(userId));
if (query) {
endpoint.searchParams.set("query", query);
}
- const projects = await getAll(endpoint, undefined, fetch);
+ return getAll(endpoint, undefined, fetch);
+}
+
+/**
+ * Get a list of all projects owned by the user
+ */
+export async function getOwned(
+ userId: number,
+ query?: string,
+ fetch = globalThis.fetch,
+): Promise {
+ const projects = await getForUser(userId, query, fetch);
return projects.filter((project) => project.user === userId);
}
@@ -89,12 +100,7 @@ export async function getShared(
query?: string,
fetch = globalThis.fetch,
): Promise {
- const endpoint = new URL("projects/", BASE_API_URL);
- endpoint.searchParams.set("user", String(userId));
- if (query) {
- endpoint.searchParams.set("query", query);
- }
- const projects = await getAll(endpoint, undefined, fetch);
+ const projects = await getForUser(userId, query, fetch);
return projects.filter((project) => project.user !== userId);
}
@@ -104,9 +110,9 @@ export async function getShared(
* it returns the updated project object.
*/
export async function pinProject(
- csrftoken: string,
id: number,
pinned = true,
+ csrf_token: string,
fetch = globalThis.fetch,
): Promise {
const endpoint = new URL(`projects/${id}/`, BASE_API_URL);
@@ -114,30 +120,213 @@ export async function pinProject(
credentials: "include",
method: "PATCH", // this component can only update whether a project is pinned
headers: {
- [CSRF_HEADER_NAME]: csrftoken,
+ [CSRF_HEADER_NAME]: csrf_token,
"Content-type": "application/json",
},
};
// The endpoint returns the updated project
- const res = await fetch(endpoint, {
+ const resp = await fetch(endpoint, {
...options,
body: JSON.stringify({ pinned }),
+ }).catch(console.error);
+
+ if (!resp) {
+ throw new Error("API error");
+ }
+
+ if (isErrorCode(resp.status)) {
+ throw new Error(resp.statusText);
+ }
+
+ return resp.json();
+}
+
+// writable methods
+/**
+ * Create a new project
+ *
+ * @param project
+ * @param csrf_token
+ * @param fetch
+ */
+export async function create(
+ project: {
+ title: string;
+ description?: string;
+ private?: boolean;
+ pinned?: boolean;
+ },
+ csrf_token: string,
+ fetch = globalThis.fetch,
+) {
+ const endpoint = new URL("projects/", BASE_API_URL);
+ const resp = await fetch(endpoint, {
+ body: JSON.stringify(project),
+ credentials: "include",
+ headers: {
+ "Content-type": "application/json",
+ [CSRF_HEADER_NAME]: csrf_token,
+ Referer: APP_URL,
+ },
+ method: "POST",
+ }).catch(console.error);
+
+ if (!resp) {
+ throw new Error("API unavailable");
+ }
+
+ if (isErrorCode(resp.status)) {
+ const { data } = await resp.json();
+ throw new Error(data);
+ }
+
+ return resp.json();
+}
+
+export async function edit(
+ project_id: number,
+ data: Partial,
+ csrf_token: string,
+ fetch = globalThis.fetch,
+) {
+ const endpoint = new URL(`projects/${project_id}/`, BASE_API_URL);
+
+ const resp = await fetch(endpoint, {
+ body: JSON.stringify(data),
+ credentials: "include",
+ headers: {
+ "Content-type": "application/json",
+ [CSRF_HEADER_NAME]: csrf_token,
+ Referer: APP_URL,
+ },
+ method: "PATCH",
+ }).catch(console.error);
+
+ if (!resp) {
+ throw new Error("API unavailable");
+ }
+
+ if (isErrorCode(resp.status)) {
+ const { data } = await resp.json();
+ throw new Error(data);
+ }
+
+ return resp.json();
+}
+
+/**
+ * Delete a project. There is no undo.
+ *
+ * @param project_id
+ * @param csrf_token
+ * @param fetch
+ */
+export async function destroy(
+ project_id: number,
+ csrf_token: string,
+ fetch = globalThis.fetch,
+) {
+ const endpoint = new URL(`projects/${project_id}/`, BASE_API_URL);
+
+ return fetch(endpoint, {
+ credentials: "include",
+ headers: {
+ "Content-type": "application/json",
+ [CSRF_HEADER_NAME]: csrf_token,
+ Referer: APP_URL,
+ },
+ method: "DELETE",
});
- if (isErrorCode(res.status)) {
- error(res.status, {
- message: res.statusText,
- });
+}
+
+/**
+ * Add documents to a project
+ *
+ * @param project_id
+ * @param documents
+ * @param csrf_token
+ * @param fetch
+ */
+export async function add(
+ project_id: number,
+ documents: (string | number)[],
+ csrf_token: string,
+ fetch = globalThis.fetch,
+): Promise {
+ const endpoint = new URL(`projects/${project_id}/documents/`, BASE_API_URL);
+ const data = documents.map((document) => ({ document }));
+ const resp = await fetch(endpoint, {
+ body: JSON.stringify(data),
+ credentials: "include",
+ headers: {
+ "Content-type": "application/json",
+ [CSRF_HEADER_NAME]: csrf_token,
+ Referer: APP_URL,
+ },
+ method: "POST",
+ }).catch(console.error);
+
+ if (!resp) {
+ throw new Error("API unavailable");
+ }
+
+ // trying out some new error handling
+ if (isErrorCode(resp.status)) {
+ return {
+ error: {
+ status: resp.status,
+ message: resp.statusText,
+ ...(await resp.json()),
+ },
+ };
+ }
+
+ return resp.json();
+}
+
+export async function remove(
+ project_id: number,
+ documents: (string | number)[],
+ csrf_token: string,
+ fetch = globalThis.fetch,
+): Promise {
+ const endpoint = new URL(`projects/${project_id}/documents/`, BASE_API_URL);
+ endpoint.searchParams.set("document_id__in", documents.join(","));
+
+ const resp = await fetch(endpoint, {
+ credentials: "include",
+ headers: {
+ "Content-type": "application/json",
+ [CSRF_HEADER_NAME]: csrf_token,
+ Referer: APP_URL,
+ },
+ method: "DELETE",
+ }).catch(console.error);
+
+ if (!resp) {
+ throw new Error("API unavailable");
}
- return res.json();
+
+ // trying out some new error handling
+ if (isErrorCode(resp.status)) {
+ return {
+ error: {
+ status: resp.status,
+ message: resp.statusText,
+ ...(await resp.json()),
+ },
+ };
+ }
+
+ return null;
}
/**
* Get documents in a project with membership access
*
+ * @deprecated
* @export
- * @param {number} id
- * @param {globalThis.fetch} fetch
*/
export async function documents(
id: number | string,
@@ -151,15 +340,23 @@ export async function documents(
endpoint.searchParams.set("ordering", "-created_at");
endpoint.searchParams.set("per_page", "12");
- const res = await fetch(endpoint, { credentials: "include" }).catch((e) => {
- error(500, { message: e });
- });
+ const resp = await fetch(endpoint, { credentials: "include" }).catch(
+ console.error,
+ );
+
+ if (!resp) {
+ throw new Error("API error");
+ }
- if (isErrorCode(res.status)) {
- error(res.status, {
- message: res.statusText,
- });
+ if (isErrorCode(resp.status)) {
+ throw new Error(resp.statusText);
}
- return res.json();
+ return resp.json();
+}
+
+// utils
+
+export function canonicalUrl(project: Project): URL {
+ return new URL(`documents/projects/${project.id}-${project.slug}/`, APP_URL);
}
diff --git a/src/lib/api/tests/collaborators.test.ts b/src/lib/api/tests/collaborators.test.ts
new file mode 100644
index 000000000..6e354a2ca
--- /dev/null
+++ b/src/lib/api/tests/collaborators.test.ts
@@ -0,0 +1,177 @@
+import type { Page, Project, ProjectAccess, ProjectUser, User } from "../types";
+
+import { afterEach, describe, expect, test as base, vi } from "vitest";
+import { APP_URL, BASE_API_URL, CSRF_HEADER_NAME } from "@/config/config.js";
+import * as collaborators from "../collaborators";
+
+type Use = (value: T) => Promise;
+
+const test = base.extend({
+ async me({}, use: Use) {
+ const { me } = await import("@/test/fixtures/accounts");
+
+ await use(me);
+ },
+
+ async project({}, use: Use) {
+ const { project } = await import("@/test/fixtures/projects");
+
+ await use(project);
+ },
+
+ async users({}, use: Use>) {
+ const { default: users } = await import(
+ "@/test/fixtures/projects/project-users.json"
+ );
+
+ await use(users as Page);
+ },
+});
+
+describe("manage project users", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("collaborators.list", async ({ project, users }) => {
+ const mockFetch = vi.fn().mockImplementation(async (endpoint, options) => {
+ return {
+ ok: true,
+ status: 200,
+ async json() {
+ return users;
+ },
+ };
+ });
+
+ const result = await collaborators.list(project.id, mockFetch);
+
+ expect(result).toEqual(users.results);
+ expect(mockFetch).toBeCalledWith(
+ new URL(
+ `projects/${project.id}/users/?expand=user&per_page=100`,
+ BASE_API_URL,
+ ),
+ { credentials: "include" },
+ );
+ });
+
+ test("collaborators.add", async ({ project, users, me }) => {
+ const mockFetch = vi.fn().mockImplementation(async (endpoint, options) => {
+ // this doesn't have uniform expansion but I think that's ok for now
+ const updated = [...users.results, { user: me, access: "admin" }];
+ return {
+ ok: true,
+ status: 200,
+ async json() {
+ return {
+ ...users,
+ results: updated,
+ };
+ },
+ };
+ });
+
+ const result = await collaborators.add(
+ project.id,
+ { email: me.email, access: "admin" },
+ "token",
+ mockFetch,
+ );
+
+ expect(result.results).toMatchObject([
+ ...users.results,
+ { user: me, access: "admin" },
+ ]);
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ new URL(`projects/${project.id}/users/`, BASE_API_URL),
+ {
+ body: JSON.stringify({ email: me.email, access: "admin" }),
+ credentials: "include",
+ headers: {
+ "Content-type": "application/json",
+ [CSRF_HEADER_NAME]: "token",
+ Referer: APP_URL,
+ },
+ method: "POST",
+ },
+ );
+ });
+
+ test("collaborators.update", async ({ project, users }) => {
+ const mockFetch = vi.fn().mockImplementation(async (endpoint, options) => {
+ const { user, access } = JSON.parse(options.body);
+ const updated: ProjectUser = users.results.find(
+ (u) => u.user.id === 1020,
+ );
+
+ return {
+ ok: true,
+ status: 200,
+ async json() {
+ return {
+ user: updated.user,
+ access,
+ };
+ },
+ };
+ });
+
+ const me = users.results.find((u) => u.user.id === 1020);
+
+ const updated = await collaborators.update(
+ project.id,
+ me.user.id,
+ "edit",
+ "token",
+ mockFetch,
+ );
+
+ expect(updated).toMatchObject({ user: me.user, access: "edit" });
+ expect(mockFetch).toHaveBeenCalledWith(
+ new URL(`projects/${project.id}/users/${me.user.id}/`, BASE_API_URL),
+ {
+ body: JSON.stringify({ user: me.user.id, access: "edit" }),
+ credentials: "include",
+ headers: {
+ "Content-type": "application/json",
+ [CSRF_HEADER_NAME]: "token",
+ Referer: APP_URL,
+ },
+ method: "PATCH",
+ },
+ );
+ });
+
+ test("collaborators.remove", async ({ project, users }) => {
+ const mockFetch = vi.fn().mockImplementation(async (endpoint, options) => {
+ return {
+ ok: true,
+ status: 204,
+ };
+ });
+
+ const me = users.results.find((u) => u.user.id === 1020);
+ const resp = await collaborators.remove(
+ project.id,
+ me.user.id,
+ "token",
+ mockFetch,
+ );
+
+ expect(resp.status).toStrictEqual(204);
+ expect(mockFetch).toHaveBeenCalledWith(
+ new URL(`projects/${project.id}/users/${me.user.id}/`, BASE_API_URL),
+ {
+ credentials: "include",
+ headers: {
+ "Content-type": "application/json",
+ [CSRF_HEADER_NAME]: "token",
+ Referer: APP_URL,
+ },
+ method: "DELETE",
+ },
+ );
+ });
+});
diff --git a/src/lib/api/tests/projects.test.ts b/src/lib/api/tests/projects.test.ts
index f14b5d36e..259db5556 100644
--- a/src/lib/api/tests/projects.test.ts
+++ b/src/lib/api/tests/projects.test.ts
@@ -1,10 +1,32 @@
-import { vi, describe, it, test, expect, beforeEach, afterEach } from "vitest";
+import type { Page, Project, ProjectMembershipItem } from "$lib/api/types";
-import { BASE_API_URL } from "@/config/config";
+import {
+ vi,
+ describe,
+ it,
+ test as base,
+ expect,
+ beforeEach,
+ afterEach,
+} from "vitest";
+
+import { APP_URL, BASE_API_URL, CSRF_HEADER_NAME } from "@/config/config";
import { project, projectList } from "@/test/fixtures/projects";
import * as projects from "../projects";
+type Use = (value: T) => Promise;
+
+const test = base.extend({
+ async documents({}, use: Use>) {
+ const { default: documents } = await import(
+ "@/test/fixtures/projects/project-documents.json"
+ );
+
+ await use(documents);
+ },
+});
+
describe("projects.get", () => {
let mockFetch;
beforeEach(() => {
@@ -80,6 +102,32 @@ describe("projects.list", () => {
});
});
+describe("projects for users", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("projects.getForUser", async () => {
+ const mockFetch = vi.fn().mockImplementation(async (endpont, options) => {
+ return {
+ ok: true,
+ status: 200,
+ async json() {
+ return projectList;
+ },
+ };
+ });
+
+ const result = await projects.getForUser(1, undefined, mockFetch);
+
+ expect(result).toMatchObject(projectList.results);
+ expect(mockFetch).toHaveBeenCalledWith(
+ new URL(`projects/?user=1&per_page=100`, BASE_API_URL),
+ { credentials: "include" },
+ );
+ });
+});
+
describe("projects.getOwned", () => {
let mockFetch;
beforeEach(() => {
@@ -152,7 +200,7 @@ describe("projects.pinProject", () => {
vi.restoreAllMocks();
});
it("makes a PATCH request to the provided project ID", async () => {
- await projects.pinProject("csrftoken", 1, false, mockFetch);
+ await projects.pinProject(1, false, "csrftoken", mockFetch);
expect(mockFetch).toHaveBeenCalledWith(
new URL(`${BASE_API_URL}projects/1/`),
{
@@ -169,7 +217,7 @@ describe("projects.pinProject", () => {
it("throws a 500 error if fetch fails", async () => {
mockFetch = vi.fn().mockRejectedValue("Error");
await expect(
- projects.pinProject("csrftoken", 1, false, mockFetch),
+ projects.pinProject(1, false, "csrftoken", mockFetch),
).rejects.toThrowError();
});
it("throws an error if fetch succeeds with an error status", async () => {
@@ -179,9 +227,176 @@ describe("projects.pinProject", () => {
statusText: "Whoops",
});
await expect(
- projects.pinProject("csrftoken", 1, false, mockFetch),
+ projects.pinProject(1, false, "csrftoken", mockFetch),
).rejects.toThrowError();
});
});
-test.todo("projects.documents");
+describe("project lifecycle", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("projects.create", async () => {
+ const mockFetch = vi.fn().mockImplementation(async (endpoint, options) => {
+ return {
+ ok: true,
+ status: 201,
+ async json() {
+ return project;
+ },
+ };
+ });
+
+ const data = {
+ title: project.title,
+ description: project.description,
+ private: project.private,
+ pinned: project.pinned,
+ };
+
+ const created = await projects.create(data, "token", mockFetch);
+
+ expect(created).toMatchObject(project);
+ expect(mockFetch).toBeCalledWith(new URL("projects/", BASE_API_URL), {
+ body: JSON.stringify(data),
+ credentials: "include",
+ headers: {
+ "Content-type": "application/json",
+ [CSRF_HEADER_NAME]: "token",
+ Referer: APP_URL,
+ },
+ method: "POST",
+ });
+ });
+
+ test("projects.edit", async () => {
+ const mockFetch = vi.fn().mockImplementation(async (endpoint, options) => {
+ const update = JSON.parse(options.body);
+ return {
+ ok: true,
+ status: 200,
+ async json() {
+ return { ...project, ...update };
+ },
+ };
+ });
+
+ const update: Partial = { title: "New title" };
+
+ const updated = await projects.edit(project.id, update, "token", mockFetch);
+
+ expect(updated).toMatchObject({ ...project, ...update });
+ expect(mockFetch).toHaveBeenCalledWith(
+ new URL(`projects/${project.id}/`, BASE_API_URL),
+ {
+ body: JSON.stringify(update),
+ credentials: "include",
+ headers: {
+ "Content-type": "application/json",
+ [CSRF_HEADER_NAME]: "token",
+ Referer: APP_URL,
+ },
+ method: "PATCH",
+ },
+ );
+ });
+
+ test("projects.destroy", async () => {
+ const mockFetch = vi.fn().mockImplementation(async (endpoint, options) => {
+ return {
+ ok: true,
+ status: 204,
+ };
+ });
+
+ const resp = await projects.destroy(project.id, "token", mockFetch);
+
+ expect(resp.status).toEqual(204);
+ expect(mockFetch).toHaveBeenCalledWith(
+ new URL(`projects/${project.id}/`, BASE_API_URL),
+ {
+ credentials: "include",
+ headers: {
+ "Content-type": "application/json",
+ [CSRF_HEADER_NAME]: "token",
+ Referer: APP_URL,
+ },
+ method: "DELETE",
+ },
+ );
+ });
+});
+
+describe("project utils", () => {
+ test("projects.canonicalUrl", () => {
+ const url = projects.canonicalUrl(project);
+ expect(url).toStrictEqual(
+ new URL(`documents/projects/${project.id}-${project.slug}/`, APP_URL),
+ );
+ });
+});
+
+describe("manage project documents", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("projects.add", async ({ documents }) => {
+ const mockFetch = vi.fn().mockImplementation(async (endpoint, options) => {
+ const docs = JSON.parse(options.body).map((d) => {
+ d.edit_access = true;
+ return d;
+ });
+
+ return {
+ ok: true,
+ status: 201,
+ async json() {
+ return docs;
+ },
+ };
+ });
+
+ const ids = documents.results.map((d) => d.document as number);
+ const docs = await projects.add(1, ids, "token", mockFetch);
+
+ expect(docs).toMatchObject(documents.results);
+ expect(mockFetch).toBeCalledWith(
+ new URL("projects/1/documents/", BASE_API_URL),
+ {
+ body: JSON.stringify(ids.map((document) => ({ document }))),
+ credentials: "include",
+ headers: {
+ "Content-type": "application/json",
+ [CSRF_HEADER_NAME]: "token",
+ Referer: APP_URL,
+ },
+ method: "POST",
+ },
+ );
+ });
+
+ test("projects.remove", async ({ documents }) => {
+ const mockFetch = vi.fn().mockImplementation(async () => {
+ return {
+ ok: true,
+ status: 204,
+ };
+ });
+ const ids = documents.results.map((d) => d.document as number);
+ const endpoint = new URL(`projects/1/documents/`, BASE_API_URL);
+ endpoint.searchParams.set("document_id__in", ids.join(","));
+
+ expect(await projects.remove(1, ids, "token", mockFetch));
+ expect(mockFetch).toBeCalledWith(endpoint, {
+ credentials: "include",
+ headers: {
+ "Content-type": "application/json",
+ [CSRF_HEADER_NAME]: "token",
+ Referer: APP_URL,
+ },
+ method: "DELETE",
+ });
+ });
+});
diff --git a/src/lib/api/types.d.ts b/src/lib/api/types.d.ts
index 7f8e012e4..7b39d1156 100644
--- a/src/lib/api/types.d.ts
+++ b/src/lib/api/types.d.ts
@@ -14,6 +14,8 @@ export type { Page, User, Org, Project };
export type Access = "public" | "private" | "organization"; // https://www.documentcloud.org/help/api#access-levels
+export type ProjectAccess = "view" | "edit" | "admin";
+
export type Data = Record;
export type Highlights = Record;
@@ -31,6 +33,13 @@ export type ViewerMode = ReadMode | WriteMode;
export type Zoom = number | Sizes | "width" | "height";
+export interface APIError {
+ error: {
+ status: number;
+ message: string;
+ };
+}
+
export interface NoteHighlight {
title: string[];
description: string[];
@@ -151,7 +160,7 @@ export interface Document {
file_url?: string | URL;
// expandable relationship fields
- projects?: number[] | Project[];
+ projects?: (Project | number)[];
notes?: Note[];
sections?: Section[];
revisions?: Revision[];
@@ -214,6 +223,11 @@ export interface ProjectMembershipItem {
edit_access: boolean;
}
+export interface ProjectUser {
+ user: User;
+ access: ProjectAccess;
+}
+
export type ProjectMembershipList = Page;
export interface OCREngine {
diff --git a/src/lib/components/accounts/tests/__snapshots__/UserMenu.test.ts.snap b/src/lib/components/accounts/tests/__snapshots__/UserMenu.test.ts.snap
index 730655aed..04de28da8 100644
--- a/src/lib/components/accounts/tests/__snapshots__/UserMenu.test.ts.snap
+++ b/src/lib/components/accounts/tests/__snapshots__/UserMenu.test.ts.snap
@@ -4,11 +4,11 @@ exports[`UserMenu 1`] = `