Merge pull request 'Scope mobile Search by type and status' (#794) from timmy/793-scope-mobile-search into main
Merge pull request 'Scope mobile Search by type and status' (#794) from timmy/793-scope-mobile-search into main
This commit is contained in:
commit
7636e30482
|
|
@ -408,6 +408,12 @@ filter, preserves the selected release lane, shows the current draft count, resp
|
||||||
the device safe area, and moves out of the way while a full-screen task is open.
|
the device safe area, and moves out of the way while a full-screen task is open.
|
||||||
Desktop layout is unchanged.
|
Desktop layout is unchanged.
|
||||||
|
|
||||||
|
Mobile **Search** provides touch-sized **Type** and **Status** controls. Operators can
|
||||||
|
scope results to issues, pull requests, open work, closed work, or all accessible work;
|
||||||
|
the server applies that scope before pagination. The selected scope is bounded and
|
||||||
|
addressable, survives preview/back, reload, sharing, and sign-in continuation, and a
|
||||||
|
scope change cancels obsolete requests before restarting at the first page.
|
||||||
|
|
||||||
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
|
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
|
||||||
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
|
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
|
||||||
accepts a valid future local date and time and returns the item at that exact instant; the picker
|
accepts a valid future local date and time and returns the item at that exact instant; the picker
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@
|
||||||
let timer = null;
|
let timer = null;
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
let activeController = null;
|
let activeController = null;
|
||||||
let state = { status: 'idle', query: '', items: [], more: false, next: 1 };
|
let scope = { kind:'all', state:'all' };
|
||||||
|
let state = { status: 'idle', query: '', items: [], more: false, next: 1, scope };
|
||||||
|
|
||||||
function publish(next) {
|
function publish(next) {
|
||||||
state = next;
|
state = next;
|
||||||
|
|
@ -33,7 +34,8 @@
|
||||||
const requestController = new AbortController();
|
const requestController = new AbortController();
|
||||||
activeController = requestController;
|
activeController = requestController;
|
||||||
try {
|
try {
|
||||||
const result = await search(query, requestController.signal, page);
|
const requestScope = { ...scope };
|
||||||
|
const result = await search(query, requestController.signal, page, requestScope);
|
||||||
const partial = result.partial === true;
|
const partial = result.partial === true;
|
||||||
const incoming = result.items || result;
|
const incoming = result.items || result;
|
||||||
const combined = append ? state.items.concat(incoming) : incoming;
|
const combined = append ? state.items.concat(incoming) : incoming;
|
||||||
|
|
@ -44,12 +46,13 @@
|
||||||
status: 'ready', query, items, partial,
|
status: 'ready', query, items, partial,
|
||||||
more: !!result.has_more,
|
more: !!result.has_more,
|
||||||
next: result.next_page,
|
next: result.next_page,
|
||||||
|
scope:requestScope,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error && error.name === 'AbortError') return;
|
if (error && error.name === 'AbortError') return;
|
||||||
if (current === generation) publish(append
|
if (current === generation) publish(append
|
||||||
? { ...state, status: 'ready' }
|
? { ...state, status: 'ready' }
|
||||||
: { status: 'error', query, items: [], error });
|
: { status: 'error', query, items: [], error, scope:{ ...scope } });
|
||||||
} finally {
|
} finally {
|
||||||
if (activeController === requestController) activeController = null;
|
if (activeController === requestController) activeController = null;
|
||||||
}
|
}
|
||||||
|
|
@ -64,12 +67,22 @@
|
||||||
if (activeController !== null) activeController.abort();
|
if (activeController !== null) activeController.abort();
|
||||||
activeController = null;
|
activeController = null;
|
||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
publish({ status: 'idle', query, items: [], more: false, next: 1 });
|
publish({ status: 'idle', query, items: [], more: false, next: 1, scope:{ ...scope } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
publish({ status: 'loading', query, items: [], more: false, next: 1 });
|
publish({ status: 'loading', query, items: [], more: false, next: 1, scope:{ ...scope } });
|
||||||
timer = setTimeout(() => requestPage(query, 1, current, false), delay);
|
timer = setTimeout(() => requestPage(query, 1, current, false), delay);
|
||||||
},
|
},
|
||||||
|
setScope(value) {
|
||||||
|
const next = {
|
||||||
|
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
|
||||||
|
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
|
||||||
|
};
|
||||||
|
if (next.kind === scope.kind && next.state === scope.state) return;
|
||||||
|
const query = state.query;
|
||||||
|
scope = next;
|
||||||
|
this.setQuery(query);
|
||||||
|
},
|
||||||
loadMore() {
|
loadMore() {
|
||||||
if (state.status !== 'ready' || !state.more || activeController) return;
|
if (state.status !== 'ready' || !state.more || activeController) return;
|
||||||
const current = generation;
|
const current = generation;
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
|
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
|
||||||
#cmd-palette.open { display: block; }
|
#cmd-palette.open { display: block; }
|
||||||
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
|
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
|
||||||
|
.cmd-search-scope { display:grid; grid-template-columns:auto minmax(120px,1fr) auto minmax(120px,1fr); gap:6px 10px; align-items:center; margin:8px 0 0; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
|
||||||
|
.cmd-search-scope legend { padding:0 4px; color:#93a4b8; font-size:12px; }
|
||||||
|
.cmd-search-scope label { font-size:12px; color:#cbd5e1; }
|
||||||
|
.cmd-search-scope select { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
|
||||||
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
|
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
|
||||||
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
||||||
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
|
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
|
||||||
|
|
@ -685,6 +689,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; }
|
.cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; }
|
||||||
#close-command-palette, #cmd-load-more { min-height:44px; }
|
#close-command-palette, #cmd-load-more { min-height:44px; }
|
||||||
#cmd-input { flex:0 0 auto; min-height:44px; }
|
#cmd-input { flex:0 0 auto; min-height:44px; }
|
||||||
|
.cmd-search-scope { grid-template-columns:auto minmax(0,1fr); }
|
||||||
|
#cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; }
|
||||||
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
|
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
|
||||||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||||
|
|
|
||||||
|
|
@ -4285,8 +4285,9 @@
|
||||||
let commandSearchState = { status:'idle', query:'', items:[] };
|
let commandSearchState = { status:'idle', query:'', items:[] };
|
||||||
let commandItems = [];
|
let commandItems = [];
|
||||||
let commandSelection = -1;
|
let commandSelection = -1;
|
||||||
async function searchGlobalWork(query, signal, page = 1) {
|
async function searchGlobalWork(query, signal, page = 1, scope = {kind:'all', state:'all'}) {
|
||||||
const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page, {
|
const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page +
|
||||||
|
'&kind=' + encodeURIComponent(scope.kind) + '&state=' + encodeURIComponent(scope.state), {
|
||||||
headers: { Accept:'application/json' },
|
headers: { Accept:'application/json' },
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
@ -4301,6 +4302,14 @@
|
||||||
renderCommands(state.query);
|
renderCommands(state.query);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
function currentSearchScope() {
|
||||||
|
return { kind:qs('#cmd-search-kind').value, state:qs('#cmd-search-state').value };
|
||||||
|
}
|
||||||
|
function applySearchScope(scope = {kind:'all', state:'all'}) {
|
||||||
|
qs('#cmd-search-kind').value = scope.kind;
|
||||||
|
qs('#cmd-search-state').value = scope.state;
|
||||||
|
commandSearch.setScope(scope);
|
||||||
|
}
|
||||||
function safeSearchUrl(value) {
|
function safeSearchUrl(value) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(value);
|
const url = new URL(value);
|
||||||
|
|
@ -4393,8 +4402,11 @@
|
||||||
onState: renderSearchPreview,
|
onState: renderSearchPreview,
|
||||||
});
|
});
|
||||||
function canonicalSearchPreviewUrl() {
|
function canonicalSearchPreviewUrl() {
|
||||||
const { query, preview } = taskOverlayHistory.currentState();
|
const { query, preview, scope = currentSearchScope() } = taskOverlayHistory.currentState();
|
||||||
const params = new URLSearchParams({ search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number });
|
const params = new URLSearchParams({
|
||||||
|
search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number,
|
||||||
|
search_kind:scope.kind, search_state:scope.state,
|
||||||
|
});
|
||||||
return new URL('?' + params, window.location.origin + window.location.pathname).href;
|
return new URL('?' + params, window.location.origin + window.location.pathname).href;
|
||||||
}
|
}
|
||||||
function createSearchStart(claim) {
|
function createSearchStart(claim) {
|
||||||
|
|
@ -4458,7 +4470,9 @@
|
||||||
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', {
|
||||||
|
query:qs('#cmd-input').value, scope:currentSearchScope(), preview:item.result,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
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');
|
||||||
|
|
@ -4491,7 +4505,7 @@
|
||||||
}
|
}
|
||||||
function openCommandPalette(navigate = true) {
|
function openCommandPalette(navigate = true) {
|
||||||
if (navigate) {
|
if (navigate) {
|
||||||
taskOverlayHistory.open('search');
|
taskOverlayHistory.open('search', { scope:currentSearchScope() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
qs('#cmd-palette').classList.add('open');
|
qs('#cmd-palette').classList.add('open');
|
||||||
|
|
@ -4541,6 +4555,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') {
|
||||||
|
if (detail?.scope) applySearchScope(detail.scope);
|
||||||
if (detail?.query !== undefined) {
|
if (detail?.query !== undefined) {
|
||||||
qs('#cmd-input').value = detail.query;
|
qs('#cmd-input').value = detail.query;
|
||||||
commandSearch.setQuery(detail.query);
|
commandSearch.setQuery(detail.query);
|
||||||
|
|
@ -4548,6 +4563,7 @@
|
||||||
openCommandPalette(false);
|
openCommandPalette(false);
|
||||||
}
|
}
|
||||||
if (kind === 'search-preview' && detail?.preview && previous !== 'search') {
|
if (kind === 'search-preview' && detail?.preview && previous !== 'search') {
|
||||||
|
if (detail?.scope) applySearchScope(detail.scope);
|
||||||
if (detail?.query !== undefined) qs('#cmd-input').value = detail.query;
|
if (detail?.query !== undefined) qs('#cmd-input').value = detail.query;
|
||||||
searchPreviewReturnKind = 'search';
|
searchPreviewReturnKind = 'search';
|
||||||
searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close());
|
searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close());
|
||||||
|
|
@ -4560,6 +4576,14 @@
|
||||||
qs('#open-palette').addEventListener('click', openCommandPalette);
|
qs('#open-palette').addEventListener('click', openCommandPalette);
|
||||||
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
|
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
|
||||||
qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore());
|
qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore());
|
||||||
|
function changeSearchScope() {
|
||||||
|
const scope = currentSearchScope();
|
||||||
|
commandSelection = -1;
|
||||||
|
taskOverlayHistory.update({ scope });
|
||||||
|
commandSearch.setScope(scope);
|
||||||
|
}
|
||||||
|
qs('#cmd-search-kind').addEventListener('change', changeSearchScope);
|
||||||
|
qs('#cmd-search-state').addEventListener('change', changeSearchScope);
|
||||||
qs('#cmd-input').addEventListener('input', (e) => {
|
qs('#cmd-input').addEventListener('input', (e) => {
|
||||||
commandSelection = -1;
|
commandSelection = -1;
|
||||||
taskOverlayHistory.update({ query:e.target.value });
|
taskOverlayHistory.update({ query:e.target.value });
|
||||||
|
|
|
||||||
|
|
@ -429,6 +429,21 @@
|
||||||
<button id="close-command-palette" type="button">Close</button>
|
<button id="close-command-palette" type="button">Close</button>
|
||||||
</div>
|
</div>
|
||||||
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
|
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
|
||||||
|
<fieldset class="cmd-search-scope">
|
||||||
|
<legend>Filter search results</legend>
|
||||||
|
<label for="cmd-search-kind">Type</label>
|
||||||
|
<select id="cmd-search-kind">
|
||||||
|
<option value="all">All work</option>
|
||||||
|
<option value="issue">Issues</option>
|
||||||
|
<option value="pull">Pull requests</option>
|
||||||
|
</select>
|
||||||
|
<label for="cmd-search-state">Status</label>
|
||||||
|
<select id="cmd-search-state">
|
||||||
|
<option value="all">Any status</option>
|
||||||
|
<option value="open">Open</option>
|
||||||
|
<option value="closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div>
|
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div>
|
||||||
<button id="cmd-load-more" hidden>More results</button>
|
<button id="cmd-load-more" hidden>More results</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@
|
||||||
return query.length <= maxQueryLength ? query : '';
|
return query.length <= maxQueryLength ? query : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanScope(value) {
|
||||||
|
return {
|
||||||
|
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
|
||||||
|
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function cleanPreview(value) {
|
function cleanPreview(value) {
|
||||||
if (!value || !['issue', 'pull'].includes(value.kind)) return null;
|
if (!value || !['issue', 'pull'].includes(value.kind)) return null;
|
||||||
const repository = typeof value.repository === 'string' ? value.repository : '';
|
const repository = typeof value.repository === 'string' ? value.repository : '';
|
||||||
|
|
@ -41,11 +48,12 @@
|
||||||
const kind = allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
|
const kind = allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
|
||||||
if (!searchKinds.has(kind)) return { kind };
|
if (!searchKinds.has(kind)) return { kind };
|
||||||
const query = cleanQuery(state.searchQuery);
|
const query = cleanQuery(state.searchQuery);
|
||||||
|
const scope = state.searchScope === undefined ? null : cleanScope(state.searchScope);
|
||||||
const preview = kind === 'search-preview' ? cleanPreview(state.searchPreview) : null;
|
const preview = kind === 'search-preview' ? cleanPreview(state.searchPreview) : null;
|
||||||
if (kind === 'search-preview' && state.searchPreview !== undefined && !preview) {
|
if (kind === 'search-preview' && state.searchPreview !== undefined && !preview) {
|
||||||
return { kind:'search', ...(query ? { query } : {}) };
|
return { kind:'search', ...(query ? { query } : {}) };
|
||||||
}
|
}
|
||||||
return { kind, ...(query ? { query } : {}), ...(preview ? { preview } : {}) };
|
return { kind, ...(query ? { query } : {}), ...(scope ? { scope } : {}), ...(preview ? { preview } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function urlDetail() {
|
function urlDetail() {
|
||||||
|
|
@ -56,9 +64,14 @@
|
||||||
const query = cleanQuery(rawQuery);
|
const query = cleanQuery(rawQuery);
|
||||||
if (rawQuery.trim() && !query) return { kind:null };
|
if (rawQuery.trim() && !query) return { kind:null };
|
||||||
const preview = parsePreview(params.get('preview'));
|
const preview = parsePreview(params.get('preview'));
|
||||||
|
const hasScope = params.has('search_kind') || params.has('search_state');
|
||||||
|
const scope = hasScope ? cleanScope({
|
||||||
|
kind:params.get('search_kind'), state:params.get('search_state'),
|
||||||
|
}) : null;
|
||||||
return {
|
return {
|
||||||
kind:preview ? 'search-preview' : 'search',
|
kind:preview ? 'search-preview' : 'search',
|
||||||
...(query ? { query } : {}),
|
...(query ? { query } : {}),
|
||||||
|
...(scope ? { scope } : {}),
|
||||||
...(preview ? { preview } : {}),
|
...(preview ? { preview } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -67,8 +80,10 @@
|
||||||
const state = { ...(base || {}), taskOverlay:detail.kind };
|
const state = { ...(base || {}), taskOverlay:detail.kind };
|
||||||
delete state.searchQuery;
|
delete state.searchQuery;
|
||||||
delete state.searchPreview;
|
delete state.searchPreview;
|
||||||
|
delete state.searchScope;
|
||||||
if (searchKinds.has(detail.kind)) {
|
if (searchKinds.has(detail.kind)) {
|
||||||
if (detail.query) state.searchQuery = detail.query;
|
if (detail.query) state.searchQuery = detail.query;
|
||||||
|
if (detail.scope) state.searchScope = cleanScope(detail.scope);
|
||||||
if (detail.kind === 'search-preview' && detail.preview) state.searchPreview = detail.preview;
|
if (detail.kind === 'search-preview' && detail.preview) state.searchPreview = detail.preview;
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
|
|
@ -79,8 +94,14 @@
|
||||||
const params = new URLSearchParams(location.search || '');
|
const params = new URLSearchParams(location.search || '');
|
||||||
params.delete('search');
|
params.delete('search');
|
||||||
params.delete('preview');
|
params.delete('preview');
|
||||||
|
params.delete('search_kind');
|
||||||
|
params.delete('search_state');
|
||||||
if (searchKinds.has(detail.kind)) {
|
if (searchKinds.has(detail.kind)) {
|
||||||
params.set('search', detail.query || '');
|
params.set('search', detail.query || '');
|
||||||
|
if (detail.scope) {
|
||||||
|
params.set('search_kind', detail.scope.kind);
|
||||||
|
params.set('search_state', detail.scope.state);
|
||||||
|
}
|
||||||
if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview));
|
if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview));
|
||||||
}
|
}
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
|
|
@ -115,7 +136,11 @@
|
||||||
},
|
},
|
||||||
open(kind, detail = {}) {
|
open(kind, detail = {}) {
|
||||||
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) }));
|
const next = stateDetail(toState({
|
||||||
|
kind, query:cleanQuery(detail.query),
|
||||||
|
scope:detail.scope === undefined ? undefined : cleanScope(detail.scope),
|
||||||
|
preview:cleanPreview(detail.preview),
|
||||||
|
}));
|
||||||
if (active.kind === next.kind && JSON.stringify(active) === JSON.stringify(next)) return true;
|
if (active.kind === next.kind && JSON.stringify(active) === JSON.stringify(next)) return true;
|
||||||
const previous = active.kind;
|
const previous = active.kind;
|
||||||
history.pushState(toState(next), '', urlFor(next));
|
history.pushState(toState(next), '', urlFor(next));
|
||||||
|
|
@ -128,6 +153,7 @@
|
||||||
const next = stateDetail(toState({
|
const next = stateDetail(toState({
|
||||||
kind:active.kind,
|
kind:active.kind,
|
||||||
query:detail.query === undefined ? active.query : cleanQuery(detail.query),
|
query:detail.query === undefined ? active.query : cleanQuery(detail.query),
|
||||||
|
scope:detail.scope === undefined ? active.scope : cleanScope(detail.scope),
|
||||||
preview:detail.preview === undefined ? active.preview : cleanPreview(detail.preview),
|
preview:detail.preview === undefined ? active.preview : cleanPreview(detail.preview),
|
||||||
}));
|
}));
|
||||||
history.replaceState(toState(next), '', urlFor(next));
|
history.replaceState(toState(next), '', urlFor(next));
|
||||||
|
|
@ -146,6 +172,7 @@
|
||||||
delete state.taskOverlay;
|
delete state.taskOverlay;
|
||||||
delete state.searchQuery;
|
delete state.searchQuery;
|
||||||
delete state.searchPreview;
|
delete state.searchPreview;
|
||||||
|
delete state.searchScope;
|
||||||
active = { kind:null };
|
active = { kind:null };
|
||||||
history.replaceState(state, '', urlFor(active));
|
history.replaceState(state, '', urlFor(active));
|
||||||
onChange(null, previous, active);
|
onChange(null, previous, active);
|
||||||
|
|
|
||||||
|
|
@ -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/search-preview.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/commands.js", "static/task-overlay-history.js", "static/search-preview.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",
|
||||||
|
|
|
||||||
|
|
@ -463,14 +463,27 @@ def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def global_search(query: str, limit: int = 10, page: int = 1) -> dict:
|
async def global_search(
|
||||||
|
query: str,
|
||||||
|
limit: int = 10,
|
||||||
|
page: int = 1,
|
||||||
|
kind: str = "all",
|
||||||
|
state: str = "all",
|
||||||
|
) -> dict:
|
||||||
"""Search accessible issues and pulls concurrently with balanced pagination."""
|
"""Search accessible issues and pulls concurrently with balanced pagination."""
|
||||||
stream_limit = (limit + 1) // 2
|
item_types = ("issues", "pulls") if kind == "all" else (
|
||||||
|
"issues" if kind == "issue" else "pulls",
|
||||||
|
)
|
||||||
|
stream_limit = (limit + 1) // 2 if kind == "all" else limit
|
||||||
async def load(item_type: str) -> Any:
|
async def load(item_type: str) -> Any:
|
||||||
|
params = {
|
||||||
|
"q": query, "type": item_type, "state": state,
|
||||||
|
"limit": stream_limit, "page": page,
|
||||||
|
}
|
||||||
response = await _get_client().get(
|
response = await _get_client().get(
|
||||||
"/api/v1/repos/issues/search",
|
"/api/v1/repos/issues/search",
|
||||||
headers=_auth(),
|
headers=_auth(),
|
||||||
params={"q": query, "type": item_type, "limit": stream_limit, "page": page},
|
params=params,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
|
|
@ -478,9 +491,7 @@ async def global_search(query: str, limit: int = 10, page: int = 1) -> dict:
|
||||||
raise ValueError("Gitea global search response was not a list")
|
raise ValueError("Gitea global search response was not a list")
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
outcomes = await asyncio.gather(
|
outcomes = await asyncio.gather(*(load(item_type) for item_type in item_types), return_exceptions=True)
|
||||||
load("issues"), load("pulls"), return_exceptions=True
|
|
||||||
)
|
|
||||||
for outcome in outcomes:
|
for outcome in outcomes:
|
||||||
if isinstance(outcome, asyncio.CancelledError):
|
if isinstance(outcome, asyncio.CancelledError):
|
||||||
raise outcome
|
raise outcome
|
||||||
|
|
@ -489,15 +500,16 @@ async def global_search(query: str, limit: int = 10, page: int = 1) -> dict:
|
||||||
|
|
||||||
streams: list[list[dict]] = []
|
streams: list[list[dict]] = []
|
||||||
seen: set[tuple[str, str, int]] = set()
|
seen: set[tuple[str, str, int]] = set()
|
||||||
for outcome, kind in zip(outcomes, ("issue", "pull"), strict=True):
|
result_kinds = tuple("issue" if item_type == "issues" else "pull" for item_type in item_types)
|
||||||
|
for outcome, result_kind in zip(outcomes, result_kinds, strict=True):
|
||||||
normalized_stream = []
|
normalized_stream = []
|
||||||
if isinstance(outcome, BaseException):
|
if isinstance(outcome, BaseException):
|
||||||
streams.append(normalized_stream)
|
streams.append(normalized_stream)
|
||||||
continue
|
continue
|
||||||
for item in outcome:
|
for item in outcome:
|
||||||
normalized = _normalize_global_search_item(item, kind)
|
normalized = _normalize_global_search_item(item, result_kind)
|
||||||
if normalized is not None:
|
if normalized is not None:
|
||||||
identity = (kind, normalized["repository"], normalized["number"])
|
identity = (result_kind, normalized["repository"], normalized["number"])
|
||||||
if identity in seen:
|
if identity in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(identity)
|
seen.add(identity)
|
||||||
|
|
|
||||||
23
src/main.py
23
src/main.py
|
|
@ -1006,11 +1006,15 @@ def _share_target_login_redirect(request: Request) -> str:
|
||||||
search_values = request.query_params.getlist("search")
|
search_values = request.query_params.getlist("search")
|
||||||
preview_values = request.query_params.getlist("preview")
|
preview_values = request.query_params.getlist("preview")
|
||||||
if search_values or preview_values:
|
if search_values or preview_values:
|
||||||
allowed = {"search", "preview"}
|
allowed = {"search", "preview", "search_kind", "search_state"}
|
||||||
|
kind_values = request.query_params.getlist("search_kind")
|
||||||
|
state_values = request.query_params.getlist("search_state")
|
||||||
if (
|
if (
|
||||||
set(request.query_params.keys()) - allowed
|
set(request.query_params.keys()) - allowed
|
||||||
or len(search_values) != 1
|
or len(search_values) != 1
|
||||||
or len(preview_values) != 1
|
or len(preview_values) != 1
|
||||||
|
or len(kind_values) > 1
|
||||||
|
or len(state_values) > 1
|
||||||
or len(search_values[0]) > 200
|
or len(search_values[0]) > 200
|
||||||
or len(preview_values[0]) > 200
|
or len(preview_values[0]) > 200
|
||||||
or not re.fullmatch(
|
or not re.fullmatch(
|
||||||
|
|
@ -1019,9 +1023,14 @@ def _share_target_login_redirect(request: Request) -> str:
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
return "login"
|
return "login"
|
||||||
continuation = urlencode(
|
continuation_values = [
|
||||||
(("search", search_values[0].strip()), ("preview", preview_values[0]))
|
("search", search_values[0].strip()), ("preview", preview_values[0]),
|
||||||
)
|
]
|
||||||
|
if kind_values and kind_values[0] in {"all", "issue", "pull"}:
|
||||||
|
continuation_values.append(("search_kind", kind_values[0]))
|
||||||
|
if state_values and state_values[0] in {"all", "open", "closed"}:
|
||||||
|
continuation_values.append(("search_state", state_values[0]))
|
||||||
|
continuation = urlencode(continuation_values)
|
||||||
return f"login?{urlencode({'continue': f'./?{continuation}'})}"
|
return f"login?{urlencode({'continue': f'./?{continuation}'})}"
|
||||||
limits = {"title": 200, "text": 8000, "url": 2048}
|
limits = {"title": 200, "text": 8000, "url": 2048}
|
||||||
if any(
|
if any(
|
||||||
|
|
@ -2712,13 +2721,15 @@ async def global_search(
|
||||||
q: str = Query(min_length=2, max_length=100),
|
q: str = Query(min_length=2, max_length=100),
|
||||||
limit: int = Query(default=10, ge=1, le=25),
|
limit: int = Query(default=10, ge=1, le=25),
|
||||||
page: int = Query(default=1, ge=1, le=100),
|
page: int = Query(default=1, ge=1, le=100),
|
||||||
|
kind: Literal["all", "issue", "pull"] = Query(default="all"),
|
||||||
|
state: Literal["all", "open", "closed"] = Query(default="all"),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
query = q.strip()
|
query = q.strip()
|
||||||
if len(query) < 2:
|
if len(query) < 2:
|
||||||
raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters")
|
raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters")
|
||||||
try:
|
try:
|
||||||
result = await asyncio.wait_for(
|
result = await asyncio.wait_for(
|
||||||
gitea_proxy.global_search(query, limit, page),
|
gitea_proxy.global_search(query, limit, page, kind, state),
|
||||||
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
|
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -2727,7 +2738,7 @@ async def global_search(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
headers={"Retry-After": "1"},
|
headers={"Retry-After": "1"},
|
||||||
)
|
)
|
||||||
return JSONResponse({"query": query, **result})
|
return JSONResponse({"query": query, "scope": {"kind": kind, "state": state}, **result})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview")
|
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview")
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,25 @@ def test_command_script_resolves_inside_dashboard_subpath():
|
||||||
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
|
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_search_renders_touch_sized_type_and_status_scope_controls():
|
||||||
|
html = dashboard_bundle_text()
|
||||||
|
css = (FRONTEND / "dashboard.css").read_text()
|
||||||
|
|
||||||
|
assert '<fieldset class="cmd-search-scope"' in html
|
||||||
|
assert '<legend>Filter search results</legend>' in html
|
||||||
|
assert '<label for="cmd-search-kind">Type</label>' in html
|
||||||
|
assert '<select id="cmd-search-kind"' in html
|
||||||
|
assert '<option value="pull">Pull requests</option>' in html
|
||||||
|
assert '<label for="cmd-search-state">Status</label>' in html
|
||||||
|
assert '<select id="cmd-search-state"' in html
|
||||||
|
assert '.cmd-search-scope' in css
|
||||||
|
assert '#cmd-search-kind, #cmd-search-state { min-height:44px;' in css
|
||||||
|
assert "commandSearch.setScope(scope)" in html
|
||||||
|
assert "taskOverlayHistory.update({ scope })" in html
|
||||||
|
assert "'&kind=' + encodeURIComponent(scope.kind)" in html
|
||||||
|
assert "'&state=' + encodeURIComponent(scope.state)" in html
|
||||||
|
|
||||||
|
|
||||||
def test_remote_command_search_ignores_stale_responses():
|
def test_remote_command_search_ignores_stale_responses():
|
||||||
script = f"""
|
script = f"""
|
||||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||||
|
|
@ -85,6 +104,37 @@ if (ready[0].query !== 'release' || ready[0].items[0].title !== 'Current result'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_command_search_scope_change_aborts_and_restarts_without_stale_results():
|
||||||
|
script = f"""
|
||||||
|
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||||
|
(async () => {{
|
||||||
|
const pending = [];
|
||||||
|
const states = [];
|
||||||
|
const controller = filterCommands.createGlobalSearchController({{
|
||||||
|
delay: 0,
|
||||||
|
search: (query, signal, page, scope) => new Promise(resolve => pending.push({{query, signal, page, scope, resolve}})),
|
||||||
|
onState: state => states.push(state),
|
||||||
|
}});
|
||||||
|
controller.setQuery('mobile');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
controller.setScope({{kind:'pull', state:'open'}});
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
if (!pending[0].signal.aborted) throw new Error('scope change did not abort old request');
|
||||||
|
if (JSON.stringify(pending[1].scope) !== JSON.stringify({{kind:'pull', state:'open'}})) {{
|
||||||
|
throw new Error('new request did not receive scope');
|
||||||
|
}}
|
||||||
|
pending[1].resolve({{items:[{{kind:'pull',repository:'a/b',number:2}}],has_more:false,next_page:2}});
|
||||||
|
pending[0].resolve({{items:[{{kind:'issue',repository:'a/b',number:1}}],has_more:false,next_page:2}});
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
const ready = states.filter(state => state.status === 'ready').at(-1);
|
||||||
|
if (ready.items.length !== 1 || ready.items[0].kind !== 'pull') throw new Error('stale scope result entered list');
|
||||||
|
if (ready.scope.kind !== 'pull' || ready.scope.state !== 'open') throw new Error('scope missing from state');
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
|
||||||
|
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
def test_remote_command_search_aborts_superseded_and_cleared_queries():
|
def test_remote_command_search_aborts_superseded_and_cleared_queries():
|
||||||
script = f"""
|
script = f"""
|
||||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||||
|
|
@ -193,7 +243,7 @@ def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath()
|
||||||
assert 'aria-controls="cmd-results"' in html
|
assert 'aria-controls="cmd-results"' in html
|
||||||
assert 'role="listbox"' in html
|
assert 'role="listbox"' in html
|
||||||
assert "fetch('api/v1/search?q='" in html
|
assert "fetch('api/v1/search?q='" in html
|
||||||
assert "async function searchGlobalWork(query, signal, page = 1)" in html
|
assert "async function searchGlobalWork(query, signal, page = 1, scope" in html
|
||||||
assert "signal," in html
|
assert "signal," in html
|
||||||
assert "Some results are temporarily unavailable." in html
|
assert "Some results are temporarily unavailable." in html
|
||||||
assert 'id="cmd-load-more"' in html
|
assert 'id="cmd-load-more"' in html
|
||||||
|
|
|
||||||
|
|
@ -918,6 +918,30 @@ async def test_anonymous_search_preview_preserves_only_bounded_canonical_continu
|
||||||
assert oversized.headers["location"] == "login"
|
assert oversized.headers["location"] == "login"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_anonymous_search_preview_preserves_only_valid_search_scope(access_control):
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
valid = await client.get("/", params={
|
||||||
|
"search": "release blocker", "preview": "pull:stackchain/api:42",
|
||||||
|
"search_kind": "pull", "search_state": "open",
|
||||||
|
})
|
||||||
|
invalid = await client.get("/", params={
|
||||||
|
"search": "release blocker", "preview": "pull:stackchain/api:42",
|
||||||
|
"search_kind": "script", "search_state": "secret",
|
||||||
|
})
|
||||||
|
|
||||||
|
valid_continue = parse_qs(urlsplit(valid.headers["location"]).query)["continue"][0]
|
||||||
|
invalid_continue = parse_qs(urlsplit(invalid.headers["location"]).query)["continue"][0]
|
||||||
|
assert valid_continue.endswith(
|
||||||
|
"search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42&"
|
||||||
|
"search_kind=pull&search_state=open"
|
||||||
|
)
|
||||||
|
assert invalid_continue == (
|
||||||
|
"./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control):
|
async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control):
|
||||||
transport = httpx.ASGITransport(app=main.app)
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||||
security_center = first.feature_bundles["security-center"]
|
security_center = first.feature_bundles["security-center"]
|
||||||
assert b"function attachSecurityCenter" not in first.runtime_bytes
|
assert b"function attachSecurityCenter" not in first.runtime_bytes
|
||||||
assert b"function attachSecurityCenter" in security_center.runtime_bytes
|
assert b"function attachSecurityCenter" in security_center.runtime_bytes
|
||||||
|
assert b"function createGlobalSearchController" not in first.runtime_bytes
|
||||||
|
assert b"function createGlobalSearchController" in first.feature_bundles["today-timer"].runtime_bytes
|
||||||
assert b"gitea_time_logged" not in first.runtime_bytes
|
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||||
assert b"gitea_time_logged" in security_center.runtime_bytes
|
assert b"gitea_time_logged" in security_center.runtime_bytes
|
||||||
# Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions.
|
# Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions.
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,60 @@ import pytest
|
||||||
from src import gitea_proxy, main
|
from src import gitea_proxy, main
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_global_search_endpoint_forwards_valid_type_and_status_scope(monkeypatch):
|
||||||
|
requested = []
|
||||||
|
|
||||||
|
async def search(query, limit, page, kind, state):
|
||||||
|
requested.append((query, limit, page, kind, state))
|
||||||
|
return {"items": [], "partial": False, "has_more": False, "next_page": 2}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/search?q=mobile&limit=7&page=1&kind=pull&state=open"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert requested == [("mobile", 7, 1, "pull", "open")]
|
||||||
|
assert response.json()["scope"] == {"kind": "pull", "state": "open"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_global_search_specific_type_uses_one_full_width_scoped_stream():
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
requests.append(dict(request.url.params))
|
||||||
|
return httpx.Response(200, json=[{
|
||||||
|
"number": 9,
|
||||||
|
"title": "Review mobile search",
|
||||||
|
"state": "open",
|
||||||
|
"repository": {"full_name": "stackchain/web"},
|
||||||
|
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||||
|
}])
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
|
try:
|
||||||
|
result = await gitea_proxy.global_search(
|
||||||
|
"mobile", limit=7, page=2, kind="pull", state="open"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert requests == [{
|
||||||
|
"q": "mobile", "type": "pulls", "state": "open", "limit": "7", "page": "2"
|
||||||
|
}]
|
||||||
|
assert [item["kind"] for item in result["items"]] == ["pull"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch):
|
async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch):
|
||||||
requested = []
|
requested = []
|
||||||
|
|
||||||
async def search(query, limit, page):
|
async def search(query, limit, page, kind, state):
|
||||||
requested.append((query, limit, page))
|
requested.append((query, limit, page, kind, state))
|
||||||
return {
|
return {
|
||||||
"items": [{
|
"items": [{
|
||||||
"kind": "issue",
|
"kind": "issue",
|
||||||
|
|
@ -31,8 +79,8 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["cache-control"] == "no-store"
|
assert response.headers["cache-control"] == "no-store"
|
||||||
assert requested == [("mobile", 7, 2)]
|
assert requested == [("mobile", 7, 2, "all", "all")]
|
||||||
assert response.json() == {"query": "mobile", "items": [{
|
assert response.json() == {"query": "mobile", "scope": {"kind": "all", "state": "all"}, "items": [{
|
||||||
"kind": "issue",
|
"kind": "issue",
|
||||||
"repository": "stackchain/api",
|
"repository": "stackchain/api",
|
||||||
"number": 42,
|
"number": 42,
|
||||||
|
|
@ -110,7 +158,12 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
|
||||||
await gitea_proxy.stop_client()
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
assert {request["type"] for request in requests} == {"issues", "pulls"}
|
assert {request["type"] for request in requests} == {"issues", "pulls"}
|
||||||
assert all(request["q"] == "mobile queue" and request["limit"] == "4" for request in requests)
|
assert all(
|
||||||
|
request["q"] == "mobile queue"
|
||||||
|
and request["limit"] == "4"
|
||||||
|
and request["state"] == "all"
|
||||||
|
for request in requests
|
||||||
|
)
|
||||||
assert results == {
|
assert results == {
|
||||||
"items": [{
|
"items": [{
|
||||||
"kind": "issue", "repository": "stackchain/api", "number": 42,
|
"kind": "issue", "repository": "stackchain/api", "number": 42,
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,7 @@ def test_dashboard_routes_mobile_task_overlays_through_browser_history():
|
||||||
assert "createTaskOverlayHistory({" in html
|
assert "createTaskOverlayHistory({" in html
|
||||||
assert "taskOverlayHistory.open('new')" in html
|
assert "taskOverlayHistory.open('new')" in html
|
||||||
assert "taskOverlayHistory.open('find')" in html
|
assert "taskOverlayHistory.open('find')" in html
|
||||||
assert "taskOverlayHistory.open('search')" in html
|
assert "taskOverlayHistory.open('search', { scope:currentSearchScope() })" in html
|
||||||
assert "taskOverlayHistory.open('search-preview')" in html
|
assert "taskOverlayHistory.open('search-preview')" in html
|
||||||
assert "taskOverlayHistory.close()" in html
|
assert "taskOverlayHistory.close()" in html
|
||||||
assert "saveIssueCaptureDraft();" in html
|
assert "saveIssueCaptureDraft();" in html
|
||||||
|
|
@ -290,10 +290,45 @@ process.stdout.write(JSON.stringify({{
|
||||||
assert payload["oversized"]["state"] == {"kind": None}
|
assert payload["oversized"]["state"] == {"kind": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_history_round_trips_only_bounded_type_and_status_scope():
|
||||||
|
script = f"""
|
||||||
|
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
|
||||||
|
function restore(search) {{
|
||||||
|
const location = {{pathname:'/dashboard/', search, hash:''}};
|
||||||
|
const history = {{state:null, replaceState(state, title, url) {{this.state=state; this.url=url;}}, back() {{}}}};
|
||||||
|
const controller = createTaskOverlayHistory({{
|
||||||
|
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
|
||||||
|
}});
|
||||||
|
return {{state:controller.currentState(), url:history.url}};
|
||||||
|
}}
|
||||||
|
const location = {{pathname:'/dashboard/', search:'', hash:''}};
|
||||||
|
const history = {{state:null, pushState(state,title,url) {{this.state=state; this.url=url;}}, replaceState() {{}}, back() {{}}}};
|
||||||
|
const controller = createTaskOverlayHistory({{
|
||||||
|
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
|
||||||
|
}});
|
||||||
|
controller.open('search', {{query:'mobile', scope:{{kind:'pull',state:'open'}}}});
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
opened:controller.currentState(), url:history.url,
|
||||||
|
valid:restore('?search=mobile&search_kind=issue&search_state=closed'),
|
||||||
|
invalid:restore('?search=mobile&search_kind=script&search_state=secret'),
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
assert payload["opened"] == {
|
||||||
|
"kind": "search", "query": "mobile", "scope": {"kind": "pull", "state": "open"}
|
||||||
|
}
|
||||||
|
assert payload["url"] == "/dashboard/?search=mobile&search_kind=pull&search_state=open"
|
||||||
|
assert payload["valid"]["state"]["scope"] == {"kind": "issue", "state": "closed"}
|
||||||
|
assert payload["invalid"]["state"]["scope"] == {"kind": "all", "state": "all"}
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_persists_search_query_and_restores_canonical_preview():
|
def test_dashboard_persists_search_query_and_restores_canonical_preview():
|
||||||
html = dashboard_bundle_text()
|
html = dashboard_bundle_text()
|
||||||
|
|
||||||
assert "taskOverlayHistory.update({ query:e.target.value })" in html
|
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 "scope:currentSearchScope(), preview:item.result" in html
|
||||||
assert "detail?.query" in html
|
assert "detail?.query" in html
|
||||||
assert "searchPreview.open(detail.preview).catch" in html
|
assert "searchPreview.open(detail.preview).catch" in html
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user