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
5 changes: 3 additions & 2 deletions e2e/board-flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ test.describe('board flow', () => {
await titleInput.press('Enter');
await expect(issueDrawer.getByLabel('Issue title')).toHaveValue(updatedTitle);

await issueDrawer.getByLabel('Edit description').click();
const descriptionInput = issueDrawer.getByLabel('Issue description');
await descriptionInput.fill(updatedDescription);
await descriptionInput.blur();
await expect(issueDrawer.getByLabel('Issue description')).toHaveValue(updatedDescription);
await issueDrawer.getByRole('button', { name: 'Save' }).click();
await expect(issueDrawer.getByText(updatedDescription)).toBeVisible();

await issueDrawer.getByLabel('Issue state').selectOption({ label: 'Done' });
await expect(page.locator('[data-testid="column-Done"]')).toContainText(updatedTitle);
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/App.board-drawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe('App board drawer flows', () => {
const drawer = await screen.findByRole('dialog', { name: 'Issue detail drawer' });

expect(within(drawer).getByLabelText('Issue title')).toHaveValue('Ready item');
expect(within(drawer).getByLabelText('Issue description')).toHaveValue('Ready description');
expect(within(drawer).getByText('Ready description')).toBeInTheDocument();
expect(within(drawer).getByText('INV-1 — Backlog item')).toBeInTheDocument();
expect(within(drawer).getByText('No child issues.')).toBeInTheDocument();
});
Expand Down
14 changes: 7 additions & 7 deletions packages/web/src/App.issue-detail-edit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,10 @@ describe('App issue detail editing', () => {
fireEvent.click(await screen.findByRole('button', { name: 'Open INV-1' }));
const drawer = await screen.findByLabelText('Issue detail drawer');

fireEvent.click(within(drawer).getByLabelText('Edit description'));
const descriptionInput = within(drawer).getByLabelText('Issue description');
fireEvent.change(descriptionInput, { target: { value: 'Updated description' } });
fireEvent.blur(descriptionInput);
fireEvent.click(within(drawer).getByText('Save'));

await waitFor(() =>
expect(mutate).toHaveBeenCalledWith({
Expand All @@ -131,7 +132,7 @@ describe('App issue detail editing', () => {
);

await waitFor(() =>
expect(within(drawer).getByLabelText('Issue description')).toHaveValue('Updated description'),
expect(within(drawer).getByText('Updated description')).toBeInTheDocument(),
);
});

Expand All @@ -154,9 +155,10 @@ describe('App issue detail editing', () => {
fireEvent.click(await screen.findByRole('button', { name: 'Open INV-1' }));
const drawer = await screen.findByLabelText('Issue detail drawer');

fireEvent.click(within(drawer).getByLabelText('Edit description'));
const descriptionInput = within(drawer).getByLabelText('Issue description');
fireEvent.change(descriptionInput, { target: { value: 'Locally edited draft' } });
fireEvent.blur(descriptionInput);
fireEvent.click(within(drawer).getByText('Save'));

await waitFor(() =>
expect(mutate).toHaveBeenCalledWith({
Expand All @@ -168,9 +170,7 @@ describe('App issue detail editing', () => {
);

await waitFor(() =>
expect(within(drawer).getByLabelText('Issue description')).toHaveValue(
'Persisted description from server',
),
expect(within(drawer).getByText('Persisted description from server')).toBeInTheDocument(),
);
});

Expand All @@ -188,7 +188,7 @@ describe('App issue detail editing', () => {
const secondDrawer = await screen.findByLabelText('Issue detail drawer');

expect(within(secondDrawer).getByLabelText('Issue title')).toHaveValue('Ready item');
expect(within(secondDrawer).getByLabelText('Issue description')).toHaveValue('Ready description');
expect(within(secondDrawer).getByText('Ready description')).toBeInTheDocument();
});

it('shows the updated title after closing and reopening the same issue', async () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/App.issue-meta.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ describe('App issue metadata flows', () => {

expect(await screen.findByText('No issues in Backlog yet.')).toBeInTheDocument();
// Canceled column is collapsed by default, so the empty message is hidden;
// just verify the collapsed column header is rendered
expect(screen.getByLabelText(/Canceled column/i)).toBeInTheDocument();
// just verify at least one element references the Canceled column
expect(screen.getAllByLabelText(/Canceled column/i).length).toBeGreaterThan(0);
});

it('shows "No labels available" message when labels array is empty', async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/App.navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('App navigation and backlog flows', () => {
expect(await screen.findByRole('heading', { name: 'Issue detail' })).toBeInTheDocument();
expect(screen.getByText('INV-2')).toBeInTheDocument();
await waitFor(() => expect(screen.getByLabelText('Issue title')).toHaveValue('Ready item'));
await waitFor(() => expect(screen.getByLabelText('Issue description')).toHaveValue('Ready description'));
await waitFor(() => expect(screen.getByText('Ready description')).toBeInTheDocument());
expect(screen.getByText('INV-1 — Backlog item')).toBeInTheDocument();
});

Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/App.routing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe('App routing', () => {
expect(await screen.findByRole('heading', { name: 'Issue detail' })).toBeInTheDocument();
expect(screen.getByText('INV-2')).toBeInTheDocument();
await waitFor(() => expect(screen.getByLabelText('Issue title')).toHaveValue('Ready item'));
await waitFor(() => expect(screen.getByLabelText('Issue description')).toHaveValue('Ready description'));
await waitFor(() => expect(screen.getByText('Ready description')).toBeInTheDocument());
expect(screen.getByText('INV-1 — Backlog item')).toBeInTheDocument();
});

Expand Down
8 changes: 5 additions & 3 deletions packages/web/src/components/IssueCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,11 @@ export function IssueCard({
</div>

<div className="issue-card__footer">
<div className="issue-card__avatar" aria-hidden="true">
{getInitials(issue.assignee?.name)}
</div>
{issue.assignee ? (
<div className="issue-card__avatar" aria-hidden="true">
{getInitials(issue.assignee.name)}
</div>
) : null}
<span className="issue-card__assignee">{issue.assignee?.name ?? 'Unassigned'}</span>
</div>
</button>
Expand Down
55 changes: 43 additions & 12 deletions packages/web/src/components/IssueDetailDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export function IssueDetailDrawer({
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
const [selectedAssigneeId, setSelectedAssigneeId] = useState('');
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [commentBody, setCommentBody] = useState('');
const isSavingTitleRef = useRef(false);
const titleTextareaRef = useRef<HTMLTextAreaElement | null>(null);
Expand Down Expand Up @@ -320,18 +321,48 @@ export function IssueDetailDrawer({
</span>

<div className="issue-panel__section">
<label className="issue-panel__label" htmlFor="issue-description">
Description
</label>
<textarea
id="issue-description"
aria-label="Issue description"
className="issue-panel__textarea"
value={description}
disabled={savingState}
onChange={(event) => setDescription(event.target.value)}
onBlur={() => void commitDescription().catch(() => undefined)}
/>
<label className="issue-panel__label">Description</label>
{isEditingDescription ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<RichTextEditor
value={description}
onChange={setDescription}
placeholder="Add a description…"
submitLabel="Save"
disabled={savingState}
ariaLabel="Issue description"
onSubmit={() => {
setIsEditingDescription(false);
void commitDescription().catch(() => undefined);
}}
/>
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
className="ui-action ui-action--subtle"
onClick={() => {
setDescription(activeIssue.description ?? '');
setIsEditingDescription(false);
}}
>Cancel</button>
</div>
</div>
) : (
<div
style={{ position: 'relative', cursor: 'pointer', minHeight: 32 }}
onClick={() => setIsEditingDescription(true)}
role="button"
tabIndex={0}
aria-label="Edit description"
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setIsEditingDescription(true); }}
>
{description ? (
<MarkdownRenderer content={description} />
) : (
<span style={{ color: 'var(--fg-dim)', fontSize: 13 }}>Add a description…</span>
)}
</div>
)}
</div>

{activeIssue.children.nodes.length > 0 ? (
Expand Down
7 changes: 6 additions & 1 deletion packages/web/src/components/Primitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ export function Avatar({ user, size = 20 }: { user?: AvatarUser | null | undefin
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--fg-faint)', fontSize: size * 0.5,
flexShrink: 0,
}}>?</div>
}}>
<svg width={size * 0.55} height={size * 0.55} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="8" cy="5.5" r="2.5" />
<path d="M2.5 14.5c0-3 2.5-4.5 5.5-4.5s5.5 1.5 5.5 4.5" />
</svg>
</div>
);
}
const initials = user.avatar ?? (user.name ? user.name.trim().split(/\s+/).filter(Boolean).map(w => w[0]).join('').slice(0, 2).toUpperCase() : '?');
Expand Down
16 changes: 15 additions & 1 deletion packages/web/src/routes/BoardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,9 @@ export function BoardPage() {
);
const teams = queryData?.teams.nodes ?? EMPTY_TEAMS;
const users = queryData?.users.nodes ?? EMPTY_USERS;
const labels = queryData?.issueLabels.nodes ?? EMPTY_LABELS;
const labels = useMemo(() => Array.from(
new Map((queryData?.issueLabels.nodes ?? EMPTY_LABELS).map(l => [l.id, l])).values()
), [queryData?.issueLabels.nodes]);
const baseIssues = queryData?.issues.nodes ?? EMPTY_ISSUES;
const [createdIssues, setCreatedIssues] = useState<IssueSummary[]>([]);
const [issueOverrides, setIssueOverrides] = useState<Record<string, IssueSummary>>({});
Expand Down Expand Up @@ -1924,6 +1926,18 @@ export function BoardPage() {
</select>
</div>

<details style={{ position: 'relative', fontSize: 11 }}>
<summary style={{ cursor: 'pointer', padding: '2px 8px', borderRadius: 'var(--r-1)', color: 'var(--fg-muted)' }}>Columns</summary>
<fieldset style={{ position: 'absolute', top: '100%', right: 0, zIndex: 10, background: 'var(--bg-raised)', border: '1px solid var(--border)', borderRadius: 'var(--r-2)', padding: 8, display: 'flex', flexDirection: 'column', gap: 4, minWidth: 160 }}>
{(selectedTeam?.states.nodes ?? []).map((state) => (
<label key={state.id} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, cursor: 'pointer' }}>
<input type="checkbox" checked={!collapsedColumns[state.id]} onChange={() => toggleColumnCollapse(state.id)} aria-label={`Toggle ${state.name} column`} />
{state.name}
</label>
))}
</fieldset>
</details>
Comment on lines +1929 to +1939
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The new "Columns" dropdown uses hardcoded strings for the label and aria-labels. To support internationalization, these should be replaced with translation keys from the project's locale system.


<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginLeft: 4 }}>
<button type="button" onClick={() => { const name = window.prompt('View name'); if (name) { const nextView: SavedBoardView = { id: crypto.randomUUID(), name, state: { ...boardViewState } }; const nextViews = [...savedBoardViews, nextView]; setSavedBoardViews(nextViews); writeSavedBoardViews(activeTeamKey, nextViews); setActiveSavedBoardViewId(nextView.id); } }} style={{ height: 22, padding: '0 6px', fontSize: 11, border: '1px solid var(--border)', borderRadius: 'var(--r-2)', background: 'transparent', color: 'var(--fg-muted)', cursor: 'pointer' }}>Save view</button>
<button type="button" onClick={() => { resetBoardViewState(); setActiveSavedBoardViewId(''); }} style={{ height: 22, padding: '0 6px', fontSize: 11, border: 'none', background: 'transparent', color: 'var(--fg-dim)', cursor: 'pointer' }}>Clear</button>
Expand Down
95 changes: 85 additions & 10 deletions packages/web/src/routes/IssuePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function IssuePage() {
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
const [selectedAssigneeId, setSelectedAssigneeId] = useState('');
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [commentBody, setCommentBody] = useState('');
const isSavingTitleRef = useRef(false);
const titleTextareaRef = useRef<HTMLTextAreaElement | null>(null);
Expand Down Expand Up @@ -524,7 +525,9 @@ export function IssuePage() {
}

const activeIssue = issueSnapshot;
const allLabels = data?.issueLabels.nodes ?? [];
const allLabels = Array.from(
new Map((data?.issueLabels.nodes ?? []).map(l => [l.id, l])).values()
);
Comment on lines +528 to +530
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The label deduplication logic is executed on every render. To improve performance and maintain consistency with the implementation in BoardPage.tsx, this should be wrapped in useMemo.

Suggested change
const allLabels = Array.from(
new Map((data?.issueLabels.nodes ?? []).map(l => [l.id, l])).values()
);
const allLabels = useMemo(() => Array.from(
new Map((data?.issueLabels.nodes ?? []).map(l => [l.id, l])).values()
), [data?.issueLabels.nodes]);

const allUsers = data?.users.nodes ?? [];

// --- Render ---
Expand All @@ -538,6 +541,31 @@ export function IssuePage() {
<Btn variant="ghost" icon={<IcoChevL size={12} />} onClick={() => navigate(-1)}>
{selectedTeam.key}
</Btn>
{activeIssue.parent ? (
<>
<span style={{ color: 'var(--fg-faint)', display: 'inline-flex' }}>
<IcoChevR size={10} />
</span>
<button
type="button"
onClick={() => navigate(`/issue/${activeIssue.parent!.id}`)}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 11, color: 'var(--fg-muted)', padding: '2px 4px',
borderRadius: 'var(--r-1)',
}}
className="mono"
title={activeIssue.parent.title}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg-hover)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'none'; }}
>
{activeIssue.parent.identifier}
</button>
</>
) : null}
<span style={{ color: 'var(--fg-faint)', display: 'inline-flex' }}>
<IcoChevR size={10} />
</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--fg-dim)' }}>
{activeIssue.identifier}
</span>
Expand Down Expand Up @@ -612,15 +640,62 @@ export function IssuePage() {
/>

{/* Description */}
<textarea
aria-label="Issue description"
className="issue-panel__description-input"
value={description}
placeholder="Add a description…"
disabled={isSavingState}
onChange={(e) => setDescription(e.target.value)}
onBlur={() => void commitDescription().catch(() => undefined)}
/>
{isEditingDescription ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<RichTextEditor
value={description}
onChange={setDescription}
placeholder="Add a description…"
submitLabel="Save"
disabled={isSavingState}
ariaLabel="Issue description"
onSubmit={() => {
setIsEditingDescription(false);
void commitDescription().catch(() => undefined);
}}
/>
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
style={{
height: 26, padding: '0 12px', fontSize: 12, fontWeight: 500,
borderRadius: 'var(--r-2)', border: '1px solid var(--border)', cursor: 'pointer',
background: 'transparent', color: 'var(--fg-muted)',
}}
onClick={() => {
setDescription(issueSnapshot?.description ?? '');
setIsEditingDescription(false);
}}
>Cancel</button>
</div>
Comment on lines +658 to +670
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is significant duplication of inline styles for the "Save" and "Cancel" buttons. Additionally, these strings (and others in the description section like "Edit" and "Add a description…") are hardcoded. Consider extracting the shared styles into a constant or a reusable component, and moving the strings to a localization system to support internationalization.

</div>
) : (
<div
style={{ position: 'relative', cursor: 'pointer', minHeight: 32 }}
onClick={() => setIsEditingDescription(true)}
role="button"
tabIndex={0}
aria-label="Edit description"
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setIsEditingDescription(true); }}
>
{description ? (
<MarkdownRenderer content={description} />
) : (
<span style={{ color: 'var(--fg-dim)', fontSize: 13 }}>Add a description…</span>
)}
<button
type="button"
style={{
position: 'absolute', top: 0, right: 0,
height: 22, padding: '0 8px', fontSize: 11, fontWeight: 500,
borderRadius: 'var(--r-2)', border: '1px solid var(--border)', cursor: 'pointer',
background: 'var(--bg-hover)', color: 'var(--fg-muted)',
opacity: 0.7,
}}
onClick={(e) => { e.stopPropagation(); setIsEditingDescription(true); }}
>Edit</button>
</div>
)}

{/* Parent issue */}
{activeIssue.parent ? (
Expand Down
Loading