Complete the mobile Filed follow-up loop #875
|
|
@ -59,10 +59,23 @@
|
||||||
qs('#my-work').focus();
|
qs('#my-work').focus();
|
||||||
}
|
}
|
||||||
let mobileQueueCounts = {};
|
let mobileQueueCounts = {};
|
||||||
|
function openFiledFollowUp() {
|
||||||
|
selectMobileQueue('filed');
|
||||||
|
const target = filedFollowUpTarget(lastMyWork);
|
||||||
|
if (!target) {
|
||||||
|
qs('#my-work-action-status').textContent = 'No filed issues are ready to open.';
|
||||||
|
return 'empty';
|
||||||
|
}
|
||||||
|
const index = lastMyWork.indexOf(target.item);
|
||||||
|
const trigger = qs('#my-work-list [data-' + target.kind + '-index="' + index + '"]');
|
||||||
|
openRoutedWork(target.kind === 'update' ? { ...target.item, kind:'update' } : target.item, trigger);
|
||||||
|
return target.kind === 'update' ? 'opened-update' : 'opened-issue';
|
||||||
|
}
|
||||||
const mobileQueueLauncher = createMobileQueueLauncher({
|
const mobileQueueLauncher = createMobileQueueLauncher({
|
||||||
openToday: () => mobileWorkEntry.open(),
|
openToday: () => mobileWorkEntry.open(),
|
||||||
openAgenda: openAgendaSession,
|
openAgenda: openAgendaSession,
|
||||||
openUpdates: openUpdateTriage,
|
openUpdates: openUpdateTriage,
|
||||||
|
openFiled: openFiledFollowUp,
|
||||||
selectFilter: selectMobileQueue,
|
selectFilter: selectMobileQueue,
|
||||||
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
||||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||||
|
|
|
||||||
|
|
@ -1214,6 +1214,7 @@
|
||||||
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
|
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
|
||||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||||
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
||||||
|
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
|
||||||
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
||||||
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
||||||
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
|
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
if (name === 'today') return options.openToday();
|
if (name === 'today') return options.openToday();
|
||||||
if (name === 'agenda') return options.openAgenda();
|
if (name === 'agenda') return options.openAgenda();
|
||||||
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
||||||
|
if (name === 'filed' && options.openFiled) return options.openFiled();
|
||||||
options.selectFilter(name);
|
options.selectFilter(name);
|
||||||
const action = options.firstAction(name);
|
const action = options.firstAction(name);
|
||||||
if (!action) {
|
if (!action) {
|
||||||
|
|
|
||||||
|
|
@ -91,9 +91,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateQueues(counts) {
|
function updateQueues(counts) {
|
||||||
const names = 'today agenda attention update later draft'.split(' ');
|
const names = 'today agenda attention update filed later draft'.split(' ');
|
||||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||||
const actionableNames = ['today', 'attention', 'update', 'later', 'draft'];
|
const actionableNames = ['today', 'attention', 'update', 'filed', 'later', 'draft'];
|
||||||
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
|
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
|
||||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||||
element.textContent = String(normalized[name] || 0);
|
element.textContent = String(normalized[name] || 0);
|
||||||
|
|
@ -119,6 +119,7 @@
|
||||||
+ ', Agenda ' + normalized.agenda + ' due'
|
+ ', Agenda ' + normalized.agenda + ' due'
|
||||||
+ ', Attention ' + normalized.attention
|
+ ', Attention ' + normalized.attention
|
||||||
+ ', Updates ' + normalized.update
|
+ ', Updates ' + normalized.update
|
||||||
|
+ ', Filed ' + normalized.filed
|
||||||
+ ', Later ' + normalized.later
|
+ ', Later ' + normalized.later
|
||||||
+ ', Drafts ' + normalized.draft
|
+ ', Drafts ' + normalized.draft
|
||||||
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');
|
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');
|
||||||
|
|
|
||||||
|
|
@ -961,6 +961,12 @@ function findQueueItems(items, query) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filedFollowUpTarget(items) {
|
||||||
|
const item = (items || []).find(candidate => candidate?.is_filed);
|
||||||
|
if (!item) return null;
|
||||||
|
return { kind:item.has_update ? 'update' : 'issue', item };
|
||||||
|
}
|
||||||
|
|
||||||
function countMyWork(items) {
|
function countMyWork(items) {
|
||||||
return {
|
return {
|
||||||
all: items.length,
|
all: items.length,
|
||||||
|
|
@ -987,6 +993,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||||
buildMyWork.removeIssue = removeIssue;
|
buildMyWork.removeIssue = removeIssue;
|
||||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||||
buildMyWork.findQueueItems = findQueueItems;
|
buildMyWork.findQueueItems = findQueueItems;
|
||||||
|
buildMyWork.filedFollowUpTarget = filedFollowUpTarget;
|
||||||
buildMyWork.countMyWork = countMyWork;
|
buildMyWork.countMyWork = countMyWork;
|
||||||
buildMyWork.acknowledgeNotification = acknowledgeNotification;
|
buildMyWork.acknowledgeNotification = acknowledgeNotification;
|
||||||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||||
|
|
|
||||||
|
|
@ -331,7 +331,7 @@ sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
|
||||||
const close = new FakeElement();
|
const close = new FakeElement();
|
||||||
const badge = new FakeElement();
|
const badge = new FakeElement();
|
||||||
const deadline = new FakeElement();
|
const deadline = new FakeElement();
|
||||||
const rows = Object.fromEntries(['today','agenda','attention','update','later','draft','recaps'].map(name => [name, new FakeElement()]));
|
const rows = Object.fromEntries(['today','agenda','attention','update','filed','later','draft','recaps'].map(name => [name, new FakeElement()]));
|
||||||
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
|
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
|
||||||
const selected = [];
|
const selected = [];
|
||||||
const utilities = [];
|
const utilities = [];
|
||||||
|
|
@ -342,7 +342,7 @@ const dock = createDock({{
|
||||||
observe() {{}},
|
observe() {{}},
|
||||||
}});
|
}});
|
||||||
dock.start();
|
dock.start();
|
||||||
dock.updateQueues({{today:2, agenda:5, attention:1, update:5, later:3, draft:4}});
|
dock.updateQueues({{today:2, agenda:5, attention:1, update:5, filed:2, later:3, draft:4}});
|
||||||
queues.click();
|
queues.click();
|
||||||
const opened = sheet.open;
|
const opened = sheet.open;
|
||||||
rows.update.click();
|
rows.update.click();
|
||||||
|
|
@ -387,9 +387,9 @@ process.stdout.write(JSON.stringify({{
|
||||||
"utilities": ["recaps:trigger"],
|
"utilities": ["recaps:trigger"],
|
||||||
"badge": "0 active",
|
"badge": "0 active",
|
||||||
"populated": {
|
"populated": {
|
||||||
"badge": "5 active",
|
"badge": "6 active",
|
||||||
"badgeHidden": False,
|
"badgeHidden": False,
|
||||||
"badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Later 3, Drafts 4; 5 active queues",
|
"badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 6 active queues",
|
||||||
"deadline": "5 due",
|
"deadline": "5 due",
|
||||||
"deadlineHidden": False,
|
"deadlineHidden": False,
|
||||||
"agendaDue": "true",
|
"agendaDue": "true",
|
||||||
|
|
@ -398,13 +398,13 @@ process.stdout.write(JSON.stringify({{
|
||||||
"badge": "1 active",
|
"badge": "1 active",
|
||||||
"badgeHidden": False,
|
"badgeHidden": False,
|
||||||
"deadlineHidden": True,
|
"deadlineHidden": True,
|
||||||
"badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Later 0, Drafts 0; 1 active queue",
|
"badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue",
|
||||||
},
|
},
|
||||||
"clearedBadgeHidden": True,
|
"clearedBadgeHidden": True,
|
||||||
"clearedDeadlineHidden": True,
|
"clearedDeadlineHidden": True,
|
||||||
"clearedAgendaDue": None,
|
"clearedAgendaDue": None,
|
||||||
"clearedBadgeLabel": "Queues: no active queues; no upcoming deadlines",
|
"clearedBadgeLabel": "Queues: no active queues; no upcoming deadlines",
|
||||||
"counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "later": "0", "draft": "0", "recaps": "0"},
|
"counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "filed": "0", "later": "0", "draft": "0", "recaps": "0"},
|
||||||
"updateLabel": "Updates, 0 unread conversations",
|
"updateLabel": "Updates, 0 unread conversations",
|
||||||
"queueFocuses": 3,
|
"queueFocuses": 3,
|
||||||
"columnState": None,
|
"columnState": None,
|
||||||
|
|
@ -439,6 +439,28 @@ process.stdout.write(JSON.stringify({{opened, empty, calls}}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_queue_launcher_uses_dedicated_filed_follow_up_flow():
|
||||||
|
script = f"""
|
||||||
|
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||||
|
const calls = [];
|
||||||
|
const launcher = createLauncher({{
|
||||||
|
openFiled: () => {{ calls.push('filed-follow-up'); return 'opened-update'; }},
|
||||||
|
selectFilter: name => calls.push('generic-filter:' + name),
|
||||||
|
firstAction: () => {{ throw new Error('generic card launch must not run'); }},
|
||||||
|
announce: message => calls.push('announce:' + message),
|
||||||
|
}});
|
||||||
|
const result = launcher.open('filed');
|
||||||
|
process.stdout.write(JSON.stringify({{result, calls}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"result": "opened-update",
|
||||||
|
"calls": ["filed-follow-up"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation():
|
def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||||
|
|
@ -556,6 +578,11 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||||
assert 'data-mobile-queue="attention"' in html
|
assert 'data-mobile-queue="attention"' in html
|
||||||
assert 'data-mobile-queue="update"' in html
|
assert 'data-mobile-queue="update"' in html
|
||||||
assert '<strong>Updates</strong><small>Unread conversations</small>' in html
|
assert '<strong>Updates</strong><small>Unread conversations</small>' in html
|
||||||
|
assert 'data-mobile-queue="filed"' in html
|
||||||
|
assert '<strong>Filed</strong><small>Issues you delegated</small>' in html
|
||||||
|
assert 'data-mobile-queue-count="filed"' in html
|
||||||
|
assert "openFiled: openFiledFollowUp" in html
|
||||||
|
assert "filedFollowUpTarget(lastMyWork)" in html
|
||||||
assert 'data-mobile-queue="later"' in html
|
assert 'data-mobile-queue="later"' in html
|
||||||
assert 'data-mobile-queue="draft"' in html
|
assert 'data-mobile-queue="draft"' in html
|
||||||
assert 'data-mobile-queue="recaps"' in html
|
assert 'data-mobile-queue="recaps"' in html
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,34 @@ AGENDA_SESSION_LAUNCHER = Path(__file__).parents[1] / "frontend" / "agenda-sessi
|
||||||
UPDATE_TRIAGE_LAUNCHER = Path(__file__).parents[1] / "frontend" / "update-triage-launcher.js"
|
UPDATE_TRIAGE_LAUNCHER = Path(__file__).parents[1] / "frontend" / "update-triage-launcher.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_filed_follow_up_target_preserves_queue_order_and_selects_update_reader():
|
||||||
|
script = f"""
|
||||||
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
const choose = buildMyWork.filedFollowUpTarget;
|
||||||
|
const available = typeof choose === 'function';
|
||||||
|
const quiet = {{key:'repo#1', is_filed:true, has_update:false}};
|
||||||
|
const unread = {{key:'repo#2', is_filed:true, has_update:true, notification_id:22}};
|
||||||
|
const unrelated = {{key:'repo#3', is_filed:false, has_update:true}};
|
||||||
|
const results = available ? {{
|
||||||
|
first:choose([unrelated, unread, quiet]),
|
||||||
|
fallback:choose([unrelated, quiet, unread]),
|
||||||
|
empty:choose([unrelated]),
|
||||||
|
}} : null;
|
||||||
|
process.stdout.write(JSON.stringify({{available, results}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"available": True,
|
||||||
|
"results": {
|
||||||
|
"first": {"kind": "update", "item": {"key": "repo#2", "is_filed": True, "has_update": True, "notification_id": 22}},
|
||||||
|
"fallback": {"kind": "issue", "item": {"key": "repo#1", "is_filed": True, "has_update": False}},
|
||||||
|
"empty": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_updates_rank_actionable_work_before_newer_routine_activity():
|
def test_updates_rank_actionable_work_before_newer_routine_activity():
|
||||||
payload = {
|
payload = {
|
||||||
"user": {"login": "timmy"},
|
"user": {"login": "timmy"},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user