Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-toys-lie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@guardian/cql": minor
---

Make suggestions position-dependent (rather than surfacing all possible positions for a query), and add `showTypeaheadForQueryStr`, enabling typeahead suggestions in queryStr positions
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,4 @@ This repository uses [changesets](https://github.com/changesets/changesets) for

To release a new version with your changes, run `bun changeset add` and follow the prompts. This will create a new changeset file in the .changeset directory. Commit this file with your PR.

When your PR is merged, Changesets will create a PR to release the new version.
When your PR is merged, Changesets will create a PR to release the new version. Please feel free to merge a PR that has been generated by a PR you have merged.
45 changes: 41 additions & 4 deletions lib/cql/src/cqlInput/editor/plugins/cql.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,24 @@ import { TestTypeaheadHelpers } from "../../../lang/fixtures/TestTypeaheadHelper
import { isVisibleDataAttr } from "../../popover/Popover";
import { docToCqlStrWithSelection, tick } from "../../../utils/test";
import { createParser } from "../../../lang/Cql";
import { Typeahead } from "../../../lang/typeahead";
import { Typeahead, TypeaheadConfig } from "../../../lang/typeahead";
import { chip, chipValue, IS_SELECTED } from "../schema";
import { Node, NodeType } from "prosemirror-model";
import { cqlQueryStrFromQueryAst } from "../../../lang/interpreter";
import { EditorView } from "prosemirror-view";
import { TokenType } from "../../../lang/token";

const typeheadHelpers = new TestTypeaheadHelpers();
const testCqlService = new Typeahead(typeheadHelpers.typeaheadFields);

const createCqlEditor = (
initialQuery: string = "",
config: CqlConfig = { syntaxHighlighting: true },
typeaheadConfig: Partial<TypeaheadConfig> = {},
) => {
const typeheadHelpers = new TestTypeaheadHelpers();
const testCqlService = new Typeahead(
typeheadHelpers.typeaheadFields,
typeaheadConfig,
);

document.body.innerHTML = "";
const container = document.body;
const typeaheadEl = document.createElement("div");
Expand Down Expand Up @@ -221,6 +225,39 @@ describe("cql plugin", () => {
});

describe("typeahead", () => {
describe("queryStr", () => {
const createCqlEditorWithQueryStrTypeahead = (
initialQuery: string = "",
) =>
createCqlEditor(
initialQuery,
{},
{ showTypeaheadForQueryStr: true, minCharsForQueryStrTypeahead: 2 },
);
it("does not display a popover at the start of the query when fewer than the minimum chars necessary for a queryStr suggestion are added", async () => {
const { container } = createCqlEditorWithQueryStrTypeahead("t");

await assertPopoverVisibility(container, false);
});

it("does display a popover at the start of the query when the minimum chars necessary for a queryStr suggestion are added", async () => {
const { container } = createCqlEditorWithQueryStrTypeahead("ta");

await assertPopoverVisibility(container, true);
});

it("accepts a suggestion from a popover, and applies a chipKey", async () => {
const { editor, container, waitFor } =
createCqlEditorWithQueryStrTypeahead("ta");

await selectPopoverOptionWithEnter(editor, container, "Tag");
const nodeAtCaret = getNodeTypeAtSelection(editor.view);
expect(nodeAtCaret.name).toBe("chipValue");

await waitFor("tag:");
});
});

describe("chip keys", () => {
it("displays a colon between chip keys and values on first render", async () => {
const queryStr = "+x:y";
Expand Down
17 changes: 9 additions & 8 deletions lib/cql/src/cqlInput/editor/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ const getFieldValueRanges = (
literalOffsetStart: number,
literalOffsetEnd: number,
): [number, number, number][] => {

return [
[from, 0, 1 /* <chipKey> end / <chipValue> start */],
[from, literalOffsetStart, 0],
Expand Down Expand Up @@ -630,18 +629,12 @@ export const getNextPositionAfterTypeaheadSelection = (
);

if (nodeTypeAfterIndex === -1) {
console.warn(
`Attempted to find a selection, but the position ${currentPos} w/in node ${suggestionNode.type.name} is not one of ${typeaheadSelectionSequence.map((_) => _.name).join(",")}`,
);
return;
}

const nodeTypeToSelect = typeaheadSelectionSequence[nodeTypeAfterIndex + 1];

if (!nodeTypeToSelect) {
console.warn(
`Attempted to find a selection, but the position ${currentPos} w/in node ${suggestionNode.type.name} does not have anything to follow a node of type ${nodeTypeAfterIndex}`,
);
return;
}

Expand Down Expand Up @@ -744,9 +737,17 @@ export const applyChipLifecycleRules = (tr: Transaction): void => {
};

export const applySuggestion =
(view: EditorView) => (from: number, to: number, value: string) => {
(view: EditorView) =>
(
from: number,
to: number,
position: TypeaheadSuggestion["position"],
_value: string,
) => {
const tr = view.state.tr;

const value = position === "queryStr" ? `${_value}:` : _value;

tr.replaceRangeWith(from, to, schema.text(value)).setMeta(
TRANSACTION_APPLY_SUGGESTION,
true,
Expand Down
16 changes: 13 additions & 3 deletions lib/cql/src/cqlInput/popover/TypeaheadPopover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@ export class TypeaheadPopover extends Popover {
private updateRendererState: (state: PopoverRendererState) => void =
noopUpdateRendererState;

private _applySuggestion: (from: number, to: number, value: string) => void;
private _applySuggestion: (
from: number,
to: number,
position: TypeaheadSuggestion["position"],
value: string,
) => void;
private _skipSuggestion: () => void;
private currentSuggestion: TypeaheadSuggestion | undefined;
private currentOptionIndex = 0;
Expand All @@ -58,7 +63,12 @@ export class TypeaheadPopover extends Popover {
private view: EditorView,
protected popoverEl: HTMLElement,
// Apply a suggestion to the input, replacing the given range
applySuggestion: (from: number, to: number, value: string) => void,
applySuggestion: (
from: number,
to: number,
position: TypeaheadSuggestion["position"],
value: string,
) => void,
// Skip a suggestion, and move on to the next valid field
skipSuggestion: () => void,
// A callback that receives everything necessary to render popover content
Expand Down Expand Up @@ -201,7 +211,7 @@ export class TypeaheadPopover extends Popover {
this.hide();
}

this._applySuggestion(from, to, value);
this._applySuggestion(from, to, position, value);
};

private skipSuggestion = () => {
Expand Down
50 changes: 44 additions & 6 deletions lib/cql/src/lang/typeahead.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ProseMirrorToken } from "../cqlInput/editor/utils";
import { mergeDeep } from "../utils/merge";
import { CqlQuery } from "./ast";
import {
DateSuggestionOption,
Expand Down Expand Up @@ -71,26 +72,57 @@ export class TypeaheadField {
}
}

export type TypeaheadConfig = {
showTypeaheadForQueryStr: boolean;
minCharsForQueryStrTypeahead: number;
};

const defaultTypeaheadConfig: TypeaheadConfig = {
showTypeaheadForQueryStr: false,
minCharsForQueryStrTypeahead: 2,
};

export class Typeahead {
private typeaheadFieldEntries: TextSuggestionOption[];
private abortController: AbortController | undefined;
private config: TypeaheadConfig;

constructor(private typeaheadFields: TypeaheadField[]) {
constructor(
private typeaheadFields: TypeaheadField[],
config: Partial<TypeaheadConfig> = {},
) {
this.config = mergeDeep(defaultTypeaheadConfig, config);
this.typeaheadFieldEntries = this.typeaheadFields.map((field) =>
field.toSuggestionOption(),
);
}

/**
* Get suggestions for the given query and position.
*
* `position` is a caret position, 0-indexed from the start of the string,
* where every character has a before and after. For example, the query
*
* `s t r k : v`
* | | | | | | | |
* 0 1 2 3 4 5 6 7
*
* would give suggestions for:
* - keys containing `str` for positions 0-3, if `showTypeaheadForQueryStr`
* was `true`
* - keys containing `k` for positions 4-5
* - keys containing `v` for positions 6-7
*/
public async getSuggestions(
program: CqlQuery,
query: CqlQuery,
position: number,
signal?: AbortSignal,
): Promise<TypeaheadSuggestion | undefined> {
return new Promise((resolve, reject) => {
// Abort existing fetch, if it exists
this.abortController?.abort();

if (!program.content) {
if (!query.content) {
return resolve(undefined);
}

Expand All @@ -100,9 +132,15 @@ export class Typeahead {
reject(new DOMException("Aborted", "AbortError"));
});

const maybeSuggestionAtPos = getAstNodeAtPos(program.content, position);
const maybeSuggestionAtPos = getAstNodeAtPos(query.content, position);

const isValidSuggestionNode =
maybeSuggestionAtPos?.key.tokenType !== "STRING" ||
(this.config.showTypeaheadForQueryStr &&
(maybeSuggestionAtPos?.key.literal?.length ?? 0) >=
this.config.minCharsForQueryStrTypeahead);

if (!maybeSuggestionAtPos) {
if (!maybeSuggestionAtPos || !isValidSuggestionNode) {
return resolve(undefined);
}

Expand All @@ -124,7 +162,7 @@ export class Typeahead {
return {
from: keyToken.from,
to: Math.max(keyToken.to, keyToken.to - 1), // Do not include ':'
position: "chipKey",
position: keyToken.tokenType === "STRING" ? "queryStr" : "chipKey",
suggestions,
type: "TEXT",
suffix: ":",
Expand Down
7 changes: 5 additions & 2 deletions lib/cql/src/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import {
} from "./cqlInput/editor/debug.ts";
import { createParser } from "./lang/Cql.ts";
import { Typeahead, TypeaheadField } from "./lang/typeahead.ts";
import { CapiTypeaheadProvider } from "./typeahead/CapiTypeaheadHelpers.ts";
import { toolsSuggestionOptionResolvers } from "./typeahead/tools-index/config";
import { DebugChangeEventDetail, QueryChangeEventDetail } from "./types/dom";
import { CapiTypeaheadProvider } from "./lib.ts";

const setUrlParam = (key: string, value: string) => {
const urlParams = new URLSearchParams(window.location.search);
Expand Down Expand Up @@ -135,7 +135,10 @@ const typeaheadHelpersCapi = new CapiTypeaheadProvider(
initialEndpointCapi,
"test",
);
const capiTypeahead = new Typeahead(typeaheadHelpersCapi.typeaheadFields);
const capiTypeahead = new Typeahead(typeaheadHelpersCapi.typeaheadFields, {
showTypeaheadForQueryStr: true,
minCharsForQueryStrTypeahead: 2,
});

const CqlInputCapi = createCqlInput(capiTypeahead, {
syntaxHighlighting: true,
Expand Down