fix(webui): selector race conditions / wonkiness

develop
Tomáš Mládek 2024-01-27 14:54:47 +01:00
parent 2958d44cc0
commit 7533697907
3 changed files with 135 additions and 106 deletions

View File

@ -139,126 +139,144 @@
let options: SelectorOption[] = []; let options: SelectorOption[] = [];
let searchResult: UpListing | undefined = undefined; let searchResult: UpListing | undefined = undefined;
let optionsAbortController: AbortController | undefined = undefined;
const updateOptions = debounce(async (query: string, doSearch: boolean) => { const updateOptions = debounce(async (query: string, doSearch: boolean) => {
updating = true; updating = true;
let result: SelectorOption[] = []; let result: SelectorOption[] = [];
optionsAbortController?.abort();
const abortController = new AbortController();
optionsAbortController = abortController;
if (query.length === 0 && emptyOptions !== undefined) { if (query.length === 0 && emptyOptions !== undefined) {
options = emptyOptions; options = emptyOptions;
updating = false; updating = false;
return; return;
} }
if (types.includes('Number')) { try {
const number = parseFloat(query); if (types.includes('Number')) {
if (!Number.isNaN(number)) { const number = parseFloat(query);
result.push({ if (!Number.isNaN(number)) {
t: 'Number', result.push({
c: number t: 'Number',
}); c: number
} });
}
if (types.includes('String') && query.length) {
result.push({
t: 'String',
c: query
});
}
options = result;
if (types.includes('Address') || types.includes('LabelledAddress')) {
if (doSearch) {
if (emptyOptions === undefined || query.length > 0) {
searchResult = await baseSearchOnce(query);
} else {
searchResult = undefined;
} }
} }
let exactHits = Object.entries(addressToLabels) if (types.includes('String') && query.length) {
.filter(([_, labels]) => labels.map((l) => l.toLowerCase()).includes(query.toLowerCase()))
.map(([addr, _]) => addr);
if (exactHits.length) {
exactHits.forEach((addr) =>
result.push({
t: 'Address',
c: addr,
labels: addressToLabels[addr],
entry: undefined
})
);
} else if (query.length && types.includes('NewAddress')) {
result.push({ result.push({
t: 'NewAddress', t: 'String',
c: query c: query
}); });
} }
let validOptions = (searchResult?.entries || []).filter((e) => !exactHits.includes(e.entity)); options = result;
// only includes LabelledAddress
if (!types.includes('Address')) { if (types.includes('Address') || types.includes('LabelledAddress')) {
validOptions = validOptions.filter((e) => e.attribute == ATTR_LABEL); if (doSearch) {
if (emptyOptions === undefined || query.length > 0) {
searchResult = await baseSearchOnce(query, { abortController });
} else {
searchResult = undefined;
}
}
let exactHits = Object.entries(addressToLabels)
.filter(([_, labels]) => labels.map((l) => l.toLowerCase()).includes(query.toLowerCase()))
.map(([addr, _]) => addr);
if (exactHits.length) {
exactHits.forEach((addr) =>
result.push({
t: 'Address',
c: addr,
labels: addressToLabels[addr],
entry: undefined
})
);
} else if (query.length && types.includes('NewAddress')) {
result.push({
t: 'NewAddress',
c: query
});
}
let validOptions = (searchResult?.entries || []).filter(
(e) => !exactHits.includes(e.entity)
);
// only includes LabelledAddress
if (!types.includes('Address')) {
validOptions = validOptions.filter((e) => e.attribute == ATTR_LABEL);
}
const sortedOptions = matchSorter(validOptions, inputValue, {
keys: ['value.c', (i) => addressToLabels[i.entity]?.join(' ')]
});
for (const entry of sortedOptions) {
const common = {
t: 'Address' as const,
c: entry.entity
};
if (entry.attribute == ATTR_LABEL && entry.value.c) {
result.push({
...common,
labels: [entry.value.c.toString()]
});
} else {
result.push({ ...common, entry });
}
}
} }
const sortedOptions = matchSorter(validOptions, inputValue, { if (types.includes('Attribute')) {
keys: ['value.c', (i) => addressToLabels[i.entity]?.join(' ')] const allAttributes = await api.fetchAllAttributes({ abortController });
}); const attributes = attributeOptions
? allAttributes.filter((attr) => attributeOptions!.includes(attr.name))
for (const entry of sortedOptions) { : allAttributes;
const common = { if (emptyOptions === undefined || query.length > 0) {
t: 'Address' as const, result.push(
c: entry.entity ...attributes
}; .filter(
if (entry.attribute == ATTR_LABEL && entry.value.c) { (attr) =>
result.push({ attr.name.toLowerCase().includes(query.toLowerCase()) ||
...common, attr.labels.some((label) => label.toLowerCase().includes(query.toLowerCase()))
labels: [entry.value.c.toString()] )
}); .map(
} else { (attribute) =>
result.push({ ...common, entry }); ({
t: 'Attribute',
...attribute
}) as SelectorOption
)
);
} }
const attributeToCreate = query.toUpperCase().replaceAll(/[^A-Z0-9]/g, '_');
if (
!attributeOptions &&
query &&
!allAttributes.map((attr) => attr.name).includes(attributeToCreate) &&
types.includes('NewAttribute')
) {
result.push({
t: 'NewAttribute',
name: attributeToCreate,
label: query
});
}
}
} catch (e: unknown) {
if (e && (e as Error).name !== 'AbortError') {
throw e;
} }
} }
if (types.includes('Attribute')) { if (abortController.signal.aborted) {
const allAttributes = await api.fetchAllAttributes(); dbg('%o Aborted search', selectorEl);
const attributes = attributeOptions return;
? allAttributes.filter((attr) => attributeOptions!.includes(attr.name))
: allAttributes;
if (emptyOptions === undefined || query.length > 0) {
result.push(
...attributes
.filter(
(attr) =>
attr.name.toLowerCase().includes(query.toLowerCase()) ||
attr.labels.some((label) => label.toLowerCase().includes(query.toLowerCase()))
)
.map(
(attribute) =>
({
t: 'Attribute',
...attribute
}) as SelectorOption
)
);
}
const attributeToCreate = query.toUpperCase().replaceAll(/[^A-Z0-9]/g, '_');
if (
!attributeOptions &&
query &&
!allAttributes.map((attr) => attr.name).includes(attributeToCreate) &&
types.includes('NewAttribute')
) {
result.push({
t: 'NewAttribute',
name: attributeToCreate,
label: query
});
}
} }
options = result; options = result;
@ -269,7 +287,6 @@
$: { $: {
if (inputFocused) { if (inputFocused) {
updateOptions.cancel();
updateOptions(inputValue, true); updateOptions(inputValue, true);
addressToLabels = {}; addressToLabels = {};
} }
@ -278,7 +295,6 @@
let addressToLabels: { [key: string]: string[] } = {}; let addressToLabels: { [key: string]: string[] } = {};
function onAddressResolved(address: string, ev: CustomEvent<string[]>) { function onAddressResolved(address: string, ev: CustomEvent<string[]>) {
addressToLabels[address] = ev.detail; addressToLabels[address] = ev.detail;
updateOptions.cancel();
updateOptions(inputValue, false); updateOptions(inputValue, false);
} }

View File

@ -5,6 +5,7 @@ import type { EntityInfo, EntityListing, ListingResult } from '@upnd/upend/types
import { useSWR } from '$lib/util/fetch'; import { useSWR } from '$lib/util/fetch';
import api from './api'; import api from './api';
import debug from 'debug'; import debug from 'debug';
import type { ApiFetchOptions } from '@upnd/upend/api';
const dbg = debug('kestrel:lib'); const dbg = debug('kestrel:lib');
@ -34,11 +35,20 @@ export function useEntity(address: string) {
}; };
} }
export function query(query: string) { export function query(query: string, options?: ApiFetchOptions) {
dbg(`Querying: ${query}`); dbg(`Querying: ${query}`);
const controller = options?.abortController || new AbortController();
const timeout = options?.timeout || api.timeout;
if (timeout > 0) {
setTimeout(() => controller.abort(), timeout);
}
const signal = controller.signal;
const { data, error, revalidate } = useSWR<ListingResult, unknown>(`${api.apiUrl}/query`, { const { data, error, revalidate } = useSWR<ListingResult, unknown>(`${api.apiUrl}/query`, {
method: 'POST', method: 'POST',
body: query body: query,
signal
}); });
const result = derived(data, ($values) => { const result = derived(data, ($values) => {

View File

@ -2,20 +2,23 @@ import type { PutInput } from '@upnd/upend/types';
import { query as queryFn } from '$lib/entity'; import { query as queryFn } from '$lib/entity';
import api from '$lib/api'; import api from '$lib/api';
import { ATTR_LABEL } from '@upnd/upend/constants'; import { ATTR_LABEL } from '@upnd/upend/constants';
import type { ApiFetchOptions } from '@upnd/upend/api';
export function baseSearch(query: string) { export function baseSearch(query: string, options?: ApiFetchOptions) {
return queryFn( return queryFn(
`(or (matches (contains "${query}") ? ?) (matches ? (contains "${query}") ?) (matches ? ? (contains "${query}")))` `(or (matches (contains "${query}") ? ?) (matches ? (contains "${query}") ?) (matches ? ? (contains "${query}")))`,
options
); );
} }
export function baseSearchOnce(query: string) { export function baseSearchOnce(query: string, options?: ApiFetchOptions) {
return api.query( return api.query(
`(or (matches (contains "${query}") ? ?) (matches ? (contains "${query}") ?) (matches ? ? (contains "${query}")))` `(or (matches (contains "${query}") ? ?) (matches ? (contains "${query}") ?) (matches ? ? (contains "${query}")))`,
options
); );
} }
export async function createLabelled(label: string) { export async function createLabelled(label: string, options?: ApiFetchOptions) {
let body: PutInput; let body: PutInput;
if (label.match('^[\\w]+://[\\w]')) { if (label.match('^[\\w]+://[\\w]')) {
body = { body = {
@ -33,7 +36,7 @@ export async function createLabelled(label: string) {
} }
}); });
await api.putEntityAttribute(entity, ATTR_LABEL, { t: 'String', c: label }); await api.putEntityAttribute(entity, ATTR_LABEL, { t: 'String', c: label }, undefined, options);
return entity; return entity;
} }