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: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@tanstack/react-query-devtools": "^5.101.2",
"@tanstack/react-query-persist-client": "^5.101.2",
"@tanstack/react-router": "^1.170.17",
"@uiw/react-md-editor": "^4.1.1",
"axios": "^1.18.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand All @@ -55,6 +56,7 @@
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.7",
"react-intl": "^10.1.15",
"react-reverse-portal": "^2.3.0",
"recharts": "^3.9.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
Expand Down
1,271 changes: 1,271 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

51 changes: 50 additions & 1 deletion src/api/redmine/RedmineApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
TTimeEntryActivity,
TUpdateIssue,
TUpdateTimeEntry,
TUploadAttachment,
TUploadResponse,
TUser,
TVersion,
} from "./types";
Expand All @@ -44,6 +46,9 @@ export class RedmineApiClient {
});
this.instance.interceptors.response.use(
(response) => {
if (response.config.headers?.["Accept"] === "text/html") {
return response;
}
const contentType = response.headers["content-type"];
Comment thread
CrawlerCode marked this conversation as resolved.
if (contentType && typeof contentType === "string" && !contentType.startsWith("application/json")) {
throw new Error(`Invalid content-type '${contentType}'. Expected 'application/json'`);
Expand Down Expand Up @@ -137,7 +142,7 @@ export class RedmineApiClient {
}

async getIssue(id: number): Promise<TIssue> {
return this.instance.get(`/issues/${id}.json?include=allowed_statuses`).then((res) => res.data.issue);
return this.instance.get(`/issues/${id}.json?include=allowed_statuses,attachments`).then((res) => res.data.issue);
}

async createIssue(issue: TCreateIssue) {
Expand Down Expand Up @@ -295,4 +300,48 @@ export class RedmineApiClient {
async getCurrentUser(): Promise<TUser> {
return this.instance.get("/users/current.json?include=memberships").then((res) => res.data.user);
}

// Attachments
async uploadAttachment(file: File): Promise<TUploadAttachment> {
const arrayBuffer = await file.arrayBuffer();
const response = await this.instance.post<TUploadResponse>(`/uploads.json?filename=${file.name}`, arrayBuffer, {
Comment thread
CrawlerCode marked this conversation as resolved.
headers: { "Content-Type": "application/octet-stream" },
});
return {
token: response.data.upload.token,
filename: file.name,
content_type: file.type,
};
}

async removeAttachment(id: number): Promise<void> {
await this.instance.delete(`/attachments/${id}.json`);
}

// Other
async detectTextFormatting(): Promise<"none" | "common_mark" | "textile" | undefined> {
// available since Redmine 6.0.0
const indexPage = await this.instance.get<string>("/", {
headers: { Accept: "text/html" },
});
const matchFormatting = String(indexPage.data).match(/data-text-formatting="(common_mark|textile|)"/);
if (matchFormatting) {
if (matchFormatting[1] === "") {
return "none";
}
return matchFormatting[1] as "common_mark" | "textile";
}

// fallback for Redmine < 6.0.0
const newsPage = await this.instance.get<string>("/news", {
headers: { Accept: "text/html" },
});
const matchToolbar = String(newsPage.data).match(/javascripts\/jstoolbar\/(common_mark|markdown|textile).js/);
if (matchToolbar) {
if (matchToolbar[1] === "markdown") {
return "common_mark";
}
return matchToolbar[1] as "common_mark" | "textile";
}
}
}
29 changes: 29 additions & 0 deletions src/api/redmine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export type TIssue = {
closed_on?: string;
allowed_statuses?: TIssueStatus[]; // available since Redmine 5.0.0
custom_fields?: TCustomFieldValue[];
attachments?: TAttachment[]; // available since Redmine 3.4.0
};

export type TCreateIssue = {
Expand All @@ -56,11 +57,13 @@ export type TCreateIssue = {
due_date?: Date | null;
estimated_hours?: number | null;
done_ratio?: number | null;
uploads?: TUploadAttachment[];
};

export type TUpdateIssue = Partial<TCreateIssue> & {
notes?: string | null;
private_notes?: boolean;
uploads?: TUploadAttachment[];
};

export type TIssuePriority = {
Expand All @@ -87,6 +90,32 @@ export type TSearchResult = {
description: string;
};

export type TUploadResponse = {
upload: {
id: number;
token: string;
};
};

export type TUploadAttachment = {
token: string;
filename: string;
content_type?: string;
description?: string;
Comment thread
CrawlerCode marked this conversation as resolved.
};

export type TAttachment = {
id: number;
filename: string;
description: string;
content_type: string;
filesize: number;
content_url: string;
thumbnail_url: string;
created_on: string;
author: TReference;
};

// Projects
export type TProject = {
id: number;
Expand Down
Loading
Loading