fix(webui): selector race conditions / wonkiness
parent
2958d44cc0
commit
7533697907
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue