diff --git a/cmd/web/frontend/docs/ui-design-system.ja.md b/cmd/web/frontend/docs/ui-design-system.ja.md index 4db4a26f..f14cf6a7 100644 --- a/cmd/web/frontend/docs/ui-design-system.ja.md +++ b/cmd/web/frontend/docs/ui-design-system.ja.md @@ -633,6 +633,16 @@ gap: 14px; リンクは等幅 ID と ledger-muted タイムスタンプを持つ 3 カラム内 grid です。 - `comment-list` はカード形状を再利用し、各コメントの前に `border-top` separator を入れます。 +- Change request 操作はラベル付きメニューグループとして表示します。 + `コメントを入力…` はエディターを開き、設定済みショートカットは指示本文を + 即時送信します。将来、短い表示名に長い agent 指示を割り当てられるよう、 + ショートカットの表示名と本文は別々の値として扱います。 +- Reject 操作は split button を使用します。主ボタンはエディターを開き、 + chevron 側はショートカットメニューを開きます。両方で disabled と送信中の + 状態を共有します。 +- Issue 詳細 sidebar の status shortcut には `Quick Action` の見出しを付けます。 + backlog では `Ready`、ready では `Draft`、review では `Done` と赤色の Reject + split button を表示します。 - `status-actions` は同じカード内の wrap-flex アクション行です。 - `issue-description` は共有 markdown レンダラーを使い、line-height `1.65`、アンカー色 `--primary-black`、インライン画像に border を付けます。 diff --git a/cmd/web/frontend/docs/ui-design-system.md b/cmd/web/frontend/docs/ui-design-system.md index 6a86243f..71170430 100644 --- a/cmd/web/frontend/docs/ui-design-system.md +++ b/cmd/web/frontend/docs/ui-design-system.md @@ -634,6 +634,16 @@ palette: link is a 3-column inner grid with monospace IDs and ledger-muted timestamps. - `comment-list` reuses the card shape and prefixes each comment with a `border-top` separator. +- Change-request actions expose a labelled menu group. `Write a comment…` + opens the editor, while configured shortcuts submit their instruction body + immediately. Shortcut labels and bodies are separate values so future + configuration can keep compact labels for longer agent instructions. +- Reject actions use a split button: the primary segment opens the editor and + the chevron segment opens the shortcut menu. Both segments share disabled + and submitting states. +- The issue-detail sidebar labels its status shortcuts as `Quick Action`: + backlog shows `Ready`, ready shows `Draft`, and review shows `Done` together + with the red Reject split button. - `status-actions` is a wrap-flex action row inside the same card. - `issue-description` uses the shared markdown renderer with `1.65` line height, anchor color `--primary-black`, and bordered inline images. diff --git a/cmd/web/frontend/src/app/dashboard/page.tsx b/cmd/web/frontend/src/app/dashboard/page.tsx index 5b6505df..7200775e 100644 --- a/cmd/web/frontend/src/app/dashboard/page.tsx +++ b/cmd/web/frontend/src/app/dashboard/page.tsx @@ -4,7 +4,7 @@ import { useLayoutData } from "@/components/layout"; import { IssuesView } from "@/features/issues/components/issues-view"; export default function DashboardPage() { - const { summary, onAddIssue, onRejectIssue, onStatusChange } = useLayoutData(); + const { summary, onAddIssue, onRejectIssue, onRejectShortcut, onStatusChange } = useLayoutData(); return ( ); diff --git a/cmd/web/frontend/src/app/issues/page.tsx b/cmd/web/frontend/src/app/issues/page.tsx index aba5d7cb..382d7c31 100644 --- a/cmd/web/frontend/src/app/issues/page.tsx +++ b/cmd/web/frontend/src/app/issues/page.tsx @@ -4,7 +4,7 @@ import { useLayoutData, useLayoutShellData } from "@/components/layout"; import { IssuesView } from "@/features/issues/components/issues-view"; export default function IssuesPage() { - const { summary, onAddIssue, onRejectIssue, onStatusChange } = useLayoutData(); + const { summary, onAddIssue, onRejectIssue, onRejectShortcut, onStatusChange } = useLayoutData(); const { isProjectIssueScope } = useLayoutShellData(); return ( @@ -13,6 +13,7 @@ export default function IssuesPage() { summary={summary} onAddIssue={onAddIssue} onRejectIssue={onRejectIssue} + onRejectShortcut={onRejectShortcut} onStatusChange={onStatusChange} /> ); diff --git a/cmd/web/frontend/src/components/layout/index.tsx b/cmd/web/frontend/src/components/layout/index.tsx index 786f2382..c96b7585 100644 --- a/cmd/web/frontend/src/components/layout/index.tsx +++ b/cmd/web/frontend/src/components/layout/index.tsx @@ -15,6 +15,7 @@ import { PanelMessage } from "@/components/ui/pannel-message"; import { ToastStack } from "@/components/ui/toast"; import { createIssue, + createChangeRequest, deleteProject, fetchProjects, fetchSummary, @@ -35,6 +36,7 @@ import { AddIssueDialog } from "@/components/dialog/add-issue"; import { AddProjectDialog } from "@/components/dialog/add-project"; import { DeleteProjectDialog } from "@/components/dialog/delete-project"; import { ChangeRequestDialog } from "@/features/issues/components/change-request-dialog"; +import type { ChangeRequestShortcut } from "@/features/issues/change-request-shortcuts"; import { Header } from "./header"; import { Sidebar } from "./sidebar"; import { useTheme } from "./use-theme"; @@ -59,6 +61,7 @@ export type LayoutData = { onSelectIssue: (issueID: number) => void; onAddIssue: (status?: IssueStatus) => void; onRejectIssue: (issueID: number) => void; + onRejectShortcut: (issueID: number, shortcut: ChangeRequestShortcut) => Promise; onStatusChange: (id: number, status: IssueStatus) => Promise; }; @@ -78,6 +81,7 @@ export type LayoutShellData = { projects: Project[]; rejectIssue: IssueSummary | null; rejectIssueError: string; + rejectRequestRecovery: { body: string; requestCreated: boolean }; summary: Summary | null; title: string | null; onIssueDetailTitleChange: (title: string | null) => void; @@ -123,6 +127,10 @@ function LayoutContent({ children }: { children: ReactNode }) { const [isDeletingProject, setIsDeletingProject] = useState(false); const [rejectIssueID, setRejectIssueID] = useState(null); const [rejectIssueError, setRejectIssueError] = useState(""); + const [rejectRequestRecovery, setRejectRequestRecovery] = useState<{ + body: string; + requestCreated: boolean; + }>({ body: "", requestCreated: false }); const [isMovingRejectedIssue, setIsMovingRejectedIssue] = useState(false); const [issueDetailTitleOverride, setIssueDetailTitleOverride] = useState(null); const [refreshIntervalMs, setRefreshIntervalMs] = useState( @@ -257,17 +265,47 @@ function LayoutContent({ children }: { children: ReactNode }) { function handleRejectIssue(issueID: number) { setRejectIssueID(issueID); setRejectIssueError(""); + setRejectRequestRecovery({ body: "", requestCreated: false }); modal.openModal(modalIDs.rejectIssue); } + async function handleRejectShortcut(issueID: number, shortcut: ChangeRequestShortcut) { + try { + await createChangeRequest(issueID, { + author: "reviewer", + body: shortcut.body, + }, { silent: true }); + } catch (error) { + toast.error({ + message: error instanceof Error ? error.message : t("issues.reject.errors.submitFailed"), + }); + return; + } + + try { + await moveRejectedIssueReady(issueID); + } catch (error) { + setRejectIssueID(issueID); + setRejectRequestRecovery({ body: shortcut.body, requestCreated: true }); + setRejectIssueError( + error instanceof Error ? error.message : t("layout.failedToRejectIssue"), + ); + modal.openModal(modalIDs.rejectIssue); + } + } + + async function moveRejectedIssueReady(issueID: number) { + await updateIssueStatus(issueID, "ready", { silent: true }); + toast.success({ message: t("toast.success.issueRejected") }); + void load({ silent: true }); + } + async function handleMoveRejectedIssueReady() { if (rejectIssueID === null) return; setIsMovingRejectedIssue(true); setRejectIssueError(""); try { - await updateIssueStatus(rejectIssueID, "ready", { silent: true }); - await load({ silent: true }); - toast.success({ message: t("toast.success.issueRejected") }); + await moveRejectedIssueReady(rejectIssueID); } catch (error) { const message = error instanceof Error ? error.message : t("layout.failedToRejectIssue"); setRejectIssueError(message); @@ -336,6 +374,7 @@ function LayoutContent({ children }: { children: ReactNode }) { onSelectIssue: setSelectedIssueID, onAddIssue: handleAddIssue, onRejectIssue: handleRejectIssue, + onRejectShortcut: handleRejectShortcut, onStatusChange: handleStatusChange, } : null; @@ -356,6 +395,7 @@ function LayoutContent({ children }: { children: ReactNode }) { projects, rejectIssue: issues.find((issue) => issue.id === rejectIssueID) ?? null, rejectIssueError, + rejectRequestRecovery, summary, title: issueDetailTitleOverride ?? issueDetailTitle ?? issueScopeTitle( issueScope, @@ -508,6 +548,8 @@ function LayoutModalContent({ shellData }: { shellData: LayoutShellData }) { isMovingIssue={shellData.isMovingRejectedIssue} issueID={shellData.rejectIssue.id} issueTitle={shellData.rejectIssue.title} + initialBody={shellData.rejectRequestRecovery.body} + initialRequestCreated={shellData.rejectRequestRecovery.requestCreated} onCancel={shellData.onCloseModal} onMoveIssueReady={shellData.onMoveRejectedIssueReady} onSuccess={shellData.onCloseModal} diff --git a/cmd/web/frontend/src/components/ui/markdown-editor/index.tsx b/cmd/web/frontend/src/components/ui/markdown-editor/index.tsx index 5eddb5fd..9e96f7fc 100644 --- a/cmd/web/frontend/src/components/ui/markdown-editor/index.tsx +++ b/cmd/web/frontend/src/components/ui/markdown-editor/index.tsx @@ -23,6 +23,7 @@ export function MarkdownEditor({ initialTab = "raw", isSaving = false, labels, + readOnly = false, rows = 12, showActions = true, stablePanelRows, @@ -37,6 +38,7 @@ export function MarkdownEditor({ initialTab?: MarkdownEditorTab; isSaving?: boolean; labels: MarkdownEditorLabels; + readOnly?: boolean; rows?: number; showActions?: boolean; stablePanelRows?: number; @@ -176,6 +178,7 @@ export function MarkdownEditor({ aria-label={labels.textarea} className={styles.textarea} rows={rows} + readOnly={readOnly} value={draft} onChange={(event) => handleDraftChange(event.target.value)} /> diff --git a/cmd/web/frontend/src/features/issues/change-request-shortcuts.ts b/cmd/web/frontend/src/features/issues/change-request-shortcuts.ts new file mode 100644 index 00000000..b4d92b78 --- /dev/null +++ b/cmd/web/frontend/src/features/issues/change-request-shortcuts.ts @@ -0,0 +1,21 @@ +export type ChangeRequestVariant = "continue" | "reject"; + +export type ChangeRequestShortcut = { + id: string; + label: string; + body: string; +}; + +export const builtInChangeRequestShortcuts = { + continue: [ + { id: "ok", label: "Ok", body: "Ok" }, + { id: "retry", label: "Retry", body: "Retry" }, + ], + reject: [ + { + id: "fix-ci-conflict", + label: "Fix CI & Conflict", + body: "Fix CI & Conflict", + }, + ], +} satisfies Record; diff --git a/cmd/web/frontend/src/features/issues/components/board/index.tsx b/cmd/web/frontend/src/features/issues/components/board/index.tsx index 04a849df..c29e14b7 100644 --- a/cmd/web/frontend/src/features/issues/components/board/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/board/index.tsx @@ -2,11 +2,13 @@ import { useTranslation } from "react-i18next"; import { IssueCard } from "@/features/issues/components/card"; import { IconProxy, type IconProxyName } from "@/components/ui/icon-proxy"; import type { IssueStatus, Summary } from "@/lib/types"; +import type { ChangeRequestShortcut } from "@/features/issues/change-request-shortcuts"; import { boardColumns } from "./board-columns"; import styles from "./index.module.css"; type StatusChangeHandler = (id: number, status: IssueStatus) => Promise; type RejectIssueHandler = (id: number) => void; +type RejectShortcutHandler = (id: number, shortcut: ChangeRequestShortcut) => Promise; const boardActions = [ { icon: "filter", titleKey: "issues.board.filter" }, @@ -19,12 +21,14 @@ export function IssueBoard({ summary, onAddIssue, onRejectIssue, + onRejectShortcut, onStatusChange, }: { showFilterSortActions?: boolean; summary: Summary; onAddIssue: (status?: IssueStatus) => void; onRejectIssue?: RejectIssueHandler; + onRejectShortcut?: RejectShortcutHandler; onStatusChange: StatusChangeHandler; }) { const { t } = useTranslation(); @@ -80,6 +84,7 @@ export function IssueBoard({ key={issue.id} issue={issue} onRejectIssue={onRejectIssue} + onRejectShortcut={onRejectShortcut} onStatusChange={onStatusChange} /> )) diff --git a/cmd/web/frontend/src/features/issues/components/card/index.module.css b/cmd/web/frontend/src/features/issues/components/card/index.module.css index a602c981..38a451a9 100644 --- a/cmd/web/frontend/src/features/issues/components/card/index.module.css +++ b/cmd/web/frontend/src/features/issues/components/card/index.module.css @@ -142,6 +142,7 @@ .quickActionButton { align-items: center; + block-size: 32px; background: var(--quick-action-bg); border: 1px solid var(--quick-action-border); border-radius: var(--radius-sm); @@ -153,8 +154,7 @@ gap: 6px; justify-content: center; line-height: 1; - min-height: 30px; - padding: 8px 12px; + padding: 0 12px; } .quickActionButton:hover, @@ -162,27 +162,6 @@ opacity: 0.9; } -.rejectActionButton { - align-items: center; - background: var(--surface); - border: 1px solid var(--danger-border, #d4a1a1); - border-radius: var(--radius-sm); - color: var(--danger-text, #9f1d1d); - display: inline-flex; - flex-shrink: 0; - font-size: 13px; - font-weight: 500; - justify-content: center; - line-height: 1; - min-height: 30px; - padding: 8px 12px; -} - -.rejectActionButton:hover, -.rejectActionButton:focus-visible { - background: var(--danger-surface, #fff3f3); -} - .quickAction-ready { --quick-action-bg: var(--status-ready-text); --quick-action-border: transparent; diff --git a/cmd/web/frontend/src/features/issues/components/card/index.test.tsx b/cmd/web/frontend/src/features/issues/components/card/index.test.tsx index 7ba1ef0c..15cb732f 100644 --- a/cmd/web/frontend/src/features/issues/components/card/index.test.tsx +++ b/cmd/web/frontend/src/features/issues/components/card/index.test.tsx @@ -47,6 +47,7 @@ function issueWithCommentCount(commentCount: number): IssueSummary { function renderCard(props: Partial[0]> = {}) { const onRejectIssue = vi.fn(); + const onRejectShortcut = vi.fn(async () => undefined); const onStatusChange = vi.fn(async () => undefined); const rendered = render( @@ -54,13 +55,14 @@ function renderCard(props: Partial[0]> = {}) { , ); - return { onRejectIssue, onStatusChange, unmount: rendered.unmount }; + return { onRejectIssue, onRejectShortcut, onStatusChange, unmount: rendered.unmount }; } describe("IssueCard", () => { diff --git a/cmd/web/frontend/src/features/issues/components/card/index.tsx b/cmd/web/frontend/src/features/issues/components/card/index.tsx index d0d965dc..8ebf535b 100644 --- a/cmd/web/frontend/src/features/issues/components/card/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/card/index.tsx @@ -14,6 +14,8 @@ import { toast } from "@/lib/toast"; import { PriorityBadge } from "@/features/issues/components/priority-badge"; import { ProjectBadge } from "@/features/issues/components/project-badge"; import { StatusBadge } from "@/features/issues/components/status-badge"; +import { RejectAction } from "@/features/issues/components/reject-action"; +import type { ChangeRequestShortcut } from "@/features/issues/change-request-shortcuts"; import { useIssueThreadID } from "@/features/issues/hooks/use-issue-thread-id"; import { pullRequestArtifact } from "@/features/issues/artifacts"; import { PendingBadge } from "./pending-badge"; @@ -21,6 +23,7 @@ import styles from "./index.module.css"; type IssueStatusChangeHandler = (id: number, status: IssueStatus) => Promise; type IssueRejectHandler = (id: number) => void; +type IssueRejectShortcutHandler = (id: number, shortcut: ChangeRequestShortcut) => Promise; type IssueMetric = { icon: IconProxyName; @@ -45,12 +48,14 @@ export function IssueCard({ issue, onStatusChange, onRejectIssue, + onRejectShortcut, readonly = false, runCount, }: { issue: IssueSummary; onStatusChange: IssueStatusChangeHandler; onRejectIssue?: IssueRejectHandler; + onRejectShortcut?: IssueRejectShortcutHandler; readonly?: boolean; runCount?: number; }) { @@ -58,7 +63,7 @@ export function IssueCard({ const statusOptions = statusOptionsFor(issue.status); const canChangeStatus = !readonly && statusOptions.length > 1; const quickStatusTarget = readonly ? undefined : quickStatusTargets[issue.status]; - const canReject = !readonly && issue.status === "review" && onRejectIssue !== undefined; + const canReject = !readonly && issue.status === "review" && onRejectIssue !== undefined && onRejectShortcut !== undefined; const cardRef = useRef(null); const [isMenuOpen, setIsMenuOpen] = useState(false); const { isThreadIDLoading, threadID } = useIssueThreadID(issue.id, isMenuOpen); @@ -219,13 +224,10 @@ export function IssueCard({ {quickStatusTarget || canReject ? (
{canReject ? ( - + onRejectIssue(issue.id)} + onSelectShortcut={(shortcut) => onRejectShortcut(issue.id, shortcut)} + /> ) : null} {quickStatusTarget ? ( + {quickStatusAction || canReject ? ( +
+

{t("issues.detailPage.quickActions")}

+
+ {quickStatusAction ? ( + + ) : null} + {canReject ? ( + + ) : null} +
) : null} ); } +function quickStatusActionFor(status: IssueStatus): { + status: IssueStatus; + labelKey: "statuses.ready" | "issues.board.draft" | "statuses.done"; +} | null { + if (status === "backlog") return { status: "ready", labelKey: "statuses.ready" }; + if (status === "ready") return { status: "backlog", labelKey: "issues.board.draft" }; + if (status === "review") return { status: "done", labelKey: "statuses.done" }; + return null; +} + function dependencyIssueLinks( dependencyIDs: number[], issueOptions: IssueSummary[], diff --git a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.stories.tsx b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.stories.tsx index 75d478e0..c57d2485 100644 --- a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.stories.tsx +++ b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.stories.tsx @@ -22,6 +22,6 @@ export const WithContinueAction: Story = { ...storyComments[0], type: "blocker", }, - onContinueWithComment: () => undefined, + onContinueWithComment: async () => undefined, }, }; diff --git a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.test.tsx b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.test.tsx index ff95538f..47d1437a 100644 --- a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.test.tsx +++ b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, within } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import "@/lib/i18n"; @@ -25,11 +25,43 @@ describe("CommentCard", () => { await user.click(screen.getByRole("button", { name: "Comment actions for codex" })); const menu = screen.getByRole("menu", { name: "Comment actions for codex" }); - const action = within(menu).getByRole("menuitem", { name: "Continue with comment" }); + const action = within(menu).getByRole("menuitem", { name: "Write a comment…" }); expect(action.querySelector(".lucide-arrow-right")).toBeInTheDocument(); await user.click(action); expect(onContinueWithComment).toHaveBeenCalledOnce(); expect(screen.queryByRole("menu")).not.toBeInTheDocument(); }); + + it("selects the Ok shortcut with its separate label and body", async () => { + const user = userEvent.setup(); + const onContinueWithComment = vi.fn(); + + render(); + + await user.click(screen.getByRole("button", { name: "Comment actions for codex" })); + expect(screen.getByRole("menuitem", { name: "Retry" })).toBeVisible(); + await user.click(screen.getByRole("menuitem", { name: "Ok" })); + + expect(onContinueWithComment).toHaveBeenCalledWith({ id: "ok", label: "Ok", body: "Ok" }); + }); + + it("prevents another shortcut selection while submitting", async () => { + const user = userEvent.setup(); + let resolveSubmission: () => void = () => undefined; + const onContinueWithComment = vi.fn( + () => new Promise((resolve) => { resolveSubmission = resolve; }), + ); + + render(); + + const trigger = screen.getByRole("button", { name: "Comment actions for codex" }); + await user.click(trigger); + await user.click(screen.getByRole("menuitem", { name: "Ok" })); + + expect(trigger).toBeDisabled(); + expect(onContinueWithComment).toHaveBeenCalledTimes(1); + resolveSubmission(); + await waitFor(() => expect(trigger).toBeEnabled()); + }); }); diff --git a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.tsx b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.tsx index 33f5f65d..7d094983 100644 --- a/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/issue-detail-page/comment-card/index.tsx @@ -1,9 +1,10 @@ import { useId, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu"; +import { ContextMenu, ContextMenuGroupLabel, ContextMenuItem } from "@/components/ui/context-menu"; import { IconProxy } from "@/components/ui/icon-proxy"; import { Markdown } from "@/components/ui/markdown"; import { CommentTypeBadge } from "@/features/issues/components/comment-type-badge"; +import { builtInChangeRequestShortcuts, type ChangeRequestShortcut } from "@/features/issues/change-request-shortcuts"; import type { Comment } from "@/lib/types"; import { formatDateTime } from "../format"; import styles from "./index.module.css"; @@ -11,15 +12,28 @@ import styles from "./index.module.css"; export function CommentCard({ comment, onContinueWithComment, + shortcuts = builtInChangeRequestShortcuts.continue, }: { comment: Comment; - onContinueWithComment?: () => void; + onContinueWithComment?: (shortcut?: ChangeRequestShortcut) => Promise; + shortcuts?: readonly ChangeRequestShortcut[]; }) { const { t } = useTranslation(); const menuID = useId(); const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); const menuLabel = t("issues.detailPage.commentActions", { author: comment.author }); + async function handleShortcut(shortcut: ChangeRequestShortcut) { + setIsMenuOpen(false); + setIsSubmitting(true); + try { + await onContinueWithComment?.(shortcut); + } finally { + setIsSubmitting(false); + } + } + return (
@@ -40,6 +54,7 @@ export function CommentCard({ {...triggerProps} aria-label={menuLabel} className={styles.menuButton} + disabled={isSubmitting} title={menuLabel} type="button" > @@ -47,15 +62,26 @@ export function CommentCard({ )} > + {t("issues.continueWithComment.action")} } onSelect={() => { setIsMenuOpen(false); - onContinueWithComment(); + void onContinueWithComment(); }} > - {t("issues.continueWithComment.action")} + {t("issues.changeRequest.writeComment")} + {shortcuts.map((shortcut) => ( + void handleShortcut(shortcut)} + > + {shortcut.label} + + ))} ) : (
@@ -483,25 +535,27 @@ export function IssueDetailPage() { ) : null} ) : null} - {issueState.kind === "ready" && changeRequestDialogVariant ? ( + {issueState.kind === "ready" && changeRequestDialog ? ( { setChangeRequestError(""); - setChangeRequestDialogVariant(null); + setChangeRequestDialog(null); }} - onMoveIssueReady={handleMoveIssueReady} + onMoveIssueReady={() => handleMoveIssueReady(changeRequestDialog.variant)} onSuccess={() => { setChangeRequestError(""); - setChangeRequestDialogVariant(null); - if (changeRequestDialogVariant === "continue") { + setChangeRequestDialog(null); + if (changeRequestDialog.variant === "continue") { void loadChangeRequests(); } }} - variant={changeRequestDialogVariant} + variant={changeRequestDialog.variant} /> ) : null}
diff --git a/cmd/web/frontend/src/features/issues/components/issues-view/index.tsx b/cmd/web/frontend/src/features/issues/components/issues-view/index.tsx index c32b9fc5..5641217f 100644 --- a/cmd/web/frontend/src/features/issues/components/issues-view/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/issues-view/index.tsx @@ -2,7 +2,7 @@ import type { IssueStatus, Summary } from "@/lib/types"; import { IssueBoard } from "@/features/issues/components/board"; -import type { RejectIssueHandler, StatusChangeHandler } from "./types"; +import type { RejectIssueHandler, RejectShortcutHandler, StatusChangeHandler } from "./types"; import styles from "./index.module.css"; export function IssuesView({ @@ -10,12 +10,14 @@ export function IssuesView({ summary, onAddIssue, onRejectIssue, + onRejectShortcut, onStatusChange, }: { showFilterSortActions?: boolean; summary: Summary; onAddIssue: (status?: IssueStatus) => void; onRejectIssue?: RejectIssueHandler; + onRejectShortcut?: RejectShortcutHandler; onStatusChange: StatusChangeHandler; }) { return ( @@ -25,6 +27,7 @@ export function IssuesView({ summary={summary} onAddIssue={onAddIssue} onRejectIssue={onRejectIssue} + onRejectShortcut={onRejectShortcut} onStatusChange={onStatusChange} /> diff --git a/cmd/web/frontend/src/features/issues/components/issues-view/types.ts b/cmd/web/frontend/src/features/issues/components/issues-view/types.ts index acfa6527..86aae079 100644 --- a/cmd/web/frontend/src/features/issues/components/issues-view/types.ts +++ b/cmd/web/frontend/src/features/issues/components/issues-view/types.ts @@ -1,4 +1,6 @@ import type { IssueStatus } from "@/lib/types"; +import type { ChangeRequestShortcut } from "@/features/issues/change-request-shortcuts"; export type StatusChangeHandler = (id: number, status: IssueStatus) => Promise; export type RejectIssueHandler = (id: number) => void; +export type RejectShortcutHandler = (id: number, shortcut: ChangeRequestShortcut) => Promise; diff --git a/cmd/web/frontend/src/features/issues/components/reject-action/index.module.css b/cmd/web/frontend/src/features/issues/components/reject-action/index.module.css new file mode 100644 index 00000000..53fee545 --- /dev/null +++ b/cmd/web/frontend/src/features/issues/components/reject-action/index.module.css @@ -0,0 +1,47 @@ +.splitButton { + display: inline-flex; +} + +.primaryButton, +.menuButton { + align-items: center; + block-size: 32px; + background: var(--danger); + border: 1px solid var(--danger); + color: var(--white); + display: inline-flex; + font-size: 13px; + font-weight: 500; + justify-content: center; + line-height: 1; +} + +.primaryButton { + border-radius: var(--radius-sm) 0 0 var(--radius-sm); + padding: 0 12px; +} + +.menuButton { + border-left: 0; + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + padding: 0 var(--space-2-5); +} + +.primaryButton:hover:not(:disabled), +.primaryButton:focus-visible:not(:disabled), +.menuButton:hover:not(:disabled), +.menuButton:focus-visible:not(:disabled) { + background: var(--danger-text, #9f1d1d); +} + +.primaryButton:focus-visible, +.menuButton:focus-visible { + outline: 2px solid var(--danger); + outline-offset: 2px; +} + +.primaryButton:disabled, +.menuButton:disabled { + cursor: not-allowed; + opacity: 0.6; +} diff --git a/cmd/web/frontend/src/features/issues/components/reject-action/index.stories.tsx b/cmd/web/frontend/src/features/issues/components/reject-action/index.stories.tsx new file mode 100644 index 00000000..27b180d1 --- /dev/null +++ b/cmd/web/frontend/src/features/issues/components/reject-action/index.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RejectAction } from "."; + +const meta = { + title: "Features/Issues/RejectAction", + component: RejectAction, + args: { + onOpenDialog: () => undefined, + onSelectShortcut: async () => undefined, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/cmd/web/frontend/src/features/issues/components/reject-action/index.test.tsx b/cmd/web/frontend/src/features/issues/components/reject-action/index.test.tsx new file mode 100644 index 00000000..b1e973a9 --- /dev/null +++ b/cmd/web/frontend/src/features/issues/components/reject-action/index.test.tsx @@ -0,0 +1,39 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import "@/lib/i18n"; +import { RejectAction } from "."; + +describe("RejectAction", () => { + it("opens the dialog from the primary action", async () => { + const user = userEvent.setup(); + const onOpenDialog = vi.fn(); + + render( + undefined} />, + ); + + await user.click(screen.getByRole("button", { name: "Reject" })); + expect(onOpenDialog).toHaveBeenCalledOnce(); + }); + + it("submits the selected shortcut from the menu", async () => { + const user = userEvent.setup(); + const onSelectShortcut = vi.fn(async () => undefined); + + render( + undefined} onSelectShortcut={onSelectShortcut} />, + ); + + await user.click(screen.getByRole("button", { name: "Reject shortcuts" })); + const menu = screen.getByRole("menu", { name: "Reject shortcuts" }); + expect(within(menu).getByRole("menuitem", { name: "Write a comment…" })).toBeVisible(); + await user.click(within(menu).getByRole("menuitem", { name: "Fix CI & Conflict" })); + + expect(onSelectShortcut).toHaveBeenCalledWith({ + id: "fix-ci-conflict", + label: "Fix CI & Conflict", + body: "Fix CI & Conflict", + }); + }); +}); diff --git a/cmd/web/frontend/src/features/issues/components/reject-action/index.tsx b/cmd/web/frontend/src/features/issues/components/reject-action/index.tsx new file mode 100644 index 00000000..fe63072c --- /dev/null +++ b/cmd/web/frontend/src/features/issues/components/reject-action/index.tsx @@ -0,0 +1,91 @@ +import { useId, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ContextMenu, + ContextMenuGroupLabel, + ContextMenuItem, +} from "@/components/ui/context-menu"; +import { IconProxy } from "@/components/ui/icon-proxy"; +import { + builtInChangeRequestShortcuts, + type ChangeRequestShortcut, +} from "@/features/issues/change-request-shortcuts"; +import styles from "./index.module.css"; + +export function RejectAction({ + disabled = false, + onOpenDialog, + onSelectShortcut, + shortcuts = builtInChangeRequestShortcuts.reject, +}: { + disabled?: boolean; + onOpenDialog: () => void; + onSelectShortcut: (shortcut: ChangeRequestShortcut) => Promise; + shortcuts?: readonly ChangeRequestShortcut[]; +}) { + const { t } = useTranslation(); + const menuID = useId(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const isDisabled = disabled || isSubmitting; + + async function handleShortcut(shortcut: ChangeRequestShortcut) { + setIsMenuOpen(false); + setIsSubmitting(true); + try { + await onSelectShortcut(shortcut); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+ + ( + + )} + > + {t("issues.reject.action")} + { + setIsMenuOpen(false); + onOpenDialog(); + }} + > + {t("issues.changeRequest.writeComment")} + + {shortcuts.map((shortcut) => ( + void handleShortcut(shortcut)} + > + {shortcut.label} + + ))} + +
+ ); +} diff --git a/cmd/web/frontend/src/lib/i18n.ts b/cmd/web/frontend/src/lib/i18n.ts index b93dc9b5..82e6d4bc 100644 --- a/cmd/web/frontend/src/lib/i18n.ts +++ b/cmd/web/frontend/src/lib/i18n.ts @@ -178,6 +178,7 @@ const resources = { }, reject: { action: "Reject", + shortcutMenu: "Reject の定型文", cancel: "キャンセル", close: "閉じる", emptyRequest: "リクエスト本文なし", @@ -213,6 +214,9 @@ const resources = { submit: "コメントをつけて継続", title: "コメントをつけて継続 #{{id}}", }, + changeRequest: { + writeComment: "コメントを入力…", + }, detail: { issueStatus: "Issue ステータス", openDetail: "詳細ページを開く", @@ -222,6 +226,7 @@ const resources = { workspace: "Workspace", }, detailPage: { + quickActions: "Quick Action", backToIssue: "Issue に戻る", backToList: "一覧に戻る", approvalRequest: "承認リクエスト", @@ -774,6 +779,7 @@ const resources = { }, reject: { action: "Reject", + shortcutMenu: "Reject shortcuts", cancel: "Cancel", close: "Close", emptyRequest: "No request body", @@ -809,6 +815,9 @@ const resources = { submit: "Continue with comment", title: "Continue with comment #{{id}}", }, + changeRequest: { + writeComment: "Write a comment…", + }, detail: { issueStatus: "Issue Status", openDetail: "Open detail page", @@ -818,6 +827,7 @@ const resources = { workspace: "Workspace", }, detailPage: { + quickActions: "Quick Action", backToIssue: "Back to issue", backToList: "Back to list", approvalRequest: "Approval request", diff --git a/cmd/web/frontend/src/stories/fixtures.ts b/cmd/web/frontend/src/stories/fixtures.ts index 0d54f1cf..56a645e8 100644 --- a/cmd/web/frontend/src/stories/fixtures.ts +++ b/cmd/web/frontend/src/stories/fixtures.ts @@ -108,6 +108,7 @@ export const storyShellData: LayoutShellData = { onSelectIssue: noop, onAddIssue: noop, onRejectIssue: noop, + onRejectShortcut: asyncNoop, onStatusChange: asyncNoop, }, loadState: { @@ -118,6 +119,7 @@ export const storyShellData: LayoutShellData = { projects: projectFixtures, rejectIssue: null, rejectIssueError: "", + rejectRequestRecovery: { body: "", requestCreated: false }, summary: storySummary, title: "Tasq", onIssueDetailTitleChange: noop,