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: 4 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1539,7 +1539,10 @@
"watchers": "Watchers",
"digestHint": "Updates appear in your digest.",
"memberUnavailable": "Member unavailable",
"saveError": "Could not save this change. Try again."
"saveError": "Could not save this change. Try again.",
"statusLabel": "Status",
"showEarlierUpdates": "Show {{count}} earlier updates",
"hideEarlierUpdates": "Hide earlier updates"
},
"form": {
"title": "Title",
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1531,7 +1531,10 @@
"watchers": "关注者",
"digestHint": "更新会显示在你的摘要中。",
"memberUnavailable": "成员不可用",
"saveError": "未能保存更改,请重试。"
"saveError": "未能保存更改,请重试。",
"statusLabel": "状态",
"showEarlierUpdates": "显示{{count}}条更早的更新",
"hideEarlierUpdates": "隐藏更早的更新"
},
"form": {
"title": "标题",
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/v2/__tests__/V2PodBoard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -660,3 +660,24 @@ test('a delayed Follow response cannot replace another follower from the current
expect(await screen.findByRole('button', { name: /Following/ })).toHaveAttribute('aria-pressed', 'true');
expect(document.querySelectorAll('.v2-board__watcher')).toHaveLength(2);
});

test('detail status is a segment that moves the task, and older updates fold behind a toggle', async () => {
const updates = ['one', 'two', 'three', 'four', 'five'].map((text) => ({ text, author: 'Kai', authorId: 'kai-id' }));
const task = { taskId: 'TASK-5', title: 'Segmented', status: 'pending', watchers: [], updates };
wireAxios([task]);
axios.patch.mockResolvedValue({ data: { task: { ...task, status: 'claimed' } } });
renderBoard();
fireEvent.click(await screen.findByTitle('TASK-5'));
const dialog = screen.getByRole('dialog', { name: /Segmented/ });
const segment = within(dialog).getByRole('radiogroup', { name: 'Status' });
expect(within(segment).getByRole('radio', { name: 'Pending' })).toHaveAttribute('aria-checked', 'true');
fireEvent.click(within(segment).getByRole('radio', { name: /in progress/i }));
await waitFor(() => expect(axios.patch).toHaveBeenCalledWith('/api/v1/tasks/pod-1/TASK-5', { status: 'claimed' }, expect.any(Object)));
// Newest three show; the two oldest fold behind the toggle.
expect(within(dialog).queryByText('one')).toBeNull();
expect(within(dialog).queryByText('two')).toBeNull();
expect(within(dialog).getByText('five')).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: 'Show 2 earlier updates' }));
expect(within(dialog).getByText('one')).toBeInTheDocument();
expect(within(dialog).getByRole('button', { name: 'Hide earlier updates' })).toHaveAttribute('aria-expanded', 'true');
});
9 changes: 8 additions & 1 deletion frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5092,7 +5092,14 @@ test('TASK-443 phone ownership sheet keeps its inset and scroll containment', ()
expect(modal).toContain('overflow: auto');
expect(modal).toContain('display: flex');
expect(modal).toContain('flex-direction: column');
expect(read('../components/V2PodBoard.tsx')).toContain('className="v2-modal v2-board__detail"');
expect(read('../components/V2PodBoard.tsx')).toContain('className="v2-modal v2-board__detail v2-board__drawer"');
});

test('TASK-699 task detail is a 400px right drawer on desktop and a sheet on phones', () => {
const css = read('../v2.css');
expect(ruleBody(css, '.v2-board .v2-board__drawer')).toContain('width: 400px');
expect(ruleBody(css, '.v2-board .v2-board__overlay--drawer')).toContain('justify-content: flex-end');
expect(read('../components/V2PodBoard.tsx')).toContain('className="v2-modal v2-board__detail v2-board__drawer"');
});


Expand Down
77 changes: 43 additions & 34 deletions frontend/src/v2/components/V2PodBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ interface ColumnMeta {
key: CanonicalStatus;
labelKey: string;
emptyKey: string;
pillClass: string;
// Alias statuses written before the PATCH vocabulary gate (#921) render in
// their canonical column instead of disappearing.
statuses: string[];
Expand All @@ -94,7 +93,6 @@ const COLUMNS: ColumnMeta[] = [
key: 'pending',
labelKey: 'board.col.pending',
emptyKey: 'board.emptyCol.pending',
pillClass: 'v2-inspector__pill v2-inspector__pill--progress',
statuses: ['pending', 'todo', 'open'],
moves: [
{ to: 'claimed', labelKey: 'board.move.start' },
Expand All @@ -105,7 +103,6 @@ const COLUMNS: ColumnMeta[] = [
key: 'claimed',
labelKey: 'board.col.inProgress',
emptyKey: 'board.emptyCol.inProgress',
pillClass: 'v2-inspector__pill v2-inspector__pill--progress',
statuses: ['claimed', 'in_progress', 'in-progress'],
moves: [
{ to: 'done', labelKey: 'board.move.finish' },
Expand All @@ -116,7 +113,6 @@ const COLUMNS: ColumnMeta[] = [
key: 'blocked',
labelKey: 'board.col.blocked',
emptyKey: 'board.emptyCol.blocked',
pillClass: 'v2-inspector__pill v2-inspector__pill--blocked',
statuses: ['blocked'],
moves: [
{ to: 'claimed', labelKey: 'board.move.resume' },
Expand All @@ -127,7 +123,6 @@ const COLUMNS: ColumnMeta[] = [
key: 'done',
labelKey: 'board.col.done',
emptyKey: 'board.emptyCol.done',
pillClass: 'v2-inspector__pill v2-inspector__pill--complete',
statuses: ['done', 'completed', 'complete'],
moves: [
{ to: 'pending', labelKey: 'board.move.reopen' },
Expand All @@ -154,6 +149,7 @@ const V2PodBoard: React.FC = () => {
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [selected, setSelected] = useState<BoardTask | null>(null);
const [updatesExpanded, setUpdatesExpanded] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newAssignee, setNewAssignee] = useState('');
Expand Down Expand Up @@ -186,6 +182,7 @@ const V2PodBoard: React.FC = () => {
taskMutationRef.current += 1;
setDetailSaving(false);
setDetailError(null);
setUpdatesExpanded(false);
setSelected(task);
}, []);

Expand Down Expand Up @@ -635,11 +632,6 @@ const V2PodBoard: React.FC = () => {
return byColumn;
}, [tasks]);

const pillFor = (status: string): { className: string; label: string } => {
const col = COLUMNS.find((c) => c.statuses.includes(status)) || COLUMNS[0];
return { className: col.pillClass, label: t(col.labelKey) };
};

return (
<div className="v2-board" data-testid="v2-board">
<header className="v2-board__head">
Expand Down Expand Up @@ -779,9 +771,9 @@ const V2PodBoard: React.FC = () => {
)}

{selected && (
<div className="v2-modal__overlay v2-board__overlay" role="presentation" onClick={() => openTask(null)}>
<div className="v2-modal__overlay v2-board__overlay v2-board__overlay--drawer" role="presentation" onClick={() => openTask(null)}>
<div
className="v2-modal v2-board__detail"
className="v2-modal v2-board__detail v2-board__drawer"
ref={detailDialogRef}
role="dialog"
aria-label={selected.title}
Expand All @@ -797,7 +789,24 @@ const V2PodBoard: React.FC = () => {
</div>
<div className="v2-modal__body">
<div className="v2-board__detail-meta">
{(() => { const pill = pillFor(selected.status); return <span className={pill.className}>{pill.label}</span>; })()}
<div className="v2-board__status-segment" role="radiogroup" aria-label={t('board.detail.statusLabel')}>
{COLUMNS.map((column) => {
const current = column.statuses.includes(selected.status);
return (
<button
key={column.key}
type="button"
role="radio"
aria-checked={current}
className="v2-board__status-option"
disabled={detailSaving || !taskWriteAllowed || current}
onClick={() => moveTask(selected, column.statuses[0] as CanonicalStatus)}
>
{t(column.labelKey)}
</button>
);
})}
</div>
{selected.prUrl && (
<a className="v2-board__detail-pr" href={selected.prUrl} target="_blank" rel="noreferrer">
{t('board.detail.pr')}
Expand Down Expand Up @@ -832,32 +841,32 @@ const V2PodBoard: React.FC = () => {
</section>
{detailError && <p className="v2-modal__error" role="alert">{detailError}</p>}
{selected.notes && <p className="v2-board__detail-notes">{selected.notes}</p>}
<div className="v2-board__detail-moves">
{(COLUMNS.find((c) => c.statuses.includes(selected.status))?.moves || []).map((move) => (
<button
key={move.to}
type="button"
className="v2-board__card-move"
onClick={() => moveTask(selected, move.to)}
>
{t(move.labelKey)}
</button>
))}
</div>
{(selected.updates?.length || 0) > 0 && (
<>
<div className="v2-modal__section-title">{t('board.detail.updates')}</div>
<ul className="v2-board__detail-updates">
{(selected.updates || []).slice().reverse().map((update, index) => (
// Updates have no id; index-in-reversed-list is stable
// for a render of an immutable snapshot.
// eslint-disable-next-line react/no-array-index-key
<li key={index}>
<span className="v2-board__detail-update-author">{update.author}</span>
<span className="v2-board__detail-update-text">{update.text}</span>
</li>
))}
{(() => {
// Newest first. The three newest stay visible; older ones fold behind a toggle.
const ordered = (selected.updates || []).slice().reverse();
const earlier = Math.max(0, ordered.length - 3);
return (updatesExpanded ? ordered : ordered.slice(0, ordered.length - earlier)).map((update, index) => (
// Updates have no id; index-in-reversed-list is stable
// for a render of an immutable snapshot.
// eslint-disable-next-line react/no-array-index-key
<li key={index}>
<span className="v2-board__detail-update-author">{update.author}</span>
<span className="v2-board__detail-update-text">{update.text}</span>
</li>
));
})()}
</ul>
{(selected.updates?.length || 0) > 3 && (
<button type="button" className="v2-board__updates-toggle" aria-expanded={updatesExpanded} onClick={() => setUpdatesExpanded((open) => !open)}>
{updatesExpanded
? t('board.detail.hideEarlierUpdates')
: t('board.detail.showEarlierUpdates', { count: (selected.updates?.length || 0) - 3 })}
</button>
)}
</>
)}
</div>
Expand Down
18 changes: 12 additions & 6 deletions frontend/src/v2/v2.css
Original file line number Diff line number Diff line change
Expand Up @@ -9044,12 +9044,6 @@ body.modern-ui.v2-canvas {
white-space: pre-wrap;
}

.v2-board__detail-moves {
display: flex;
gap: 8px;
margin-bottom: 14px;
}

.v2-board__detail-updates {
list-style: none;
margin: 0;
Expand Down Expand Up @@ -13765,6 +13759,15 @@ body.modern-ui.v2-canvas {
.v2-board__detail .v2-modal__body { padding: 16px 24px 24px; display: grid; gap: 20px; }
.v2-root .v2-board__detail button.v2-modal__close { width: 32px; height: 32px; border: 1px solid var(--v2-border); border-radius: 4px; flex-shrink: 0; }
.v2-board__grab { display: none; }
/* D215 task board: details open in a 400px right drawer, not a centred modal. */
.v2-board .v2-board__overlay--drawer { align-items: stretch; justify-content: flex-end; padding-top: 0; }
.v2-board .v2-board__drawer { width: 400px; max-width: 100%; height: 100%; max-height: none; border-radius: 14px 0 0 14px; box-shadow: var(--v2-shadow-lg); }
.v2-board__status-segment { display: inline-flex; max-width: 100%; border: 1px solid var(--v2-border); border-radius: 6px; overflow: hidden; }
.v2-root button.v2-board__status-option { min-height: 32px; padding: 0 12px; border: 0; border-radius: 0; background: var(--v2-bg); color: var(--v2-text-secondary); font-size: 13px; font-weight: 600; }
.v2-root button.v2-board__status-option + button.v2-board__status-option { border-left: 1px solid var(--v2-border-soft); }
.v2-root button.v2-board__status-option[aria-checked="true"] { background: var(--v2-surface); color: var(--v2-text-primary); }
.v2-root button.v2-board__status-option:disabled { cursor: default; }
.v2-root button.v2-board__updates-toggle { justify-self: start; min-height: 32px; padding: 0; border: 0; background: none; color: var(--v2-text-secondary); font-size: 13px; font-weight: 600; text-decoration: underline; }
.v2-board__ownership { margin: 0; display: grid; grid-template-columns: 112px minmax(0, 1fr); border: 1px solid var(--v2-border-soft); border-radius: 4px; }
.v2-board__ownership dt, .v2-board__ownership dd { margin: 0; padding: 10px 12px; min-height: 44px; display: flex; align-items: center; border-top: 1px solid var(--v2-border-soft); }
.v2-board__ownership dt:first-of-type, .v2-board__ownership dd:first-of-type { border-top: 0; }
Expand All @@ -13789,6 +13792,9 @@ body.modern-ui.v2-canvas {
.v2-board__col { flex: 0 0 78%; min-width: 250px; }
.v2-root button.v2-board__card-move { min-height: 44px; padding: 0 14px; font-size: 13px; }
.v2-board .v2-board__detail { position: absolute; inset: 44px 0 0; width: 100%; max-height: none; border-radius: 6px 6px 0 0; }
.v2-board .v2-board__drawer { width: 100%; height: auto; border-radius: 6px 6px 0 0; }
.v2-root button.v2-board__status-option { min-height: 44px; padding: 0 14px; }
.v2-root button.v2-board__updates-toggle { min-height: 44px; }
.v2-board__grab { display: block; width: 36px; height: 4px; border-radius: 2px; background: var(--v2-border-soft); margin: 8px auto 0; flex-shrink: 0; }
.v2-board__detail .v2-modal__head { padding: 12px 16px 14px; }
.v2-board__detail .v2-modal__body { padding: 14px 16px 24px; gap: 18px; }
Expand Down
Loading