stackchain-dashboard/frontend/saved-searches.js
timmy 19772b5797
All checks were successful
CI / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
feat: sync saved mobile search views (Closes #821)
2026-08-14 10:46:13 +00:00

160 lines
6.8 KiB
JavaScript

(function (root, factory) {
const createSavedSearches = factory();
if (typeof module === 'object' && module.exports) module.exports = createSavedSearches;
if (root) root.createSavedSearches = createSavedSearches;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
function createSavedSearches(options) {
const fetchJson = options.fetchJson;
const createId = options.createId || (() => crypto.randomUUID().replace(/-/g, ''));
const onOpen = options.onOpen;
const onState = options.onState || (() => {});
let current = { revision:0, views:[] };
function snapshot() {
return { revision:current.revision, views:current.views.map(view => ({ ...view })) };
}
function publish(status, message) {
onState({ ...snapshot(), status, ...(message ? { message } : {}) });
}
function normalizeSearch(name, search, id) {
const cleanName = String(name || '').trim();
const query = String(search?.query || '').trim();
if (!cleanName || cleanName.length > 60) throw new Error('Name must be between 1 and 60 characters.');
if (query.length < 2 || query.length > 200) throw new Error('Search query must be between 2 and 200 characters.');
return {
id, name:cleanName, query,
kind:['all', 'issue', 'pull'].includes(search?.kind) ? search.kind : 'all',
state:['all', 'open', 'closed'].includes(search?.state) ? search.state : 'all',
repository:String(search?.repository || '').trim(),
};
}
async function replace(views) {
publish('saving');
try {
current = await fetchJson('api/v1/saved-searches', {
method:'PUT', headers:{ 'Content-Type':'application/json' },
body:JSON.stringify({ revision:current.revision, views }),
});
publish('ready');
return snapshot();
} catch (error) {
const conflict = error?.status === 409 && error?.payload?.detail?.snapshot;
if (conflict) {
current = conflict;
publish('conflict', 'Saved searches changed on another device.');
} else {
publish('error', 'Saved searches could not sync. Search still works.');
}
throw error;
}
}
return {
async load() {
publish('loading');
try {
current = await fetchJson('api/v1/saved-searches');
publish('ready');
} catch (error) {
publish('error', 'Saved searches could not load. Search still works.');
}
return snapshot();
},
save(name, search) {
if (current.views.length >= 20) return Promise.reject(new Error('Saved searches are limited to 20.'));
const created = normalizeSearch(name, search, createId());
return replace(current.views.concat(created));
},
rename(id, name) {
const existing = current.views.find(view => view.id === id);
if (!existing) return Promise.reject(new Error('Saved search no longer exists.'));
const changed = normalizeSearch(name, existing, existing.id);
return replace(current.views.map(view => view.id === id ? changed : view));
},
remove(id) {
if (!current.views.some(view => view.id === id)) return Promise.resolve(snapshot());
return replace(current.views.filter(view => view.id !== id));
},
open(id) {
const selected = current.views.find(view => view.id === id);
if (selected) onOpen({ ...selected });
},
snapshot,
};
}
createSavedSearches.mount = function mountSavedSearches(document, fetch, search, applyScope, history) {
const query = selector => document.querySelector(selector);
const input = query('#cmd-input');
const request = async (url, init) => {
const response = await fetch(url, init);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.detail?.message || payload.detail || 'Saved Search request failed.');
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
};
let controller;
function render(state) {
const list = query('#saved-search-list');
const status = query('#saved-search-status');
list.replaceChildren();
status.textContent = ({loading:'Loading…', saving:'Syncing…', ready:'',
conflict:state.message, error:state.message})[state.status] || '';
state.views.forEach(view => {
const row = document.createElement('div');
row.className = 'saved-search-row';
const open = document.createElement('button');
open.type = 'button'; open.className = 'saved-search-open saved-search-action';
open.textContent = view.name; open.title = view.query;
open.addEventListener('click', () => controller.open(view.id));
const actions = document.createElement('div');
actions.className = 'saved-search-row-actions';
for (const action of ['Rename', 'Delete']) {
const button = document.createElement('button');
button.type = 'button'; button.className = 'saved-search-action'; button.textContent = action;
button.setAttribute('aria-label', action + ' ' + view.name);
button.addEventListener('click', async () => {
if (action === 'Rename') {
const name = globalThis.prompt('Rename saved search', view.name);
if (name !== null && name.trim()) await controller.rename(view.id, name).catch(() => {});
} else if (globalThis.confirm('Delete saved search “' + view.name + '”?')) {
await controller.remove(view.id).catch(() => {});
}
});
actions.append(button);
}
row.append(open, actions); list.append(row);
});
}
controller = createSavedSearches({ fetchJson:request, onState:render, onOpen:view => {
search.setQuery('');
input.value = view.query;
applyScope(view);
search.setQuery(view.query);
history.update({query:view.query, scope:view});
input.focus();
}});
query('#save-current-search').addEventListener('click', async () => {
const name = query('#saved-search-name').value.trim();
const current = {query:input.value.trim(), kind:query('#cmd-search-kind').value,
state:query('#cmd-search-state').value, repository:query('#cmd-search-repository').value.trim()};
if (!name || current.query.length < 2) {
query('#saved-search-status').textContent = 'Enter a name and at least 2 search characters.';
return;
}
await controller.save(name, current).then(() => { query('#saved-search-name').value = ''; })
.catch(error => { query('#saved-search-status').textContent = error.message; });
});
return controller;
};
return createSavedSearches;
});