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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions cmd/web/frontend/docs/ui-design-system.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,11 @@ gap: 14px;
- クイックアクションボタンはスコープ付き変数 (`--quick-action-bg`、
`--quick-action-border`、`--quick-action-text`) を使い、各バリアント
(`quickAction-ready`、`quickAction-done`) は色だけを上書きします。
- blocked のカードでは、`Ready` への直接遷移を `Resolve` に置き換えます。
この操作は comment の全ページから最新の blocker comment を取得し、
Continue with Comment ダイアログを開きます。blocker は読み取り専用の文脈として
表示し、フリーテキストまたは組み込みの `Ok` / `Retry` ショートカットで
Change Request を作成してから Issue を `ready` へ移動します。

### Issue Board

Expand Down
5 changes: 5 additions & 0 deletions cmd/web/frontend/docs/ui-design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,11 @@ This is the recurring shape for `runs-section`, `comment-list`,
- Quick action button uses scoped variables (`--quick-action-bg`,
`--quick-action-border`, `--quick-action-text`) so each variant
(`quickAction-ready`, `quickAction-done`) only overrides colors.
- A blocked card replaces the direct `Ready` transition with `Resolve`. The
action loads the latest blocker comment across all comment pages and opens a
continue-with-comment dialog. The blocker is read-only context; free text or
the built-in `Ok` / `Retry` shortcuts create the change request before the
issue moves to `ready`.

### Issue Board

Expand Down
3 changes: 2 additions & 1 deletion cmd/web/frontend/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, onRejectShortcut, onStatusChange } = useLayoutData();
const { summary, onAddIssue, onRejectIssue, onRejectShortcut, onResolveIssue, onStatusChange } = useLayoutData();

return (
<IssuesView
Expand All @@ -13,6 +13,7 @@ export default function DashboardPage() {
onAddIssue={onAddIssue}
onRejectIssue={onRejectIssue}
onRejectShortcut={onRejectShortcut}
onResolveIssue={onResolveIssue}
onStatusChange={onStatusChange}
/>
);
Expand Down
3 changes: 2 additions & 1 deletion cmd/web/frontend/src/app/issues/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, onRejectShortcut, onStatusChange } = useLayoutData();
const { summary, onAddIssue, onRejectIssue, onRejectShortcut, onResolveIssue, onStatusChange } = useLayoutData();
const { isProjectIssueScope } = useLayoutShellData();

return (
Expand All @@ -14,6 +14,7 @@ export default function IssuesPage() {
onAddIssue={onAddIssue}
onRejectIssue={onRejectIssue}
onRejectShortcut={onRejectShortcut}
onResolveIssue={onResolveIssue}
onStatusChange={onStatusChange}
/>
);
Expand Down
86 changes: 86 additions & 0 deletions cmd/web/frontend/src/components/layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,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 { ResolveIssueDialog } from "@/features/issues/components/resolve-issue-dialog";
import type { ChangeRequestShortcut } from "@/features/issues/change-request-shortcuts";
import { Header } from "./header";
import { Sidebar } from "./sidebar";
Expand All @@ -62,6 +63,7 @@ export type LayoutData = {
onAddIssue: (status?: IssueStatus) => void;
onRejectIssue: (issueID: number) => void;
onRejectShortcut: (issueID: number, shortcut: ChangeRequestShortcut) => Promise<void>;
onResolveIssue: (issueID: number) => void;
onStatusChange: (id: number, status: IssueStatus) => Promise<void>;
};

Expand All @@ -73,6 +75,7 @@ export type LayoutShellData = {
deleteProjectError: string;
isDeletingProject: boolean;
isMovingRejectedIssue: boolean;
isMovingResolvedIssue: boolean;
isIssueDetailPage: boolean;
isProjectIssueScope: boolean;
issues: IssueSummary[];
Expand All @@ -82,6 +85,9 @@ export type LayoutShellData = {
rejectIssue: IssueSummary | null;
rejectIssueError: string;
rejectRequestRecovery: { body: string; requestCreated: boolean };
resolveIssue: IssueSummary | null;
resolveIssueError: string;
resolveRequestRecovery: { body: string; requestCreated: boolean };
summary: Summary | null;
title: string | null;
onIssueDetailTitleChange: (title: string | null) => void;
Expand All @@ -92,6 +98,9 @@ export type LayoutShellData = {
onDeleteProject: () => void;
onConfirmDeleteProject: () => Promise<void>;
onMoveRejectedIssueReady: () => Promise<void>;
onMoveResolvedIssueReady: () => Promise<void>;
onResolvedRequestCreated: (body: string) => void;
onResolvedIssueSuccess: () => void;
};

const layoutDataContext = createContext<LayoutData | null>(null);
Expand Down Expand Up @@ -132,6 +141,14 @@ function LayoutContent({ children }: { children: ReactNode }) {
requestCreated: boolean;
}>({ body: "", requestCreated: false });
const [isMovingRejectedIssue, setIsMovingRejectedIssue] = useState(false);
const [resolveIssueID, setResolveIssueID] = useState<number | null>(null);
const [resolveIssueError, setResolveIssueError] = useState("");
const [resolveRequestRecovery, setResolveRequestRecovery] = useState<{
issueID: number | null;
body: string;
requestCreated: boolean;
}>({ issueID: null, body: "", requestCreated: false });
const [isMovingResolvedIssue, setIsMovingResolvedIssue] = useState(false);
const [issueDetailTitleOverride, setIssueDetailTitleOverride] = useState<string | null>(null);
const [refreshIntervalMs, setRefreshIntervalMs] = useState(
defaultRefreshIntervalMs,
Expand Down Expand Up @@ -315,6 +332,46 @@ function LayoutContent({ children }: { children: ReactNode }) {
}
}

function handleResolveIssue(issueID: number) {
setResolveIssueID(issueID);
setResolveIssueError("");
setResolveRequestRecovery((current) =>
current.issueID === issueID
? current
: { issueID, body: "", requestCreated: false },
);
modal.openModal(modalIDs.resolveIssue);
}

function handleResolvedRequestCreated(body: string) {
setResolveRequestRecovery({ issueID: resolveIssueID, body, requestCreated: true });
}

function handleResolvedIssueSuccess() {
setResolveRequestRecovery({ issueID: null, body: "", requestCreated: false });
modal.closeModal();
}

async function handleMoveResolvedIssueReady() {
if (resolveIssueID === null) return;
setIsMovingResolvedIssue(true);
setResolveIssueError("");
try {
await updateIssueStatus(resolveIssueID, "ready", { silent: true });
toast.success({ message: t("toast.success.continuedWithComment") });
void load({ silent: true });
} catch (error) {
const message =
error instanceof Error
? error.message
: t("issues.continueWithComment.errors.statusUpdateFailed");
setResolveIssueError(message);
throw new Error(message);
} finally {
setIsMovingResolvedIssue(false);
}
}

async function handleConfirmDeleteProject() {
if (!activeProject) return;
setIsDeletingProject(true);
Expand All @@ -340,6 +397,7 @@ function LayoutContent({ children }: { children: ReactNode }) {
setAddIssueError("");
setDeleteProjectError("");
setRejectIssueError("");
setResolveIssueError("");
modal.closeModal();
}

Expand Down Expand Up @@ -375,6 +433,7 @@ function LayoutContent({ children }: { children: ReactNode }) {
onAddIssue: handleAddIssue,
onRejectIssue: handleRejectIssue,
onRejectShortcut: handleRejectShortcut,
onResolveIssue: handleResolveIssue,
onStatusChange: handleStatusChange,
}
: null;
Expand All @@ -388,6 +447,7 @@ function LayoutContent({ children }: { children: ReactNode }) {
isIssueDetailPage,
isDeletingProject,
isMovingRejectedIssue,
isMovingResolvedIssue,
isProjectIssueScope,
issues,
layoutData,
Expand All @@ -396,6 +456,12 @@ function LayoutContent({ children }: { children: ReactNode }) {
rejectIssue: issues.find((issue) => issue.id === rejectIssueID) ?? null,
rejectIssueError,
rejectRequestRecovery,
resolveIssue: issues.find((issue) => issue.id === resolveIssueID) ?? null,
resolveIssueError,
resolveRequestRecovery: {
body: resolveRequestRecovery.body,
requestCreated: resolveRequestRecovery.requestCreated,
},
summary,
title: issueDetailTitleOverride ?? issueDetailTitle ?? issueScopeTitle(
issueScope,
Expand All @@ -410,6 +476,9 @@ function LayoutContent({ children }: { children: ReactNode }) {
onDeleteProject: handleDeleteProject,
onConfirmDeleteProject: handleConfirmDeleteProject,
onMoveRejectedIssueReady: handleMoveRejectedIssueReady,
onMoveResolvedIssueReady: handleMoveResolvedIssueReady,
onResolvedRequestCreated: handleResolvedRequestCreated,
onResolvedIssueSuccess: handleResolvedIssueSuccess,
};

return (
Expand Down Expand Up @@ -558,6 +627,23 @@ function LayoutModalContent({ shellData }: { shellData: LayoutShellData }) {
);
}

if (modal.activeModalID === modalIDs.resolveIssue && shellData.resolveIssue) {
return (
<ResolveIssueDialog
error={shellData.resolveIssueError}
isMovingIssue={shellData.isMovingResolvedIssue}
issueID={shellData.resolveIssue.id}
issueTitle={shellData.resolveIssue.title}
initialBody={shellData.resolveRequestRecovery.body}
initialRequestCreated={shellData.resolveRequestRecovery.requestCreated}
onCancel={shellData.onCloseModal}
onMoveIssueReady={shellData.onMoveResolvedIssueReady}
onRequestCreated={shellData.onResolvedRequestCreated}
onSuccess={shellData.onResolvedIssueSuccess}
/>
);
}

return null;
}

Expand Down
1 change: 1 addition & 0 deletions cmd/web/frontend/src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export const modalIDs = {
addProject: "addProject",
deleteProject: "deleteProject",
rejectIssue: "rejectIssue",
resolveIssue: "resolveIssue",
} as const;
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import styles from "./index.module.css";
type StatusChangeHandler = (id: number, status: IssueStatus) => Promise<void>;
type RejectIssueHandler = (id: number) => void;
type RejectShortcutHandler = (id: number, shortcut: ChangeRequestShortcut) => Promise<void>;
type ResolveIssueHandler = (id: number) => void;

const boardActions = [
{ icon: "filter", titleKey: "issues.board.filter" },
Expand All @@ -22,13 +23,15 @@ export function IssueBoard({
onAddIssue,
onRejectIssue,
onRejectShortcut,
onResolveIssue,
onStatusChange,
}: {
showFilterSortActions?: boolean;
summary: Summary;
onAddIssue: (status?: IssueStatus) => void;
onRejectIssue?: RejectIssueHandler;
onRejectShortcut?: RejectShortcutHandler;
onResolveIssue?: ResolveIssueHandler;
onStatusChange: StatusChangeHandler;
}) {
const { t } = useTranslation();
Expand Down Expand Up @@ -85,6 +88,7 @@ export function IssueBoard({
issue={issue}
onRejectIssue={onRejectIssue}
onRejectShortcut={onRejectShortcut}
onResolveIssue={onResolveIssue}
onStatusChange={onStatusChange}
/>
))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function issueWithCommentCount(commentCount: number): IssueSummary {
function renderCard(props: Partial<Parameters<typeof IssueCard>[0]> = {}) {
const onRejectIssue = vi.fn();
const onRejectShortcut = vi.fn(async () => undefined);
const onResolveIssue = vi.fn();
const onStatusChange = vi.fn(async () => undefined);
const rendered = render(
<QueryClientProvider client={queryClient}>
Expand All @@ -56,13 +57,14 @@ function renderCard(props: Partial<Parameters<typeof IssueCard>[0]> = {}) {
issue={issue}
onRejectIssue={onRejectIssue}
onRejectShortcut={onRejectShortcut}
onResolveIssue={onResolveIssue}
onStatusChange={onStatusChange}
{...props}
/>
</MemoryRouter>
</QueryClientProvider>,
);
return { onRejectIssue, onRejectShortcut, onStatusChange, unmount: rendered.unmount };
return { onRejectIssue, onRejectShortcut, onResolveIssue, onStatusChange, unmount: rendered.unmount };
}

describe("IssueCard", () => {
Expand Down Expand Up @@ -251,16 +253,16 @@ describe("IssueCard", () => {
expect(onStatusChange).toHaveBeenCalledWith(24, "ready");
});

it("renders draft actions for blocked issues", async () => {
it("opens the resolve flow for blocked issues", async () => {
const user = userEvent.setup();
const { onStatusChange } = renderCard({
const { onResolveIssue, onStatusChange } = renderCard({
issue: {
...issue,
status: "blocked",
},
});

const quickAction = screen.getByRole("button", { name: "Ready" });
const quickAction = screen.getByRole("button", { name: "Resolve" });

await user.click(screen.getByRole("button", {
name: "Issue actions for Wire issue board to generated client",
Expand All @@ -274,7 +276,8 @@ describe("IssueCard", () => {

await user.click(quickAction);

expect(onStatusChange).toHaveBeenCalledWith(24, "ready");
expect(onResolveIssue).toHaveBeenCalledWith(24);
expect(onStatusChange).not.toHaveBeenCalled();
});

it("renders a done quick action for review issues", async () => {
Expand Down Expand Up @@ -325,6 +328,7 @@ describe("IssueCard", () => {
});

expect(screen.queryByRole("button", { name: "Ready" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Resolve" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument();
});

Expand Down
Loading
Loading