Skip to content
Draft
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ At least **Redmine version `3.0` or higher** required. Recommended version `5.0`

| Feature | Unsupported Redmine version |
| --------------------------------------------------------------------------------- | --------------------------- |
| Auto-detect text formatting (CommonMark, Textile) from Redmine instance | `< 6.0.0` |
| Show only **enabled** issue field for selected tracker when _creating new issues_ | `< 5.0.0` |
| Show only **allowed statuses** when _updating issue_ | `< 5.0.0` |
| Show spent vs estimated hours | `< 5.0.0` |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@tanstack/react-query-devtools": "^5.99.0",
"@tanstack/react-query-persist-client": "^5.99.0",
"@tanstack/react-router": "^1.168.22",
"@uiw/react-md-editor": "^4.1.0",
"axios": "^1.15.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand Down
1,244 changes: 1,244 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

39 changes: 38 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"];
if (contentType && !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,36 @@ 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=${encodeURIComponent(file.name)}`, arrayBuffer, {
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> {
const resp = await this.instance.get<string>("/", {
headers: { Accept: "text/html" },
});
// available since Redmine 6.0.0
const match = String(resp.data).match(/data-text-formatting="(common_mark|textile|)"/);
if (match) {
if (match[1] === "") {
return "none";
}
return match[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;
};

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
254 changes: 254 additions & 0 deletions src/components/form/RedmineMdEditorField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { TAttachment, TUploadAttachment } from "@/api/redmine/types";
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
import { useFieldContext } from "@/hooks/useAppForm";
import { useSettings } from "@/provider/SettingsProvider";
import MDEditor, { commands } from "@uiw/react-md-editor";
import {
BoldIcon,
ChevronsLeftRightEllipsisIcon,
CodeXmlIcon,
EyeIcon,
Grid2x2PlusIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
HeadingIcon,
ImageIcon,
ItalicIcon,
ListIcon,
ListIndentIncreaseIcon,
ListOrderedIcon,
ListTodoIcon,
PencilIcon,
StrikethroughIcon,
TypeIcon,
UnderlineIcon,
} from "lucide-react";
import { ComponentProps, useId, useState } from "react";
import { useIntl } from "react-intl";

type RedmineTextEditorFieldProps = Omit<ComponentProps<typeof MDEditor>, "id" | "value" | "onChange" | "onBlur"> & {
required?: boolean;
attachments?: TAttachment[];
uploads?: TUploadAttachment[];
onUploadImage?: (file: File) => Promise<{ url: string; alt?: string } | void>;
};

export const RedmineMdEditorField = ({ title, required, className, attachments, uploads, onUploadImage, ...props }: RedmineTextEditorFieldProps) => {
const { state, handleChange, handleBlur } = useFieldContext<string | null>();
const isInvalid = !state.meta.isValid && state.meta.isTouched;
const id = useId();

const { settings } = useSettings();
const { formatMessage } = useIntl();

const [preview, setPreview] = useState<"edit" | "preview">("edit");

return (
<Field data-invalid={isInvalid} className={className}>
{title && (
<FieldLabel required={required} htmlFor={id} className="truncate">
{title}
</FieldLabel>
)}
<MDEditor
id={id}
aria-invalid={isInvalid}
commands={[
commands.group(
[
{
...commands.bold,
buttonProps: { "aria-label": "Bold", title: formatMessage({ id: "editor.command.bold" }) },
icon: <BoldIcon className="size-4" />,
},
{
...commands.italic,
buttonProps: { "aria-label": "Italic", title: formatMessage({ id: "editor.command.italic" }) },
icon: <ItalicIcon className="size-4" />,
},
{
name: "underline",
keyCommand: "underline",
shortcuts: "ctrlcmd+u",
prefix: "<u>",
suffix: "</u>",
buttonProps: { "aria-label": "Underline", title: formatMessage({ id: "editor.command.underline" }) },
icon: <UnderlineIcon className="size-4" />,
execute: commands.bold.execute,
},
{
...commands.strikethrough,
buttonProps: { "aria-label": "Strikethrough", title: formatMessage({ id: "editor.command.strikethrough" }) },
icon: <StrikethroughIcon className="size-4" />,
},
],
{
name: "format",
groupName: "format",
buttonProps: { "aria-label": "Format" },
icon: <TypeIcon className="size-4" />,
}
),
commands.group(
[
{
...commands.heading1,
buttonProps: { "aria-label": "Heading 1", title: formatMessage({ id: "editor.command.heading1" }) },
icon: <Heading1Icon className="size-4" />,
},
{
...commands.heading2,
buttonProps: { "aria-label": "Heading 2", title: formatMessage({ id: "editor.command.heading2" }) },
icon: <Heading2Icon className="size-4" />,
},
{
...commands.heading3,
buttonProps: { "aria-label": "Heading 3", title: formatMessage({ id: "editor.command.heading3" }) },
icon: <Heading3Icon className="size-4" />,
},
],
{
name: "heading",
groupName: "heading",
buttonProps: { "aria-label": "Heading" },
icon: <HeadingIcon className="size-4" />,
}
),
commands.group(
[
{
...commands.unorderedListCommand,
buttonProps: { "aria-label": "Unordered List", title: formatMessage({ id: "editor.command.unordered-list" }) },
icon: <ListIcon className="size-4" />,
},
{
...commands.orderedListCommand,
buttonProps: { "aria-label": "Ordered List", title: formatMessage({ id: "editor.command.ordered-list" }) },
icon: <ListOrderedIcon className="size-4" />,
},
{
...commands.checkedListCommand,
buttonProps: { "aria-label": "Checked List", title: formatMessage({ id: "editor.command.checked-list" }) },
icon: <ListTodoIcon className="size-4" />,
},
],
{
name: "list",
groupName: "list",
buttonProps: { "aria-label": "List" },
icon: <ListIcon className="size-4" />,
}
),
{
...commands.code,
buttonProps: { "aria-label": "Inline Code", title: formatMessage({ id: "editor.command.inline-code" }) },
icon: <ChevronsLeftRightEllipsisIcon className="size-4" />,
},
{
...commands.codeBlock,
buttonProps: { "aria-label": "Code Block", title: formatMessage({ id: "editor.command.code-block" }) },
icon: <CodeXmlIcon className="size-4" />,
},
{
...commands.quote,
buttonProps: { "aria-label": "Quote", title: formatMessage({ id: "editor.command.quote" }) },
icon: <ListIndentIncreaseIcon className="size-4" />,
},
{
...commands.table,
buttonProps: { "aria-label": "Table", title: formatMessage({ id: "editor.command.table" }) },
icon: <Grid2x2PlusIcon className="size-4" />,
},
{
...commands.image,
buttonProps: { "aria-label": "Image", title: formatMessage({ id: "editor.command.image" }) },
icon: <ImageIcon className="size-4" />,
},
]}
preview={preview}
extraCommands={[
preview === "edit"
? {
name: "preview",
keyCommand: "preview",
buttonProps: { "aria-label": "Preview", title: formatMessage({ id: "editor.command.preview" }) },
icon: <EyeIcon className="size-4" />,
execute: () => setPreview("preview"),
}
: {
name: "edit",
keyCommand: "preview",
buttonProps: { "aria-label": "Edit", title: formatMessage({ id: "editor.command.edit" }) },
icon: <PencilIcon className="size-4" />,
execute: () => setPreview("edit"),
},
]}
height={150}
{...props}
value={state.value ?? undefined}
onChange={(value) => handleChange(value ?? null)}
onBlur={handleBlur}
onPaste={async (event) => {
if (!onUploadImage) return;
const files = Array.from(event.clipboardData.items)
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((f): f is File => f !== null);
if (files.length === 0) return;
event.preventDefault();
for (const file of files) {
try {
const upload = await onUploadImage(file);
if (!upload) continue;
const imageMarkdown = `![${upload.alt}](${upload.url})`;
handleChange((prev) => (prev ?? "") + imageMarkdown + " ");
} catch (error) {
console.error("Image upload failed", error);
}
}
}}
previewOptions={{
/**
* Transform attachment or upload filename to actual preview URL
*/
urlTransform: (url) => {
if (!url.startsWith("http")) {
const attachment = attachments?.find((att) => att.filename === url);
if (attachment) {
return attachment.content_url;
}
const upload = uploads?.find((up) => up.filename === url);
const { uploadId } = upload?.token.match(/^(?<uploadId>\d+)\..*$/)?.groups || {};
if (upload && uploadId) {
return `${settings.redmineURL}/attachments/download/${uploadId}/${upload.filename}`;
}
}
return url;
},
/**
* Transform issue references like #123 or ##123 to links to the corresponding Redmine issue
*/
rehypeRewrite: (node, _i, parent) => {
if (node.type !== "text" || !parent || (parent.type === "element" && parent.tagName === "a")) return;
const parts = node.value.split(/((?:^|(?<=\s))##?\d+(?=$|[\s,.\-!]))/);
if (parts.length === 1) return;
parent.children = parts.map((part) => {
const match = part.match(/^##?(\d+)$/);
if (match) {
return {
type: "element",
tagName: "a",
properties: { href: `${settings.redmineURL}/issues/${match[1]}`, target: "_blank" },
children: [{ type: "text", value: part }],
} as const;
}
return { type: "text", value: part } as const;
});
},
}}
/>
{isInvalid && <FieldError errors={state.meta.errors} />}
</Field>
);
};
Loading
Loading