Make mobile My Work sheets URL-addressable and back-button safe (#206)
Closes #205
This commit is contained in:
commit
2fecccfbde
|
|
@ -71,7 +71,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.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; }
|
||||||
.mark-update-read { min-height:44px; width:100%; }
|
.mark-update-read { min-height:44px; width:100%; }
|
||||||
.read-update { min-height:44px; width:100%; }
|
.read-update { min-height:44px; width:100%; display:flex; align-items:center; justify-content:center; }
|
||||||
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
|
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
|
||||||
.load-more-notifications[hidden] { display:none; }
|
.load-more-notifications[hidden] { display:none; }
|
||||||
.load-more-work { min-height:44px; width:100%; margin-top:10px; }
|
.load-more-work { min-height:44px; width:100%; margin-top:10px; }
|
||||||
|
|
@ -287,6 +287,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<div class="small" id="notification-page-status" aria-live="polite"></div>
|
<div class="small" id="notification-page-status" aria-live="polite"></div>
|
||||||
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
|
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
|
||||||
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
||||||
|
<div class="small" id="work-route-share-status" aria-live="polite"></div>
|
||||||
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
|
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
|
||||||
<button id="bulk-mark-read" type="button"></button>
|
<button id="bulk-mark-read" type="button"></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -454,6 +455,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
|
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
|
||||||
</section>
|
</section>
|
||||||
<div class="issue-sheet-actions">
|
<div class="issue-sheet-actions">
|
||||||
|
<button class="share-work-route" type="button">Share</button>
|
||||||
<button id="release-issue" type="button">Release assignment</button>
|
<button id="release-issue" type="button">Release assignment</button>
|
||||||
<button id="close-issue" type="button">Close issue</button>
|
<button id="close-issue" type="button">Close issue</button>
|
||||||
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||||
|
|
@ -539,6 +541,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<div id="update-reply-status" class="small" aria-live="assertive"></div>
|
<div id="update-reply-status" class="small" aria-live="assertive"></div>
|
||||||
</section>
|
</section>
|
||||||
<div class="update-sheet-actions">
|
<div class="update-sheet-actions">
|
||||||
|
<button class="share-work-route" type="button">Share</button>
|
||||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -571,6 +574,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<div id="pull-comment-status" class="small" aria-live="assertive"></div>
|
<div id="pull-comment-status" class="small" aria-live="assertive"></div>
|
||||||
</section>
|
</section>
|
||||||
<div class="pull-sheet-actions">
|
<div class="pull-sheet-actions">
|
||||||
|
<button class="share-work-route" type="button">Share</button>
|
||||||
<button id="merge-pull" type="button" disabled>Merge</button>
|
<button id="merge-pull" type="button" disabled>Merge</button>
|
||||||
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -594,7 +598,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<div id="review-sheet-status" class="small" aria-live="polite">Choose a review request.</div>
|
<div id="review-sheet-status" class="small" aria-live="polite">Choose a review request.</div>
|
||||||
<button class="review-retry" id="retry-review-load" hidden>Retry loading review</button>
|
<button class="review-retry" id="retry-review-load" hidden>Retry loading review</button>
|
||||||
<p class="review-sheet-body" id="review-sheet-body"></p>
|
<p class="review-sheet-body" id="review-sheet-body"></p>
|
||||||
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
|
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><button class="share-work-route" type="button">Share</button><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
|
||||||
<h2>Changed files</h2>
|
<h2>Changed files</h2>
|
||||||
<div id="review-files" class="muted"></div>
|
<div id="review-files" class="muted"></div>
|
||||||
<section class="review-inline-composer" id="review-inline-composer" aria-labelledby="review-inline-title" hidden>
|
<section class="review-inline-composer" id="review-inline-composer" aria-labelledby="review-inline-title" hidden>
|
||||||
|
|
@ -654,6 +658,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<script src="static/create-issue-sheet.js"></script>
|
<script src="static/create-issue-sheet.js"></script>
|
||||||
<script src="static/pull-sheet.js"></script>
|
<script src="static/pull-sheet.js"></script>
|
||||||
<script src="static/review-sheet.js"></script>
|
<script src="static/review-sheet.js"></script>
|
||||||
|
<script src="static/work-route.js"></script>
|
||||||
<script src="static/context-poller.js"></script>
|
<script src="static/context-poller.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
|
|
@ -917,6 +922,48 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
onClose: () => closeUpdateSheet(false),
|
onClose: () => closeUpdateSheet(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function routedWorkItem(item) {
|
||||||
|
if (item?.has_update && item.kind === 'update') {
|
||||||
|
return { ...item, kind:'update' };
|
||||||
|
}
|
||||||
|
return { ...item, kind:item?.is_review ? 'review' : item?.kind };
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRoutedWork(item, trigger, options = {}) {
|
||||||
|
if (!item) return;
|
||||||
|
if (item.has_update && item.kind === 'update') updateTrigger = trigger;
|
||||||
|
else if (item.is_review) reviewTrigger = trigger;
|
||||||
|
else if (item.kind === 'issue') issueTrigger = trigger;
|
||||||
|
else if (item.kind === 'pull') pullTrigger = trigger;
|
||||||
|
workRoute.open(routedWorkItem(item), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workRoute = createWorkRoute.createController({
|
||||||
|
location: window.location,
|
||||||
|
history: window.history,
|
||||||
|
eventTarget: window,
|
||||||
|
onOpen: item => {
|
||||||
|
closeOpenWorkSheets();
|
||||||
|
if (item.kind === 'update') notificationReader.open(item, lastMyWork);
|
||||||
|
else if (item.kind === 'review') openReviewSheet(item, reviewTrigger);
|
||||||
|
else if (item.kind === 'issue') openIssueSheet(item, issueTrigger);
|
||||||
|
else if (item.kind === 'pull') openPullSheet(item, pullTrigger);
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
if (selectedIssue) closeIssueSheet(false);
|
||||||
|
if (selectedPull) closePullSheet(false);
|
||||||
|
if (selectedReview) closeReviewSheet(false);
|
||||||
|
if (selectedUpdate) closeUpdateSheet(true, false);
|
||||||
|
},
|
||||||
|
onInvalid: () => {
|
||||||
|
window.history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||||
|
closeOpenWorkSheets();
|
||||||
|
qs('#my-work-action-status').textContent = 'Route unavailable · this item is no longer in My Work.';
|
||||||
|
qs('#my-work').scrollIntoView({ block:'start' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
workRoute.start();
|
||||||
|
|
||||||
function closeOpenWorkSheets() {
|
function closeOpenWorkSheets() {
|
||||||
['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector =>
|
['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector =>
|
||||||
qs(selector).classList.remove('open')
|
qs(selector).classList.remove('open')
|
||||||
|
|
@ -930,20 +977,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function openWorkSessionItem(item) {
|
function openWorkSessionItem(item) {
|
||||||
closeOpenWorkSheets();
|
openRoutedWork(item, null, { replace:true });
|
||||||
if (selectedWorkFilter === 'update' && item.has_update && Number.isInteger(item.notification_id)) {
|
|
||||||
updateTrigger = null;
|
|
||||||
notificationReader.open(item, lastMyWork);
|
|
||||||
} else if (item.is_review) {
|
|
||||||
openReviewSheet(item, null);
|
|
||||||
} else if (item.kind === 'issue') {
|
|
||||||
openIssueSheet(item, null);
|
|
||||||
} else if (item.kind === 'pull') {
|
|
||||||
openPullSheet(item, null);
|
|
||||||
} else if (item.has_update && Number.isInteger(item.notification_id)) {
|
|
||||||
updateTrigger = null;
|
|
||||||
notificationReader.open(item, lastMyWork);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const workSession = createWorkSession({
|
const workSession = createWorkSession({
|
||||||
|
|
@ -1093,6 +1127,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||||||
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 routeHref = createWorkRoute.serialize(routeItem);
|
||||||
const contents =
|
const contents =
|
||||||
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '</span>' +
|
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '</span>' +
|
||||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||||
|
|
@ -1104,35 +1140,36 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
|
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
|
||||||
'<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) ?
|
||||||
'<button class="read-update" data-update-index="' + index + '">Read update</button>' : '';
|
'<a class="read-update" href="' + escAttr(createWorkRoute.serialize({ kind:'update', notification_id:item.notification_id })) + '" data-update-index="' + index + '">Read update</a>' : '';
|
||||||
if (item.is_review) {
|
if (item.is_review) {
|
||||||
return '<article class="my-work-card"><button class="my-work-card-main review-trigger" data-review-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
|
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 + '</article>';
|
||||||
}
|
}
|
||||||
if (item.kind === 'issue') {
|
if (item.kind === 'issue') {
|
||||||
return '<article class="my-work-card"><button class="my-work-card-main issue-trigger" data-issue-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
|
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 + '</article>';
|
||||||
}
|
}
|
||||||
if (item.kind === 'pull') {
|
if (item.kind === 'pull') {
|
||||||
return '<article class="my-work-card"><button class="my-work-card-main pull-trigger" data-pull-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
|
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 + '</article>';
|
||||||
}
|
}
|
||||||
return '<article class="my-work-card"><a class="my-work-card-main" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>' + readUpdate + markRead + '</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 + '</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 === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.') + '</div>';
|
'No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.') + '</div>';
|
||||||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||||||
button.addEventListener('click', () => openReviewSheet(lastMyWork[Number(button.dataset.reviewIndex)], button));
|
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-issue-index]').forEach(button => {
|
document.querySelectorAll('[data-issue-index]').forEach(button => {
|
||||||
button.addEventListener('click', () => openIssueSheet(lastMyWork[Number(button.dataset.issueIndex)], button));
|
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.issueIndex)], button); });
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-pull-index]').forEach(button => {
|
document.querySelectorAll('[data-pull-index]').forEach(button => {
|
||||||
button.addEventListener('click', () => openPullSheet(lastMyWork[Number(button.dataset.pullIndex)], button));
|
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.pullIndex)], button); });
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-update-index]').forEach(button => {
|
document.querySelectorAll('[data-update-index]').forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', event => {
|
||||||
|
event.preventDefault();
|
||||||
const item = lastMyWork[Number(button.dataset.updateIndex)];
|
const item = lastMyWork[Number(button.dataset.updateIndex)];
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
updateTrigger = button;
|
updateTrigger = button;
|
||||||
notificationReader.open(item, lastMyWork);
|
workRoute.open({ ...item, kind:'update' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-notification-id]').forEach(button => {
|
document.querySelectorAll('[data-notification-id]').forEach(button => {
|
||||||
|
|
@ -1162,6 +1199,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
bulkButton.textContent = bulkConfirmationPending ?
|
bulkButton.textContent = bulkConfirmationPending ?
|
||||||
'Confirm marking ' + bulkLabel + ' read' :
|
'Confirm marking ' + bulkLabel + ' read' :
|
||||||
'Mark ' + bulkLabel + ' read';
|
'Mark ' + bulkLabel + ' read';
|
||||||
|
workRoute.setItems(lastMyWork);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reviewFileElement(filename) {
|
function reviewFileElement(filename) {
|
||||||
|
|
@ -1327,7 +1365,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeIssueSheet() {
|
function closeIssueSheet(navigate = true) {
|
||||||
|
if (navigate && createWorkRoute.parse(window.location.hash)) {
|
||||||
|
workRoute.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
qs('#issue-sheet').classList.remove('open');
|
qs('#issue-sheet').classList.remove('open');
|
||||||
selectedIssue = null;
|
selectedIssue = null;
|
||||||
selectedIssueDetail = null;
|
selectedIssueDetail = null;
|
||||||
|
|
@ -1425,7 +1467,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePullSheet() {
|
function closePullSheet(navigate = true) {
|
||||||
|
if (navigate && createWorkRoute.parse(window.location.hash)) {
|
||||||
|
workRoute.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
qs('#pull-sheet').classList.remove('open');
|
qs('#pull-sheet').classList.remove('open');
|
||||||
selectedPull = null;
|
selectedPull = null;
|
||||||
selectedPullDetail = null;
|
selectedPullDetail = null;
|
||||||
|
|
@ -1481,7 +1527,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
closeFindWorkSheet();
|
closeFindWorkSheet();
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
qs('#my-work-action-status').textContent = confirmed.repository + '#' + confirmed.number + ' assigned to you.';
|
qs('#my-work-action-status').textContent = confirmed.repository + '#' + confirmed.number + ' assigned to you.';
|
||||||
openIssueSheet(claimed, qs('#find-work'));
|
openRoutedWork(claimed, qs('#find-work'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
qs('#find-work-status').textContent = error.message + ' Refresh and retry.';
|
qs('#find-work-status').textContent = error.message + ' Refresh and retry.';
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
|
|
@ -1699,7 +1745,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeReviewSheet() {
|
function closeReviewSheet(navigate = true) {
|
||||||
|
if (navigate && createWorkRoute.parse(window.location.hash)) {
|
||||||
|
workRoute.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
qs('#review-sheet').classList.remove('open');
|
qs('#review-sheet').classList.remove('open');
|
||||||
selectedReview = null;
|
selectedReview = null;
|
||||||
progress = null;
|
progress = null;
|
||||||
|
|
@ -1710,7 +1760,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
if (reviewTrigger?.isConnected) reviewTrigger.focus();
|
if (reviewTrigger?.isConnected) reviewTrigger.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeUpdateSheet(restoreTrigger = true) {
|
function closeUpdateSheet(restoreTrigger = true, navigate = true) {
|
||||||
|
if (navigate && createWorkRoute.parse(window.location.hash)) {
|
||||||
|
workRoute.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
qs('#update-sheet').classList.remove('open');
|
qs('#update-sheet').classList.remove('open');
|
||||||
selectedUpdate = null;
|
selectedUpdate = null;
|
||||||
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
|
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
|
||||||
|
|
@ -1961,7 +2015,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
);
|
);
|
||||||
if (!item) return false;
|
if (!item) return false;
|
||||||
searchPreview.close();
|
searchPreview.close();
|
||||||
await openIssueSheet(item, qs('#find-work'));
|
openRoutedWork(item, qs('#find-work'));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function runCommandItem(item) {
|
function runCommandItem(item) {
|
||||||
|
|
@ -2056,6 +2110,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
closePullSheet();
|
closePullSheet();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (e.key === 'Escape' && selectedReview) {
|
||||||
|
e.preventDefault();
|
||||||
|
closeReviewSheet();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape' && selectedUpdate) {
|
||||||
|
e.preventDefault();
|
||||||
|
closeUpdateSheet();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ((e.metaKey||e.ctrlKey) && e.key==='k') {
|
if ((e.metaKey||e.ctrlKey) && e.key==='k') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (qs('#cmd-palette').classList.contains('open')) {
|
if (qs('#cmd-palette').classList.contains('open')) {
|
||||||
|
|
@ -2065,6 +2129,17 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
qs('#close-search-preview').addEventListener('click', closeSearchPreview);
|
qs('#close-search-preview').addEventListener('click', closeSearchPreview);
|
||||||
|
document.querySelectorAll('.share-work-route').forEach(button => {
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
const status = qs('#work-route-share-status');
|
||||||
|
try {
|
||||||
|
const result = await createWorkRoute.share(window.location.href, navigator, navigator.clipboard);
|
||||||
|
status.textContent = result === 'shared' ? 'Work link shared.' : 'Work link copied.';
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = error?.name === 'AbortError' ? 'Share canceled.' : 'Could not share this work link.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
qs('#claim-search-result').addEventListener('click', async () => {
|
qs('#claim-search-result').addEventListener('click', async () => {
|
||||||
if (!searchPreviewDetail || (!searchPreviewDetail.claimable && !searchPreviewDetail.assigned_to_me)) return;
|
if (!searchPreviewDetail || (!searchPreviewDetail.claimable && !searchPreviewDetail.assigned_to_me)) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -2134,7 +2209,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
closeCreateIssueSheet();
|
closeCreateIssueSheet();
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
qs('#my-work-action-status').textContent = created.key + ' created and assigned to you.';
|
qs('#my-work-action-status').textContent = created.key + ' created and assigned to you.';
|
||||||
openIssueSheet(created, qs('#new-issue'));
|
openRoutedWork(created, qs('#new-issue'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
|
|
|
||||||
124
frontend/work-route.js
Normal file
124
frontend/work-route.js
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
(function (root, factory) {
|
||||||
|
const api = factory();
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||||
|
else root.createWorkRoute = api;
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||||
|
|
||||||
|
function positiveInteger(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isSafeInteger(number) && number > 0 ? number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(fragment) {
|
||||||
|
const parts = String(fragment || '').split('/');
|
||||||
|
if (parts[0] !== '#' || parts[1] !== 'my-work') return null;
|
||||||
|
if (parts[2] === 'update' && parts.length === 4) {
|
||||||
|
const notificationId = positiveInteger(parts[3]);
|
||||||
|
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
|
||||||
|
}
|
||||||
|
if (!['issue', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
|
||||||
|
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
|
||||||
|
const number = positiveInteger(parts[5]);
|
||||||
|
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialize(item) {
|
||||||
|
if (item?.kind === 'update') {
|
||||||
|
const notificationId = positiveInteger(item.notification_id);
|
||||||
|
return notificationId ? '#/my-work/update/' + notificationId : '';
|
||||||
|
}
|
||||||
|
if (!['issue', 'pull', 'review'].includes(item?.kind)) return '';
|
||||||
|
const repository = String(item.repository || '').split('/');
|
||||||
|
const number = positiveInteger(item.number);
|
||||||
|
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
|
||||||
|
return '#/my-work/' + item.kind + '/' + repository.join('/') + '/' + number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameRoute(item, route) {
|
||||||
|
if (route.kind === 'update') {
|
||||||
|
return Number(item.notification_id) === route.notification_id;
|
||||||
|
}
|
||||||
|
const itemKind = item.is_review ? 'review' : item.kind;
|
||||||
|
return itemKind === route.kind && item.repository === route.repository &&
|
||||||
|
Number(item.number) === route.number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createController({ location, history, eventTarget, onOpen, onClose, onInvalid }) {
|
||||||
|
let items = [];
|
||||||
|
let started = false;
|
||||||
|
let active = '';
|
||||||
|
let ready = false;
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
const fragment = String(location.hash || '');
|
||||||
|
if (!fragment) {
|
||||||
|
if (active) onClose();
|
||||||
|
active = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const route = parse(fragment);
|
||||||
|
if (!route) {
|
||||||
|
if (fragment.startsWith('#/my-work/')) onInvalid();
|
||||||
|
active = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = items.find(candidate => sameRoute(candidate, route));
|
||||||
|
if (!item) {
|
||||||
|
if (ready) onInvalid();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (active === fragment) return;
|
||||||
|
active = fragment;
|
||||||
|
onOpen({ ...item, kind: route.kind });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
eventTarget.addEventListener('popstate', sync);
|
||||||
|
eventTarget.addEventListener('hashchange', sync);
|
||||||
|
sync();
|
||||||
|
},
|
||||||
|
setItems(nextItems) {
|
||||||
|
items = Array.isArray(nextItems) ? nextItems.slice() : [];
|
||||||
|
ready = true;
|
||||||
|
sync();
|
||||||
|
},
|
||||||
|
open(item, options = {}) {
|
||||||
|
const fragment = serialize(item);
|
||||||
|
if (!fragment) return false;
|
||||||
|
const method = options.replace ? 'replaceState' : 'pushState';
|
||||||
|
history[method]({ workRoute: fragment }, '', fragment);
|
||||||
|
active = fragment;
|
||||||
|
onOpen(item);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
if (parse(location.hash)) history.back();
|
||||||
|
else {
|
||||||
|
active = '';
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sync,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function share(url, navigatorObject, clipboard) {
|
||||||
|
if (typeof navigatorObject?.share === 'function') {
|
||||||
|
await navigatorObject.share({ url });
|
||||||
|
return 'shared';
|
||||||
|
}
|
||||||
|
if (typeof clipboard?.writeText === 'function') {
|
||||||
|
await clipboard.writeText(url);
|
||||||
|
return 'copied';
|
||||||
|
}
|
||||||
|
throw new Error('Sharing is unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { parse, serialize, createController, share };
|
||||||
|
});
|
||||||
|
|
@ -13,6 +13,134 @@ ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
||||||
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
||||||
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
|
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
|
||||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||||
|
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments():
|
||||||
|
script = f"""
|
||||||
|
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||||
|
const inputs = [
|
||||||
|
{{kind:'issue',repository:'stackchain/api',number:17}},
|
||||||
|
{{kind:'pull',repository:'stackchain/dashboard',number:42}},
|
||||||
|
{{kind:'review',repository:'stackchain/dashboard',number:42}},
|
||||||
|
{{kind:'update',notification_id:913}},
|
||||||
|
];
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
paths: inputs.map(routes.serialize),
|
||||||
|
parsed: inputs.map(item => routes.parse(routes.serialize(item))),
|
||||||
|
invalid: [
|
||||||
|
'#/my-work/issue/../../etc/1',
|
||||||
|
'#/my-work/issue/stackchain/api/not-a-number',
|
||||||
|
'#/my-work/update/-1',
|
||||||
|
'#/other/issue/stackchain/api/1',
|
||||||
|
].map(routes.parse),
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"paths": [
|
||||||
|
"#/my-work/issue/stackchain/api/17",
|
||||||
|
"#/my-work/pull/stackchain/dashboard/42",
|
||||||
|
"#/my-work/review/stackchain/dashboard/42",
|
||||||
|
"#/my-work/update/913",
|
||||||
|
],
|
||||||
|
"parsed": [
|
||||||
|
{"kind": "issue", "repository": "stackchain/api", "number": 17},
|
||||||
|
{"kind": "pull", "repository": "stackchain/dashboard", "number": 42},
|
||||||
|
{"kind": "review", "repository": "stackchain/dashboard", "number": 42},
|
||||||
|
{"kind": "update", "notification_id": 913},
|
||||||
|
],
|
||||||
|
"invalid": [None, None, None, None],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_work_route_controller_restores_direct_links_and_uses_history_for_close():
|
||||||
|
script = f"""
|
||||||
|
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||||
|
const listeners = {{}};
|
||||||
|
const location = {{hash:'#/my-work/issue/stackchain/api/17', href:'https://forge.example/dashboard/#/my-work/issue/stackchain/api/17'}};
|
||||||
|
const calls = [];
|
||||||
|
const history = {{
|
||||||
|
state: null,
|
||||||
|
pushState(state, _, hash) {{ this.state = state; location.hash = hash; calls.push(['push', hash]); }},
|
||||||
|
replaceState(state, _, hash) {{ this.state = state; location.hash = hash; calls.push(['replace', hash]); }},
|
||||||
|
back() {{ calls.push(['back']); location.hash = ''; listeners.popstate(); }},
|
||||||
|
}};
|
||||||
|
const controller = routes.createController({{
|
||||||
|
location, history,
|
||||||
|
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
|
||||||
|
onOpen(item) {{ calls.push(['open', item.kind, item.repository, item.number || item.notification_id]); }},
|
||||||
|
onClose() {{ calls.push(['close']); }},
|
||||||
|
onInvalid() {{ calls.push(['invalid']); }},
|
||||||
|
}});
|
||||||
|
controller.start();
|
||||||
|
controller.setItems([{{kind:'issue',repository:'stackchain/api',number:17}}]);
|
||||||
|
controller.close();
|
||||||
|
controller.open({{kind:'pull',repository:'stackchain/dashboard',number:9}});
|
||||||
|
controller.open({{kind:'review',repository:'stackchain/dashboard',number:10}}, {{replace:true}});
|
||||||
|
process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
output = json.loads(result.stdout)
|
||||||
|
|
||||||
|
assert output["calls"] == [
|
||||||
|
["open", "issue", "stackchain/api", 17],
|
||||||
|
["back"],
|
||||||
|
["close"],
|
||||||
|
["push", "#/my-work/pull/stackchain/dashboard/9"],
|
||||||
|
["open", "pull", "stackchain/dashboard", 9],
|
||||||
|
["replace", "#/my-work/review/stackchain/dashboard/10"],
|
||||||
|
["open", "review", "stackchain/dashboard", 10],
|
||||||
|
]
|
||||||
|
assert output["hash"] == "#/my-work/review/stackchain/dashboard/10"
|
||||||
|
|
||||||
|
|
||||||
|
def test_work_route_share_prefers_native_share_and_falls_back_to_clipboard():
|
||||||
|
script = f"""
|
||||||
|
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||||
|
const calls = [];
|
||||||
|
(async () => {{
|
||||||
|
const native = await routes.share('https://forge.example/dashboard/#/my-work/update/9', {{
|
||||||
|
share: async payload => calls.push(['native', payload.url]),
|
||||||
|
}}, null);
|
||||||
|
const fallback = await routes.share('https://forge.example/dashboard/#/my-work/update/10', {{}}, {{
|
||||||
|
writeText: async text => calls.push(['clipboard', text]),
|
||||||
|
}});
|
||||||
|
process.stdout.write(JSON.stringify({{native, fallback, calls}}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"native": "shared",
|
||||||
|
"fallback": "copied",
|
||||||
|
"calls": [
|
||||||
|
["native", "https://forge.example/dashboard/#/my-work/update/9"],
|
||||||
|
["clipboard", "https://forge.example/dashboard/#/my-work/update/10"],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert '<script src="static/work-route.js"></script>' in html
|
||||||
|
assert 'const workRoute = createWorkRoute.createController({' in html
|
||||||
|
assert 'workRoute.setItems(lastMyWork);' in html
|
||||||
|
assert 'href="' + "' + escAttr(createWorkRoute.serialize(" in html
|
||||||
|
assert '.read-update { min-height:44px; width:100%; display:flex;' in html
|
||||||
|
assert 'workRoute.close();' in html
|
||||||
|
assert html.count('class="share-work-route"') == 4
|
||||||
|
assert 'createWorkRoute.share(window.location.href, navigator, navigator.clipboard)' in html
|
||||||
|
assert 'Route unavailable · this item is no longer in My Work.' in html
|
||||||
|
|
||||||
|
|
||||||
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
|
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
|
||||||
|
|
@ -1771,7 +1899,7 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
|
||||||
assert '<script src="static/create-issue-sheet.js"></script>' in html
|
assert '<script src="static/create-issue-sheet.js"></script>' in html
|
||||||
assert 'createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage })' in html
|
assert 'createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage })' in html
|
||||||
assert 'lastMyWork = buildMyWork(lastContextSnapshot);' in html
|
assert 'lastMyWork = buildMyWork(lastContextSnapshot);' in html
|
||||||
assert 'openIssueSheet(created' in html
|
assert 'openRoutedWork(created' in html
|
||||||
assert 'issueCapture.loadLabels(repository)' in html
|
assert 'issueCapture.loadLabels(repository)' in html
|
||||||
assert "input[name=\"create-issue-label\"]:checked" in html
|
assert "input[name=\"create-issue-label\"]:checked" in html
|
||||||
assert '.create-issue-label-option' in html and 'min-height:44px' in html
|
assert '.create-issue-label-option' in html and 'min-height:44px' in html
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user