forked from SolidOS/solid-ui
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathautocompletePicker.ts
More file actions
310 lines (282 loc) · 11.4 KB
/
Copy pathautocompletePicker.ts
File metadata and controls
310 lines (282 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
/* Autocomplete Picker: Create and edit data using public data
**
** As the data source is passed as a parameter, all kinds of APIa and query services can be used
**
*/
import * as debug from '../../../debug'
import * as style from '../../../style'
import styleConstants from '../../../styleConstants'
import * as widgets from '../../../widgets'
import { store } from 'solid-logic'
import { NamedNode, Literal } from 'rdflib'
import { queryPublicDataByName, bindingToTerm, AUTOCOMPLETE_LIMIT, QueryParameters } from './publicData'
import { filterByLanguage, getPreferredLanguages, defaultPreferredLanguages } from './language'
const AUTOCOMPLETE_THRESHOLD = 4 // don't check until this many characters typed
const AUTOCOMPLETE_ROWS = 20 // 20?
const AUTOCOMPLETE_ROWS_STRETCH = 40
/*
Autocomplete happens in 6 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. Single one selected. Optionally waiting for accept button to be pressed, OR can change string and go to 5 or 2
6. Locked with a value. Press 'edit' button to return to 5
*/
export type AutocompleteDecoration = {
acceptButton?: HTMLElement,
cancelButton: HTMLElement, // Must have cancel button
editButton?: HTMLElement,
deleteButton?: HTMLElement
}
export type AutocompleteOptions = {
targetClass?: NamedNode,
currentObject?: NamedNode,
currentName?: Literal,
queryParams: QueryParameters,
size?: number,
permanent?: boolean
}
interface Callback1 {
(_subject: NamedNode, _name: Literal): any;
}
export function setVisible (element:HTMLElement, visible:boolean) {
element.style.display = visible ? '' : 'none' // Do not use visibility, it holds the real estate
}
// The core of the autocomplete UI
export async function renderAutoComplete (dom: HTMLDocument,
acOptions:AutocompleteOptions,
decoration: AutocompleteDecoration,
callback: Callback1) {
function complain (message) {
const errorRow = table.appendChild(dom.createElement('tr'))
debug.log(message)
const err = new Error(message)
errorRow.appendChild(widgets.errorMessageBlock(dom, err, 'pink'))
// errorMessageBlock will log the stack to the console
style.setStyle(errorRow, 'autocompleteRowStyle')
errorRow.style.padding = '1em'
}
function finish (object, name) {
debug.log('Auto complete: finish! ' + object)
if (object.termType === 'Literal' && acOptions.queryParams.objectURIBase) {
object = store.sym(acOptions.queryParams.objectURIBase.value + object.value)
}
// remove(decoration.cancelButton)
// remove(decoration.acceptButton)
// remove(div)
clearList()
callback(object, name)
}
async function gotIt (object:NamedNode | Literal, name: Literal) {
if (decoration.acceptButton) {
(decoration.acceptButton as any).disbaled = false
setVisible(decoration.acceptButton, true) // now wait for confirmation
searchInput.value = name.value // complete it
foundName = name
foundObject = object
debug.log('Auto complete: name: ' + name)
debug.log('Auto complete: waiting for accept ' + object)
clearList() // This may be an option - nice and clean but does not allow change of mind
return
}
setVisible(decoration.cancelButton, true)
finish(object, name)
}
async function acceptButtonHandler (_event) {
if (foundName && searchInput.value === foundName.value) { // still
finish(foundObject, foundName)
}
}
async function cancelButtonHandler (_event) {
debug.log('Auto complete: Canceled by user! ')
if (acOptions.permanent) {
initialize()
} else {
if (div.parentNode) {
div.parentNode.removeChild(div)
}
}
}
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 clearList () {
while (table.children.length > 1) {
table.removeChild(table.lastChild as Node)
}
}
async function inputEventHHandler (_event) {
// console.log('@@ AC inputEventHHandler called')
setVisible(decoration.cancelButton, true) // only allow cancel when there is something to cancel
refreshList() /// @@ debounqce does not work with jest
/*
if (runningTimeout) {
clearTimeout(runningTimeout)
}
runningTimeout = setTimeout(refreshList, AUTOCOMPLETE_DEBOUNCE_MS)
*/
}
async function loadBindingsAndFilterByLanguage (filter, languagePrefs) {
// console.log('@@ loadBindingsAndFilterByLanguage ' + filter)
let bindings
try {
bindings = await queryPublicDataByName(filter, targetClass as any,
languagePrefs || defaultPreferredLanguages, acOptions.queryParams)
} catch (err) {
complain('Error querying db of organizations: ' + err)
inputEventHandlerLock = false
return
}
loadedEnough = bindings.length < AUTOCOMPLETE_LIMIT
if (loadedEnough) {
lastFilter = filter
} else {
lastFilter = undefined
}
clearList()
const slimmed = filterByLanguage(bindings, languagePrefs)
return slimmed
}
function filterByName (filter, bindings) {
return bindings.filter(binding => nameMatch(filter, binding.name.value))
}
async function refreshList () {
// console.log('@@ refreshList called')
function rowForBinding (binding) {
const row = dom.createElement('tr')
style.setStyle(row, 'autocompleteRowStyle')
row.setAttribute('style', 'padding: 0.3em;')
row.style.color = allDisplayed ? '#080' : '#088' // green means 'you should find it here'
row.textContent = binding.name.value
const object = bindingToTerm(binding.subject)
const nameTerm = bindingToTerm(binding.name)
row.addEventListener('click', async _event => {
debug.log(' click row textContent: ' + row.textContent)
debug.log(' click name: ' + nameTerm.value)
if (object && nameTerm) {
gotIt(object, nameTerm as Literal)
}
})
return row
} // rowForBinding
function compareBindingsByName (self, other) {
return other.name.value > self.name.value
? 1
: other.name.name < self.name.value ? -1 : 0
}
if (inputEventHandlerLock) {
debug.log(`Ignoring "${searchInput.value}" because of lock `)
return
}
debug.log(`Setting lock at "${searchInput.value}"`)
inputEventHandlerLock = true
const 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)) {
debug.log(` Querying database at "${filter}" cf last "${lastFilter}".`)
lastBindings = await loadBindingsAndFilterByLanguage(filter, languagePrefs) // freesh query
}
// Trim table as search gets tighter:
const slimmed = filterByName(filter, lastBindings)
if (loadedEnough && slimmed.length <= AUTOCOMPLETE_ROWS_STRETCH) {
numberOfRows = slimmed.length // stretch if it means we get all items
}
allDisplayed = loadedEnough && slimmed.length <= numberOfRows
debug.log(` Filter:"${filter}" lastBindings: ${lastBindings.length}, slimmed to ${slimmed.length}; rows: ${numberOfRows}, Enough? ${loadedEnough}, All displayed? ${allDisplayed}`)
const displayable = slimmed.slice(0, numberOfRows)
displayable.sort(compareBindingsByName)
clearList()
for (const binding of displayable) {
table.appendChild(rowForBinding(binding))
}
if (slimmed.length === 1) {
gotIt(bindingToTerm(slimmed[0].subject), bindingToTerm(slimmed[0].name) as Literal)
}
} // else
inputEventHandlerLock = false
} // refreshList
function initialize () {
if (acOptions.currentObject) { // If have existing value then jump into the endgame of the autocomplete
searchInput.value = acOptions.currentName ? acOptions.currentName.value : '??? wot no name for ' + acOptions.currentObject
foundName = acOptions.currentName
lastFilter = acOptions.currentName ? acOptions.currentName.value : undefined
foundObject = acOptions.currentObject
} else {
searchInput.value = ''
lastFilter = undefined
foundObject = undefined
}
if (decoration.deleteButton) {
setVisible(decoration.deleteButton, !!acOptions.currentObject)
}
if (decoration.acceptButton) {
setVisible(decoration.acceptButton, false) // hide until input complete
}
if (decoration.editButton) {
setVisible(decoration.editButton, true)
}
if (decoration.cancelButton) {
setVisible(decoration.cancelButton, false) // only allow cancel when there is something to cancel
}
inputEventHandlerLock = false
clearList()
} // initialiize
// const queryParams: QueryParameters = acOptions.queryParams
const targetClass = acOptions.targetClass
if (!targetClass) throw new Error('renderAutoComplete: missing targetClass')
// console.log(`renderAutoComplete: targetClass=${targetClass}` )
if (decoration.acceptButton) {
decoration.acceptButton.addEventListener('click', acceptButtonHandler, false)
}
if (decoration.cancelButton) {
decoration.cancelButton.addEventListener('click', cancelButtonHandler, false)
}
// var candidatesLoaded = false
let lastBindings
let loadedEnough = false
let inputEventHandlerLock = false
let allDisplayed = false
let lastFilter = undefined as (string | undefined)
let numberOfRows = AUTOCOMPLETE_ROWS // this gets slimmed down
const div = dom.createElement('div')
let foundName = undefined as (Literal | undefined)// once found accepted string must match this
let foundObject = undefined as (NamedNode | Literal | undefined)
const table = div.appendChild(dom.createElement('table'))
table.setAttribute('data-testid', 'autocomplete-table')
table.setAttribute('style', 'max-width: 30em; margin: 0.5em;')
const head = table.appendChild(dom.createElement('tr'))
style.setStyle(head, 'autocompleteRowStyle') // textInputStyle or
const cell = head.appendChild(dom.createElement('td'))
const searchInput = cell.appendChild(dom.createElement('input'))
searchInput.setAttribute('type', 'text')
initialize()
const size = acOptions.size || styleConstants.textInputSize || 20
searchInput.setAttribute('size', size)
searchInput.setAttribute('data-testid', 'autocomplete-input')
const searchInputStyle = style.textInputStyle || // 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)
// console.log('@@ renderAutoComplete returns ' + div.innerHTML)
return div
} // renderAutoComplete
// ENDS