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 searchResult: UpListing | undefined = undefined;
let optionsAbortController: AbortController | undefined = undefined;
const updateOptions = debounce(async (query: string, doSearch: boolean) => {
updating = true;
let result: SelectorOption[] = [];
optionsAbortController?.abort();
const abortController = new AbortController();
optionsAbortController = abortController;
if (query.length === 0 && emptyOptions !== undefined) {
options = emptyOptions;
updating = false;
return;
}
if (types.includes('Number')) {
const number = parseFloat(query);
if (!Number.isNaN(number)) {
result.push({
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;
try {
if (types.includes('Number')) {
const number = parseFloat(query);
if (!Number.isNaN(number)) {
result.push({
t: 'Number',
c: number
});
}
}
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')) {
if (types.includes('String') && query.length) {
result.push({
t: 'NewAddress',
t: 'String',
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);
options = result;
if (types.includes('Address') || types.includes('LabelledAddress')) {
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, {
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 });
if (types.includes('Attribute')) {
const allAttributes = await api.fetchAllAttributes({ abortController });
const attributes = attributeOptions
? 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
});
}
}
} catch (e: unknown) {
if (e && (e as Error).name !== 'AbortError') {
throw e;
}
}
if (types.includes('Attribute')) {
const allAttributes = await api.fetchAllAttributes();
const attributes = attributeOptions
? 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
});
}
if (abortController.signal.aborted) {
dbg('%o Aborted search', selectorEl);
return;
}
options = result;
@ -269,7 +287,6 @@
$: {
if (inputFocused) {
updateOptions.cancel();
updateOptions(inputValue, true);
addressToLabels = {};
}
@ -278,7 +295,6 @@
let addressToLabels: { [key: string]: string[] } = {};
function onAddressResolved(address: string, ev: CustomEvent<string[]>) {
addressToLabels[address] = ev.detail;
updateOptions.cancel();
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 api from './api';
import debug from 'debug';
import type { ApiFetchOptions } from '@upnd/upend/api';
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}`);
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`, {
method: 'POST',
body: query
body: query,
signal
});
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 api from '$lib/api';
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(
`(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(
`(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;
if (label.match('^[\\w]+://[\\w]')) {
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;
}