feat: guide mobile Find Work workflow (Closes #979)
This commit is contained in:
parent
611b10b477
commit
53e556ddab
|
|
@ -617,9 +617,21 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.work-session-nav button { min-height:44px; width:100%; }
|
||||
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.find-work-sheet.open { display:flex; }
|
||||
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.find-work-header-actions { display:flex; gap:8px; }
|
||||
.mobile-find-work-nav { position:sticky; top:0; z-index:4; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:6px; padding:6px 0; background:#0b1526; }
|
||||
.mobile-find-work-nav button { min-height:44px; min-width:0; padding:6px; }
|
||||
.mobile-find-work-nav button[aria-current="step"] { border-color:#60a5fa; background:#17355b; color:#fff; }
|
||||
.mobile-find-work-nav button:disabled { opacity:.5; }
|
||||
.find-work-stage { display:grid; gap:12px; min-width:0; outline:none; }
|
||||
.find-work-stage[hidden] { display:none; }
|
||||
.find-work-review { padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||
.find-work-review > div:first-child, .find-work-review-list { display:grid; gap:8px; }
|
||||
.find-work-review-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; align-items:center; padding:10px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
|
||||
.find-work-review-item > div { display:grid; gap:4px; min-width:0; }
|
||||
.find-work-review-item button, .find-work-stage-actions button { min-height:44px; }
|
||||
.find-work-stage-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 env(safe-area-inset-bottom); background:#101f36; }
|
||||
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
|
||||
.find-work-search { position:sticky; top:0; z-index:2; display:grid; gap:6px; padding:8px 0; background:#0b1526; }
|
||||
.find-work-search > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
|
|
|
|||
|
|
@ -1095,6 +1095,7 @@
|
|||
}
|
||||
return offlineToday.warm(confirmedOwnerLogin, todayMyWork);
|
||||
}
|
||||
let findWorkNavigation = null;
|
||||
const findWorkController = createFindWork({
|
||||
fetchJson: fetchReviewJson,
|
||||
onItems: renderAvailableIssues,
|
||||
|
|
@ -1109,9 +1110,20 @@
|
|||
qs('#find-work-selection-status').textContent = state.count ?
|
||||
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No work selected.';
|
||||
qs('#claim-selected-work').disabled = state.count === 0;
|
||||
findWorkNavigation?.sync({ selectedCount:state.count });
|
||||
renderAvailableIssues(findWorkController.items());
|
||||
},
|
||||
});
|
||||
|
||||
findWorkNavigation = createMobileFindWorkNavigation({
|
||||
document,
|
||||
history:window.history,
|
||||
eventTarget:window,
|
||||
controller:findWorkController,
|
||||
todayWork,
|
||||
openFit:openFindWorkEstimateReview,
|
||||
});
|
||||
findWorkNavigation.start();
|
||||
let findWorkSearchTimer = null;
|
||||
|
||||
function updateFindWorkMatchStatus() {
|
||||
|
|
@ -2192,26 +2204,22 @@
|
|||
));
|
||||
}
|
||||
|
||||
function closeFindWorkEstimateReview() {
|
||||
qs('#find-work-estimate-review').hidden = true;
|
||||
qs('#batch-find-work-actions').hidden = !findWorkController.selection().active;
|
||||
}
|
||||
|
||||
function openFindWorkEstimateReview(items) {
|
||||
const plan = todayWork.planning();
|
||||
const planned = Object.values(plan.estimates).reduce((sum, minutes) => sum + minutes, 0);
|
||||
qs('#find-work-estimate-summary').textContent = formatPlanMinutes(Math.max(0, plan.capacity_minutes - planned)) +
|
||||
' remaining. Estimate selected work before assigning it.';
|
||||
const estimatesRequired = Number.isInteger(plan.capacity_minutes) && plan.capacity_minutes > 0;
|
||||
qs('#find-work-estimate-summary').textContent = estimatesRequired ?
|
||||
formatPlanMinutes(Math.max(0, plan.capacity_minutes - planned)) +
|
||||
' remaining. Estimate selected work before assigning it.' :
|
||||
items.length + ' selected. Confirm to assign and queue this batch in Today.';
|
||||
qs('#find-work-estimate-list').innerHTML = items.map(item => {
|
||||
const id = String(item.repository || '') + '#' + String(item.number || '');
|
||||
return '<div class="find-work-estimate-row"><div><span class="small">' + escapeHtml(id) +
|
||||
'</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div><label class="small">' +
|
||||
'<input type="number" inputmode="numeric" min="5" max="1440" step="5" data-find-work-estimate="' +
|
||||
escAttr(id) + '" aria-label="Estimate for ' + escAttr(item.title || id) + ' in minutes" /> min</label></div>';
|
||||
'</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div>' +
|
||||
(estimatesRequired ? '<label class="small"><input type="number" inputmode="numeric" min="5" max="1440" step="5" data-find-work-estimate="' +
|
||||
escAttr(id) + '" aria-label="Estimate for ' + escAttr(item.title || id) + ' in minutes" /> min</label>' : '') + '</div>';
|
||||
}).join('');
|
||||
qs('#batch-find-work-actions').hidden = true;
|
||||
qs('#find-work-estimate-review').hidden = false;
|
||||
qs('[data-find-work-estimate]')?.focus();
|
||||
if (estimatesRequired) qs('[data-find-work-estimate]')?.focus();
|
||||
}
|
||||
|
||||
function formatCalendarDueDate(value) {
|
||||
|
|
@ -4290,6 +4298,7 @@
|
|||
}
|
||||
findingWork = true;
|
||||
qs('#find-work-sheet').classList.add('open');
|
||||
findWorkNavigation.open(batchFindWork.pending() > 0);
|
||||
const retainedItems = findWorkController.items();
|
||||
if (retainedItems.length) renderAvailableIssues(retainedItems);
|
||||
else qs('#find-work-list').textContent = '';
|
||||
|
|
@ -5635,15 +5644,13 @@
|
|||
});
|
||||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||||
qs('#find-work').addEventListener('click', openFindWorkSheet);
|
||||
qs('#close-find-work').addEventListener('click', closeFindWorkSheet);
|
||||
qs('#select-find-work').addEventListener('click', () => findWorkController.startSelection());
|
||||
|
||||
qs('#fill-find-work-today').addEventListener('click', () => {
|
||||
const remainingSlots = todayWork.limit - todayWork.read().length;
|
||||
const outcome = findWorkController.fillSelection(remainingSlots);
|
||||
qs('#find-work-status').textContent = remainingSlots < 1 ? 'Today is full.' :
|
||||
outcome.selected ? outcome.selected + ' ranked work selected.' : 'No matching work.';
|
||||
if (outcome.selected && todayWork.planning().capacity_minutes !== null)
|
||||
openFindWorkEstimateReview(findWorkController.selectedItems());
|
||||
if (outcome.selected) findWorkNavigation.go('review');
|
||||
});
|
||||
qs('#find-work-search-form').addEventListener('submit', event => event.preventDefault());
|
||||
qs('#find-work-search').addEventListener('input', event => {
|
||||
|
|
@ -5669,33 +5676,7 @@
|
|||
input.dispatchEvent(new Event('input', { bubbles:true }));
|
||||
input.focus();
|
||||
});
|
||||
qs('#cancel-find-work-selection').addEventListener('click', () => findWorkController.cancelSelection());
|
||||
|
||||
qs('#claim-selected-work').addEventListener('click', async event => {
|
||||
if (todayWork.planning().capacity_minutes !== null) {
|
||||
openFindWorkEstimateReview(findWorkController.selectedItems());
|
||||
return;
|
||||
}
|
||||
event.currentTarget.disabled = true;
|
||||
const outcome = await batchFindWork.run(findWorkController.selectedItems());
|
||||
if (outcome.status === 'complete') {
|
||||
const assignedOnly = outcome.failed.filter(item => item.assigned).length;
|
||||
const unavailable = outcome.failed.length - assignedOnly;
|
||||
qs('#find-work-status').textContent = outcome.queued.length + ' queued' +
|
||||
(unavailable ? ' · ' + unavailable + ' unavailable' : '') +
|
||||
(assignedOnly ? ' · ' + assignedOnly + ' assigned but not queued' : '') + '.';
|
||||
if (outcome.failed.length) {
|
||||
renderAvailableIssues(findWorkController.items());
|
||||
event.currentTarget.disabled = false;
|
||||
} else {
|
||||
findWorkController.cancelSelection();
|
||||
}
|
||||
refreshMyWorkView();
|
||||
} else {
|
||||
event.currentTarget.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#cancel-find-work-estimates').addEventListener('click', closeFindWorkEstimateReview);
|
||||
qs('#confirm-find-work-estimates').addEventListener('click', async event => {
|
||||
event.currentTarget.disabled = true;
|
||||
const outcome = await batchFindWork.run(findWorkController.selectedItems(), findWorkEstimateValues());
|
||||
|
|
@ -5706,10 +5687,18 @@
|
|||
return;
|
||||
}
|
||||
if (outcome.status !== 'complete') return;
|
||||
const assignedOnly = outcome.failed.filter(item => item.assigned).length;
|
||||
const unavailable = outcome.failed.length - assignedOnly;
|
||||
qs('#find-work-status').textContent = outcome.queued.length + ' queued' +
|
||||
(outcome.failed.length ? ' · ' + outcome.failed.length + ' unavailable.' : '.');
|
||||
closeFindWorkEstimateReview();
|
||||
if (!outcome.failed.length) findWorkController.cancelSelection();
|
||||
(unavailable ? ' · ' + unavailable + ' unavailable' : '') +
|
||||
(assignedOnly ? ' · ' + assignedOnly + ' assigned but not queued' : '') + '.';
|
||||
if (!outcome.failed.length) {
|
||||
findWorkNavigation.complete();
|
||||
findWorkController.cancelSelection();
|
||||
} else {
|
||||
renderAvailableIssues(findWorkController.items());
|
||||
findWorkNavigation.sync({ selectedCount:findWorkController.selection().count, recovery:true });
|
||||
}
|
||||
refreshMyWorkView();
|
||||
});
|
||||
qs('#load-more-available').addEventListener('click', async event => {
|
||||
|
|
|
|||
|
|
@ -843,42 +843,60 @@
|
|||
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
|
||||
<div class="find-work-header-actions"><button id="select-find-work" type="button">Select work</button><button id="close-find-work" type="button">Close</button></div>
|
||||
</div>
|
||||
<p class="small">Claim an available issue and continue it in My Work.</p>
|
||||
<form class="find-work-search" id="find-work-search-form" role="search">
|
||||
<label for="find-work-search">Search repository, #number, or title</label>
|
||||
<div><input id="find-work-search" type="search" maxlength="100" autocomplete="off" />
|
||||
<button id="clear-find-work-search" type="button" hidden>Clear</button></div>
|
||||
<span id="find-work-match-status" class="small" aria-live="polite"></span>
|
||||
</form>
|
||||
<details class="find-work-filters" id="find-work-filters">
|
||||
<summary>Filter work <span id="find-work-filter-count" class="small"></span></summary>
|
||||
<div class="find-work-filter-panel" aria-label="Filter available work">
|
||||
<fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<button id="clear-find-work-filters" type="button">Clear filters</button>
|
||||
</div>
|
||||
</details>
|
||||
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
|
||||
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
|
||||
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
|
||||
<button class="find-work-batch-recovery" id="resume-find-work-batch" type="button"
|
||||
aria-describedby="find-work-status" hidden>Resume assigning & queueing</button>
|
||||
<div class="find-work-list" id="find-work-list"></div>
|
||||
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
|
||||
<section class="find-work-estimate-review" id="find-work-estimate-review" aria-labelledby="find-work-estimate-heading" hidden>
|
||||
<div><strong id="find-work-estimate-heading">Fit this work into Today</strong>
|
||||
<span class="small" id="find-work-estimate-summary" aria-live="polite"></span></div>
|
||||
<div id="find-work-estimate-list"></div>
|
||||
<div class="find-work-estimate-actions">
|
||||
<button id="cancel-find-work-estimates" type="button">Back</button>
|
||||
<button id="confirm-find-work-estimates" type="button">Confirm & assign</button>
|
||||
<p class="small">Choose available issues, review the batch, then fit it into Today.</p>
|
||||
<nav class="mobile-find-work-nav" aria-label="Find Work steps">
|
||||
<button type="button" data-find-work-stage="discover">Discover</button>
|
||||
<button type="button" data-find-work-stage="review" disabled>Review</button>
|
||||
<button type="button" data-find-work-stage="fit" disabled>Fit & assign</button>
|
||||
</nav>
|
||||
<section class="find-work-stage" id="find-work-discover-stage" tabindex="-1" aria-labelledby="find-work-heading">
|
||||
<form class="find-work-search" id="find-work-search-form" role="search">
|
||||
<label for="find-work-search">Search repository, #number, or title</label>
|
||||
<div><input id="find-work-search" type="search" maxlength="100" autocomplete="off" />
|
||||
<button id="clear-find-work-search" type="button" hidden>Clear</button></div>
|
||||
<span id="find-work-match-status" class="small" aria-live="polite"></span>
|
||||
</form>
|
||||
<details class="find-work-filters" id="find-work-filters">
|
||||
<summary>Filter work <span id="find-work-filter-count" class="small"></span></summary>
|
||||
<div class="find-work-filter-panel" aria-label="Filter available work">
|
||||
<fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<button id="clear-find-work-filters" type="button">Clear filters</button>
|
||||
</div>
|
||||
</details>
|
||||
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
|
||||
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
|
||||
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
|
||||
<button class="find-work-batch-recovery" id="resume-find-work-batch" type="button"
|
||||
aria-describedby="find-work-status" hidden>Resume assigning & queueing</button>
|
||||
<div class="find-work-list" id="find-work-list"></div>
|
||||
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
|
||||
<div class="find-work-batch-actions" id="batch-find-work-actions" hidden>
|
||||
<span id="find-work-selection-status" class="small" aria-live="polite">No work selected.</span>
|
||||
<button id="cancel-find-work-selection" type="button">Cancel</button>
|
||||
<button id="claim-selected-work" type="button" disabled>Review selected</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="find-work-stage find-work-review" id="find-work-review-stage" tabindex="-1" aria-labelledby="find-work-review-heading" hidden>
|
||||
<div><strong id="find-work-review-heading">Review selected work</strong>
|
||||
<span class="small" id="find-work-review-summary" aria-live="polite"></span></div>
|
||||
<div class="find-work-review-list" id="find-work-review-list"></div>
|
||||
<div class="find-work-stage-actions">
|
||||
<button id="back-find-work-discover" type="button">Back to discover</button>
|
||||
<button id="continue-find-work-fit" type="button">Continue</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="find-work-stage" id="find-work-fit-stage" tabindex="-1" aria-labelledby="find-work-estimate-heading" hidden>
|
||||
<div class="find-work-estimate-review" id="find-work-estimate-review">
|
||||
<div><strong id="find-work-estimate-heading">Fit this work into Today</strong>
|
||||
<span class="small" id="find-work-estimate-summary" aria-live="polite"></span></div>
|
||||
<div id="find-work-estimate-list"></div>
|
||||
<div class="find-work-estimate-actions">
|
||||
<button id="cancel-find-work-estimates" type="button">Back to review</button>
|
||||
<button id="confirm-find-work-estimates" type="button">Confirm & assign</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="find-work-batch-actions" id="batch-find-work-actions" hidden>
|
||||
<span id="find-work-selection-status" class="small" aria-live="polite">No work selected.</span>
|
||||
<button id="cancel-find-work-selection" type="button">Cancel</button>
|
||||
<button id="claim-selected-work" type="button" disabled>Assign & queue Today</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
|
@ -1596,6 +1614,7 @@
|
|||
<script src="static/mobile-review-detail-nav.js"></script>
|
||||
<script src="static/mobile-search-preview-nav.js"></script>
|
||||
<script src="static/mobile-plan-today-nav.js"></script>
|
||||
<script src="static/mobile-find-work-nav.js"></script>
|
||||
<script src="static/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
194
frontend/mobile-find-work-nav.js
Normal file
194
frontend/mobile-find-work-nav.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createMobileFindWorkNavigation = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
const stages = ['discover', 'review', 'fit'];
|
||||
|
||||
return function createMobileFindWorkNavigation(options) {
|
||||
const document = options.document;
|
||||
const buttons = options.buttons || Object.fromEntries(stages.map(name =>
|
||||
[name, document?.querySelector('[data-find-work-stage="' + name + '"]')]
|
||||
));
|
||||
const sections = options.sections || Object.fromEntries(stages.map(name =>
|
||||
[name, document?.getElementById('find-work-' + name + '-stage')]
|
||||
));
|
||||
const history = options.history;
|
||||
const eventTarget = options.eventTarget;
|
||||
const onStageChange = options.onStageChange || (() => {});
|
||||
const listeners = new Map();
|
||||
let current = 'discover';
|
||||
let selectedCount = 0;
|
||||
let recovery = false;
|
||||
let started = false;
|
||||
|
||||
function escape(value) {
|
||||
return String(value || '').replace(/[&<>"']/g, character =>
|
||||
({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[character]
|
||||
);
|
||||
}
|
||||
|
||||
function renderReview() {
|
||||
const reviewList = options.reviewList || document?.getElementById('find-work-review-list');
|
||||
const reviewSummary = options.reviewSummary || document?.getElementById('find-work-review-summary');
|
||||
if (!reviewList) return;
|
||||
const items = options.controller?.selectedItems?.() || options.getSelectedItems?.() || [];
|
||||
const planning = options.todayWork?.planning?.();
|
||||
const today = options.todayWork ? {
|
||||
limit:options.todayWork.limit, count:options.todayWork.read().length,
|
||||
capacityMinutes:planning.capacity_minutes, estimates:planning.estimates,
|
||||
} : options.getTodayState?.() || {};
|
||||
const remainingSlots = Math.max(0, (Number(today.limit) || 0) - (Number(today.count) || 0));
|
||||
const planned = Object.values(today.estimates || {}).reduce((sum, minutes) => sum + minutes, 0);
|
||||
const remainingMinutes = today.capacityMinutes === null || today.capacityMinutes === undefined ? null :
|
||||
Math.max(0, today.capacityMinutes - planned);
|
||||
const formatMinutes = minutes => [Math.floor(minutes / 60) ? Math.floor(minutes / 60) + 'h' : '',
|
||||
minutes % 60 ? minutes % 60 + 'm' : ''].filter(Boolean).join(' ') || '0m';
|
||||
if (reviewSummary) reviewSummary.textContent = items.length + ' selected · ' + remainingSlots +
|
||||
' Today slot' + (remainingSlots === 1 ? '' : 's') + ' open' +
|
||||
(remainingMinutes === null ? '' : ' · ' + formatMinutes(remainingMinutes) + ' available') + '.';
|
||||
reviewList.innerHTML = items.map(item => {
|
||||
const id = String(item.repository || '') + '#' + String(item.number || '');
|
||||
return '<article class="find-work-review-item"><div><span class="small">' + escape(id) +
|
||||
'</span><strong>' + escape(item.title || 'Untitled work') + '</strong></div>' +
|
||||
'<button type="button" data-find-work-review-remove="' + escape(id) + '">Remove</button></article>';
|
||||
}).join('');
|
||||
reviewList.querySelectorAll('[data-find-work-review-remove]').forEach(button =>
|
||||
button.addEventListener('click', () => {
|
||||
const item = items.find(candidate => String(candidate.repository || '') + '#' +
|
||||
String(candidate.number || '') === button.dataset.findWorkReviewRemove);
|
||||
if (item) (options.controller?.toggleSelection || options.toggleSelection)?.(item);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function allowed(name) {
|
||||
return stages.includes(name) && (name === 'discover' || selectedCount > 0 || recovery);
|
||||
}
|
||||
|
||||
function paint(name, focus = false, notify = true) {
|
||||
if (!allowed(name)) name = 'discover';
|
||||
current = name;
|
||||
stages.forEach(stage => {
|
||||
const button = buttons[stage];
|
||||
const section = sections[stage];
|
||||
if (button) {
|
||||
button.disabled = stage !== 'discover' && selectedCount === 0 && !recovery;
|
||||
if (stage === name) button.setAttribute('aria-current', 'step');
|
||||
else button.removeAttribute('aria-current');
|
||||
}
|
||||
if (section) section.hidden = stage !== name;
|
||||
});
|
||||
if (name === 'review') renderReview();
|
||||
if (name === 'fit') options.openFit?.(options.controller?.selectedItems?.() || []);
|
||||
if (notify) onStageChange(name);
|
||||
if (focus) sections[name]?.focus?.();
|
||||
return name;
|
||||
}
|
||||
|
||||
function stateFor(name) {
|
||||
const state = { ...(history?.state || {}) };
|
||||
if (name === 'discover') delete state.findWorkStage;
|
||||
else state.findWorkStage = name;
|
||||
return state;
|
||||
}
|
||||
|
||||
function go(name) {
|
||||
if (!allowed(name) || name === current) return false;
|
||||
const currentIndex = stages.indexOf(current);
|
||||
const nextIndex = stages.indexOf(name);
|
||||
if (nextIndex < currentIndex && history?.go) {
|
||||
history.go(nextIndex - currentIndex);
|
||||
return true;
|
||||
}
|
||||
if (nextIndex === currentIndex - 1 && history?.back) {
|
||||
history.back();
|
||||
return true;
|
||||
}
|
||||
history?.pushState?.(stateFor(name), '');
|
||||
paint(name, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function onPopState(event) {
|
||||
if (event.state?.taskOverlay !== 'find') return;
|
||||
paint(event.state?.findWorkStage || 'discover', true);
|
||||
}
|
||||
|
||||
function sync(state = {}) {
|
||||
selectedCount = Math.max(0, Number(state.selectedCount) || 0);
|
||||
recovery = state.recovery === true;
|
||||
if (!allowed(current)) paint('discover');
|
||||
else paint(current, false, false);
|
||||
}
|
||||
|
||||
function open(hasRecovery) {
|
||||
if (options.controller) {
|
||||
selectedCount = options.controller.selection().count;
|
||||
recovery = hasRecovery === true;
|
||||
}
|
||||
const restored = history?.state?.taskOverlay === 'find' ? history.state.findWorkStage : null;
|
||||
return paint(allowed(restored) ? restored : 'discover');
|
||||
}
|
||||
|
||||
function complete() {
|
||||
selectedCount = 0;
|
||||
recovery = false;
|
||||
history?.replaceState?.(stateFor('discover'), '');
|
||||
return paint('discover');
|
||||
}
|
||||
|
||||
function back() {
|
||||
if (current === 'discover') return false;
|
||||
history?.back?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
stages.forEach(name => {
|
||||
const button = buttons[name];
|
||||
if (!button) return;
|
||||
const listener = event => { event.preventDefault(); go(name); };
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
});
|
||||
if (document) {
|
||||
const bind = (id, action) => {
|
||||
const button = document.getElementById(id);
|
||||
if (!button) return;
|
||||
const listener = event => { event.preventDefault(); action(); };
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
};
|
||||
bind('select-find-work', () => { complete(); options.controller?.startSelection?.(); });
|
||||
bind('cancel-find-work-selection', () => { complete(); options.controller?.cancelSelection?.(); });
|
||||
bind('claim-selected-work', () => go('review'));
|
||||
bind('continue-find-work-fit', () => go('fit'));
|
||||
bind('back-find-work-discover', back);
|
||||
bind('cancel-find-work-estimates', back);
|
||||
bind('close-find-work', () => history?.go?.(-(stages.indexOf(current) + 1)));
|
||||
}
|
||||
eventTarget?.addEventListener?.('popstate', onPopState);
|
||||
paint('discover');
|
||||
},
|
||||
stop() {
|
||||
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
|
||||
listeners.clear();
|
||||
eventTarget?.removeEventListener?.('popstate', onPopState);
|
||||
started = false;
|
||||
},
|
||||
sync,
|
||||
open,
|
||||
go,
|
||||
back,
|
||||
complete,
|
||||
renderReview,
|
||||
stage: () => current,
|
||||
};
|
||||
};
|
||||
});
|
||||
|
|
@ -82,6 +82,7 @@ const SHELL = [
|
|||
BASE + 'static/mobile-review-detail-nav.js',
|
||||
BASE + 'static/mobile-search-preview-nav.js',
|
||||
BASE + 'static/mobile-plan-today-nav.js',
|
||||
BASE + 'static/mobile-find-work-nav.js',
|
||||
BASE + 'static/checklist-conflict.js',
|
||||
BASE + 'static/voice-transcript-store.js',
|
||||
BASE + 'static/voice-issue-capture.js',
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ FEATURE_SOURCES = {
|
|||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-plan-today-nav.js",
|
||||
"static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js",
|
||||
"static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
|||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
FIND_WORK_NAV = Path(__file__).parents[1] / "frontend" / "mobile-find-work-nav.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
|
|
@ -437,14 +438,15 @@ def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset():
|
|||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = WORKER.read_text()
|
||||
navigation = FIND_WORK_NAV.read_text()
|
||||
|
||||
assert 'id="select-find-work"' in html
|
||||
assert 'id="batch-find-work-actions"' in html
|
||||
assert 'id="claim-selected-work"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "createBatchFindWork({" in dashboard
|
||||
assert "findWorkController.startSelection()" in dashboard
|
||||
assert "findWorkController.toggleSelection(item)" in dashboard
|
||||
assert "options.controller?.startSelection?.()" in navigation
|
||||
assert "options.controller?.toggleSelection" in navigation
|
||||
assert ".find-work-batch-actions" in css
|
||||
assert "position:sticky" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
|
|
@ -472,7 +474,7 @@ def test_mobile_find_work_exposes_fill_today_action_with_capacity_handoff():
|
|||
assert 'id="fill-find-work-today"' in html
|
||||
assert 'aria-describedby="find-work-selection-status"' in html
|
||||
assert "findWorkController.fillSelection(remainingSlots)" in dashboard
|
||||
assert "openFindWorkEstimateReview(findWorkController.selectedItems())" in dashboard
|
||||
assert "openFit:openFindWorkEstimateReview" in dashboard
|
||||
assert ".fill-find-work-today" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
|
|
|
|||
135
tests/test_mobile_find_work_navigation.py
Normal file
135
tests/test_mobile_find_work_navigation.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-find-work-nav.js"
|
||||
FRONTEND = CONTROLLER.parent
|
||||
|
||||
|
||||
def run_navigation(scenario: str) -> dict:
|
||||
script = f"""
|
||||
const createNavigation = require({json.dumps(str(CONTROLLER))});
|
||||
class FakeElement {{
|
||||
constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.hidden=false; this.disabled=false; this.focused=0; }}
|
||||
addEventListener(name, callback) {{ (this.listeners[name] ||= []).push(callback); }}
|
||||
removeEventListener(name, callback) {{ this.listeners[name]=(this.listeners[name]||[]).filter(item=>item!==callback); }}
|
||||
click() {{ for (const callback of this.listeners.click||[]) callback({{preventDefault(){{}}}}); }}
|
||||
setAttribute(name,value) {{ this.attributes[name]=value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
focus() {{ this.focused++; }}
|
||||
}}
|
||||
class FakeEvents {{
|
||||
constructor() {{ this.listeners={{}}; }}
|
||||
addEventListener(name, callback) {{ (this.listeners[name] ||= []).push(callback); }}
|
||||
removeEventListener(name, callback) {{ this.listeners[name]=(this.listeners[name]||[]).filter(item=>item!==callback); }}
|
||||
emit(name,event) {{ for (const callback of this.listeners[name]||[]) callback(event); }}
|
||||
}}
|
||||
{scenario}
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_find_work_navigation_gates_review_and_fit_until_work_is_selected():
|
||||
result = run_navigation("""
|
||||
const names=['discover','review','fit'];
|
||||
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const sections=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const events=new FakeEvents();
|
||||
const history={state:{taskOverlay:'find'}, pushes:[], backs:0,
|
||||
pushState(state){this.state=state;this.pushes.push(state);}, back(){this.backs++;}};
|
||||
const navigation=createNavigation({buttons,sections,history,eventTarget:events,onStageChange:()=>{}});
|
||||
navigation.start();
|
||||
const initial={stage:navigation.stage(),reviewDisabled:buttons.review.disabled,fitDisabled:buttons.fit.disabled,hidden:Object.fromEntries(names.map(name=>[name,sections[name].hidden]))};
|
||||
navigation.sync({selectedCount:2,recovery:false});
|
||||
buttons.review.click();
|
||||
buttons.fit.click();
|
||||
process.stdout.write(JSON.stringify({initial,stage:navigation.stage(),pushes:history.pushes,selected:Object.fromEntries(names.map(name=>[name,buttons[name].attributes['aria-current']||null]))}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"initial": {
|
||||
"stage": "discover",
|
||||
"reviewDisabled": True,
|
||||
"fitDisabled": True,
|
||||
"hidden": {"discover": False, "review": True, "fit": True},
|
||||
},
|
||||
"stage": "fit",
|
||||
"pushes": [
|
||||
{"taskOverlay": "find", "findWorkStage": "review"},
|
||||
{"taskOverlay": "find", "findWorkStage": "fit"},
|
||||
],
|
||||
"selected": {"discover": None, "review": None, "fit": "step"},
|
||||
}
|
||||
|
||||
|
||||
def test_browser_back_moves_fit_to_review_to_discover_without_losing_selection():
|
||||
result = run_navigation("""
|
||||
const names=['discover','review','fit'];
|
||||
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const sections=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const events=new FakeEvents(); const changes=[];
|
||||
const history={state:{taskOverlay:'find'},pushState(state){this.state=state;},back(){}};
|
||||
const navigation=createNavigation({buttons,sections,history,eventTarget:events,onStageChange:stage=>changes.push(stage)});
|
||||
navigation.start(); navigation.sync({selectedCount:1,recovery:false}); navigation.go('review'); navigation.go('fit');
|
||||
events.emit('popstate',{state:{taskOverlay:'find',findWorkStage:'review'}});
|
||||
const afterFirst=navigation.stage();
|
||||
events.emit('popstate',{state:{taskOverlay:'find'}});
|
||||
process.stdout.write(JSON.stringify({afterFirst,afterSecond:navigation.stage(),changes,reviewDisabled:buttons.review.disabled,fitDisabled:buttons.fit.disabled}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"afterFirst": "review",
|
||||
"afterSecond": "discover",
|
||||
"changes": ["discover", "review", "fit", "review", "discover"],
|
||||
"reviewDisabled": False,
|
||||
"fitDisabled": False,
|
||||
}
|
||||
|
||||
|
||||
def test_reopening_restores_progress_but_completion_resets_to_discovery():
|
||||
result = run_navigation("""
|
||||
const names=['discover','review','fit'];
|
||||
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const sections=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const events=new FakeEvents();
|
||||
const history={state:{taskOverlay:'find',findWorkStage:'review'},replaced:[],pushState(state){this.state=state;},replaceState(state){this.state=state;this.replaced.push(state);},back(){}};
|
||||
const navigation=createNavigation({buttons,sections,history,eventTarget:events,onStageChange:()=>{}});
|
||||
navigation.start(); navigation.sync({selectedCount:1,recovery:false}); navigation.open();
|
||||
const restored=navigation.stage();
|
||||
navigation.complete();
|
||||
process.stdout.write(JSON.stringify({restored,completed:navigation.stage(),state:history.state,replaced:history.replaced}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"restored": "review",
|
||||
"completed": "discover",
|
||||
"state": {"taskOverlay": "find"},
|
||||
"replaced": [{"taskOverlay": "find"}],
|
||||
}
|
||||
|
||||
|
||||
def test_find_work_ships_guided_mobile_flow_in_the_offline_bundle():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
bundle = (FRONTEND.parent / "src" / "frontend_bundle.py").read_text()
|
||||
service_worker = (FRONTEND / "service-worker.js").read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert '<nav class="mobile-find-work-nav"' in html
|
||||
assert 'aria-label="Find Work steps"' in html
|
||||
for name, label in (("discover", "Discover"), ("review", "Review"), ("fit", "Fit & assign")):
|
||||
assert f'data-find-work-stage="{name}"' in html
|
||||
assert f">{label}</button>" in html
|
||||
assert f'id="find-work-{name}-stage"' in html
|
||||
assert 'id="find-work-review-list"' in html
|
||||
assert 'id="continue-find-work-fit"' in html
|
||||
assert '<script src="static/mobile-find-work-nav.js"></script>' in html
|
||||
assert '"static/mobile-find-work-nav.js"' in bundle
|
||||
assert "static/mobile-find-work-nav.js" in service_worker
|
||||
assert ".mobile-find-work-nav" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "createMobileFindWorkNavigation" in dashboard
|
||||
assert "findWorkNavigation?.sync" in dashboard
|
||||
|
|
@ -1019,6 +1019,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/mobile-review-detail-nav.js",
|
||||
"/dashboard/static/mobile-search-preview-nav.js",
|
||||
"/dashboard/static/mobile-plan-today-nav.js",
|
||||
"/dashboard/static/mobile-find-work-nav.js",
|
||||
"/dashboard/static/checklist-conflict.js",
|
||||
"/dashboard/static/voice-transcript-store.js",
|
||||
"/dashboard/static/voice-issue-capture.js",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user