function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {}, onFacets = () => {} }) { let available = []; let pagination = { page: 1, total: 0, has_more: false }; let loadRequest = null; let loadGeneration = 0; let query = ''; let filters = { repositories: [], labels: [] }; let facetView; let claimRequest = null; const previewed = new Set(); let selecting = false; const selected = new Map(); function itemKey(item) { return String(item?.repository || '') + '#' + String(item?.number || ''); } function emitSelection() { const state = { active: selecting, count: selected.size, ids: Array.from(selected.keys()) }; onSelection(state); return state; } function apply(result, append) { const incoming = Array.isArray(result?.items) ? result.items : []; if (append) { const byId = new Map(available.map(item => [item.id, item])); incoming.forEach(item => { if (item && !byId.has(item.id)) byId.set(item.id, { ...item }); }); available = Array.from(byId.values()); } else { available = incoming.map(item => ({ ...item })); } const availableKeys = new Set(available.map(itemKey)); previewed.forEach(key => { if (!availableKeys.has(key)) previewed.delete(key); }); pagination = { page: Number(result?.page) || 1, total: Number(result?.total) || available.length, has_more: result?.has_more === true, }; onItems(available.slice()); onPagination({ ...pagination }); if (result?.facets) onFacets({ repositories: Array.isArray(result.facets.repositories) ? result.facets.repositories.slice() : [], labels: Array.isArray(result.facets.labels) ? result.facets.labels.slice() : [], }); if (result?.facets && typeof document !== 'undefined') { facetView ||= attachFindWorkFacets(document.querySelector('#find-work-filters'), value => String(value).replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character])), next => { filters = next; loadPage(1, false); }); facetView(result.facets); } } function loadPage(page, append, requestedQuery = query) { if (loadRequest && requestedQuery === query && append) return loadRequest; const generation = ++loadGeneration; const params = new URLSearchParams(); params.set('facets', 'true'); if (requestedQuery) params.set('q', requestedQuery); filters.repositories.forEach(value => params.append('repository', value)); filters.labels.forEach(value => params.append('label', value)); const queryPart = params.toString() ? '&' + params.toString() : ''; const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, { headers: { Accept: 'application/json' }, }).then(result => { if (generation !== loadGeneration) return result; apply(result, append); if (result?.refresh_failed === true) { onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.'); } else if (result?.revalidating === true) { onStatus('Showing saved available work while the catalog refreshes…'); } return result; }).finally(() => { if (loadRequest === request) loadRequest = null; }); loadRequest = request; return request; } return { reset(result) { apply(result, false); }, load() { return loadPage(1, false); }, loadMore() { if (!pagination.has_more) return Promise.resolve(false); return loadPage(pagination.page + 1, true); }, search(value) { query = String(value || '').trim(); return loadPage(1, false, query); }, query() { return query; }, setFilters(next) { filters = { repositories: Array.from(new Set((next?.repositories || []).map(String))).slice(0, 10), labels: Array.from(new Set((next?.labels || []).map(String))).slice(0, 10), }; return loadPage(1, false); }, filters() { return { repositories: filters.repositories.slice(), labels: filters.labels.slice() }; }, items() { return available.slice(); }, startSelection() { selecting = true; selected.clear(); return emitSelection(); }, cancelSelection() { selecting = false; selected.clear(); return emitSelection(); }, toggleSelection(item) { if (!selecting || !item) return emitSelection(); const key = itemKey(item); if (selected.has(key)) selected.delete(key); else selected.set(key, item); return emitSelection(); }, fillSelection(limit) { limit = Math.max(0, Math.floor(Number(limit) || 0)); if (!limit) return { selected: selected.size, added: 0, limit }; selecting = true; const before = selected.size; available.some(item => { if (selected.size >= limit) return true; selected.set(itemKey(item), item); return false; }); emitSelection(); return { selected: selected.size, added: selected.size - before, limit }; }, isSelected(item) { return selected.has(itemKey(item)); }, selectedItems() { return Array.from(selected.values()); }, selection() { return { active: selecting, count: selected.size, ids: Array.from(selected.keys()) }; }, togglePreview(item) { const key = itemKey(item); if (previewed.has(key)) previewed.delete(key); else previewed.add(key); return previewed.has(key); }, isPreviewed(item) { return previewed.has(itemKey(item)); }, claim(item) { if (claimRequest) return claimRequest; const key = item.repository + '#' + item.number; onStatus('Assigning ' + key + '…'); const path = 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') + '/issues/' + encodeURIComponent(item.number) + '/claim'; claimRequest = fetchJson(path, { method: 'PATCH', headers: { Accept: 'application/json' }, }).then(confirmed => { if (!Array.isArray(confirmed?.assignees) || !confirmed.assignees.length) { throw new Error('Issue assignment was not confirmed.'); } available = available.filter(candidate => candidate.repository !== item.repository || candidate.number !== item.number ); pagination.total = Math.max(available.length, pagination.total - 1); pagination.has_more = available.length < pagination.total; previewed.delete(itemKey(item)); selected.delete(itemKey(item)); emitSelection(); onItems(available.slice()); onPagination({ ...pagination }); onStatus('Assigned ' + key + ' to you.'); return confirmed; }).finally(() => { claimRequest = null; }); return claimRequest; }, }; } function attachFindWorkFacets(root, escapeHtml, onChange) { const values = name => Array.from(root.querySelectorAll('input[name="' + name + '"]:checked'), input => input.value); root.onchange = event => event.target.type === 'checkbox' && onChange({ repositories:values('find-work-repository'), labels:values('find-work-label') }); root.querySelector('#clear-find-work-filters').onclick = () => { root.querySelectorAll('input').forEach(input => { input.checked = false; }); onChange({ repositories:[], labels:[] }); }; return facets => { const active = { repositories:values('find-work-repository'), labels:values('find-work-label') }; const render = (id, choices, selected, name) => { root.querySelector(id).innerHTML = choices.map(value => '').join(''); }; render('#find-work-repository-filters', facets.repositories || [], active.repositories, 'find-work-repository'); render('#find-work-label-filters', facets.labels || [], active.labels, 'find-work-label'); const count = active.repositories.length + active.labels.length; root.querySelector('#find-work-filter-count').textContent = count ? '(' + count + ' active)' : ''; }; } if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;