Merge pull request 'Select updates to mark read from mobile My Work' (#546) from timmy/545-select-updates into main
This commit is contained in:
commit
9c5b100d6a
|
|
@ -153,6 +153,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||||
.my-work-card:hover { border-color:var(--accent); }
|
.my-work-card:hover { border-color:var(--accent); }
|
||||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||||
|
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||||
|
.update-selection-controls button { min-height:44px; }
|
||||||
|
.update-selector { min-height:44px; display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
||||||
|
.update-selector input { width:22px; height:22px; flex:0 0 auto; }
|
||||||
|
.my-work-card.selection-active { border-color:#31577f; }
|
||||||
|
.my-work-card.selected { border-color:#60a5fa; box-shadow:inset 4px 0 #60a5fa; }
|
||||||
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||||
.later-actions button { min-height:44px; width:100%; }
|
.later-actions button { min-height:44px; width:100%; }
|
||||||
.today-actions button { min-height:44px; width:100%; }
|
.today-actions button { min-height:44px; width:100%; }
|
||||||
|
|
@ -464,6 +470,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
||||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||||
.my-work-list { grid-template-columns:1fr; }
|
.my-work-list { grid-template-columns:1fr; }
|
||||||
|
.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }
|
||||||
.my-work-card { min-width:0; overflow-x:hidden; }
|
.my-work-card { min-width:0; overflow-x:hidden; }
|
||||||
.card-planning > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; list-style:none; }
|
.card-planning > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; list-style:none; }
|
||||||
.card-planning > summary::-webkit-details-marker { display:none; }
|
.card-planning > summary::-webkit-details-marker { display:none; }
|
||||||
|
|
|
||||||
|
|
@ -616,6 +616,13 @@
|
||||||
},
|
},
|
||||||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
||||||
});
|
});
|
||||||
|
const notificationSelection = createNotificationSelection({
|
||||||
|
limit: 50,
|
||||||
|
onChange: () => {
|
||||||
|
bulkConfirmationPending = false;
|
||||||
|
renderMyWork();
|
||||||
|
},
|
||||||
|
});
|
||||||
const notificationPager = createNotificationPager({
|
const notificationPager = createNotificationPager({
|
||||||
load: fetchNotificationPage,
|
load: fetchNotificationPage,
|
||||||
onNotifications: items => {
|
onNotifications: items => {
|
||||||
|
|
@ -2086,6 +2093,8 @@
|
||||||
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) :
|
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) :
|
||||||
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
|
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
|
||||||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||||||
|
const selection = notificationSelection.snapshot();
|
||||||
|
const selectedIds = new Set(selection.ids);
|
||||||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
||||||
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
||||||
const routeItem = routedWorkItem(item);
|
const routeItem = routedWorkItem(item);
|
||||||
|
|
@ -2099,7 +2108,13 @@
|
||||||
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
|
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
|
||||||
(item.deferred_until ? '<span class="small">Deferred until ' + escapeHtml(fmt(item.deferred_until)) + '</span>' : '') +
|
(item.deferred_until ? '<span class="small">Deferred until ' + escapeHtml(fmt(item.deferred_until)) + '</span>' : '') +
|
||||||
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
||||||
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
|
const selectable = selectedWorkFilter === 'update' && selection.active &&
|
||||||
|
item.has_update && Number.isInteger(item.notification_id);
|
||||||
|
const selector = selectable ?
|
||||||
|
'<label class="update-selector"><input type="checkbox" data-select-notification-id="' + item.notification_id + '" aria-label="Select update ' + escAttr(item.key + ' ' + item.title) + '"' + (selectedIds.has(item.notification_id) ? ' checked' : '') + '> Select</label>' : '';
|
||||||
|
const cardClasses = 'my-work-card' + (selectable ? ' selection-active' : '') +
|
||||||
|
(selectedIds.has(item.notification_id) ? ' selected' : '');
|
||||||
|
const markRead = item.has_update && Number.isInteger(item.notification_id) && !selection.active ?
|
||||||
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
|
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
|
||||||
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
|
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
|
||||||
'<a class="read-update" href="' + escAttr(createWorkRoute.serialize({ kind:'update', notification_id:item.notification_id })) + '" data-update-index="' + index + '">Read update</a>' : '';
|
'<a class="read-update" href="' + escAttr(createWorkRoute.serialize({ kind:'update', notification_id:item.notification_id })) + '" data-update-index="' + index + '">Read update</a>' : '';
|
||||||
|
|
@ -2115,19 +2130,30 @@
|
||||||
const planningActions = selectedWorkFilter === 'later' ? laterActions :
|
const planningActions = selectedWorkFilter === 'later' ? laterActions :
|
||||||
'<details class="card-planning" data-card-planning><summary aria-expanded="false">Plan or defer</summary><div class="card-planning-actions">' + todayActions + laterActions + '</div></details>';
|
'<details class="card-planning" data-card-planning><summary aria-expanded="false">Plan or defer</summary><div class="card-planning-actions">' + todayActions + laterActions + '</div></details>';
|
||||||
if (item.is_review) {
|
if (item.is_review) {
|
||||||
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||||
}
|
}
|
||||||
if (item.kind === 'issue') {
|
if (item.kind === 'issue') {
|
||||||
return '<article class="my-work-card"><a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||||
}
|
}
|
||||||
if (item.kind === 'pull') {
|
if (item.kind === 'pull') {
|
||||||
return '<article class="my-work-card"><a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||||
}
|
}
|
||||||
return '<article class="my-work-card"><a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + planningActions + '</article>';
|
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + planningActions + '</article>';
|
||||||
}).join('') : '<div class="muted">' + (incomplete ?
|
}).join('') : '<div class="muted">' + (incomplete ?
|
||||||
'More work is available. Load the next page.' :
|
'More work is available. Load the next page.' :
|
||||||
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>';
|
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>';
|
||||||
cardPlanning.wire();
|
cardPlanning.wire();
|
||||||
|
document.querySelectorAll('[data-select-notification-id]').forEach(input => {
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
const notificationId = Number(input.dataset.selectNotificationId);
|
||||||
|
const result = input.checked ? notificationSelection.select(notificationId) :
|
||||||
|
notificationSelection.toggle(notificationId);
|
||||||
|
if (result === 'limit') {
|
||||||
|
input.checked = false;
|
||||||
|
qs('#my-work-action-status').textContent = 'Select up to 50 updates per batch.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||||||
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
||||||
});
|
});
|
||||||
|
|
@ -2246,20 +2272,22 @@
|
||||||
document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus();
|
document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const allIds = notificationIds(visible);
|
|
||||||
const ids = allIds.slice(0, Math.min(lastNotifications.length, 50));
|
|
||||||
const bulkBar = qs('#bulk-mark-read-bar');
|
const bulkBar = qs('#bulk-mark-read-bar');
|
||||||
const bulkButton = qs('#bulk-mark-read');
|
const bulkButton = qs('#bulk-mark-read');
|
||||||
bulkBar.hidden = selectedWorkFilter !== 'update' || ids.length === 0;
|
const updateIds = notificationIds(visible);
|
||||||
|
const selectionControls = qs('#update-selection-controls');
|
||||||
|
selectionControls.hidden = selectedWorkFilter !== 'update' || updateIds.length === 0;
|
||||||
|
qs('#select-updates').hidden = selection.active;
|
||||||
|
qs('#cancel-update-selection').hidden = !selection.active;
|
||||||
|
qs('#update-selection-status').textContent = selection.active ?
|
||||||
|
selection.count + ' of 50 selected' : 'Choose specific updates to keep important work unread.';
|
||||||
|
bulkBar.hidden = selectedWorkFilter !== 'update' || !selection.active;
|
||||||
qs('#load-more-notifications').hidden =
|
qs('#load-more-notifications').hidden =
|
||||||
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
|
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
|
||||||
bulkButton.disabled = bulkMarkPending;
|
bulkButton.disabled = bulkMarkPending || selection.count === 0;
|
||||||
const bulkLabel = allIds.length > ids.length ?
|
|
||||||
'next ' + ids.length + ' of ' + allIds.length + ' loaded updates' :
|
|
||||||
'all ' + ids.length + ' updates';
|
|
||||||
bulkButton.textContent = bulkConfirmationPending ?
|
bulkButton.textContent = bulkConfirmationPending ?
|
||||||
'Confirm marking ' + bulkLabel + ' read' :
|
'Confirm marking ' + selection.count + ' selected read' :
|
||||||
'Mark ' + bulkLabel + ' read';
|
'Mark ' + selection.count + ' selected read';
|
||||||
workRoute.setItems(lastMyWork);
|
workRoute.setItems(lastMyWork);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5161,32 +5189,44 @@
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
qs('#select-updates').addEventListener('click', () => {
|
||||||
|
notificationSelection.start();
|
||||||
|
qs('#update-selection-status').textContent = '0 of 50 selected';
|
||||||
|
document.querySelector('[data-select-notification-id]')?.focus();
|
||||||
|
});
|
||||||
|
qs('#cancel-update-selection').addEventListener('click', () => {
|
||||||
|
notificationSelection.cancel();
|
||||||
|
qs('#select-updates').focus();
|
||||||
|
});
|
||||||
qs('#bulk-mark-read').addEventListener('click', async () => {
|
qs('#bulk-mark-read').addEventListener('click', async () => {
|
||||||
const allIds = notificationIds(filterMyWork(lastMyWork, 'update'));
|
const selection = notificationSelection.snapshot();
|
||||||
const ids = allIds.slice(0, 50);
|
if (!selection.ids.length || bulkMarkPending) return;
|
||||||
if (!ids.length || bulkMarkPending) return;
|
|
||||||
if (!bulkConfirmationPending) {
|
if (!bulkConfirmationPending) {
|
||||||
bulkConfirmationPending = true;
|
bulkConfirmationPending = true;
|
||||||
qs('#my-work-action-status').textContent = 'Confirm to mark all visible updates read.';
|
qs('#my-work-action-status').textContent = 'Confirm to mark only the selected updates read.';
|
||||||
renderMyWork();
|
renderMyWork();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bulkConfirmationPending = false;
|
bulkConfirmationPending = false;
|
||||||
bulkMarkPending = true;
|
bulkMarkPending = true;
|
||||||
renderMyWork();
|
renderMyWork();
|
||||||
const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, ids);
|
const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, selection.ids);
|
||||||
if (result) {
|
if (result) {
|
||||||
const marked = new Set(result.marked);
|
const marked = new Set(result.marked);
|
||||||
lastNotifications = lastNotifications.filter(item => !marked.has(item.id));
|
lastNotifications = lastNotifications.filter(item => !marked.has(item.id));
|
||||||
|
notificationSelection.retain(result.failed);
|
||||||
|
if (!result.failed.length) notificationSelection.cancel();
|
||||||
}
|
}
|
||||||
bulkMarkPending = false;
|
bulkMarkPending = false;
|
||||||
renderMyWork();
|
renderMyWork();
|
||||||
(document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus();
|
(document.querySelector('[data-select-notification-id]') || qs('#select-updates'))?.focus();
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-work-filter]').forEach(button => {
|
document.querySelectorAll('[data-work-filter]').forEach(button => {
|
||||||
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
|
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
|
const leavingUpdates = selectedWorkFilter === 'update' && button.dataset.workFilter !== 'update';
|
||||||
selectedWorkFilter = button.dataset.workFilter;
|
selectedWorkFilter = button.dataset.workFilter;
|
||||||
|
if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
|
||||||
savedWorkFilter = selectedWorkFilter;
|
savedWorkFilter = selectedWorkFilter;
|
||||||
launchFilterResolved = true;
|
launchFilterResolved = true;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,11 @@
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="update-selection-controls" id="update-selection-controls" hidden>
|
||||||
|
<button id="select-updates" type="button">Select updates</button>
|
||||||
|
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
|
||||||
|
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
<div class="my-work-list" id="my-work-list"></div>
|
<div class="my-work-list" id="my-work-list"></div>
|
||||||
<div class="small" id="work-page-status" aria-live="polite"></div>
|
<div class="small" id="work-page-status" aria-live="polite"></div>
|
||||||
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
|
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,62 @@ function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||||
|
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
||||||
|
let active = false;
|
||||||
|
const selected = new Set();
|
||||||
|
const snapshot = () => ({
|
||||||
|
active,
|
||||||
|
ids: Array.from(selected),
|
||||||
|
count: selected.size,
|
||||||
|
limit: maximum,
|
||||||
|
at_limit: selected.size >= maximum,
|
||||||
|
});
|
||||||
|
const publish = () => onChange(snapshot());
|
||||||
|
const select = notificationId => {
|
||||||
|
if (!active || !Number.isInteger(notificationId)) return 'inactive';
|
||||||
|
if (selected.has(notificationId)) return 'already-selected';
|
||||||
|
if (selected.size >= maximum) return 'limit';
|
||||||
|
selected.add(notificationId);
|
||||||
|
publish();
|
||||||
|
return 'selected';
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
if (active) return snapshot();
|
||||||
|
active = true;
|
||||||
|
publish();
|
||||||
|
return snapshot();
|
||||||
|
},
|
||||||
|
select,
|
||||||
|
toggle(notificationId) {
|
||||||
|
if (!selected.has(notificationId)) return select(notificationId);
|
||||||
|
selected.delete(notificationId);
|
||||||
|
publish();
|
||||||
|
return 'deselected';
|
||||||
|
},
|
||||||
|
retain(notificationIds) {
|
||||||
|
const allowed = new Set((notificationIds || []).filter(Number.isInteger));
|
||||||
|
let changed = false;
|
||||||
|
selected.forEach(id => {
|
||||||
|
if (!allowed.has(id)) {
|
||||||
|
selected.delete(id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (changed) publish();
|
||||||
|
return snapshot();
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
active = false;
|
||||||
|
selected.clear();
|
||||||
|
publish();
|
||||||
|
return snapshot();
|
||||||
|
},
|
||||||
|
snapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
|
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
|
||||||
let pagination = { page: 1, total: 0, has_more: false };
|
let pagination = { page: 1, total: 0, has_more: false };
|
||||||
let pending = false;
|
let pending = false;
|
||||||
|
|
@ -782,6 +838,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||||
buildMyWork.notificationIds = notificationIds;
|
buildMyWork.notificationIds = notificationIds;
|
||||||
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
|
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
|
||||||
|
buildMyWork.createNotificationSelection = createNotificationSelection;
|
||||||
buildMyWork.createNotificationPager = createNotificationPager;
|
buildMyWork.createNotificationPager = createNotificationPager;
|
||||||
buildMyWork.createWorkPager = createWorkPager;
|
buildMyWork.createWorkPager = createWorkPager;
|
||||||
buildMyWork.createNotificationReader = createNotificationReader;
|
buildMyWork.createNotificationReader = createNotificationReader;
|
||||||
|
|
|
||||||
|
|
@ -3312,6 +3312,68 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_notification_selection_tracks_a_bounded_subset_and_clears_on_cancel():
|
||||||
|
script = f"""
|
||||||
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
const states = [];
|
||||||
|
const selection = buildMyWork.createNotificationSelection({{
|
||||||
|
limit: 2,
|
||||||
|
onChange: state => states.push(state),
|
||||||
|
}});
|
||||||
|
selection.start();
|
||||||
|
const first = selection.select(42);
|
||||||
|
const duplicate = selection.select(42);
|
||||||
|
selection.toggle(42);
|
||||||
|
selection.select(43);
|
||||||
|
selection.select(44);
|
||||||
|
const second = selection.select(45);
|
||||||
|
selection.cancel();
|
||||||
|
process.stdout.write(JSON.stringify({{first, duplicate, second, states, snapshot:selection.snapshot()}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
output = json.loads(result.stdout)
|
||||||
|
|
||||||
|
assert output["first"] == "selected"
|
||||||
|
assert output["duplicate"] == "already-selected"
|
||||||
|
assert output["second"] == "limit"
|
||||||
|
assert output["states"] == [
|
||||||
|
{"active": True, "ids": [], "count": 0, "limit": 2, "at_limit": False},
|
||||||
|
{"active": True, "ids": [42], "count": 1, "limit": 2, "at_limit": False},
|
||||||
|
{"active": True, "ids": [], "count": 0, "limit": 2, "at_limit": False},
|
||||||
|
{"active": True, "ids": [43], "count": 1, "limit": 2, "at_limit": False},
|
||||||
|
{"active": True, "ids": [43, 44], "count": 2, "limit": 2, "at_limit": True},
|
||||||
|
{"active": False, "ids": [], "count": 0, "limit": 2, "at_limit": False},
|
||||||
|
]
|
||||||
|
assert output["snapshot"] == output["states"][-1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_notification_selection_retains_only_failed_ids_after_partial_acknowledgement():
|
||||||
|
script = f"""
|
||||||
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
const states = [];
|
||||||
|
const selection = buildMyWork.createNotificationSelection({{
|
||||||
|
onChange: state => states.push(state),
|
||||||
|
}});
|
||||||
|
selection.start();
|
||||||
|
selection.select(42);
|
||||||
|
selection.select(43);
|
||||||
|
selection.select(44);
|
||||||
|
const retained = selection.retain([44, 44, 99]);
|
||||||
|
process.stdout.write(JSON.stringify({{retained, states}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
output = json.loads(result.stdout)
|
||||||
|
|
||||||
|
assert output["retained"] == {
|
||||||
|
"active": True, "ids": [44], "count": 1, "limit": 50, "at_limit": False,
|
||||||
|
}
|
||||||
|
assert output["states"][-1] == output["retained"]
|
||||||
|
|
||||||
|
|
||||||
def test_notification_pager_is_single_flight_and_merges_unique_updates():
|
def test_notification_pager_is_single_flight_and_merges_unique_updates():
|
||||||
script = f"""
|
script = f"""
|
||||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
|
@ -4555,18 +4617,26 @@ async def test_mobile_update_sheet_has_persistent_accessible_reply_composer():
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
|
async def test_updates_view_offers_accessible_sticky_mobile_subset_selection():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
||||||
assert 'id="bulk-mark-read"' in html
|
assert 'id="select-updates"' in html
|
||||||
assert 'id="bulk-mark-read-bar"' in html
|
assert 'id="cancel-update-selection"' in html
|
||||||
assert 'class="my-work-bulk"' in html
|
assert 'id="update-selection-status"' in html
|
||||||
|
assert 'aria-live="polite"' in html
|
||||||
|
assert 'class="update-selector"' in html
|
||||||
|
assert 'type="checkbox"' in html
|
||||||
|
assert 'Select update ' in html
|
||||||
|
assert '.update-selector { min-height:44px;' in html
|
||||||
assert '.my-work-bulk { position:sticky;' in html
|
assert '.my-work-bulk { position:sticky;' in html
|
||||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
|
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
|
||||||
assert '.my-work-bulk button { min-height:44px; width:100%; }' in html
|
assert '.my-work-bulk button { min-height:44px; width:100%; }' in html
|
||||||
assert "'next ' + ids.length + ' of ' + allIds.length + ' loaded updates'" in html
|
assert '.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }' in html
|
||||||
assert "'Confirm marking ' + bulkLabel + ' read'" in html
|
assert "'Mark ' + selection.count + ' selected read'" in html
|
||||||
assert "const ids = allIds.slice(0, 50)" in html
|
assert "'Confirm marking ' + selection.count + ' selected read'" in html
|
||||||
|
assert 'bulkNotificationAcknowledger.acknowledge(lastMyWork, selection.ids)' in html
|
||||||
|
assert 'notificationSelection.retain(result.failed)' in html
|
||||||
|
assert "notificationSelection.cancel()" in html
|
||||||
assert "createBulkNotificationAcknowledger" in html
|
assert "createBulkNotificationAcknowledger" in html
|
||||||
assert "api/v1/notifications/read" in html
|
assert "api/v1/notifications/read" in html
|
||||||
assert "body: JSON.stringify({ ids })" in html
|
assert "body: JSON.stringify({ ids })" in html
|
||||||
|
|
@ -4583,7 +4653,8 @@ async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile(
|
||||||
assert "api/v1/notifications?page=" in html
|
assert "api/v1/notifications?page=" in html
|
||||||
assert "snapshot.notification_pagination" in html
|
assert "snapshot.notification_pagination" in html
|
||||||
assert "notificationPager.loadMore(lastNotifications)" in html
|
assert "notificationPager.loadMore(lastNotifications)" in html
|
||||||
assert "Math.min(lastNotifications.length, 50)" in html
|
assert "createNotificationSelection({" in html
|
||||||
|
assert "limit: 50" in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user