Merge pull request 'Toggle issue checklist items from mobile detail' (#906) from timmy/905-mobile-checklist-toggle into main
This commit is contained in:
commit
88e5f3f33b
|
|
@ -639,6 +639,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.markdown-content .task-list { padding-left:0; list-style:none; }
|
.markdown-content .task-list { padding-left:0; list-style:none; }
|
||||||
.markdown-content .task-list-item { display:flex; gap:8px; align-items:flex-start; }
|
.markdown-content .task-list-item { display:flex; gap:8px; align-items:flex-start; }
|
||||||
.markdown-content .task-list-item input { flex:0 0 auto; margin-top:3px; }
|
.markdown-content .task-list-item input { flex:0 0 auto; margin-top:3px; }
|
||||||
|
.markdown-content .task-list-toggle { min-width:44px; min-height:44px; margin:-9px 0 -9px -9px; cursor:pointer; accent-color:#60a5fa; }
|
||||||
|
.markdown-content.checklist-pending .task-list-toggle { opacity:.65; cursor:wait; }
|
||||||
.markdown-content a { min-height:44px; display:inline-flex; align-items:center; max-width:100%; overflow-wrap:anywhere; }
|
.markdown-content a { min-height:44px; display:inline-flex; align-items:center; max-width:100%; overflow-wrap:anywhere; }
|
||||||
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
|
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
|
||||||
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||||
|
|
|
||||||
|
|
@ -3373,20 +3373,6 @@
|
||||||
.map(input => Number(input.value)).filter(Number.isInteger);
|
.map(input => Number(input.value)).filter(Number.isInteger);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderIssueMilestoneEditor(item, confirmedMilestone, milestones) {
|
|
||||||
const select = qs('#issue-milestone');
|
|
||||||
const status = qs('#issue-milestone-status');
|
|
||||||
select.innerHTML = '<option value="">No milestone</option>';
|
|
||||||
select.innerHTML += milestones.map(milestone =>
|
|
||||||
'<option value="' + Number(milestone.id) + '">' + escapeHtml(milestone.title) + '</option>'
|
|
||||||
).join('');
|
|
||||||
const draft = issueController.loadMilestoneDraft(item);
|
|
||||||
select.value = String(draft ?? confirmedMilestone?.id ?? '');
|
|
||||||
select.disabled = false;
|
|
||||||
qs('#save-issue-milestone').disabled = false;
|
|
||||||
status.textContent = confirmedMilestone ?
|
|
||||||
'Planned for ' + confirmedMilestone.title + '.' : 'No milestone set.';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadIssuePlanning() {
|
async function loadIssuePlanning() {
|
||||||
const item = selectedIssue;
|
const item = selectedIssue;
|
||||||
|
|
@ -3399,7 +3385,7 @@
|
||||||
const planning = await planningLoader.open(selectedIssue);
|
const planning = await planningLoader.open(selectedIssue);
|
||||||
if (selectedIssue !== item) return;
|
if (selectedIssue !== item) return;
|
||||||
renderIssueLabelEditor(item, detail.labels || [], planning.labels);
|
renderIssueLabelEditor(item, detail.labels || [], planning.labels);
|
||||||
renderIssueMilestoneEditor(item, detail.milestone, planning.milestones);
|
issueController.renderMilestoneEditor(item, detail.milestone, planning.milestones);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
if (selectedIssue !== item) return;
|
if (selectedIssue !== item) return;
|
||||||
qs('#issue-label-status').textContent = 'Labels could not be loaded.';
|
qs('#issue-label-status').textContent = 'Labels could not be loaded.';
|
||||||
|
|
@ -3408,6 +3394,23 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderIssueBody(detail) {
|
||||||
|
issueController.renderTasks(qs('#issue-sheet-body'), detail, !selectedIssueOffline &&
|
||||||
|
!issueController.readOnly(selectedIssue) && detail.state === 'open' && detail.updated_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyIssueContent(editing, detail, confirmed) {
|
||||||
|
const merged = issueController.mergeContent(
|
||||||
|
lastContextSnapshot, editing, detail, confirmed, buildMyWork.replaceIssueContent
|
||||||
|
);
|
||||||
|
lastContextSnapshot = merged.snapshot;
|
||||||
|
selectedIssue = merged.item;
|
||||||
|
selectedIssueDetail = merged.detail;
|
||||||
|
qs('#issue-sheet-title').textContent = confirmed.title;
|
||||||
|
renderIssueBody(selectedIssueDetail);
|
||||||
|
if (lastContextSnapshot) paintMyWork(lastContextSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
async function openIssueSheet(item, trigger, offlineDetail = null) {
|
async function openIssueSheet(item, trigger, offlineDetail = null) {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
const readOnly = issueController.readOnly(item);
|
const readOnly = issueController.readOnly(item);
|
||||||
|
|
@ -3493,7 +3496,7 @@
|
||||||
renderPlanIssueDependencies(detail);
|
renderPlanIssueDependencies(detail);
|
||||||
issueConversation = issueController.conversation(item, detail.conversation);
|
issueConversation = issueController.conversation(item, detail.conversation);
|
||||||
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
|
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
|
||||||
qs('#issue-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
|
renderIssueBody(detail);
|
||||||
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
|
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
|
||||||
'<span class="pill">' + escapeHtml(label) + '</span>'
|
'<span class="pill">' + escapeHtml(label) + '</span>'
|
||||||
).join(' ');
|
).join(' ');
|
||||||
|
|
@ -5593,6 +5596,12 @@
|
||||||
qs('#retry-issue-load').addEventListener('click', () => {
|
qs('#retry-issue-load').addEventListener('click', () => {
|
||||||
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
|
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
|
||||||
});
|
});
|
||||||
|
issueController.bindTaskToggles({
|
||||||
|
container:qs('#issue-sheet-body'), status:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'),
|
||||||
|
current:()=>({item:selectedIssue,detail:selectedIssueDetail}),
|
||||||
|
confirmed:applyIssueContent,
|
||||||
|
restore:renderIssueBody,
|
||||||
|
});
|
||||||
qs('#issue-planning').addEventListener('toggle', event => {
|
qs('#issue-planning').addEventListener('toggle', event => {
|
||||||
if (event.currentTarget.open) loadIssuePlanning();
|
if (event.currentTarget.open) loadIssuePlanning();
|
||||||
});
|
});
|
||||||
|
|
@ -5653,14 +5662,7 @@
|
||||||
qs('#issue-edit-status').textContent = 'Saving issue…';
|
qs('#issue-edit-status').textContent = 'Saving issue…';
|
||||||
try {
|
try {
|
||||||
const confirmed = await issueController.updateContent(editing, draft);
|
const confirmed = await issueController.updateContent(editing, draft);
|
||||||
lastContextSnapshot = buildMyWork.replaceIssueContent(
|
applyIssueContent(editing, selectedIssueDetail, confirmed);
|
||||||
lastContextSnapshot, editing.repository, editing.number, confirmed
|
|
||||||
);
|
|
||||||
selectedIssue = { ...editing, ...confirmed, key: editing.key };
|
|
||||||
selectedIssueDetail = { ...selectedIssueDetail, ...confirmed };
|
|
||||||
qs('#issue-sheet-title').textContent = confirmed.title;
|
|
||||||
qs('#issue-sheet-body').innerHTML = renderMarkdown(confirmed.body || 'No description provided.');
|
|
||||||
paintMyWork(lastContextSnapshot);
|
|
||||||
if (issueEditHistoryActive) history.back();
|
if (issueEditHistoryActive) history.back();
|
||||||
else qs('#issue-edit-form').hidden = true;
|
else qs('#issue-edit-form').hidden = true;
|
||||||
qs('#issue-sheet-status').textContent = 'Issue saved.';
|
qs('#issue-sheet-status').textContent = 'Issue saved.';
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,13 @@ function createPlanningLoader({ loadLabels, loadMilestones }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
function escapeOptionHtml(value) {
|
||||||
|
return String(value || '').replace(/[&<>"']/g, character => ({
|
||||||
|
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
||||||
|
})[character]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||||
let commentRequest = null;
|
let commentRequest = null;
|
||||||
let closeRequest = null;
|
let closeRequest = null;
|
||||||
let releaseRequest = null;
|
let releaseRequest = null;
|
||||||
|
|
@ -138,6 +144,66 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||||
}).finally(() => { editRequest = null; });
|
}).finally(() => { editRequest = null; });
|
||||||
return editRequest;
|
return editRequest;
|
||||||
},
|
},
|
||||||
|
toggleTask(item, detail, taskIndex, checked) {
|
||||||
|
if (typeof toggleTask !== 'function') return Promise.reject(new Error('Checklist updates are unavailable.'));
|
||||||
|
return this.updateContent(item, {
|
||||||
|
title: detail.title,
|
||||||
|
body: toggleTask(detail.body, taskIndex, checked),
|
||||||
|
expectedUpdatedAt: detail.updated_at,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
bindTaskToggles({ container, status, retry, current, confirmed, restore }) {
|
||||||
|
container.addEventListener('change', async event => {
|
||||||
|
const control = event.target.closest('input.task-list-toggle');
|
||||||
|
const state = current();
|
||||||
|
if (!control || !state?.item || !state?.detail?.updated_at) return;
|
||||||
|
container.classList.add('checklist-pending');
|
||||||
|
container.querySelectorAll('input.task-list-toggle').forEach(input => { input.disabled = true; });
|
||||||
|
status.textContent = 'Updating checklist…';
|
||||||
|
try {
|
||||||
|
const result = await this.toggleTask(
|
||||||
|
state.item, state.detail, Number(control.dataset.taskIndex), control.checked
|
||||||
|
);
|
||||||
|
const latest = current();
|
||||||
|
if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) {
|
||||||
|
confirmed(state.item, state.detail, result);
|
||||||
|
status.textContent = 'Checklist updated.';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const latest = current();
|
||||||
|
if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) {
|
||||||
|
restore(state.detail);
|
||||||
|
status.textContent = error.message + ' Checklist was not changed; reload latest or use Edit issue.';
|
||||||
|
retry.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
renderTasks(container, detail, interactive) {
|
||||||
|
container.classList.remove('checklist-pending');
|
||||||
|
container.innerHTML = renderMarkdown(
|
||||||
|
detail.body || 'No description provided.', { interactiveTasks: Boolean(interactive) }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
mergeContent(snapshot, item, detail, confirmed, replace) {
|
||||||
|
return {
|
||||||
|
snapshot: snapshot ? replace(snapshot, item.repository, item.number, confirmed) : snapshot,
|
||||||
|
item: { ...item, ...confirmed, key:item.key },
|
||||||
|
detail: { ...detail, ...confirmed },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
renderMilestoneEditor(item, confirmed, milestones, document = globalThis.document) {
|
||||||
|
const select = document.querySelector('#issue-milestone');
|
||||||
|
const status = document.querySelector('#issue-milestone-status');
|
||||||
|
select.innerHTML = '<option value="">No milestone</option>' + milestones.map(milestone =>
|
||||||
|
'<option value="' + Number(milestone.id) + '">' + escapeOptionHtml(milestone.title) + '</option>'
|
||||||
|
).join('');
|
||||||
|
select.value = String(this.loadMilestoneDraft(item) ?? confirmed?.id ?? '');
|
||||||
|
select.disabled = false;
|
||||||
|
document.querySelector('#save-issue-milestone').disabled = false;
|
||||||
|
status.textContent = confirmed ? 'Planned for ' + confirmed.title + '.' : 'No milestone set.';
|
||||||
|
},
|
||||||
|
|
||||||
loadDueDateDraft(item) {
|
loadDueDateDraft(item) {
|
||||||
try {
|
try {
|
||||||
const raw = storage?.getItem(dueDateDraftKey(item));
|
const raw = storage?.getItem(dueDateDraftKey(item));
|
||||||
|
|
|
||||||
|
|
@ -32,23 +32,28 @@
|
||||||
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index) => code[Number(index)]);
|
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index) => code[Number(index)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderList(lines) {
|
function renderList(lines, options, firstTaskIndex) {
|
||||||
const taskList = lines.every(line => /^[-*+]\s+\[[ xX]\]\s+/.test(line));
|
const taskList = lines.every(line => /^[-*+]\s+\[[ xX]\]\s+/.test(line));
|
||||||
const items = lines.map(line => {
|
const items = lines.map((line, offset) => {
|
||||||
let body = line.replace(/^[-*+]\s+/, '');
|
let body = line.replace(/^[-*+]\s+/, '');
|
||||||
if (!taskList) return '<li>' + renderInline(body) + '</li>';
|
if (!taskList) return '<li>' + renderInline(body) + '</li>';
|
||||||
const checked = /^\[[xX]\]\s+/.test(body);
|
const checked = /^\[[xX]\]\s+/.test(body);
|
||||||
body = body.replace(/^\[[ xX]\]\s+/, '');
|
body = body.replace(/^\[[ xX]\]\s+/, '');
|
||||||
return '<li class="task-list-item"><input type="checkbox" disabled' +
|
const control = options.interactiveTasks ?
|
||||||
|
' class="task-list-toggle" data-task-index="' + (firstTaskIndex + offset) +
|
||||||
|
'" aria-label="Mark ' + escapeHtml(body) + (checked ? ' incomplete"' : ' complete"') :
|
||||||
|
' disabled';
|
||||||
|
return '<li class="task-list-item"><input type="checkbox"' + control +
|
||||||
(checked ? ' checked' : '') + '> ' + renderInline(body) + '</li>';
|
(checked ? ' checked' : '') + '> ' + renderInline(body) + '</li>';
|
||||||
}).join('');
|
}).join('');
|
||||||
return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>';
|
return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>';
|
||||||
}
|
}
|
||||||
|
|
||||||
return function renderMarkdown(raw) {
|
function renderMarkdown(raw, options = {}) {
|
||||||
const lines = String(raw || '').replace(/\r\n?/g, '\n').split('\n');
|
const lines = String(raw || '').replace(/\r\n?/g, '\n').split('\n');
|
||||||
const output = [];
|
const output = [];
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
let taskIndex = 0;
|
||||||
while (index < lines.length) {
|
while (index < lines.length) {
|
||||||
const line = lines[index];
|
const line = lines[index];
|
||||||
if (!line.trim()) {
|
if (!line.trim()) {
|
||||||
|
|
@ -90,7 +95,10 @@
|
||||||
list.push(lines[index]);
|
list.push(lines[index]);
|
||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
output.push(renderList(list));
|
output.push(renderList(list, options, taskIndex));
|
||||||
|
if (list.every(candidate => /^[-*+]\s+\[[ xX]\]\s+/.test(candidate))) {
|
||||||
|
taskIndex += list.length;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const paragraph = [];
|
const paragraph = [];
|
||||||
|
|
@ -103,5 +111,29 @@
|
||||||
output.push('<p>' + renderInline(paragraph.join('\n')).replace(/\n/g, '<br>') + '</p>');
|
output.push('<p>' + renderInline(paragraph.join('\n')).replace(/\n/g, '<br>') + '</p>');
|
||||||
}
|
}
|
||||||
return output.join('');
|
return output.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMarkdown.toggleTask = function toggleTask(raw, targetIndex, checked) {
|
||||||
|
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
|
||||||
|
let fenced = false;
|
||||||
|
let taskIndex = 0;
|
||||||
|
for (let index = 0; index < parts.length; index += 2) {
|
||||||
|
const line = parts[index];
|
||||||
|
if (/^\s*```/.test(line)) {
|
||||||
|
fenced = !fenced;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fenced) continue;
|
||||||
|
const task = line.match(/^([-*+]\s+\[)([ xX])(\])/);
|
||||||
|
if (!task) continue;
|
||||||
|
if (taskIndex === Number(targetIndex)) {
|
||||||
|
parts[index] = task[1] + (checked ? 'x' : ' ') + task[3] + line.slice(task[0].length);
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
taskIndex += 1;
|
||||||
|
}
|
||||||
|
return parts.join('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return renderMarkdown;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/private-data-registry.js');
|
importScripts(BASE + 'static/private-data-registry.js');
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v106';
|
const CACHE = 'stackchain-dashboard-shell-v107';
|
||||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
|
|
|
||||||
|
|
@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||||
assert '.update-reply-actions button { min-height:44px;' in html
|
assert '.update-reply-actions button { min-height:44px;' in html
|
||||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
assert "stackchain-dashboard-shell-v106" in worker
|
assert "stackchain-dashboard-shell-v107" in worker
|
||||||
|
|
|
||||||
|
|
@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v106" in source
|
assert "stackchain-dashboard-shell-v107" in source
|
||||||
assert "BASE + 'static/later-sync.js'" in source
|
assert "BASE + 'static/later-sync.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,11 @@ FRONTEND = Path(__file__).parent.parent / "frontend"
|
||||||
RENDERER = FRONTEND / "markdown.js"
|
RENDERER = FRONTEND / "markdown.js"
|
||||||
|
|
||||||
|
|
||||||
def render_markdown(payload):
|
def render_markdown(payload, options=None):
|
||||||
|
options = options or {}
|
||||||
script = (
|
script = (
|
||||||
f"const render = require({json.dumps(str(RENDERER))});"
|
f"const render = require({json.dumps(str(RENDERER))});"
|
||||||
f"process.stdout.write(render({json.dumps(payload)}));"
|
f"process.stdout.write(render({json.dumps(payload)}, {json.dumps(options)}));"
|
||||||
)
|
)
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
["node", "-e", script],
|
["node", "-e", script],
|
||||||
|
|
@ -23,6 +24,22 @@ def render_markdown(payload):
|
||||||
).stdout
|
).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_task(payload, task_index, checked):
|
||||||
|
script = (
|
||||||
|
f"const render = require({json.dumps(str(RENDERER))});"
|
||||||
|
f"process.stdout.write(JSON.stringify(render.toggleTask({json.dumps(payload)}, "
|
||||||
|
f"{task_index}, {json.dumps(checked)})));"
|
||||||
|
)
|
||||||
|
return json.loads(
|
||||||
|
subprocess.run(
|
||||||
|
["node", "-e", script],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
).stdout
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ScriptSourceParser(HTMLParser):
|
class ScriptSourceParser(HTMLParser):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
@ -81,6 +98,34 @@ def test_markdown_renderer_preserves_mobile_reading_structure():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_interactive_markdown_tasks_expose_accessible_source_indices():
|
||||||
|
rendered = render_markdown(
|
||||||
|
"- [ ] Verify production\n- [x] Notify support",
|
||||||
|
{"interactiveTasks": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rendered == (
|
||||||
|
'<ul class="task-list"><li class="task-list-item">'
|
||||||
|
'<input type="checkbox" class="task-list-toggle" data-task-index="0" '
|
||||||
|
'aria-label="Mark Verify production complete"> Verify production</li>'
|
||||||
|
'<li class="task-list-item"><input type="checkbox" class="task-list-toggle" '
|
||||||
|
'data-task-index="1" aria-label="Mark Notify support incomplete" checked> '
|
||||||
|
'Notify support</li></ul>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_interactive_task_toggle_changes_exact_source_marker_only():
|
||||||
|
body = (
|
||||||
|
"```md\r\n- [ ] example only\r\n```\r\n"
|
||||||
|
"- [ ] Duplicate\r\n - [X] Nested duplicate\r\n- [ ] Duplicate\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert toggle_task(body, 1, True) == (
|
||||||
|
"```md\r\n- [ ] example only\r\n```\r\n"
|
||||||
|
"- [ ] Duplicate\r\n - [X] Nested duplicate\r\n- [x] Duplicate\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_markdown_renderer_allows_only_safe_links_and_keeps_html_inert():
|
def test_markdown_renderer_allows_only_safe_links_and_keeps_html_inert():
|
||||||
rendered = render_markdown(
|
rendered = render_markdown(
|
||||||
"[Forge](https://forge.example/work?q=1&safe=yes) "
|
"[Forge](https://forge.example/work?q=1&safe=yes) "
|
||||||
|
|
@ -110,12 +155,12 @@ def test_all_read_only_work_bodies_use_the_shared_markdown_renderer():
|
||||||
"renderMarkdown(detail.body || 'No description provided.')",
|
"renderMarkdown(detail.body || 'No description provided.')",
|
||||||
"renderMarkdown(item.body || 'No description provided.')",
|
"renderMarkdown(item.body || 'No description provided.')",
|
||||||
"renderMarkdown(review.body)",
|
"renderMarkdown(review.body)",
|
||||||
"renderMarkdown(confirmed.body || 'No description provided.')",
|
"issueController.renderTasks(qs('#issue-sheet-body'), detail, !selectedIssueOffline",
|
||||||
)
|
)
|
||||||
for path in expected_paths:
|
for path in expected_paths:
|
||||||
assert path in dashboard
|
assert path in dashboard
|
||||||
|
|
||||||
assert dashboard.count("renderMarkdown(detail.body || 'No description provided.')") == 4
|
assert dashboard.count("renderMarkdown(detail.body || 'No description provided.')") == 3
|
||||||
|
|
||||||
|
|
||||||
def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||||
|
|
@ -137,4 +182,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" in css
|
assert ".markdown-content a { min-height:44px;" in css
|
||||||
assert "stackchain-dashboard-shell-v106" in worker
|
assert "stackchain-dashboard-shell-v107" in worker
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||||
|
|
||||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||||
assert "stackchain-dashboard-shell-v106" in worker
|
assert "stackchain-dashboard-shell-v107" in worker
|
||||||
|
|
||||||
|
|
||||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||||
assert "promptStorage:localStorage" in dashboard
|
assert "promptStorage:localStorage" in dashboard
|
||||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||||
assert "stackchain-dashboard-shell-v106" in worker
|
assert "stackchain-dashboard-shell-v107" in worker
|
||||||
assert ".device-setup-panel" in css
|
assert ".device-setup-panel" in css
|
||||||
assert ".device-readiness-card" in css
|
assert ".device-readiness-card" in css
|
||||||
assert "overflow-x:hidden" in css
|
assert "overflow-x:hidden" in css
|
||||||
|
|
|
||||||
|
|
@ -283,4 +283,4 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||||
assert "stackchain-dashboard-shell-v106" in service_worker
|
assert "stackchain-dashboard-shell-v107" in service_worker
|
||||||
|
|
|
||||||
|
|
@ -2201,6 +2201,65 @@ async def test_mobile_issue_sheet_manages_blockers_with_search_and_touch_safe_co
|
||||||
assert "overflow-wrap:anywhere" in html
|
assert "overflow-wrap:anywhere" in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_issue_detail_toggles_checklist_with_touch_safe_recovery():
|
||||||
|
html = await dashboard()
|
||||||
|
controller = ISSUE_SHEET.read_text()
|
||||||
|
|
||||||
|
assert "issueController.renderTasks(qs('#issue-sheet-body'), detail, !selectedIssueOffline" in html
|
||||||
|
assert "issueController.bindTaskToggles({" in html
|
||||||
|
assert "container.addEventListener('change', async event =>" in controller
|
||||||
|
assert "event.target.closest('input.task-list-toggle')" in controller
|
||||||
|
assert "state.item, state.detail, Number(control.dataset.taskIndex), control.checked" in controller
|
||||||
|
assert "current:()=>({item:selectedIssue,detail:selectedIssueDetail})" in html
|
||||||
|
assert "buildMyWork.replaceIssueContent" in html
|
||||||
|
assert "status.textContent = 'Checklist updated.'" in controller
|
||||||
|
assert "restore(state.detail)" in controller
|
||||||
|
assert "error.message + ' Checklist was not changed; reload latest or use Edit issue.'" in controller
|
||||||
|
assert '.task-list-toggle { min-width:44px; min-height:44px;' in html
|
||||||
|
assert '.checklist-pending .task-list-toggle { opacity:.65;' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_checklist_toggle_submits_exact_revision_checked_body_once():
|
||||||
|
script = f"""
|
||||||
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||||
|
let calls = [];
|
||||||
|
let finish;
|
||||||
|
const controller = createIssueSheet({{
|
||||||
|
storage:null,
|
||||||
|
toggleTask:(body, index, checked) => body.replace(index === 1 ? '[X]' : '[ ]', checked ? '[x]' : '[ ]'),
|
||||||
|
fetchJson:(url, options) => {{
|
||||||
|
calls.push({{url, options}});
|
||||||
|
return new Promise(resolve => {{ finish = resolve; }});
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
const item = {{repository:'stackchain/api', number:17}};
|
||||||
|
const detail = {{title:'Release', body:'- [ ] Build\\n- [X] Ship', updated_at:'2026-08-15T10:00:00Z'}};
|
||||||
|
const first = controller.toggleTask(item, detail, 1, true);
|
||||||
|
const duplicate = controller.toggleTask(item, detail, 1, true);
|
||||||
|
finish({{number:17,title:'Release',body:'- [ ] Build\\n- [x] Ship',updated_at:'2026-08-15T10:01:00Z'}});
|
||||||
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||||
|
calls:calls.map(call => ({{url:call.url,body:JSON.parse(call.options.body)}})),
|
||||||
|
same:first === duplicate,
|
||||||
|
confirmed:results[0],
|
||||||
|
}})));
|
||||||
|
"""
|
||||||
|
output = json.loads(subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
).stdout)
|
||||||
|
|
||||||
|
assert output["same"] is True
|
||||||
|
assert output["calls"] == [{
|
||||||
|
"url": "api/v1/repos/stackchain/api/issues/17/content",
|
||||||
|
"body": {
|
||||||
|
"title": "Release",
|
||||||
|
"body": "- [ ] Build\n- [x] Ship",
|
||||||
|
"expected_updated_at": "2026-08-15T10:00:00Z",
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
assert output["confirmed"]["updated_at"] == "2026-08-15T10:01:00Z"
|
||||||
|
|
||||||
|
|
||||||
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
|
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||||
|
|
@ -4863,7 +4922,7 @@ async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
||||||
assert 'id="retry-issue-load" type="button" hidden>Reload latest issue</button>' in html
|
assert 'id="retry-issue-load" type="button" hidden>Reload latest issue</button>' in html
|
||||||
assert 'id="issue-edit-status" class="small" aria-live="assertive"' in html
|
assert 'id="issue-edit-status" class="small" aria-live="assertive"' in html
|
||||||
assert '.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px;' in html
|
assert '.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px;' in html
|
||||||
assert 'buildMyWork.replaceIssueContent(' in html
|
assert 'buildMyWork.replaceIssueContent' in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
|
|
@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||||
source = SERVICE_WORKER.read_text()
|
source = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v106" in source
|
assert "stackchain-dashboard-shell-v107" in source
|
||||||
assert "BASE + 'static/plan-today.js'" in source
|
assert "BASE + 'static/plan-today.js'" in source
|
||||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ async function dispatchPush(payload) {{
|
||||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v106" in source
|
assert "stackchain-dashboard-shell-v107" in source
|
||||||
assert "BASE + 'static/my-work.js'" in source
|
assert "BASE + 'static/my-work.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
|
|
@ -164,14 +164,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v106" in source
|
assert "stackchain-dashboard-shell-v107" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_offline_review_next_ships_today_completion_atomically():
|
def test_offline_review_next_ships_today_completion_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v106" in source
|
assert "stackchain-dashboard-shell-v107" in source
|
||||||
assert "BASE + 'static/today-completion.js'" in source
|
assert "BASE + 'static/today-completion.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -179,7 +179,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v106" in source
|
assert "stackchain-dashboard-shell-v107" in source
|
||||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -187,14 +187,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v106" in source
|
assert "stackchain-dashboard-shell-v107" in source
|
||||||
assert "BASE + 'static/later-picker.js'" in source
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v106" in source
|
assert "stackchain-dashboard-shell-v107" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
@ -203,21 +203,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v106" in source
|
assert "stackchain-dashboard-shell-v107" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v106" in source
|
assert "stackchain-dashboard-shell-v107" in source
|
||||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v106" in source
|
assert "stackchain-dashboard-shell-v107" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -881,7 +881,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v106" in source
|
assert "stackchain-dashboard-shell-v107" in source
|
||||||
assert "BASE + 'static/queue-today.js'" in source
|
assert "BASE + 'static/queue-today.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||||
def test_readiness_runtime_is_available_in_offline_shell():
|
def test_readiness_runtime_is_available_in_offline_shell():
|
||||||
service_worker = SERVICE_WORKER.read_text()
|
service_worker = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "const CACHE = 'stackchain-dashboard-shell-v106';" in service_worker
|
assert "const CACHE = 'stackchain-dashboard-shell-v107';" in service_worker
|
||||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v106" in source
|
assert "stackchain-dashboard-shell-v107" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user