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