Pin frequent work across signed-in mobile devices #1478
|
|
@ -135,7 +135,10 @@ Mobile **Recent work** is also portable across signed-in devices. Opening an iss
|
||||||
review, Filed item, or update records its canonical detail route locally before navigation and marks
|
review, Filed item, or update records its canonical detail route locally before navigation and marks
|
||||||
the entry **Sync pending** until the authenticated API confirms it. Reconnect and foreground checks
|
the entry **Sync pending** until the authenticated API confirms it. Reconnect and foreground checks
|
||||||
merge the server list without duplicate routes, while each confirmed account remains bounded to its
|
merge the server list without duplicate routes, while each confirmed account remains bounded to its
|
||||||
five most recent items. Titles, repositories, and routes are encrypted at rest with the shared
|
five most recent items. A separate **Pin** action keeps up to 20 frequently revisited items above
|
||||||
|
Recent work even after that five-item window advances; **Unpin** removes only the pin, and both actions
|
||||||
|
apply offline-first before account-scoped synchronization. Open and Pin/Unpin remain separate touch and
|
||||||
|
keyboard targets. Titles, repositories, routes, and pins are encrypted at rest with the shared
|
||||||
private-state key; stale responses from a prior account are discarded. Set
|
private-state key; stale responses from a prior account are discarded. Set
|
||||||
`STACKCHAIN_RECENT_WORK_DB` to override `.stackchain-state/recent-work.sqlite3`.
|
`STACKCHAIN_RECENT_WORK_DB` to override `.stackchain-state/recent-work.sqlite3`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1551,7 +1551,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
|
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
|
||||||
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
|
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
|
||||||
.mobile-queue-list small { color:var(--muted); }
|
.mobile-queue-list small { color:var(--muted); }
|
||||||
.mobile-queue-list [data-recent-work-route] { min-height:56px; overflow-wrap:anywhere; }
|
.mobile-recent-work-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; min-width:0; }
|
||||||
|
.mobile-queue-list .mobile-recent-work-row button { min-height:44px; width:auto; }
|
||||||
|
.mobile-queue-list .mobile-recent-work-row [data-recent-work-route] { min-width:0; width:100%; min-height:56px; overflow-wrap:anywhere; }
|
||||||
|
.mobile-queue-list .mobile-recent-work-row [data-recent-work-pin] { min-width:64px; justify-content:center; padding-inline:12px; }
|
||||||
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
|
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
|
||||||
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
|
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
|
||||||
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
|
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
|
||||||
|
|
|
||||||
|
|
@ -439,6 +439,8 @@
|
||||||
document,
|
document,
|
||||||
section:qs('#mobile-recent-work'),
|
section:qs('#mobile-recent-work'),
|
||||||
list:qs('#mobile-recent-work-list'),
|
list:qs('#mobile-recent-work-list'),
|
||||||
|
pinnedSection:qs('#mobile-pinned-work'),
|
||||||
|
pinnedList:qs('#mobile-pinned-work-list'),
|
||||||
status:qs('#mobile-recent-work-status'),
|
status:qs('#mobile-recent-work-status'),
|
||||||
openRoute:fragment => {
|
openRoute:fragment => {
|
||||||
const sheet = qs('#mobile-queue-sheet');
|
const sheet = qs('#mobile-queue-sheet');
|
||||||
|
|
|
||||||
|
|
@ -2183,9 +2183,13 @@
|
||||||
<p class="small muted" id="mobile-queue-next-heading">Start / Continue</p>
|
<p class="small muted" id="mobile-queue-next-heading">Start / Continue</p>
|
||||||
<button id="mobile-queue-next-action" type="button">Find Work</button>
|
<button id="mobile-queue-next-action" type="button">Find Work</button>
|
||||||
</section>
|
</section>
|
||||||
|
<p id="mobile-recent-work-status" role="status" aria-live="polite" class="small"></p>
|
||||||
|
<section class="mobile-queue-group" id="mobile-pinned-work" aria-labelledby="mobile-pinned-work-heading" hidden>
|
||||||
|
<h3 id="mobile-pinned-work-heading">Pinned work</h3>
|
||||||
|
<div class="mobile-queue-list" id="mobile-pinned-work-list"></div>
|
||||||
|
</section>
|
||||||
<section class="mobile-queue-group" id="mobile-recent-work" aria-labelledby="mobile-recent-work-heading" hidden>
|
<section class="mobile-queue-group" id="mobile-recent-work" aria-labelledby="mobile-recent-work-heading" hidden>
|
||||||
<h3 id="mobile-recent-work-heading">Recent work</h3>
|
<h3 id="mobile-recent-work-heading">Recent work</h3>
|
||||||
<p id="mobile-recent-work-status" role="status" aria-live="polite" class="small"></p>
|
|
||||||
<div class="mobile-queue-list" id="mobile-recent-work-list"></div>
|
<div class="mobile-queue-list" id="mobile-recent-work-list"></div>
|
||||||
</section>
|
</section>
|
||||||
<details class="mobile-queue-priority" id="mobile-queue-priority">
|
<details class="mobile-queue-priority" id="mobile-queue-priority">
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
const getLogin = options.getLogin;
|
const getLogin = options.getLogin;
|
||||||
const fetchJson = options.fetchJson;
|
const fetchJson = options.fetchJson;
|
||||||
const limit = Math.max(1, Number(options.limit) || 5);
|
const limit = Math.max(1, Number(options.limit) || 5);
|
||||||
|
const pinnedLimit = Math.max(1, Number(options.pinnedLimit) || 20);
|
||||||
const prefix = 'stackchain.mobile-recent-work.v1.';
|
const prefix = 'stackchain.mobile-recent-work.v1.';
|
||||||
const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||||
const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']);
|
const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']);
|
||||||
|
|
@ -45,29 +46,51 @@
|
||||||
return {kind, ...(repository ? {repository} : {}), number, title, route};
|
return {kind, ...(repository ? {repository} : {}), number, title, route};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeList(value) {
|
function normalizeList(value, maximum = limit) {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
const unique = [];
|
const unique = [];
|
||||||
for (const candidate of value) {
|
for (const candidate of value) {
|
||||||
const item = normalize(candidate);
|
const item = normalize(candidate);
|
||||||
if (item && !unique.some(existing => existing.route === item.route)) unique.push(item);
|
if (item && !unique.some(existing => existing.route === item.route)) unique.push(item);
|
||||||
if (unique.length === limit) break;
|
if (unique.length === maximum) break;
|
||||||
}
|
}
|
||||||
return unique;
|
return unique;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePinOps(value) {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const unique = [];
|
||||||
|
for (const candidate of value) {
|
||||||
|
const action = candidate?.action;
|
||||||
|
const item = action === 'pin' ? normalize(candidate.item) : null;
|
||||||
|
const route = action === 'pin' ? item?.route : String(candidate?.route || '');
|
||||||
|
if ((action !== 'pin' && action !== 'unpin') || !route || (action === 'pin' && !item)) continue;
|
||||||
|
if (!unique.some(existing => (existing.item?.route || existing.route) === route)) {
|
||||||
|
unique.push(action === 'pin' ? {action, item} : {action, route});
|
||||||
|
}
|
||||||
|
if (unique.length === pinnedLimit) break;
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
function empty() {
|
||||||
|
return {items:[], pinned:[], pending:[], pinOps:[]};
|
||||||
|
}
|
||||||
|
|
||||||
function read() {
|
function read() {
|
||||||
const storageKey = key();
|
const storageKey = key();
|
||||||
if (!storageKey) return {items:[], pending:[]};
|
if (!storageKey) return empty();
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
|
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
|
||||||
if (Array.isArray(parsed)) return {items:normalizeList(parsed), pending:[]};
|
if (Array.isArray(parsed)) return {...empty(), items:normalizeList(parsed)};
|
||||||
return {
|
return {
|
||||||
items:normalizeList(parsed?.items),
|
items:normalizeList(parsed?.items),
|
||||||
|
pinned:normalizeList(parsed?.pinned, pinnedLimit),
|
||||||
pending:normalizeList(parsed?.pending),
|
pending:normalizeList(parsed?.pending),
|
||||||
|
pinOps:normalizePinOps(parsed?.pinOps),
|
||||||
};
|
};
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return {items:[], pending:[]};
|
return empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,7 +98,10 @@
|
||||||
if (!accountKey || accountKey !== key()) return false;
|
if (!accountKey || accountKey !== key()) return false;
|
||||||
try {
|
try {
|
||||||
storage.setItem(accountKey, JSON.stringify({
|
storage.setItem(accountKey, JSON.stringify({
|
||||||
items:normalizeList(value.items), pending:normalizeList(value.pending),
|
items:normalizeList(value.items),
|
||||||
|
pinned:normalizeList(value.pinned, pinnedLimit),
|
||||||
|
pending:normalizeList(value.pending),
|
||||||
|
pinOps:normalizePinOps(value.pinOps),
|
||||||
}));
|
}));
|
||||||
return true;
|
return true;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|
@ -83,18 +109,27 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasPending(value) {
|
||||||
|
return value.pending.length > 0 || value.pinOps.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
function announce(value = read(), status = null) {
|
function announce(value = read(), status = null) {
|
||||||
if (!options.status) return;
|
if (!options.status) return;
|
||||||
options.status.textContent = status || (value.pending.length ? 'Sync pending.' : '');
|
options.status.textContent = status || (hasPending(value) ? 'Sync pending.' : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function items() {
|
function items() {
|
||||||
return read().items;
|
return read().items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pinned() {
|
||||||
|
return read().pinned;
|
||||||
|
}
|
||||||
|
|
||||||
function state() {
|
function state() {
|
||||||
const value = read();
|
const value = read();
|
||||||
return {pending:value.pending.length > 0, pendingCount:value.pending.length};
|
const pendingCount = value.pending.length + value.pinOps.length;
|
||||||
|
return {pending:pendingCount > 0, pendingCount};
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleSync() {
|
function scheduleSync() {
|
||||||
|
|
@ -113,6 +148,7 @@
|
||||||
if (!accountKey || !normalized) return false;
|
if (!accountKey || !normalized) return false;
|
||||||
const current = read();
|
const current = read();
|
||||||
current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit);
|
current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit);
|
||||||
|
current.pinned = current.pinned.map(existing => existing.route === normalized.route ? normalized : existing);
|
||||||
current.pending = [normalized, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit);
|
current.pending = [normalized, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit);
|
||||||
if (!persist(current, accountKey)) return false;
|
if (!persist(current, accountKey)) return false;
|
||||||
announce(current);
|
announce(current);
|
||||||
|
|
@ -121,13 +157,62 @@
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function adopt(snapshot, accountKey, pending = []) {
|
function queuePinOp(current, operation) {
|
||||||
|
const route = operation.item?.route || operation.route;
|
||||||
|
current.pinOps = [operation, ...current.pinOps.filter(existing => (existing.item?.route || existing.route) !== route)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pin(item) {
|
||||||
|
const accountKey = key();
|
||||||
|
const normalized = normalize(item);
|
||||||
|
if (!accountKey || !normalized) return false;
|
||||||
|
const current = read();
|
||||||
|
current.pinned = [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)].slice(0, pinnedLimit);
|
||||||
|
queuePinOp(current, {action:'pin', item:normalized});
|
||||||
|
if (!persist(current, accountKey)) return false;
|
||||||
|
announce(current);
|
||||||
|
render();
|
||||||
|
scheduleSync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unpin(route) {
|
||||||
|
const accountKey = key();
|
||||||
|
route = String(route || '');
|
||||||
|
if (!accountKey || !route) return false;
|
||||||
|
const current = read();
|
||||||
|
if (!current.pinned.some(item => item.route === route)) return false;
|
||||||
|
current.pinned = current.pinned.filter(item => item.route !== route);
|
||||||
|
queuePinOp(current, {action:'unpin', route});
|
||||||
|
if (!persist(current, accountKey)) return false;
|
||||||
|
announce(current);
|
||||||
|
render();
|
||||||
|
scheduleSync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPinOps(remote, operations) {
|
||||||
|
let result = normalizeList(remote, pinnedLimit);
|
||||||
|
for (const operation of [...normalizePinOps(operations)].reverse()) {
|
||||||
|
const route = operation.item?.route || operation.route;
|
||||||
|
result = operation.action === 'pin'
|
||||||
|
? [operation.item, ...result.filter(item => item.route !== route)].slice(0, pinnedLimit)
|
||||||
|
: result.filter(item => item.route !== route);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adopt(snapshot, accountKey, pending = [], pinOps = []) {
|
||||||
if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false;
|
if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false;
|
||||||
const remote = normalizeList(snapshot.items);
|
const remote = normalizeList(snapshot.items);
|
||||||
|
const remotePinned = normalizeList(snapshot.pinned, pinnedLimit);
|
||||||
const unsent = normalizeList(pending);
|
const unsent = normalizeList(pending);
|
||||||
|
const unsentPinOps = normalizePinOps(pinOps);
|
||||||
const value = {
|
const value = {
|
||||||
items:normalizeList([...unsent, ...remote]),
|
items:normalizeList([...unsent, ...remote]),
|
||||||
|
pinned:applyPinOps(remotePinned, unsentPinOps),
|
||||||
pending:unsent,
|
pending:unsent,
|
||||||
|
pinOps:unsentPinOps,
|
||||||
};
|
};
|
||||||
persist(value, accountKey);
|
persist(value, accountKey);
|
||||||
announce(value);
|
announce(value);
|
||||||
|
|
@ -138,17 +223,36 @@
|
||||||
async function drain(accountKey) {
|
async function drain(accountKey) {
|
||||||
while (key() === accountKey) {
|
while (key() === accountKey) {
|
||||||
const current = read();
|
const current = read();
|
||||||
if (!current.pending.length) return current;
|
if (!hasPending(current)) return current;
|
||||||
const sending = current.pending[current.pending.length - 1];
|
const sending = current.pending[current.pending.length - 1];
|
||||||
|
const pinOperation = sending ? null : current.pinOps[current.pinOps.length - 1];
|
||||||
announce(current, 'Syncing recent work…');
|
announce(current, 'Syncing recent work…');
|
||||||
try {
|
try {
|
||||||
const snapshot = await fetchJson('api/v1/recent-work', {
|
let snapshot;
|
||||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(sending),
|
if (sending) {
|
||||||
});
|
snapshot = await fetchJson('api/v1/recent-work', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(sending),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const isPin = pinOperation.action === 'pin';
|
||||||
|
snapshot = await fetchJson('api/v1/recent-work/pin', {
|
||||||
|
method:isPin ? 'PUT' : 'DELETE',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify(isPin ? pinOperation.item : {route:pinOperation.route}),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (key() !== accountKey) return read();
|
if (key() !== accountKey) return read();
|
||||||
const latest = read();
|
const latest = read();
|
||||||
const pending = latest.pending.filter(item => item.route !== sending.route);
|
const pending = sending
|
||||||
if (!adopt(snapshot, accountKey, pending)) throw new Error('Recent work response is invalid.');
|
? latest.pending.filter(item => item.route !== sending.route)
|
||||||
|
: latest.pending;
|
||||||
|
const pinOps = pinOperation
|
||||||
|
? latest.pinOps.filter(operation => {
|
||||||
|
const sameRoute = (operation.item?.route || operation.route) === (pinOperation.item?.route || pinOperation.route);
|
||||||
|
return !sameRoute || operation.action !== pinOperation.action;
|
||||||
|
})
|
||||||
|
: latest.pinOps;
|
||||||
|
if (!adopt(snapshot, accountKey, pending, pinOps)) throw new Error('Recent work response is invalid.');
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
if (key() === accountKey) announce(read());
|
if (key() === accountKey) announce(read());
|
||||||
return read();
|
return read();
|
||||||
|
|
@ -160,7 +264,7 @@
|
||||||
function sync() {
|
function sync() {
|
||||||
if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; }
|
if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; }
|
||||||
const accountKey = key();
|
const accountKey = key();
|
||||||
if (!fetchJson || !accountKey || !read().pending.length) return Promise.resolve(read());
|
if (!fetchJson || !accountKey || !hasPending(read())) return Promise.resolve(read());
|
||||||
if (syncFlight && syncAccount === accountKey) return syncFlight;
|
if (syncFlight && syncAccount === accountKey) return syncFlight;
|
||||||
syncAccount = accountKey;
|
syncAccount = accountKey;
|
||||||
syncFlight = drain(accountKey).finally(() => {
|
syncFlight = drain(accountKey).finally(() => {
|
||||||
|
|
@ -176,16 +280,16 @@
|
||||||
const snapshot = await fetchJson('api/v1/recent-work');
|
const snapshot = await fetchJson('api/v1/recent-work');
|
||||||
if (key() !== accountKey) return read();
|
if (key() !== accountKey) return read();
|
||||||
const current = read();
|
const current = read();
|
||||||
adopt(snapshot, accountKey, current.pending);
|
adopt(snapshot, accountKey, current.pending, current.pinOps);
|
||||||
return current.pending.length ? sync() : read();
|
return hasPending(current) ? sync() : read();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
if (key() === accountKey) announce(read(), read().pending.length ? null : 'Recent work could not sync.');
|
if (key() === accountKey) announce(read(), hasPending(read()) ? null : 'Recent work could not sync.');
|
||||||
return read();
|
return read();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startLifecycle(lifecycle = {}) {
|
function startLifecycle(lifecycle = {}) {
|
||||||
const reconcile = () => read().pending.length ? sync() : load();
|
const reconcile = () => hasPending(read()) ? sync() : load();
|
||||||
lifecycle.window?.addEventListener?.('online', () => { void reconcile(); });
|
lifecycle.window?.addEventListener?.('online', () => { void reconcile(); });
|
||||||
lifecycle.document?.addEventListener?.('visibilitychange', () => {
|
lifecycle.document?.addEventListener?.('visibilitychange', () => {
|
||||||
if (!lifecycle.document.hidden) void reconcile();
|
if (!lifecycle.document.hidden) void reconcile();
|
||||||
|
|
@ -193,34 +297,57 @@
|
||||||
return reconcile;
|
return reconcile;
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function detail(item) {
|
||||||
const recent = items();
|
return item.kind === 'update'
|
||||||
const list = options.list;
|
? 'Update · #' + item.number
|
||||||
const section = options.section;
|
: item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number;
|
||||||
if (!list || !section || !options.document) return recent.length;
|
|
||||||
const rows = recent.map(item => {
|
|
||||||
const detail = item.kind === 'update'
|
|
||||||
? 'Update · #' + item.number
|
|
||||||
: item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number;
|
|
||||||
const button = options.document.createElement('button');
|
|
||||||
const copy = options.document.createElement('span');
|
|
||||||
const primary = options.document.createElement('strong');
|
|
||||||
const secondary = options.document.createElement('small');
|
|
||||||
primary.textContent = item.title;
|
|
||||||
secondary.textContent = detail;
|
|
||||||
copy.appendChild(primary);
|
|
||||||
copy.appendChild(secondary);
|
|
||||||
button.appendChild(copy);
|
|
||||||
button.setAttribute('type', 'button');
|
|
||||||
button.setAttribute('data-recent-work-route', item.route);
|
|
||||||
button.setAttribute('aria-label', 'Open ' + item.title + ', ' + detail.toLowerCase().replace(' · ', ' '));
|
|
||||||
button.addEventListener('click', () => options.openRoute?.(item.route));
|
|
||||||
return button;
|
|
||||||
});
|
|
||||||
list.replaceChildren(...rows);
|
|
||||||
section.hidden = rows.length === 0;
|
|
||||||
return rows.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {items, record, render, load, sync, startLifecycle, state};
|
function row(item, isPinned) {
|
||||||
|
const itemDetail = detail(item);
|
||||||
|
const wrapper = options.document.createElement('div');
|
||||||
|
const button = options.document.createElement('button');
|
||||||
|
const action = options.document.createElement('button');
|
||||||
|
const copy = options.document.createElement('span');
|
||||||
|
const primary = options.document.createElement('strong');
|
||||||
|
const secondary = options.document.createElement('small');
|
||||||
|
wrapper.setAttribute('class', 'mobile-recent-work-row');
|
||||||
|
primary.textContent = item.title;
|
||||||
|
secondary.textContent = itemDetail;
|
||||||
|
copy.appendChild(primary);
|
||||||
|
copy.appendChild(secondary);
|
||||||
|
button.appendChild(copy);
|
||||||
|
button.setAttribute('type', 'button');
|
||||||
|
button.setAttribute('data-recent-work-route', item.route);
|
||||||
|
button.setAttribute('aria-label', 'Open ' + item.title + ', ' + itemDetail.toLowerCase().replace(' · ', ' '));
|
||||||
|
button.addEventListener('click', () => options.openRoute?.(item.route));
|
||||||
|
action.textContent = isPinned ? 'Unpin' : 'Pin';
|
||||||
|
action.setAttribute('type', 'button');
|
||||||
|
action.setAttribute('data-recent-work-pin', isPinned ? 'unpin' : 'pin');
|
||||||
|
action.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + item.title);
|
||||||
|
action.addEventListener('click', () => isPinned ? unpin(item.route) : pin(item));
|
||||||
|
wrapper.appendChild(button);
|
||||||
|
wrapper.appendChild(action);
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const recent = items();
|
||||||
|
const fixed = pinned();
|
||||||
|
const list = options.list;
|
||||||
|
const section = options.section;
|
||||||
|
if (list && section && options.document) {
|
||||||
|
const rows = recent.map(item => row(item, false));
|
||||||
|
list.replaceChildren(...rows);
|
||||||
|
section.hidden = rows.length === 0;
|
||||||
|
}
|
||||||
|
if (options.pinnedList && options.pinnedSection && options.document) {
|
||||||
|
const rows = fixed.map(item => row(item, true));
|
||||||
|
options.pinnedList.replaceChildren(...rows);
|
||||||
|
options.pinnedSection.hidden = rows.length === 0;
|
||||||
|
}
|
||||||
|
return recent.length + fixed.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {items, pinned, record, pin, unpin, render, load, sync, startLifecycle, state};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
38
src/main.py
38
src/main.py
|
|
@ -980,6 +980,10 @@ class RecentWorkItem(BaseModel):
|
||||||
route: str = Field(min_length=1, max_length=300)
|
route: str = Field(min_length=1, max_length=300)
|
||||||
|
|
||||||
|
|
||||||
|
class RecentWorkRoute(BaseModel):
|
||||||
|
route: str = Field(min_length=1, max_length=300)
|
||||||
|
|
||||||
|
|
||||||
class CompletedFiledReviewReceipt(BaseModel):
|
class CompletedFiledReviewReceipt(BaseModel):
|
||||||
repository: str = Field(
|
repository: str = Field(
|
||||||
min_length=3,
|
min_length=3,
|
||||||
|
|
@ -3468,6 +3472,40 @@ async def record_recent_work(payload: RecentWorkItem):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/v1/recent-work/pin")
|
||||||
|
async def pin_recent_work(payload: RecentWorkItem):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_recent_work_store().pin, login, payload.model_dump()
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Recent work synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/v1/recent-work/pin")
|
||||||
|
async def unpin_recent_work(payload: RecentWorkRoute):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_recent_work_store().unpin, login, payload.route
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Recent work synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/unfiled-drafts")
|
@app.get("/api/v1/unfiled-drafts")
|
||||||
async def get_unfiled_drafts(response: Response):
|
async def get_unfiled_drafts(response: Response):
|
||||||
login = await _confirmed_login()
|
login = await _confirmed_login()
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,12 @@ class RecentWorkStore:
|
||||||
timeout: float = 1.0,
|
timeout: float = 1.0,
|
||||||
encryption_key: bytes | None = None,
|
encryption_key: bytes | None = None,
|
||||||
limit: int = 5,
|
limit: int = 5,
|
||||||
|
pinned_limit: int = 20,
|
||||||
):
|
):
|
||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.limit = max(1, int(limit))
|
self.limit = max(1, int(limit))
|
||||||
|
self.pinned_limit = max(1, int(pinned_limit))
|
||||||
self._cipher = PrivateStateCipher(
|
self._cipher = PrivateStateCipher(
|
||||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||||
store="recent-work",
|
store="recent-work",
|
||||||
|
|
@ -86,30 +88,47 @@ class RecentWorkStore:
|
||||||
raise ValueError("recent work item is invalid")
|
raise ValueError("recent work item is invalid")
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
def _items(self, row, login: str) -> tuple[list[dict], bool]:
|
def _state(self, row, login: str) -> tuple[dict, bool]:
|
||||||
if row is None:
|
if row is None:
|
||||||
return [], False
|
return {"items": [], "pinned": []}, False
|
||||||
payload, legacy = self._cipher.open(row[0], binding=f"items:{login}")
|
payload, legacy = self._cipher.open(row[0], binding=f"items:{login}")
|
||||||
if not isinstance(payload, list):
|
if isinstance(payload, list):
|
||||||
|
payload = {"items": payload, "pinned": []}
|
||||||
|
legacy = True
|
||||||
|
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list) or not isinstance(payload.get("pinned"), list):
|
||||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||||
try:
|
try:
|
||||||
return [self._normalize(item) for item in payload][: self.limit], legacy
|
items = [self._normalize(item) for item in payload["items"]][: self.limit]
|
||||||
|
pinned = [self._normalize(item) for item in payload["pinned"]][: self.pinned_limit]
|
||||||
|
if len({item["route"] for item in pinned}) != len(pinned):
|
||||||
|
raise ValueError("recent work item is invalid")
|
||||||
|
return {"items": items, "pinned": pinned}, legacy
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
raise PrivateStateEncryptionError("private state could not be decrypted") from error
|
raise PrivateStateEncryptionError("private state could not be decrypted") from error
|
||||||
|
|
||||||
|
def _seal(self, state: dict, login: str) -> str:
|
||||||
|
return self._cipher.seal(state, binding=f"items:{login}")
|
||||||
|
|
||||||
|
def _write(self, connection: sqlite3.Connection, login: str, state: dict) -> None:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO recent_work(login, items) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(login) DO UPDATE SET items=excluded.items",
|
||||||
|
(login, self._seal(state, login)),
|
||||||
|
)
|
||||||
|
|
||||||
def get(self, login: str) -> dict:
|
def get(self, login: str) -> dict:
|
||||||
login = self._login(login)
|
login = self._login(login)
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
items, legacy = self._items(row, login)
|
state, legacy = self._state(row, login)
|
||||||
if row is not None and legacy:
|
if row is not None and legacy:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"UPDATE recent_work SET items = ? WHERE login = ? AND items = ?",
|
"UPDATE recent_work SET items = ? WHERE login = ? AND items = ?",
|
||||||
(self._cipher.seal(items, binding=f"items:{login}"), login, row[0]),
|
(self._seal(state, login), login, row[0]),
|
||||||
)
|
)
|
||||||
return {"items": items}
|
return state
|
||||||
|
|
||||||
def record(self, login: str, item: dict) -> dict:
|
def record(self, login: str, item: dict) -> dict:
|
||||||
login = self._login(login)
|
login = self._login(login)
|
||||||
|
|
@ -119,13 +138,41 @@ class RecentWorkStore:
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
current, _legacy = self._items(row, login)
|
state, _legacy = self._state(row, login)
|
||||||
items = [normalized, *(entry for entry in current if entry["route"] != normalized["route"])]
|
state["items"] = [normalized, *(entry for entry in state["items"] if entry["route"] != normalized["route"])][: self.limit]
|
||||||
items = items[: self.limit]
|
state["pinned"] = [
|
||||||
sealed = self._cipher.seal(items, binding=f"items:{login}")
|
normalized if entry["route"] == normalized["route"] else entry
|
||||||
connection.execute(
|
for entry in state["pinned"]
|
||||||
"INSERT INTO recent_work(login, items) VALUES (?, ?) "
|
]
|
||||||
"ON CONFLICT(login) DO UPDATE SET items=excluded.items",
|
self._write(connection, login, state)
|
||||||
(login, sealed),
|
return state
|
||||||
)
|
|
||||||
return {"items": items}
|
def pin(self, login: str, item: dict) -> dict:
|
||||||
|
login = self._login(login)
|
||||||
|
normalized = self._normalize(item)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||||
|
).fetchone()
|
||||||
|
state, _legacy = self._state(row, login)
|
||||||
|
state["pinned"] = [
|
||||||
|
normalized,
|
||||||
|
*(entry for entry in state["pinned"] if entry["route"] != normalized["route"]),
|
||||||
|
][: self.pinned_limit]
|
||||||
|
self._write(connection, login, state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def unpin(self, login: str, route: str) -> dict:
|
||||||
|
login = self._login(login)
|
||||||
|
if not isinstance(route, str) or not route:
|
||||||
|
raise ValueError("recent work route is invalid")
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||||
|
).fetchone()
|
||||||
|
state, _legacy = self._state(row, login)
|
||||||
|
state["pinned"] = [entry for entry in state["pinned"] if entry["route"] != route]
|
||||||
|
self._write(connection, login, state)
|
||||||
|
return state
|
||||||
|
|
|
||||||
72
tests/e2e/test_mobile_pinned_work_release.py
Normal file
72
tests/e2e/test_mobile_pinned_work_release.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||||
|
pytest.skip("packaged pinned-work journey runs only in its gated CI job", allow_module_level=True)
|
||||||
|
pytest.importorskip("playwright.sync_api")
|
||||||
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[2]
|
||||||
|
FRONTEND = ROOT / "frontend"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("viewport", [
|
||||||
|
{"width": 320, "height": 568},
|
||||||
|
{"width": 390, "height": 844},
|
||||||
|
])
|
||||||
|
def test_operator_pins_and_reopens_frequent_work_without_phone_overflow(viewport):
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport=viewport)
|
||||||
|
page.set_content((FRONTEND / "index.html").read_text())
|
||||||
|
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||||
|
page.add_script_tag(path=FRONTEND / "mobile-recent-work.js")
|
||||||
|
page.evaluate("""() => {
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {
|
||||||
|
getItem:key => values.get(key) || null,
|
||||||
|
setItem:(key, value) => values.set(key, value),
|
||||||
|
};
|
||||||
|
const item = {
|
||||||
|
kind:'issue', repository:'stackchain/stackchain-dashboard', number:1477,
|
||||||
|
title:'Pin frequent work across signed-in mobile devices',
|
||||||
|
};
|
||||||
|
window.opened = [];
|
||||||
|
window.recentWork = createMobileRecentWork({
|
||||||
|
storage, getLogin:() => 'timmy', document,
|
||||||
|
section:document.querySelector('#mobile-recent-work'),
|
||||||
|
list:document.querySelector('#mobile-recent-work-list'),
|
||||||
|
pinnedSection:document.querySelector('#mobile-pinned-work'),
|
||||||
|
pinnedList:document.querySelector('#mobile-pinned-work-list'),
|
||||||
|
status:document.querySelector('#mobile-recent-work-status'),
|
||||||
|
openRoute:route => window.opened.push(route),
|
||||||
|
});
|
||||||
|
window.recentWork.record(item);
|
||||||
|
window.recentWork.pin(item);
|
||||||
|
document.querySelector('#mobile-queue-sheet').showModal();
|
||||||
|
}""")
|
||||||
|
|
||||||
|
expect(page.locator("#mobile-pinned-work")).to_be_visible()
|
||||||
|
expect(page.locator("#mobile-recent-work")).to_be_visible()
|
||||||
|
expect(page.locator("#mobile-recent-work-status")).to_have_text("Sync pending.")
|
||||||
|
open_button = page.get_by_role(
|
||||||
|
"button", name="Open Pin frequent work across signed-in mobile devices, issue stackchain/stackchain-dashboard #1477"
|
||||||
|
).first
|
||||||
|
pin_button = page.get_by_role(
|
||||||
|
"button", name="Unpin Pin frequent work across signed-in mobile devices"
|
||||||
|
)
|
||||||
|
for control in (open_button, pin_button):
|
||||||
|
bounds = control.bounding_box()
|
||||||
|
assert bounds and bounds["height"] >= 44
|
||||||
|
assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"]
|
||||||
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
|
||||||
|
open_button.focus()
|
||||||
|
open_button.press("Enter")
|
||||||
|
assert page.evaluate("window.opened") == [
|
||||||
|
"#/my-work/issue/stackchain/stackchain-dashboard/1477"
|
||||||
|
]
|
||||||
|
browser.close()
|
||||||
|
|
@ -93,13 +93,14 @@ const recent=createRecentWork({{
|
||||||
openRoute:route=>opened.push(route),
|
openRoute:route=>opened.push(route),
|
||||||
}});
|
}});
|
||||||
const rendered=recent.render();
|
const rendered=recent.render();
|
||||||
list.children[1].click();
|
list.children[1].children[0].click();
|
||||||
process.stdout.write(JSON.stringify({{
|
process.stdout.write(JSON.stringify({{
|
||||||
rendered,hidden:section.hidden,rows:list.children.map(button=>({{
|
rendered,hidden:section.hidden,rows:list.children.map(row=>{{const button=row.children[0];return ({{
|
||||||
label:button.attributes['aria-label'],route:button.attributes['data-recent-work-route'],
|
label:button.attributes['aria-label'],route:button.attributes['data-recent-work-route'],
|
||||||
primary:button.children[0].children[0].textContent,
|
primary:button.children[0].children[0].textContent,
|
||||||
secondary:button.children[0].children[1].textContent,
|
secondary:button.children[0].children[1].textContent,
|
||||||
}})),opened,
|
pin:row.children[1].attributes['data-recent-work-pin'],
|
||||||
|
}});}}),opened,
|
||||||
}}));
|
}}));
|
||||||
"""
|
"""
|
||||||
payload = run_node(script)
|
payload = run_node(script)
|
||||||
|
|
@ -113,12 +114,14 @@ process.stdout.write(JSON.stringify({{
|
||||||
"route": "#/my-work/issue/stackchain/dashboard/7",
|
"route": "#/my-work/issue/stackchain/dashboard/7",
|
||||||
"primary": "Fix mobile queue",
|
"primary": "Fix mobile queue",
|
||||||
"secondary": "Issue · stackchain/dashboard #7",
|
"secondary": "Issue · stackchain/dashboard #7",
|
||||||
|
"pin": "pin",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Open Review release status, update #42",
|
"label": "Open Review release status, update #42",
|
||||||
"route": "#/my-work/update/42",
|
"route": "#/my-work/update/42",
|
||||||
"primary": "Review release status",
|
"primary": "Review release status",
|
||||||
"secondary": "Update · #42",
|
"secondary": "Update · #42",
|
||||||
|
"pin": "pin",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"opened": ["#/my-work/update/42"],
|
"opened": ["#/my-work/update/42"],
|
||||||
|
|
@ -156,6 +159,73 @@ process.stdout.write(JSON.stringify({{immediate,settled:recent.items(),status:st
|
||||||
assert payload["calls"] == [["api/v1/recent-work", "POST"]]
|
assert payload["calls"] == [["api/v1/recent-work", "POST"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_work_pins_offline_first_syncs_and_renders_separate_touch_actions():
|
||||||
|
script = f"""
|
||||||
|
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||||
|
(async()=>{{
|
||||||
|
const item={{kind:'issue',repository:'stackchain/dashboard',number:1477,title:'Pin frequent work',route:'#/my-work/issue/stackchain/dashboard/1477'}};
|
||||||
|
const storageKey='stackchain.mobile-recent-work.v1.alice';
|
||||||
|
const values=new Map([[storageKey,JSON.stringify({{items:[item],pinned:[],pending:[],pinOps:[]}})]]);
|
||||||
|
const status={{textContent:''}}; const calls=[]; const opened=[];
|
||||||
|
function node(tag) {{
|
||||||
|
return {{tag,children:[],attributes:{{}},listeners:{{}},hidden:false,textContent:'',
|
||||||
|
appendChild(child){{this.children.push(child);return child;}},
|
||||||
|
replaceChildren(...children){{this.children=children;}},
|
||||||
|
setAttribute(name,value){{this.attributes[name]=String(value);}},
|
||||||
|
addEventListener(name,callback){{this.listeners[name]=callback;}},
|
||||||
|
click(){{this.listeners.click?.();}}, focus(){{this.focused=true;}},
|
||||||
|
}};
|
||||||
|
}}
|
||||||
|
const recentList=node('div'); const recentSection=node('section');
|
||||||
|
const pinnedList=node('div'); const pinnedSection=node('section');
|
||||||
|
let remote={{items:[item],pinned:[]}};
|
||||||
|
const recent=createRecentWork({{
|
||||||
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
|
||||||
|
getLogin:()=>'alice',status,debounceMs:99999,document:{{createElement:node}},
|
||||||
|
list:recentList,section:recentSection,pinnedList,pinnedSection,
|
||||||
|
openRoute:route=>opened.push(route),
|
||||||
|
fetchJson:async (url,options={{}})=>{{
|
||||||
|
calls.push([url,options.method||'GET',JSON.parse(options.body||'null')]);
|
||||||
|
if (options.method==='PUT') remote={{items:[item],pinned:[item]}};
|
||||||
|
if (options.method==='DELETE') remote={{items:[item],pinned:[]}};
|
||||||
|
return remote;
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
const pinnedImmediately=recent.pin(item);
|
||||||
|
recent.render();
|
||||||
|
const immediate={{pinned:recent.pinned(),status:status.textContent,recentHidden:recentSection.hidden,pinnedHidden:pinnedSection.hidden,
|
||||||
|
recentActions:recentList.children[0].children.map(child=>child.attributes),
|
||||||
|
pinnedActions:pinnedList.children[0].children.map(child=>child.attributes)}};
|
||||||
|
await recent.sync();
|
||||||
|
pinnedList.children[0].children[0].click();
|
||||||
|
const unpinnedImmediately=recent.unpin(item.route);
|
||||||
|
await recent.sync();
|
||||||
|
process.stdout.write(JSON.stringify({{pinnedImmediately,immediate,opened,unpinnedImmediately,settled:recent.pinned(),calls}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
payload = run_node(script)
|
||||||
|
|
||||||
|
assert payload["pinnedImmediately"] is True
|
||||||
|
assert payload["immediate"]["pinned"][0]["number"] == 1477
|
||||||
|
assert payload["immediate"]["status"] == "Sync pending."
|
||||||
|
assert payload["immediate"]["recentHidden"] is False
|
||||||
|
assert payload["immediate"]["pinnedHidden"] is False
|
||||||
|
assert payload["immediate"]["recentActions"][0]["data-recent-work-route"].endswith("/1477")
|
||||||
|
assert payload["immediate"]["recentActions"][1]["data-recent-work-pin"] == "pin"
|
||||||
|
assert payload["immediate"]["pinnedActions"][1]["data-recent-work-pin"] == "unpin"
|
||||||
|
assert payload["opened"] == ["#/my-work/issue/stackchain/dashboard/1477"]
|
||||||
|
assert payload["unpinnedImmediately"] is True
|
||||||
|
assert payload["settled"] == []
|
||||||
|
assert payload["calls"] == [
|
||||||
|
["api/v1/recent-work/pin", "PUT", payload["immediate"]["pinned"][0]],
|
||||||
|
[
|
||||||
|
"api/v1/recent-work/pin",
|
||||||
|
"DELETE",
|
||||||
|
{"route": "#/my-work/issue/stackchain/dashboard/1477"},
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
|
def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
|
||||||
html = INDEX.read_text()
|
html = INDEX.read_text()
|
||||||
dashboard = DASHBOARD.read_text()
|
dashboard = DASHBOARD.read_text()
|
||||||
|
|
@ -163,15 +233,21 @@ def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
|
||||||
|
|
||||||
assert 'id="mobile-recent-work"' in html
|
assert 'id="mobile-recent-work"' in html
|
||||||
assert 'id="mobile-recent-work-list"' in html
|
assert 'id="mobile-recent-work-list"' in html
|
||||||
|
assert 'id="mobile-pinned-work"' in html
|
||||||
|
assert 'id="mobile-pinned-work-list"' in html
|
||||||
assert 'id="mobile-recent-work-status" role="status" aria-live="polite"' in html
|
assert 'id="mobile-recent-work-status" role="status" aria-live="polite"' in html
|
||||||
assert '<script src="static/mobile-recent-work.js"></script>' in html
|
assert '<script src="static/mobile-recent-work.js"></script>' in html
|
||||||
assert "createMobileRecentWork({" in dashboard
|
assert "createMobileRecentWork({" in dashboard
|
||||||
assert "fetchJson:fetchReviewJson" in dashboard
|
assert "fetchJson:fetchReviewJson" in dashboard
|
||||||
assert "status:qs('#mobile-recent-work-status')" in dashboard
|
assert "status:qs('#mobile-recent-work-status')" in dashboard
|
||||||
|
assert "pinnedList:qs('#mobile-pinned-work-list')" in dashboard
|
||||||
|
assert "pinnedSection:qs('#mobile-pinned-work')" in dashboard
|
||||||
assert "mobileRecentWork.startLifecycle({window, document})" in dashboard
|
assert "mobileRecentWork.startLifecycle({window, document})" in dashboard
|
||||||
assert "void mobileRecentWork.load();" in dashboard
|
assert "void mobileRecentWork.load();" in dashboard
|
||||||
assert "mobileRecentWork.record(item)" in dashboard
|
assert "mobileRecentWork.record(item)" in dashboard
|
||||||
assert "mobileRecentWork.render()" in dashboard
|
assert "mobileRecentWork.render()" in dashboard
|
||||||
assert "workRoute.sync()" in dashboard
|
assert "workRoute.sync()" in dashboard
|
||||||
assert "[data-recent-work-route]" in css
|
assert "[data-recent-work-route]" in css
|
||||||
assert "min-height:56px" in css
|
assert "[data-recent-work-pin]" in css
|
||||||
|
assert "min-height:44px" in css
|
||||||
|
assert "min-width:0" in css
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,60 @@ async def test_recent_work_api_is_authenticated_csrf_protected_no_store_and_acco
|
||||||
|
|
||||||
assert forbidden.status_code == 403
|
assert forbidden.status_code == 403
|
||||||
assert saved.status_code == 200
|
assert saved.status_code == 200
|
||||||
assert saved.json() == {"items": [entry]}
|
assert saved.json() == {"items": [entry], "pinned": []}
|
||||||
assert fetched.json() == saved.json()
|
assert fetched.json() == saved.json()
|
||||||
assert fetched.headers["cache-control"] == "no-store"
|
assert fetched.headers["cache-control"] == "no-store"
|
||||||
assert other_account.json() == {"items": []}
|
assert other_account.json() == {"items": [], "pinned": []}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_recent_work_pin_api_is_csrf_protected_and_account_scoped(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||||
|
"a-separate-session-signing-secret-with-enough-entropy",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_RECENT_WORK_DB", str(tmp_path / "recent-work.sqlite3"))
|
||||||
|
active_login = "Timmy"
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"id": 1, "login": active_login}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
entry = {
|
||||||
|
"kind": "pull",
|
||||||
|
"repository": "stackchain/dashboard",
|
||||||
|
"number": 1476,
|
||||||
|
"title": "Sync recent work",
|
||||||
|
"route": "#/my-work/pull/stackchain/dashboard/1476",
|
||||||
|
}
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||||
|
)
|
||||||
|
forbidden = await client.put("/api/v1/recent-work/pin", json=entry)
|
||||||
|
headers = {
|
||||||
|
"Origin": "https://test",
|
||||||
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||||
|
}
|
||||||
|
pinned = await client.put("/api/v1/recent-work/pin", json=entry, headers=headers)
|
||||||
|
active_login = "Alexander"
|
||||||
|
isolated = await client.get("/api/v1/recent-work")
|
||||||
|
active_login = "Timmy"
|
||||||
|
unpinned = await client.request(
|
||||||
|
"DELETE",
|
||||||
|
"/api/v1/recent-work/pin",
|
||||||
|
json={"route": entry["route"]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert forbidden.status_code == 403
|
||||||
|
assert pinned.status_code == 200
|
||||||
|
assert pinned.json() == {"items": [], "pinned": [entry]}
|
||||||
|
assert isolated.json() == {"items": [], "pinned": []}
|
||||||
|
assert unpinned.status_code == 200
|
||||||
|
assert unpinned.json() == {"items": [], "pinned": []}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ def test_recent_work_is_encrypted_account_scoped_deduplicated_and_bounded(tmp_pa
|
||||||
|
|
||||||
assert [entry["number"] for entry in expected["items"]] == [3, 6, 5, 4, 2]
|
assert [entry["number"] for entry in expected["items"]] == [3, 6, 5, 4, 2]
|
||||||
assert RecentWorkStore(database, encryption_key=b"r" * 32).get("timmy") == expected
|
assert RecentWorkStore(database, encryption_key=b"r" * 32).get("timmy") == expected
|
||||||
assert store.get("alexander") == {"items": []}
|
assert store.get("alexander") == {"items": [], "pinned": []}
|
||||||
with sqlite3.connect(database) as connection:
|
with sqlite3.connect(database) as connection:
|
||||||
payload = connection.execute(
|
payload = connection.execute(
|
||||||
"SELECT items FROM recent_work WHERE login = 'timmy'"
|
"SELECT items FROM recent_work WHERE login = 'timmy'"
|
||||||
|
|
@ -33,6 +33,33 @@ def test_recent_work_is_encrypted_account_scoped_deduplicated_and_bounded(tmp_pa
|
||||||
assert "#/my-work/issue" not in payload
|
assert "#/my-work/issue" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_pinned_work_survives_recent_limit_and_unpin_keeps_recent_item(tmp_path):
|
||||||
|
database = tmp_path / "recent-work.sqlite3"
|
||||||
|
store = RecentWorkStore(database, encryption_key=b"r" * 32, limit=5)
|
||||||
|
|
||||||
|
store.record("timmy", item(1))
|
||||||
|
pinned = store.pin("timmy", item(1))
|
||||||
|
for number in range(2, 8):
|
||||||
|
store.record("timmy", item(number))
|
||||||
|
|
||||||
|
assert pinned["pinned"] == [item(1)]
|
||||||
|
assert [entry["number"] for entry in store.get("timmy")["items"]] == [7, 6, 5, 4, 3]
|
||||||
|
assert store.get("timmy")["pinned"] == [item(1)]
|
||||||
|
assert store.get("alexander") == {"items": [], "pinned": []}
|
||||||
|
|
||||||
|
store.record("timmy", item(1, title="Issue 1 current"))
|
||||||
|
unpinned = store.unpin("timmy", item(1)["route"])
|
||||||
|
|
||||||
|
assert unpinned["pinned"] == []
|
||||||
|
assert unpinned["items"][0] == item(1, title="Issue 1 current")
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
payload = connection.execute(
|
||||||
|
"SELECT items FROM recent_work WHERE login = 'timmy'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert "Issue 1" not in payload
|
||||||
|
assert "#/my-work/issue" not in payload
|
||||||
|
|
||||||
|
|
||||||
def test_recent_work_rejects_noncanonical_or_unsupported_items(tmp_path):
|
def test_recent_work_rejects_noncanonical_or_unsupported_items(tmp_path):
|
||||||
store = RecentWorkStore(tmp_path / "recent-work.sqlite3", encryption_key=b"r" * 32)
|
store = RecentWorkStore(tmp_path / "recent-work.sqlite3", encryption_key=b"r" * 32)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user