Skip to content

Commit cc497f8

Browse files
Collapse workflow-detail task rows with progressive disclosure (Closes #102)
Each task now renders as one scannable line (checkbox, title, due, assignee, status pill, single next-action affordance); the completion/proof, required link/file inputs, Close-as dropdown, Save evidence, waiting form, and assistant controls are hidden in a keyboard-operable disclosure region (aria-expanded) revealed on click. Collapses a 15-task bundle from ~5711px to ~1115px desktop (10109px -> ~2078px mobile). Completion gating and the Done(N) grouping are unchanged; deep-linked tasks auto-expand. Closes #102
1 parent f2d8ee1 commit cc497f8

5 files changed

Lines changed: 297 additions & 16 deletions

File tree

backend/e2e/bundle-detail.spec.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ function uid() {
1111
return Math.random().toString(36).slice(2, 8);
1212
}
1313

14+
// Task rows collapse to a scannable line at rest (#102). Expand the row to
15+
// reveal the required-link/file, completion-proof, waiting, and assistant
16+
// controls before interacting with them.
17+
async function expandRow(row) {
18+
const toggle = row.locator('[data-task-expand]').first();
19+
if (await toggle.count()) {
20+
if ((await toggle.getAttribute('aria-expanded')) === 'false') {
21+
await toggle.click();
22+
}
23+
await expect(row.locator('.task-checklist-details')).toBeVisible();
24+
}
25+
}
26+
1427
test.describe('Bundle detail view (issue #27)', () => {
1528

1629
// ── Scenario: Grace views a bundle with references and bundle links ──
@@ -162,6 +175,9 @@ test.describe('Bundle detail view (issue #27)', () => {
162175
const checkbox = taskRow.locator('.task-status-checkbox');
163176
await expect(checkbox).toBeDisabled();
164177

178+
// Expand the collapsed row to reach the required-link editor
179+
await expandRow(taskRow);
180+
165181
// Required link label
166182
const linkLabel = taskRow.locator('.required-link-label');
167183
await expect(linkLabel).toHaveText('Luma:');
@@ -745,6 +761,7 @@ test.describe('Bundle detail view (issue #27)', () => {
745761
const sponsorRow = page.locator('[data-task-row="' + sponsorTask.id + '"]');
746762
await expect(sponsorRow).toContainText('Add Sponsorship document link to complete');
747763
await expect(sponsorRow.locator('[data-task-checkbox="' + sponsorTask.id + '"]')).toBeDisabled();
764+
await expandRow(sponsorRow);
748765
await sponsorRow.locator('[data-skip-closure-task="' + sponsorTask.id + '"]').selectOption('not sponsored this week');
749766
await sponsorRow.locator('[data-save-completion-proof="' + sponsorTask.id + '"]').click();
750767
await expect(page.locator('[data-task-row="' + sponsorTask.id + '"] [data-task-checkbox="' + sponsorTask.id + '"]')).toBeEnabled();
@@ -784,6 +801,7 @@ test.describe('Bundle detail view (issue #27)', () => {
784801
await expect(page.locator('.bundle-link-row', { hasText: 'Mailchimp newsletter' })).not.toHaveClass(/bundle-link-row--empty/);
785802

786803
await expect(page.locator('[data-task-row="' + externalStatusTask.id + '"]')).toContainText('Add completion status: Mailchimp campaign scheduled');
804+
await expandRow(externalStatusRow);
787805
await externalStatusRow.locator('[data-completion-proof-task="' + externalStatusTask.id + '"]').fill('Mailchimp campaign scheduled');
788806
await externalStatusRow.locator('[data-save-completion-proof="' + externalStatusTask.id + '"]').click();
789807
await expect(page.locator('[data-task-row="' + externalStatusTask.id + '"] [data-task-checkbox="' + externalStatusTask.id + '"]')).toBeEnabled();
@@ -799,6 +817,7 @@ test.describe('Bundle detail view (issue #27)', () => {
799817
await expect(commentRow).toContainText('Add completion note: Book block updated or removed');
800818
await expect(commentRow.locator('[data-task-checkbox="' + commentTask.id + '"]')).toBeDisabled();
801819

820+
await expandRow(commentRow);
802821
await commentRow.locator('[data-completion-proof-task="' + commentTask.id + '"]').fill('Book block removed; no book this week');
803822
await commentRow.locator('[data-save-completion-proof="' + commentTask.id + '"]').click();
804823
await expect(page.locator('[data-task-row="' + commentTask.id + '"] [data-task-checkbox="' + commentTask.id + '"]')).toBeEnabled();
@@ -815,6 +834,7 @@ test.describe('Bundle detail view (issue #27)', () => {
815834
const skipRow = page.locator('[data-task-row="' + skipTask.id + '"]');
816835
await expect(skipRow).toContainText('Add LinkedIn link to complete');
817836
await expect(skipRow.locator('[data-task-checkbox="' + skipTask.id + '"]')).toBeDisabled();
837+
await expandRow(skipRow);
818838
await skipRow.locator('[data-skip-closure-task="' + skipTask.id + '"]').selectOption('not sponsored this week');
819839
await skipRow.locator('[data-save-completion-proof="' + skipTask.id + '"]').click();
820840
await expect(page.locator('[data-task-row="' + skipTask.id + '"] [data-task-checkbox="' + skipTask.id + '"]')).toBeEnabled();
@@ -1090,4 +1110,73 @@ test.describe('Bundle detail view (issue #27)', () => {
10901110
await expect(regularRow).not.toHaveClass(/milestone-task-row/);
10911111
});
10921112
});
1113+
1114+
// ── Scenario: Rows collapse to a scannable line and expand on demand ──
1115+
test.describe('Scenario: Task rows collapse and expand (issue #102)', () => {
1116+
let bundleId;
1117+
let taskId;
1118+
const suffix = uid();
1119+
1120+
test.beforeAll(async ({ request }) => {
1121+
const bundleRes = await request.post('/api/bundles', {
1122+
data: {
1123+
title: 'Collapse ' + suffix,
1124+
anchorDate: '2026-05-15',
1125+
bundleLinks: [{ name: 'Luma', url: '' }],
1126+
},
1127+
});
1128+
expect(bundleRes.status()).toBe(201);
1129+
bundleId = (await bundleRes.json()).bundle.id;
1130+
1131+
const taskRes = await request.post('/api/tasks', {
1132+
data: {
1133+
description: 'Collapsible required-link task ' + suffix,
1134+
date: '2026-05-15',
1135+
bundleId,
1136+
requiredLinkName: 'Luma',
1137+
source: 'template',
1138+
},
1139+
});
1140+
expect(taskRes.status()).toBe(201);
1141+
taskId = (await taskRes.json()).id;
1142+
});
1143+
1144+
test.afterAll(async ({ request }) => {
1145+
if (taskId) await request.delete('/api/tasks/' + taskId);
1146+
if (bundleId) await archiveAndDelete(request, bundleId);
1147+
});
1148+
1149+
test('row is collapsed at rest and reveals controls only after expand', async ({ page }) => {
1150+
await page.goto('/#/bundles');
1151+
await page.waitForSelector('.bundle-card');
1152+
await page.locator('.bundle-card', { hasText: 'Collapse ' + suffix }).locator('.bundle-card-title').click();
1153+
await page.waitForSelector('[data-testid="stage-badge"]');
1154+
1155+
const row = page.locator('[data-task-row="' + taskId + '"]');
1156+
await expect(row).toBeVisible();
1157+
1158+
// At rest: the checkbox is visible but the detail editor is hidden.
1159+
await expect(row.locator('.task-status-checkbox')).toBeVisible();
1160+
const details = row.locator('.task-checklist-details');
1161+
await expect(details).toBeHidden();
1162+
await expect(row.locator('.required-link-input')).toBeHidden();
1163+
1164+
// The single next-action toggle exposes an accessible collapsed state.
1165+
const toggle = row.locator('[data-task-expand]');
1166+
await expect(toggle).toBeVisible();
1167+
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
1168+
1169+
// Keyboard-operable: focus and press Enter to expand.
1170+
await toggle.focus();
1171+
await page.keyboard.press('Enter');
1172+
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
1173+
await expect(details).toBeVisible();
1174+
await expect(row.locator('.required-link-input')).toBeVisible();
1175+
1176+
// Toggling again collapses the row back to one line.
1177+
await page.keyboard.press('Enter');
1178+
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
1179+
await expect(details).toBeHidden();
1180+
});
1181+
});
10931182
});

backend/e2e/follow-up-actions.spec.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ async function screenshot(page, name) {
2121
await page.screenshot({ path: `../.tmp/screenshots/${name}.png`, fullPage: true });
2222
}
2323

24+
// Workflow-detail rows collapse to a scannable line at rest (#102). Expand the
25+
// row to reach the follow-up/waiting controls in the disclosure region.
26+
async function expandRow(row) {
27+
const toggle = row.locator('[data-task-expand]').first();
28+
if (await toggle.count()) {
29+
if ((await toggle.getAttribute('aria-expanded')) === 'false') {
30+
await toggle.click();
31+
}
32+
await expect(row.locator('.task-checklist-details')).toBeVisible();
33+
}
34+
}
35+
2436
async function cleanupTask(request, task) {
2537
if (!task) return;
2638
await request.delete('/api/tasks/' + task.id).catch(() => {});
@@ -100,6 +112,7 @@ test.describe('Operator follow-up actions (#56)', () => {
100112
await expect(workflowRow).toContainText('Sent sponsor reminder from Gmail');
101113
await screenshot(page, 'issue-56-workflow-waiting-history');
102114

115+
await expandRow(workflowRow);
103116
await workflowRow.locator('.follow-up-note').fill('Sponsor replied with approval');
104117
await Promise.all([
105118
page.waitForResponse((response) => (
@@ -110,6 +123,7 @@ test.describe('Operator follow-up actions (#56)', () => {
110123
workflowRow.locator('[data-follow-up-action="response-received"]').click(),
111124
]);
112125
await expect(workflowRow).toContainText('Response received');
126+
await expandRow(workflowRow);
113127
await expect(workflowRow.locator('.waiting-form')).toBeVisible();
114128
await screenshot(page, 'issue-56-resolved-unblocked-state');
115129

backend/e2e/podcast-operator-slice.spec.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ async function screenshot(page, name) {
1111
async function podcastRow(page, text) {
1212
const row = page.locator('.task-checklist-row', { hasText: text }).first();
1313
await expect(row).toBeVisible({ timeout: 15000 });
14+
// Rows collapse to a scannable line at rest (#102); expand to reach controls.
15+
const toggle = row.locator('[data-task-expand]').first();
16+
if (await toggle.count()) {
17+
if ((await toggle.getAttribute('aria-expanded')) === 'false') {
18+
await toggle.click();
19+
}
20+
await expect(row.locator('.task-checklist-details')).toBeVisible();
21+
}
1422
return row;
1523
}
1624

@@ -95,7 +103,9 @@ test.describe('Podcast operator workflow slice (#9)', () => {
95103
buffer: Buffer.from('podcast banner proof ' + suffix),
96104
});
97105
await bannerRow.locator('[data-upload-required-file]').click();
98-
await page.waitForSelector('.proof-present', { timeout: 15000 });
106+
// The upload reloads the bundle and the row re-collapses, so the proof marker
107+
// is present in the DOM but hidden until re-expanded (#102).
108+
await page.waitForSelector('.proof-present', { state: 'attached', timeout: 15000 });
99109
const bannerRowAfter = await podcastRow(page, 'Create a banner for a podcast event in Figma');
100110
await expect(bannerRowAfter.locator('.task-status-checkbox')).toBeEnabled();
101111
await bannerRowAfter.locator('.task-status-checkbox').check();
@@ -124,7 +134,8 @@ test.describe('Podcast operator workflow slice (#9)', () => {
124134
await waitRow.locator('.waiting-followup-input').fill('2000-01-01');
125135
await waitRow.locator('.waiting-note-input').fill('Waiting for date confirmation');
126136
await waitRow.locator('[data-mark-waiting-task]').click();
127-
await expect(page.locator('.badge-waiting', { hasText: guest })).toBeVisible({ timeout: 15000 });
137+
// Row re-collapses after marking waiting; the badge is in the DOM but hidden (#102).
138+
await expect(page.locator('.badge-waiting', { hasText: guest })).toBeAttached({ timeout: 15000 });
128139
await screenshot(page, `podcast-waiting-${suffix}`);
129140

130141
await page.goto('/#/');

backend/src/pages/index.html

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1566,6 +1566,88 @@
15661566
color: #aaa;
15671567
}
15681568

1569+
/* ── Progressive-disclosure task rows (#102) ──────────── */
1570+
1571+
.task-checklist-summary {
1572+
display: flex;
1573+
align-items: flex-start;
1574+
gap: 10px;
1575+
}
1576+
1577+
.task-checklist-heading {
1578+
flex: 1;
1579+
min-width: 0;
1580+
}
1581+
1582+
.task-checklist-pills {
1583+
display: inline-flex;
1584+
gap: 5px;
1585+
flex-wrap: wrap;
1586+
align-items: center;
1587+
}
1588+
1589+
.task-queue-label--done {
1590+
background: #e7f5ee;
1591+
color: #21734d;
1592+
}
1593+
1594+
.task-queue-label--overdue {
1595+
background: #fdecea;
1596+
color: #a3271b;
1597+
}
1598+
1599+
.task-queue-label--waiting {
1600+
background: #eef4ff;
1601+
color: #345b9c;
1602+
}
1603+
1604+
.task-disclosure-toggle {
1605+
flex: 0 0 auto;
1606+
display: inline-flex;
1607+
align-items: center;
1608+
gap: 5px;
1609+
min-height: 32px;
1610+
border: 1px solid #d8dde3;
1611+
background: #f7f9fb;
1612+
color: #34506e;
1613+
font-family: inherit;
1614+
font-size: 12px;
1615+
font-weight: 600;
1616+
padding: 4px 10px;
1617+
border-radius: 5px;
1618+
cursor: pointer;
1619+
white-space: nowrap;
1620+
transition: background 0.15s, border-color 0.15s;
1621+
}
1622+
1623+
.task-disclosure-toggle:hover {
1624+
background: #eef3f8;
1625+
border-color: #c3ccd6;
1626+
}
1627+
1628+
.task-disclosure-toggle:focus-visible {
1629+
outline: 2px solid var(--primary);
1630+
outline-offset: 1px;
1631+
}
1632+
1633+
.task-disclosure-caret {
1634+
font-size: 10px;
1635+
line-height: 1;
1636+
}
1637+
1638+
.task-checklist-details {
1639+
margin-top: 10px;
1640+
padding-top: 10px;
1641+
border-top: 1px dashed #e3e3e3;
1642+
display: flex;
1643+
flex-direction: column;
1644+
gap: 8px;
1645+
}
1646+
1647+
.task-checklist-details[hidden] {
1648+
display: none;
1649+
}
1650+
15691651
/* ── Bell notification icon ───────────────────────────── */
15701652

15711653
.notif-bell-wrapper {

0 commit comments

Comments
 (0)