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: 39 additions & 0 deletions 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 @@ -295,4 +300,38 @@ 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 & { id: number; url: string }> {
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 {
id: response.data.upload.id,
token: response.data.upload.token,
filename: file.name,
content_type: file.type,
url: `${this.instance.defaults.baseURL}/attachments/download/${response.data.upload.id}/${encodeURIComponent(file.name)}`,
};
}

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";
}
}
}
15 changes: 15 additions & 0 deletions src/api/redmine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,17 @@ export type TCreateIssue = {
done_ratio?: number | null;
};

export type TUploadAttachment = {
token: string;
filename: string;
content_type?: string;
description?: string;
};

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

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

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

// Projects
export type TProject = {
id: number;
Expand Down
210 changes: 210 additions & 0 deletions src/components/form/RedmineMdEditorField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
import { useFieldContext } from "@/hooks/useAppForm";
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;
onUploadImage?: (file: File) => Promise<{ url: string; alt?: string } | void>;
};

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

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);
}
}
}}
/>
{isInvalid && <FieldError errors={state.meta.errors} />}
</Field>
);
};
34 changes: 32 additions & 2 deletions src/components/issue/AddIssueNotesModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
/* eslint-disable react/no-children-prop */
import { redmineIssuesQueries } from "@/api/redmine/queries/issues";
import UploadsField from "@/components/issue/form/fields/UploadsField";
import { useSettings } from "@/provider/SettingsProvider";
import { markdownToTextile } from "@/utils/markdownToTextile";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useIntl } from "react-intl";
import { z } from "zod";
Expand All @@ -14,6 +17,7 @@ const addIssueNotesFormSchema = ({ formatMessage }: { formatMessage: ReturnType<
z.object({
notes: z.string(formatMessage({ id: "issues.issue.field.notes.validation.required" })).nonempty(formatMessage({ id: "issues.issue.field.notes.validation.required" })),
private_notes: z.boolean().optional(),
uploads: z.array(z.object({ token: z.string(), filename: z.string(), content_type: z.string().optional(), description: z.string().optional() })),
});

type TAddIssueNotesForm = z.infer<ReturnType<typeof addIssueNotesFormSchema>>;
Expand All @@ -25,13 +29,21 @@ type PropTypes = {
};

const AddIssueNotesModal = ({ issue, onClose, onSuccess }: PropTypes) => {
const { settings } = useSettings();
const { formatMessage } = useIntl();

const redmineApi = useRedmineApi();
const queryClient = useQueryClient();

const updateIssueMutation = useMutation({
mutationFn: (data: TUpdateIssue) => redmineApi.updateIssue(issue.id, data),
mutationFn: (data: TUpdateIssue) =>
redmineApi.updateIssue(issue.id, {
...data,
...(data.notes &&
settings.redmine.settings.textFormatting === "textile" && {
notes: markdownToTextile(data.notes),
}),
}),
onSuccess: () => {
queryClient.invalidateQueries(redmineIssuesQueries);
onSuccess();
Expand All @@ -40,11 +52,15 @@ const AddIssueNotesModal = ({ issue, onClose, onSuccess }: PropTypes) => {
successMessage: formatMessage({ id: "issues.modal.add-issue-notes.success" }),
},
});
const uploadAttachmentMutation = useMutation({
mutationFn: (file: File) => redmineApi.uploadAttachment(file),
});

const form = useAppForm({
defaultValues: {
notes: "",
private_notes: false,
uploads: [],
} satisfies TAddIssueNotesForm as TAddIssueNotesForm,
validators: {
onChange: addIssueNotesFormSchema({ formatMessage }),
Expand All @@ -65,9 +81,23 @@ const AddIssueNotesModal = ({ issue, onClose, onSuccess }: PropTypes) => {
<form.AppField
name="notes"
children={(field) => (
<field.TextareaField title={formatMessage({ id: "issues.issue.field.notes" })} placeholder={formatMessage({ id: "issues.issue.field.notes" })} required autoFocus />
<field.RedmineMdEditorField
title={formatMessage({ id: "issues.issue.field.notes" })}
textareaProps={{ placeholder: formatMessage({ id: "issues.issue.field.notes" }) }}
required
autoFocus
hideToolbar={settings.redmine.settings.textFormatting === "none"}
onUploadImage={async (file) => {
const { id: _, url, ...attachment } = await uploadAttachmentMutation.mutateAsync(file);
form.pushFieldValue("uploads", attachment);
if (settings.redmine.settings.textFormatting !== "none") {
return { url, alt: attachment.filename };
}
}}
/>
)}
/>
<form.AppField name="uploads" children={() => <UploadsField />} />

<form.AppField name="private_notes" children={(field) => <field.SwitchField title={formatMessage({ id: "issues.issue.field.private-notes" })} />} />
</FormGrid>
Expand Down
Loading
Loading