File mobile Drafts in one sequential session #630
|
|
@ -211,6 +211,10 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
|
||||
.create-issue-actions #create-issue-status { grid-column:1/-1; }
|
||||
.draft-filing-session { position:sticky; top:0; z-index:2; display:grid; gap:6px; margin-bottom:12px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); }
|
||||
.draft-filing-session[hidden] { display:none; }
|
||||
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.draft-filing-session-actions button { min-height:44px; width:100%; }
|
||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
|
|
|
|||
|
|
@ -407,7 +407,13 @@
|
|||
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
|
||||
getCurrentLogin: () => activeFlushLogin,
|
||||
});
|
||||
let resumedUnfiledCaptureId = '';
|
||||
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
|
||||
dFS.attach(qs, {
|
||||
captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController,
|
||||
getLogin:()=>activeFlushLogin, setResumedId:id=>{ rUC = id; },
|
||||
openSheet:openCreateIssueSheet, setFilingMode:setIssueFilingMode,
|
||||
});
|
||||
let rUC = '';
|
||||
let backgroundIssueSync = null;
|
||||
if ('indexedDB' in window) {
|
||||
const backgroundIssueStore = createIssueSyncStore();
|
||||
|
|
@ -2118,12 +2124,8 @@
|
|||
if (!item) return;
|
||||
if (item.kind === 'unfiled-issue') {
|
||||
try {
|
||||
const resumed = await unfiledCaptures.resume(item.capture_id, activeFlushLogin);
|
||||
issueCapture.saveDraft(resumed);
|
||||
resumedUnfiledCaptureId = item.capture_id;
|
||||
await openCreateIssueSheet();
|
||||
if (resumed.attachment) createIssueAttachmentController.restore(resumed.attachment);
|
||||
qs('#create-issue-status').textContent = 'Capture restored. Choose a repository to file it.';
|
||||
dFS.start(item.capture_id);
|
||||
await dFS.nextCapture();
|
||||
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
|
||||
} else if (item.kind === 'new-issue') openCreateIssueSheet();
|
||||
else if (item.route) workRoute.open(item.route);
|
||||
|
|
@ -4053,6 +4055,7 @@
|
|||
}
|
||||
});
|
||||
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
||||
dFS.bind();
|
||||
qs('#file-new-issue').addEventListener('click', () => {
|
||||
if (!qs('#create-issue-title').value.trim()) {
|
||||
qs('#create-issue-capture-status').textContent = 'Add a title before filing.';
|
||||
|
|
@ -4073,9 +4076,9 @@
|
|||
};
|
||||
if (showDraftCapacityDialog(unfiledCaptures)) return;
|
||||
const savedCapture = await unfiledCaptures.save(captureDraft);
|
||||
if (resumedUnfiledCaptureId) {
|
||||
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
|
||||
resumedUnfiledCaptureId = '';
|
||||
if (rUC) {
|
||||
await unfiledCaptures.completeResume(rUC);
|
||||
rUC = '';
|
||||
}
|
||||
issueCapture.clearDraft();
|
||||
qs('#create-issue-title').value = '';
|
||||
|
|
@ -4222,9 +4225,14 @@
|
|||
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
||||
await issueOutbox.enqueueDurably(durableDraft);
|
||||
const queued = admission.item;
|
||||
if (resumedUnfiledCaptureId && (!durableDraft.attachment || admission.background)) {
|
||||
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
|
||||
resumedUnfiledCaptureId = '';
|
||||
const fS = dFS.current();
|
||||
if (rUC && (!durableDraft.attachment || admission.background)) {
|
||||
await unfiledCaptures.completeResume(rUC);
|
||||
rUC = '';
|
||||
}
|
||||
if (fS && !rUC && await dFS.advance(fS.id)) {
|
||||
refreshMyWorkView();
|
||||
return;
|
||||
}
|
||||
if (!admission.background) {
|
||||
editingOutboxId = queued.id;
|
||||
|
|
|
|||
92
frontend/draft-filing-session.js
Normal file
92
frontend/draft-filing-session.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
function createDraftFilingSession({list}) {
|
||||
let orderedIds = [];
|
||||
let currentId = '';
|
||||
|
||||
function visibleIds() {
|
||||
const available = new Set(list().map(item => item.id));
|
||||
orderedIds = orderedIds.filter(id => available.has(id));
|
||||
for (const item of list()) {
|
||||
if (!orderedIds.includes(item.id)) orderedIds.push(item.id);
|
||||
}
|
||||
return orderedIds;
|
||||
}
|
||||
|
||||
function state() {
|
||||
const ids = visibleIds();
|
||||
if (!ids.length) {
|
||||
currentId = '';
|
||||
return null;
|
||||
}
|
||||
if (!ids.includes(currentId)) currentId = ids[0];
|
||||
const index = ids.indexOf(currentId);
|
||||
return {id:currentId, position:index + 1, total:ids.length, remaining:ids.length - index - 1};
|
||||
}
|
||||
|
||||
function start(id) {
|
||||
orderedIds = list().map(item => item.id);
|
||||
if (!orderedIds.includes(id)) throw new Error('This capture is no longer available.');
|
||||
currentId = id;
|
||||
return state();
|
||||
}
|
||||
|
||||
function move(offset) {
|
||||
const ids = visibleIds();
|
||||
if (!ids.length) return state();
|
||||
const index = Math.max(0, ids.indexOf(currentId));
|
||||
currentId = ids[(index + offset + ids.length) % ids.length];
|
||||
return state();
|
||||
}
|
||||
|
||||
function removeCurrentAndNext(removedId) {
|
||||
const previousIds = orderedIds.slice();
|
||||
const removedIndex = Math.max(0, previousIds.indexOf(removedId));
|
||||
orderedIds = previousIds.filter(id => id !== removedId);
|
||||
const ids = visibleIds();
|
||||
if (!ids.length) return state();
|
||||
currentId = ids[Math.min(removedIndex, ids.length - 1)];
|
||||
return state();
|
||||
}
|
||||
|
||||
const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext};
|
||||
session.attach = (qs, dependencies) => {
|
||||
const openCapture = async captureId => {
|
||||
const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin());
|
||||
dependencies.issueCapture.saveDraft(resumed);
|
||||
dependencies.setResumedId(captureId);
|
||||
await dependencies.openSheet();
|
||||
dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment);
|
||||
dependencies.setFilingMode(true);
|
||||
session.render();
|
||||
};
|
||||
return Object.assign(session, {
|
||||
nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; },
|
||||
render() {
|
||||
const state = session.current();
|
||||
qs('#draft-filing-session').hidden = !state;
|
||||
if (!state) return;
|
||||
qs('#draft-filing-progress').textContent = `Draft ${state.position} of ${state.total}`;
|
||||
qs('#draft-filing-remaining').textContent = state.remaining ? `${state.remaining} remaining` : 'Last Draft';
|
||||
qs('#previous-draft').disabled = qs('#skip-draft').disabled = state.total < 2;
|
||||
qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft';
|
||||
},
|
||||
bind() {
|
||||
[['#previous-draft','previous'],['#skip-draft','next']].forEach(([selector, method]) =>
|
||||
qs(selector).addEventListener('click', async () => {
|
||||
const state = session[method]();
|
||||
if (state) await openCapture(state.id);
|
||||
})
|
||||
);
|
||||
},
|
||||
async advance(completedId) {
|
||||
const state = session.removeCurrentAndNext(completedId);
|
||||
if (state) { await openCapture(state.id); return true; }
|
||||
qs('#draft-filing-session').hidden = true;
|
||||
qs('#submit-new-issue').textContent = 'Create & assign to me';
|
||||
return false;
|
||||
},
|
||||
});
|
||||
};
|
||||
return session;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftFilingSession;
|
||||
|
|
@ -550,6 +550,14 @@
|
|||
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
|
||||
</section>
|
||||
<section class="create-issue-filing" id="create-issue-filing" hidden>
|
||||
<aside class="draft-filing-session" id="draft-filing-session" aria-live="polite" hidden>
|
||||
<strong id="draft-filing-progress"></strong>
|
||||
<span class="small" id="draft-filing-remaining"></span>
|
||||
<div class="draft-filing-session-actions">
|
||||
<button id="previous-draft" type="button">Previous</button>
|
||||
<button id="skip-draft" type="button">Skip</button>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="create-issue-repository-picker">
|
||||
<label for="create-issue-repository-search">Repository search
|
||||
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
|
||||
|
|
@ -870,6 +878,7 @@
|
|||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
<script src="static/unfiled-captures.js"></script>
|
||||
<script src="static/draft-filing-session.js"></script>
|
||||
<script src="static/draft-capacity-dialog.js"></script>
|
||||
<script src="static/outbox-coordinator.js"></script>
|
||||
<script src="static/background-issue-sync.js"></script>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const SHELL = [
|
|||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/unfiled-captures.js',
|
||||
BASE + 'static/draft-filing-session.js',
|
||||
BASE + 'static/draft-capacity-dialog.js',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ FEATURE_SOURCES = {
|
|||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/unfiled-captures.js", "static/draft-capacity-dialog.js",
|
||||
"static/today-rollover.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
|
|
|
|||
|
|
@ -684,6 +684,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
"/dashboard/static/unfiled-captures.js",
|
||||
"/dashboard/static/draft-filing-session.js",
|
||||
"/dashboard/static/draft-capacity-dialog.js",
|
||||
"/dashboard/static/outbox-coordinator.js",
|
||||
"/dashboard/static/issue-outbox.js",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from tests.dashboard_bundle import dashboard
|
|||
|
||||
|
||||
UNFILED = Path(__file__).parents[1] / "frontend" / "unfiled-captures.js"
|
||||
DRAFT_SESSION = Path(__file__).parents[1] / "frontend" / "draft-filing-session.js"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
|
|
@ -15,6 +16,28 @@ def run_node(script: str):
|
|||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_draft_filing_session_navigates_stable_capture_ids_and_reconciles_removed_items():
|
||||
script = f"""
|
||||
const createDraftFilingSession = require({json.dumps(str(DRAFT_SESSION))});
|
||||
let captures = [{{id:'newest'}}, {{id:'middle'}}, {{id:'oldest'}}];
|
||||
const session = createDraftFilingSession({{list:()=>captures}});
|
||||
const opened = session.start('middle');
|
||||
const skipped = session.next();
|
||||
captures = [{{id:'newest'}}];
|
||||
const afterFiled = session.removeCurrentAndNext('oldest');
|
||||
const previous = session.previous();
|
||||
process.stdout.write(JSON.stringify({{opened,skipped,afterFiled,previous}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"opened": {"id": "middle", "position": 2, "total": 3, "remaining": 1},
|
||||
"skipped": {"id": "oldest", "position": 3, "total": 3, "remaining": 0},
|
||||
"afterFiled": {"id": "newest", "position": 1, "total": 1, "remaining": 0},
|
||||
"previous": {"id": "newest", "position": 1, "total": 1, "remaining": 0},
|
||||
}
|
||||
|
||||
|
||||
def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
|
|
@ -335,13 +358,13 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
|
|||
assert "requestAnimationFrame(() =>" in html
|
||||
assert "savedCard?.scrollIntoView({block:'nearest'})" in html
|
||||
assert "Saved to Drafts. Choose a repository when you’re ready to file it." in html
|
||||
assert "await unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
|
||||
assert "await dependencies.captures.resume(captureId, dependencies.getLogin())" in DRAFT_SESSION.read_text()
|
||||
assert "await createIssueAttachmentController.serialize()" in html
|
||||
assert "createUnfiledAttachmentStore()" in html
|
||||
assert "createIssueAttachmentController.restore(resumed.attachment)" in html
|
||||
assert "await unfiledCaptures.completeResume(resumedUnfiledCaptureId)" in html
|
||||
assert "dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment)" in DRAFT_SESSION.read_text()
|
||||
assert "await unfiledCaptures.completeResume(rUC)" in html
|
||||
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
|
||||
assert "issueCapture.saveDraft(resumed)" in html
|
||||
assert "dependencies.issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
|
||||
assert "item.kind === 'unfiled-issue'" in html
|
||||
assert '.create-issue-actions button { min-height:44px;' in html
|
||||
assert '.draft-card:focus-visible' in html
|
||||
|
|
@ -349,6 +372,25 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
|
|||
assert '@media(max-width:320px)' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_drafts_expose_a_safe_sequential_filing_session():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/draft-filing-session.js"></script>' in html
|
||||
assert 'id="draft-filing-session"' in html
|
||||
assert 'id="draft-filing-progress"' in html
|
||||
assert 'id="previous-draft"' in html
|
||||
assert 'id="skip-draft"' in html
|
||||
assert "dFS.start(item.capture_id)" in html
|
||||
assert "await dFS.nextCapture()" in html
|
||||
assert "await dFS.advance(fS.id)" in html
|
||||
feature = DRAFT_SESSION.read_text()
|
||||
assert "Draft ${state.position} of ${state.total}" in feature
|
||||
assert "qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft'" in feature
|
||||
assert '.draft-filing-session-actions button { min-height:44px;' in html
|
||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision():
|
||||
html = await dashboard()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user