Compare commits
No commits in common. "fa3e5e3bf0adeee9205943f5b010ab892f769a6e" and "741c27eacfdf1c4185fb0db82ffe308e0f508ead" have entirely different histories.
fa3e5e3bf0
...
741c27eacf
|
|
@ -4451,7 +4451,7 @@
|
||||||
mobileSearchViewport.rememberScroll();
|
mobileSearchViewport.rememberScroll();
|
||||||
searchPreviewReturnKind = 'search';
|
searchPreviewReturnKind = 'search';
|
||||||
searchPreview.open(item.result).catch(() => {});
|
searchPreview.open(item.result).catch(() => {});
|
||||||
taskOverlayHistory.open('search-preview', { query:qs('#cmd-input').value, preview:item.result });
|
taskOverlayHistory.open('search-preview');
|
||||||
}
|
}
|
||||||
qs('#cmd-palette').classList.remove('open');
|
qs('#cmd-palette').classList.remove('open');
|
||||||
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
||||||
|
|
@ -4496,7 +4496,7 @@
|
||||||
const taskOverlayHistory = createTaskOverlayHistory({
|
const taskOverlayHistory = createTaskOverlayHistory({
|
||||||
history: window.history,
|
history: window.history,
|
||||||
eventTarget: window,
|
eventTarget: window,
|
||||||
onChange(kind, previous, detail) {
|
onChange(kind, previous) {
|
||||||
if (previous === 'new' && kind !== 'new') {
|
if (previous === 'new' && kind !== 'new') {
|
||||||
if (!suppressCreateDraftOnHistoryClose) saveIssueCaptureDraft();
|
if (!suppressCreateDraftOnHistoryClose) saveIssueCaptureDraft();
|
||||||
suppressCreateDraftOnHistoryClose = false;
|
suppressCreateDraftOnHistoryClose = false;
|
||||||
|
|
@ -4531,18 +4531,7 @@
|
||||||
}
|
}
|
||||||
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
|
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
|
||||||
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
|
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
|
||||||
if (kind === 'search' && previous !== 'search-preview') {
|
if (kind === 'search' && previous !== 'search-preview') openCommandPalette(false);
|
||||||
if (detail?.query !== undefined) {
|
|
||||||
qs('#cmd-input').value = detail.query;
|
|
||||||
commandSearch.setQuery(detail.query);
|
|
||||||
}
|
|
||||||
openCommandPalette(false);
|
|
||||||
}
|
|
||||||
if (kind === 'search-preview' && detail?.preview && previous !== 'search') {
|
|
||||||
if (detail?.query !== undefined) qs('#cmd-input').value = detail.query;
|
|
||||||
searchPreviewReturnKind = 'search';
|
|
||||||
searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close());
|
|
||||||
}
|
|
||||||
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
|
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
|
||||||
if (kind === 'today-readiness' && previous !== 'today-readiness') renderTodayReadiness(todayReadiness.snapshot());
|
if (kind === 'today-readiness' && previous !== 'today-readiness') renderTodayReadiness(todayReadiness.snapshot());
|
||||||
},
|
},
|
||||||
|
|
@ -4552,7 +4541,6 @@
|
||||||
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
|
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
|
||||||
qs('#cmd-input').addEventListener('input', (e) => {
|
qs('#cmd-input').addEventListener('input', (e) => {
|
||||||
commandSelection = -1;
|
commandSelection = -1;
|
||||||
taskOverlayHistory.update({ query:e.target.value });
|
|
||||||
renderCommands(e.target.value);
|
renderCommands(e.target.value);
|
||||||
commandSearch.setQuery(e.target.value);
|
commandSearch.setQuery(e.target.value);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,104 +6,21 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']);
|
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']);
|
||||||
const searchKinds = new Set(['search', 'search-preview']);
|
|
||||||
const maxQueryLength = 200;
|
|
||||||
|
|
||||||
function cleanQuery(value) {
|
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
|
||||||
const query = typeof value === 'string' ? value.trim() : '';
|
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
|
||||||
return query.length <= maxQueryLength ? query : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanPreview(value) {
|
|
||||||
if (!value || !['issue', 'pull'].includes(value.kind)) return null;
|
|
||||||
const repository = typeof value.repository === 'string' ? value.repository : '';
|
|
||||||
const parts = repository.split('/');
|
|
||||||
const validPart = part => /^[A-Za-z0-9_.-]+$/.test(part);
|
|
||||||
const number = Number(value.number);
|
|
||||||
if (parts.length !== 2 || !parts.every(validPart) || !Number.isInteger(number) || number < 1) return null;
|
|
||||||
return { kind:value.kind, repository, number };
|
|
||||||
}
|
|
||||||
|
|
||||||
function previewToken(preview) {
|
|
||||||
return preview ? `${preview.kind}:${preview.repository}:${preview.number}` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePreview(value) {
|
|
||||||
const match = /^(issue|pull):([^/:]+\/[^/:]+):(\d+)$/.exec(value || '');
|
|
||||||
return match ? cleanPreview({ kind:match[1], repository:match[2], number:Number(match[3]) }) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return function createTaskOverlayHistory({ history, location, eventTarget, onChange }) {
|
|
||||||
let started = false;
|
let started = false;
|
||||||
let restoredFromUrl = false;
|
|
||||||
|
|
||||||
function stateDetail(state = history.state) {
|
function stateKind(state = history.state) {
|
||||||
const kind = allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
|
return allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
|
||||||
if (!searchKinds.has(kind)) return { kind };
|
|
||||||
const query = cleanQuery(state.searchQuery);
|
|
||||||
const preview = kind === 'search-preview' ? cleanPreview(state.searchPreview) : null;
|
|
||||||
if (kind === 'search-preview' && state.searchPreview !== undefined && !preview) {
|
|
||||||
return { kind:'search', ...(query ? { query } : {}) };
|
|
||||||
}
|
|
||||||
return { kind, ...(query ? { query } : {}), ...(preview ? { preview } : {}) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function urlDetail() {
|
|
||||||
if (!location || typeof URLSearchParams === 'undefined') return { kind:null };
|
|
||||||
const params = new URLSearchParams(location.search || '');
|
|
||||||
const rawQuery = params.get('search');
|
|
||||||
if (rawQuery === null) return { kind:null };
|
|
||||||
const query = cleanQuery(rawQuery);
|
|
||||||
if (rawQuery.trim() && !query) return { kind:null };
|
|
||||||
const preview = parsePreview(params.get('preview'));
|
|
||||||
return {
|
|
||||||
kind:preview ? 'search-preview' : 'search',
|
|
||||||
...(query ? { query } : {}),
|
|
||||||
...(preview ? { preview } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toState(detail, base = history.state) {
|
|
||||||
const state = { ...(base || {}), taskOverlay:detail.kind };
|
|
||||||
delete state.searchQuery;
|
|
||||||
delete state.searchPreview;
|
|
||||||
if (searchKinds.has(detail.kind)) {
|
|
||||||
if (detail.query) state.searchQuery = detail.query;
|
|
||||||
if (detail.kind === 'search-preview' && detail.preview) state.searchPreview = detail.preview;
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
function urlFor(detail) {
|
|
||||||
if (!location || typeof URLSearchParams === 'undefined') return undefined;
|
|
||||||
const params = new URLSearchParams(location.search || '');
|
|
||||||
params.delete('search');
|
|
||||||
params.delete('preview');
|
|
||||||
if (searchKinds.has(detail.kind)) {
|
|
||||||
params.set('search', detail.query || '');
|
|
||||||
if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview));
|
|
||||||
}
|
|
||||||
const query = params.toString();
|
|
||||||
return `${location.pathname || ''}${query ? `?${query}` : ''}${location.hash || ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let initial = stateDetail();
|
|
||||||
if (!initial.kind) {
|
|
||||||
const fromUrl = urlDetail();
|
|
||||||
if (fromUrl.kind) {
|
|
||||||
initial = fromUrl;
|
|
||||||
restoredFromUrl = true;
|
|
||||||
history.replaceState(toState(fromUrl), '', urlFor(fromUrl));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let active = initial;
|
|
||||||
|
|
||||||
function apply(state) {
|
function apply(state) {
|
||||||
const next = stateDetail(state);
|
const next = stateKind(state);
|
||||||
if (JSON.stringify(next) === JSON.stringify(active)) return;
|
if (next === active) return;
|
||||||
const previous = active.kind;
|
const previous = active;
|
||||||
active = next;
|
active = next;
|
||||||
onChange(next.kind, previous, next);
|
onChange(next, previous);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -111,48 +28,33 @@
|
||||||
if (started) return;
|
if (started) return;
|
||||||
started = true;
|
started = true;
|
||||||
eventTarget.addEventListener('popstate', event => apply(event.state));
|
eventTarget.addEventListener('popstate', event => apply(event.state));
|
||||||
if (restoredFromUrl) onChange(active.kind, null, active);
|
|
||||||
},
|
},
|
||||||
open(kind, detail = {}) {
|
open(kind) {
|
||||||
if (!allowed.has(kind)) return false;
|
if (!allowed.has(kind)) return false;
|
||||||
const next = stateDetail(toState({ kind, query:cleanQuery(detail.query), preview:cleanPreview(detail.preview) }));
|
if (active === kind) return true;
|
||||||
if (active.kind === next.kind && JSON.stringify(active) === JSON.stringify(next)) return true;
|
const previous = active;
|
||||||
const previous = active.kind;
|
const state = { ...(history.state || {}), taskOverlay: kind };
|
||||||
history.pushState(toState(next), '', urlFor(next));
|
history.pushState(state, '');
|
||||||
active = next;
|
active = kind;
|
||||||
onChange(next.kind, previous, next);
|
onChange(kind, previous);
|
||||||
return true;
|
|
||||||
},
|
|
||||||
update(detail = {}) {
|
|
||||||
if (!searchKinds.has(active.kind)) return false;
|
|
||||||
const next = stateDetail(toState({
|
|
||||||
kind:active.kind,
|
|
||||||
query:detail.query === undefined ? active.query : cleanQuery(detail.query),
|
|
||||||
preview:detail.preview === undefined ? active.preview : cleanPreview(detail.preview),
|
|
||||||
}));
|
|
||||||
history.replaceState(toState(next), '', urlFor(next));
|
|
||||||
active = next;
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
if (!active.kind) return false;
|
if (!active) return false;
|
||||||
history.back();
|
history.back();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
leave() {
|
leave() {
|
||||||
if (!active.kind) return false;
|
if (!active) return false;
|
||||||
const previous = active.kind;
|
const previous = active;
|
||||||
const state = { ...(history.state || {}) };
|
const state = { ...(history.state || {}) };
|
||||||
delete state.taskOverlay;
|
delete state.taskOverlay;
|
||||||
delete state.searchQuery;
|
history.replaceState(state, '');
|
||||||
delete state.searchPreview;
|
active = null;
|
||||||
active = { kind:null };
|
onChange(null, previous);
|
||||||
history.replaceState(state, '', urlFor(active));
|
|
||||||
onChange(null, previous, active);
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
current() { return active.kind; },
|
current() { return active; },
|
||||||
currentState() { return { ...active, ...(active.preview ? { preview:{ ...active.preview } } : {}) }; },
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ FEATURE_SOURCES = {
|
||||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||||
"security-center": ("static/security-center.js",),
|
"security-center": ("static/security-center.js",),
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/task-overlay-history.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.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/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.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/drafts.js", "static/unfiled-captures.js",
|
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||||
|
|
|
||||||
|
|
@ -212,88 +212,3 @@ def test_dashboard_routes_mobile_task_overlays_through_browser_history():
|
||||||
assert "taskOverlayHistory.close()" in html
|
assert "taskOverlayHistory.close()" in html
|
||||||
assert "saveIssueCaptureDraft();" in html
|
assert "saveIssueCaptureDraft();" in html
|
||||||
assert "history.replaceState(history.state || {}, '', cleanUrl)" in html
|
assert "history.replaceState(history.state || {}, '', cleanUrl)" in html
|
||||||
|
|
||||||
|
|
||||||
def test_search_history_serializes_bounded_query_and_stable_preview_identity():
|
|
||||||
script = f"""
|
|
||||||
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
|
|
||||||
const pushed = [];
|
|
||||||
const replaced = [];
|
|
||||||
const location = {{ pathname:'/dashboard/', search:'?keep=1', hash:'' }};
|
|
||||||
const history = {{
|
|
||||||
state: {{ page:'dashboard' }},
|
|
||||||
pushState(state, title, url) {{ this.state = state; pushed.push([state, url]); }},
|
|
||||||
replaceState(state, title, url) {{ this.state = state; replaced.push([state, url]); }},
|
|
||||||
back() {{}},
|
|
||||||
}};
|
|
||||||
const controller = createTaskOverlayHistory({{
|
|
||||||
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
|
|
||||||
}});
|
|
||||||
controller.open('search', {{ query:' release blocker ' }});
|
|
||||||
controller.update({{ query:'release candidate' }});
|
|
||||||
controller.open('search-preview', {{
|
|
||||||
query:'release candidate', preview:{{kind:'issue', repository:'stackchain/api', number:123,
|
|
||||||
title:'must not be serialized', body:'secret'}},
|
|
||||||
}});
|
|
||||||
process.stdout.write(JSON.stringify({{pushed, replaced, current:controller.currentState()}}));
|
|
||||||
"""
|
|
||||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
||||||
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
payload = json.loads(result.stdout)
|
|
||||||
assert len(payload["pushed"]) == 2
|
|
||||||
assert len(payload["replaced"]) == 1
|
|
||||||
assert payload["pushed"][0][1] == "/dashboard/?keep=1&search=release+blocker"
|
|
||||||
assert payload["replaced"][0][1] == "/dashboard/?keep=1&search=release+candidate"
|
|
||||||
assert payload["pushed"][1][1] == (
|
|
||||||
"/dashboard/?keep=1&search=release+candidate&preview=issue%3Astackchain%2Fapi%3A123"
|
|
||||||
)
|
|
||||||
assert payload["current"] == {
|
|
||||||
"kind": "search-preview",
|
|
||||||
"query": "release candidate",
|
|
||||||
"preview": {"kind": "issue", "repository": "stackchain/api", "number": 123},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_history_restores_direct_url_and_rejects_malformed_or_oversized_state():
|
|
||||||
script = f"""
|
|
||||||
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
|
|
||||||
function restore(search) {{
|
|
||||||
const changes = [];
|
|
||||||
const location = {{ pathname:'/dashboard/', search, hash:'' }};
|
|
||||||
const history = {{ state:null, replaceState(state) {{ this.state = state; }}, back() {{}} }};
|
|
||||||
const controller = createTaskOverlayHistory({{
|
|
||||||
history, location, eventTarget:{{addEventListener() {{}}}},
|
|
||||||
onChange(kind, previous, detail) {{ changes.push([kind, previous, detail]); }},
|
|
||||||
}});
|
|
||||||
controller.start();
|
|
||||||
return {{ state:controller.currentState(), changes }};
|
|
||||||
}}
|
|
||||||
process.stdout.write(JSON.stringify({{
|
|
||||||
valid:restore('?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9'),
|
|
||||||
malformed:restore('?search=ok&preview=issue%3Ainvalid%3A0'),
|
|
||||||
oversized:restore('?search=' + 'x'.repeat(201)),
|
|
||||||
}}));
|
|
||||||
"""
|
|
||||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
||||||
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
payload = json.loads(result.stdout)
|
|
||||||
expected = {
|
|
||||||
"kind": "search-preview",
|
|
||||||
"query": "release blocker",
|
|
||||||
"preview": {"kind": "pull", "repository": "stackchain/api", "number": 9},
|
|
||||||
}
|
|
||||||
assert payload["valid"]["state"] == expected
|
|
||||||
assert payload["valid"]["changes"] == [["search-preview", None, expected]]
|
|
||||||
assert payload["malformed"]["state"] == {"kind": "search", "query": "ok"}
|
|
||||||
assert payload["oversized"]["state"] == {"kind": None}
|
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_persists_search_query_and_restores_canonical_preview():
|
|
||||||
html = dashboard_bundle_text()
|
|
||||||
|
|
||||||
assert "taskOverlayHistory.update({ query:e.target.value })" in html
|
|
||||||
assert "taskOverlayHistory.open('search-preview', { query:qs('#cmd-input').value, preview:item.result })" in html
|
|
||||||
assert "detail?.query" in html
|
|
||||||
assert "searchPreview.open(detail.preview).catch" in html
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user