UNPKG

contacts-pane

Version:

Contacts Pane: Contacts manager for Address Book, Groups, and Individuals.

254 lines (233 loc) 9.09 kB
/* Create and edit data using public data ** ** organization conveys many distinct types of thing. ** */ import { NamedNode } from 'rdflib' import { store } from 'solid-logic' import { style, widgets } from 'solid-ui' import { AUTOCOMPLETE_LIMIT, filterByLanguage, getPreferredLanguages, QueryParameters, queryPublicDataByName } from './publicData' const AUTOCOMPLETE_THRESHOLD = 4 // don't check until this many characters typed const AUTOCOMPLETE_ROWS = 20 // 20? const AUTOCOMPLETE_ROWS_STRETCH = 40 const AUTOCOMPLETE_DEBOUNCE_MS = 300 const autocompleteRowStyle = 'border: 0.2em solid straw;' // @@ white /* Autocomplete happens in four phases: 1. The search string is too small to bother 2. The search string is big enough, and we have not loaded the array 3. The search string is big enough, and we have loaded array up to the limit Display them and wait for more user input 4. The search string is big enough, and we have loaded array NOT to the limit but including all matches. No more fetches. If user gets more precise, wait for them to select one - or reduce to a single 5. Optionally waiting for accept button to be pressed */ type AutocompleteOptions = { cancelButton?: HTMLElement, acceptButton?: HTMLElement, class: NamedNode, queryParams: QueryParameters } interface Callback1 { (subject: NamedNode, name: string): void; } // The core of the autocomplete UI export async function renderAutoComplete (dom: HTMLDocument, options:AutocompleteOptions, // subject:NamedNode, predicate:NamedNode, callback: Callback1) { function complain (message) { const errorRow = table.appendChild(dom.createElement('tr')) console.log(message) errorRow.appendChild(widgets.errorMessageBlock(dom, message, 'pink')) style.setStyle(errorRow, 'autocompleteRowStyle') errorRow.style.padding = '1em' } function remove (ele?: HTMLElement) { if (ele) { ele.parentNode.removeChild(ele) } } function finish (object, name) { console.log('Auto complete: finish! ' + object) // remove(options.cancelButton) // remove(options.acceptButton) // remove(div) callback(object, name) } async function gotIt(object:NamedNode, name:string) { if (options.acceptButton) { (options.acceptButton as any).disabled = false searchInput.value = name // complete it foundName = name foundObject = object console.log('Auto complete: name: ' + name) console.log('Auto complete: waiting for accept ' + object) return } finish(object, name) } async function acceptButtonHandler (_event) { if (searchInput.value === foundName) { // still finish(foundObject, foundName) } else { (options.acceptButton as any).disabled = true } } async function cancelButtonHandler (_event) { console.log('Auto complete: Canceled by user! ') div.innerHTML = '' // Clear out the table } function nameMatch (filter:string, candidate: string):boolean { const parts = filter.split(' ') // Each name part must be somewhere for (let j = 0; j < parts.length; j++) { const word = parts[j] if (candidate.toLowerCase().indexOf(word) < 0) return false } return true } function cancelText (_event) { searchInput.value = ''; if (options.acceptButton) { (options.acceptButton as any).disabled == true; // start again } candidatesLoaded = false } function thinOut (filter) { var hits = 0 var pick = null, pickedName = '' for (let j = table.children.length - 1; j > 0; j--) { // backwards as we are removing rows let row = table.children[j] if (nameMatch(filter, row.textContent)) { hits += 1 pick = row.getAttribute('subject') pickedName = row.textContent ;(row as any).style.display = '' ;(row as any).style.color = 'blue' // @@ chose color } else { ;(row as any).style.display = 'none' } } if (hits == 1) { // Maybe require green confirmation button be clicked? console.log(` auto complete elimination: "${filter}" -> "${pickedName}"`) gotIt(store.sym(pick), pickedName) // uri, name } } function clearList () { while (table.children.length > 1) { table.removeChild(table.lastChild) } } async function inputEventHHandler(_event) { if (runningTimeout) { clearTimeout(runningTimeout) } setTimeout(refreshList, AUTOCOMPLETE_DEBOUNCE_MS) } async function refreshList() { if (inputEventHandlerLock) { console.log (`Ignoring "${searchInput.value}" because of lock `) return } inputEventHandlerLock = true var languagePrefs = await getPreferredLanguages() const filter = searchInput.value.trim().toLowerCase() if (filter.length < AUTOCOMPLETE_THRESHOLD) { // too small clearList() candidatesLoaded = false numberOfRows = AUTOCOMPLETE_ROWS } else { if (allDisplayed && lastFilter && filter.startsWith(lastFilter)) { thinOut(filter) // reversible? inputEventHandlerLock = false return } var bindings try { bindings = await queryPublicDataByName(filter, OrgClass, options.queryParams) // bindings = await queryDbpedia(sparql) } catch (err) { complain('Error querying db of organizations: ' + err) inputEventHandlerLock = false return } candidatesLoaded = true const loadedEnough = bindings.length < AUTOCOMPLETE_LIMIT if (loadedEnough) { lastFilter = filter } else { lastFilter = null } clearList() const slimmed = filterByLanguage(bindings, languagePrefs) if (loadedEnough && slimmed.length <= AUTOCOMPLETE_ROWS_STRETCH) { numberOfRows = slimmed.length // stretch if it means we get all items } allDisplayed = loadedEnough && slimmed.length <= numberOfRows console.log(` Filter:"${filter}" bindings: ${bindings.length}, slimmed to ${slimmed.length}; rows: ${numberOfRows}, Enough? ${loadedEnough}, All displayed? ${allDisplayed}`) slimmed.slice(0,numberOfRows).forEach(binding => { const row = table.appendChild(dom.createElement('tr')) style.setStyle(row, 'autocompleteRowStyle') var uri = binding.subject.value var name = binding.name.value row.setAttribute('style', 'padding: 0.3em;') row.setAttribute('subject', uri) row.style.color = allDisplayed ? '#080' : '#000' // green means 'you should find it here' row.textContent = name row.addEventListener('click', async _event => { console.log(' click row textContent: ' + row.textContent) console.log(' click name: ' + name) gotIt(store.sym(uri), name) }) }) } inputEventHandlerLock = false } // refreshList /* sparqlForSearch * * name -- e.g., "mass" * theType -- e.g., <http://umbel.org/umbel/rc/EducationalOrganization> */ function sparqlForSearch (name:string, theType:NamedNode):string { let clean = name.replace(/\W/g, '') // Remove non alphanum so as to protect regexp const sparql = `select distinct ?subject, ?name where { ?subject a <${theType.uri}>; rdfs:label ?name FILTER regex(?name, "${clean}", "i") } LIMIT ${AUTOCOMPLETE_LIMIT}` return sparql } const queryParams: QueryParameters = options.queryParams const OrgClass = options.class // kb.sym('http://umbel.org/umbel/rc/EducationalOrganization') // @@@ other if (options.acceptButton) { options.acceptButton.addEventListener('click', acceptButtonHandler, false) } if (options.cancelButton) { // options.cancelButton.addEventListener('click', cancelButtonHandler, false) } let candidatesLoaded = false let runningTimeout = null let inputEventHandlerLock = false let allDisplayed = false var lastFilter = null var numberOfRows = AUTOCOMPLETE_ROWS var div = dom.createElement('div') var foundName = null // once found accepted string must match this var foundObject = null var table = div.appendChild(dom.createElement('table')) table.setAttribute('style', 'max-width: 30em; margin: 0.5em;') const head = table.appendChild(dom.createElement('tr')) style.setStyle(head, 'autocompleteRowStyle') const cell = head.appendChild(dom.createElement('td')) const searchInput = cell.appendChild(dom.createElement('input')) searchInput.setAttribute('type', 'text') const searchInputStyle = style.searchInputStyle || 'border: 0.1em solid #444; border-radius: 0.5em; width: 100%; font-size: 100%; padding: 0.1em 0.6em' // @ searchInput.setAttribute('style', searchInputStyle) searchInput.addEventListener('keyup', function (event) { if (event.keyCode === 13) { acceptButtonHandler(event) } }, false); searchInput.addEventListener('input', inputEventHHandler) return div } // renderAutoComplete const ends = 'ENDS';