client/src/lang/typeahead.ts (156 lines of code) (raw):
import { CqlQuery, CqlField } from "./ast";
import { Token } from "./token";
import {
DateSuggestionOption,
TextSuggestionOption,
TypeaheadSuggestion,
SuggestionType,
} from "./types";
import { getCqlFieldsFromCqlBinary } from "./utils";
export type TypeaheadResolver =
| ((str: string, signal?: AbortSignal) => Promise<TextSuggestionOption[]>)
| TextSuggestionOption[];
export class TypeaheadField {
constructor(
public id: string,
public name: string,
public description: string,
private resolver?: TypeaheadResolver,
public suggestionType: SuggestionType = "TEXT"
) {}
public resolveSuggestions(
str: string,
signal?: AbortSignal
): Promise<TextSuggestionOption[]> | undefined {
if (Array.isArray(this.resolver)) {
return Promise.resolve(
this.resolver.filter((item) => item.label.includes(str))
);
}
return this.resolver?.(str, signal);
}
public toSuggestionOption(): TextSuggestionOption {
return new TextSuggestionOption(this.name, this.id, this.description);
}
}
export class Typeahead {
private typeaheadFieldEntries: TextSuggestionOption[];
private abortController: AbortController | undefined;
constructor(private typeaheadFields: TypeaheadField[]) {
this.typeaheadFieldEntries = this.typeaheadFields.map((field) =>
field.toSuggestionOption()
);
}
public getSuggestions(
program: CqlQuery,
signal?: AbortSignal
): Promise<TypeaheadSuggestion[]> {
return new Promise((resolve, reject) => {
// Abort existing fetch, if it exists
this.abortController?.abort();
if (!program.content) {
return resolve([]);
}
const abortController = new AbortController();
this.abortController = abortController;
abortController.signal.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
});
const eventuallySuggestions = getCqlFieldsFromCqlBinary(
program.content
).flatMap((queryField) => this.suggestCqlField(queryField, signal));
return Promise.all(eventuallySuggestions)
.then(suggestions => resolve(suggestions.flat()))
.catch(reject);
});
}
private getSuggestionsForKeyToken(keyToken: Token): TypeaheadSuggestion[] {
const suggestions = this.suggestFieldKey(keyToken.literal ?? "");
if (!suggestions) {
return [];
}
return [
{
from: keyToken.start,
to: keyToken.end,
position: "chipKey",
suggestions,
type: "TEXT",
suffix: ":",
},
];
}
private async suggestCqlField(
q: CqlField,
signal?: AbortSignal
): Promise<TypeaheadSuggestion[]> {
const { key, value } = q;
if (!value) {
return this.getSuggestionsForKeyToken(key);
}
const keySuggestions = this.getSuggestionsForKeyToken(key);
const maybeValueSuggestions = this.suggestFieldValue(
key.literal ?? "",
value.literal ?? "",
signal
);
if (!maybeValueSuggestions) {
return Promise.resolve(keySuggestions);
}
return maybeValueSuggestions.suggestions.then((suggestions) => [
...keySuggestions,
{
from: value.start,
to: value.end,
position: "chipValue",
suggestions,
type: maybeValueSuggestions.type,
suffix: " ",
} as TypeaheadSuggestion,
]);
}
private suggestFieldKey(str: string): TextSuggestionOption[] | undefined {
if (str === "") {
return this.typeaheadFieldEntries;
}
const suggestions = this.typeaheadFieldEntries.filter((_) =>
_.value.includes(str.toLowerCase())
);
if (suggestions.length) {
return suggestions;
} else {
return undefined;
}
}
private suggestFieldValue(
key: string,
str: string,
signal?: AbortSignal
):
| { type: "TEXT"; suggestions: Promise<TextSuggestionOption[]> }
| { type: "DATE"; suggestions: Promise<DateSuggestionOption[]> }
| undefined {
const resolver = this.typeaheadFields.find((_) => _.id == key);
if (!resolver) {
return undefined;
}
if (resolver.suggestionType === "DATE") {
return {
type: "DATE",
suggestions: Promise.resolve([
new DateSuggestionOption("1 day ago", "-1d"),
new DateSuggestionOption("7 days ago", "-7d"),
new DateSuggestionOption("14 days ago", "-14d"),
new DateSuggestionOption("30 days ago", "-30d"),
new DateSuggestionOption("1 year ago", "-1y"),
]),
};
}
const suggestions = resolver.resolveSuggestions(str, signal);
if (suggestions) {
return {
type: "TEXT",
suggestions,
};
}
}
}