160 lines
6.8 KiB
JavaScript
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;
|
|
});
|