@dorsk/tsumikit 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/atoms/Badge.svelte +1 -0
- package/dist/components/atoms/Button.svelte +1 -0
- package/dist/components/atoms/Card.svelte +1 -0
- package/dist/components/atoms/Checkbox.svelte +1 -1
- package/dist/components/atoms/Dot.svelte +2 -2
- package/dist/components/atoms/Heading.svelte +1 -1
- package/dist/components/atoms/Icon.svelte +1 -0
- package/dist/components/atoms/Input.svelte +1 -0
- package/dist/components/atoms/Link.svelte +2 -2
- package/dist/components/atoms/Progress.svelte +1 -0
- package/dist/components/atoms/Select.svelte +2 -2
- package/dist/components/atoms/Slider.svelte +1 -1
- package/dist/components/atoms/Switch.svelte +1 -0
- package/dist/components/atoms/Text.svelte +1 -0
- package/dist/components/atoms/Textarea.svelte +1 -1
- package/dist/components/layouts/AppShell.svelte +1 -1
- package/dist/components/layouts/AutoGrid.svelte +1 -0
- package/dist/components/layouts/Cluster.svelte +1 -0
- package/dist/components/layouts/Container.svelte +1 -0
- package/dist/components/layouts/NavItem.svelte +1 -0
- package/dist/components/layouts/NavSection.svelte +1 -1
- package/dist/components/layouts/Stack.svelte +1 -0
- package/dist/components/molecules/Accordion.svelte +1 -1
- package/dist/components/molecules/CodeBlock.svelte +1 -1
- package/dist/components/molecules/CopyButton.svelte +1 -0
- package/dist/components/molecules/Dropzone.svelte +2 -0
- package/dist/components/molecules/EmptyState.svelte +1 -1
- package/dist/components/molecules/Field.svelte +1 -1
- package/dist/components/molecules/FileButton.svelte +1 -0
- package/dist/components/molecules/FontScalePicker.svelte +1 -0
- package/dist/components/molecules/IconButton.svelte +1 -0
- package/dist/components/molecules/Menu.svelte +1 -1
- package/dist/components/molecules/Metric.svelte +1 -0
- package/dist/components/molecules/Modal.svelte +1 -0
- package/dist/components/molecules/OptionButton.svelte +1 -0
- package/dist/components/molecules/Popover.svelte +1 -0
- package/dist/components/molecules/RadioGroup.svelte +1 -1
- package/dist/components/molecules/SegmentedControl.svelte +1 -0
- package/dist/components/molecules/SelectButton.svelte +4 -2
- package/dist/components/molecules/SelectButton.svelte.d.ts +1 -0
- package/dist/components/molecules/Tabs.svelte +1 -1
- package/dist/components/molecules/ThemePicker.svelte +1 -0
- package/dist/components/molecules/Timestamp.svelte +3 -3
- package/dist/components/molecules/Toaster.svelte +1 -1
- package/dist/components/molecules/Toggle.svelte +1 -0
- package/dist/components/molecules/Tooltip.svelte +1 -1
- package/dist/components/molecules/Truncate.svelte +2 -2
- package/dist/components/organisms/DataTable.svelte +1 -1
- package/dist/components/organisms/FilterSearchBar.svelte +340 -0
- package/dist/components/organisms/FilterSearchBar.svelte.d.ts +17 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/query/ast.d.ts +24 -0
- package/dist/query/ast.js +16 -0
- package/dist/query/index.d.ts +8 -0
- package/dist/query/index.js +9 -0
- package/dist/query/parser.d.ts +3 -0
- package/dist/query/parser.js +181 -0
- package/dist/query/query.d.ts +8 -0
- package/dist/query/query.js +128 -0
- package/dist/query/schema.d.ts +49 -0
- package/dist/query/schema.js +47 -0
- package/dist/query/suggest.d.ts +33 -0
- package/dist/query/suggest.js +125 -0
- package/package.json +1 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// AST consumers: serialise back to the textual query (for the code editor and
|
|
3
|
+
// the URL), compile to a human-readable SQL-ish WHERE (to prove the filter
|
|
4
|
+
// "properly propagates to backend & database"), and compile to a JS predicate
|
|
5
|
+
// (our fake in-memory backend).
|
|
6
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
7
|
+
import { filters, freeText } from './ast';
|
|
8
|
+
import { operatorById } from './schema';
|
|
9
|
+
function quoteIfNeeded(v) {
|
|
10
|
+
return /[\s,()]/.test(v) || v === '' ? `"${v}"` : v;
|
|
11
|
+
}
|
|
12
|
+
/** AST → canonical textual query (round-trips through the parser). */
|
|
13
|
+
export function serialize(q) {
|
|
14
|
+
const parts = [];
|
|
15
|
+
for (const f of filters(q))
|
|
16
|
+
parts.push(serializeFilter(f));
|
|
17
|
+
const text = freeText(q);
|
|
18
|
+
if (text)
|
|
19
|
+
parts.push(text);
|
|
20
|
+
return parts.join(' ');
|
|
21
|
+
}
|
|
22
|
+
export function serializeFilter(f) {
|
|
23
|
+
const op = operatorById(f.op);
|
|
24
|
+
if (!op)
|
|
25
|
+
return '';
|
|
26
|
+
if (f.op === 'in')
|
|
27
|
+
return `${f.field} in (${f.values.map(quoteIfNeeded).join(', ')})`;
|
|
28
|
+
if (f.op === 'range')
|
|
29
|
+
return `${f.field}:${quoteIfNeeded(f.values[0])}..${quoteIfNeeded(f.values[1])}`;
|
|
30
|
+
return `${f.field}${op.code}${quoteIfNeeded(f.values[0] ?? '')}`;
|
|
31
|
+
}
|
|
32
|
+
// ── SQL-ish compiler (illustrative — what the server would emit) ────────────
|
|
33
|
+
const SQL_OP = {
|
|
34
|
+
eq: '=',
|
|
35
|
+
ne: '<>',
|
|
36
|
+
gt: '>',
|
|
37
|
+
gte: '>=',
|
|
38
|
+
lt: '<',
|
|
39
|
+
lte: '<=',
|
|
40
|
+
};
|
|
41
|
+
export function toSql(q, table = 'tracks') {
|
|
42
|
+
const clauses = [];
|
|
43
|
+
for (const f of filters(q)) {
|
|
44
|
+
const col = f.field;
|
|
45
|
+
const v = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
46
|
+
switch (f.op) {
|
|
47
|
+
case 'contains':
|
|
48
|
+
clauses.push(`${col} ILIKE '%${f.values[0]}%'`);
|
|
49
|
+
break;
|
|
50
|
+
case 'not_contains':
|
|
51
|
+
clauses.push(`${col} NOT ILIKE '%${f.values[0]}%'`);
|
|
52
|
+
break;
|
|
53
|
+
case 'in':
|
|
54
|
+
clauses.push(`${col} IN (${f.values.map(v).join(', ')})`);
|
|
55
|
+
break;
|
|
56
|
+
case 'range':
|
|
57
|
+
clauses.push(`${col} BETWEEN ${v(f.values[0])} AND ${v(f.values[1])}`);
|
|
58
|
+
break;
|
|
59
|
+
default:
|
|
60
|
+
clauses.push(`${col} ${SQL_OP[f.op]} ${v(f.values[0] ?? '')}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const text = freeText(q);
|
|
64
|
+
if (text)
|
|
65
|
+
clauses.push(`to_tsvector(title) @@ plainto_tsquery(${`'${text.replace(/'/g, "''")}'`})`);
|
|
66
|
+
const where = clauses.length ? clauses.join('\n AND ') : 'TRUE';
|
|
67
|
+
return `SELECT * FROM ${table}\nWHERE ${where};`;
|
|
68
|
+
}
|
|
69
|
+
function asNumber(v) {
|
|
70
|
+
if (typeof v === 'number')
|
|
71
|
+
return v;
|
|
72
|
+
const s = String(v).trim();
|
|
73
|
+
// Plain integer/decimal (a year "2000", a play count) — take it literally.
|
|
74
|
+
// Guard against Date.parse swallowing "2000" as a year-timestamp.
|
|
75
|
+
if (/^-?\d+(\.\d+)?$/.test(s))
|
|
76
|
+
return Number(s);
|
|
77
|
+
const d = Date.parse(s); // real dates like "2021-02-01" compare by epoch ms
|
|
78
|
+
if (!Number.isNaN(d))
|
|
79
|
+
return d;
|
|
80
|
+
return Number(s);
|
|
81
|
+
}
|
|
82
|
+
function matchFilter(f, row) {
|
|
83
|
+
const cell = row[f.field];
|
|
84
|
+
const target = f.values[0] ?? '';
|
|
85
|
+
const cellStr = String(cell ?? '').toLowerCase();
|
|
86
|
+
switch (f.op) {
|
|
87
|
+
case 'contains':
|
|
88
|
+
return cellStr.includes(target.toLowerCase());
|
|
89
|
+
case 'not_contains':
|
|
90
|
+
return !cellStr.includes(target.toLowerCase());
|
|
91
|
+
case 'eq':
|
|
92
|
+
return cellStr === target.toLowerCase();
|
|
93
|
+
case 'ne':
|
|
94
|
+
return cellStr !== target.toLowerCase();
|
|
95
|
+
case 'in':
|
|
96
|
+
return f.values.map((x) => x.toLowerCase()).includes(cellStr);
|
|
97
|
+
case 'range':
|
|
98
|
+
return asNumber(cell) >= asNumber(f.values[0]) && asNumber(cell) <= asNumber(f.values[1]);
|
|
99
|
+
case 'gt':
|
|
100
|
+
return asNumber(cell) > asNumber(target);
|
|
101
|
+
case 'gte':
|
|
102
|
+
return asNumber(cell) >= asNumber(target);
|
|
103
|
+
case 'lt':
|
|
104
|
+
return asNumber(cell) < asNumber(target);
|
|
105
|
+
case 'lte':
|
|
106
|
+
return asNumber(cell) <= asNumber(target);
|
|
107
|
+
default:
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export function compilePredicate(q) {
|
|
112
|
+
const fs = filters(q);
|
|
113
|
+
const text = freeText(q).toLowerCase();
|
|
114
|
+
return (row) => {
|
|
115
|
+
for (const f of fs) {
|
|
116
|
+
if (f.values.every((v) => v === ''))
|
|
117
|
+
continue; // ignore incomplete clauses
|
|
118
|
+
if (!matchFilter(f, row))
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
if (text) {
|
|
122
|
+
const hay = Object.values(row).join(' ').toLowerCase();
|
|
123
|
+
if (!hay.includes(text))
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type FieldType = 'string' | 'enum' | 'id' | 'date' | 'number' | 'bool';
|
|
2
|
+
/** A comparison operator. `code` is how it serialises in the textual query. */
|
|
3
|
+
export type OperatorId = 'contains' | 'not_contains' | 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'range';
|
|
4
|
+
export interface Operator {
|
|
5
|
+
id: OperatorId;
|
|
6
|
+
/** Human label shown in the operator dropdown (YouTrack "一致する" etc.). */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Textual form used by the code parser/serialiser, e.g. `>=`, `:`, `!:`. */
|
|
9
|
+
code: string;
|
|
10
|
+
/** Types this operator can apply to. */
|
|
11
|
+
types: FieldType[];
|
|
12
|
+
/** range/in take two / many values. */
|
|
13
|
+
arity?: 'one' | 'two' | 'many';
|
|
14
|
+
}
|
|
15
|
+
export declare const OPERATORS: Operator[];
|
|
16
|
+
export interface ValueOption {
|
|
17
|
+
value: string;
|
|
18
|
+
label: string;
|
|
19
|
+
/** Optional secondary line (e.g. an email under a username). */
|
|
20
|
+
hint?: string;
|
|
21
|
+
}
|
|
22
|
+
/** Resolves candidate values for a field given the user's partial input. */
|
|
23
|
+
export type ValueProvider = (query: string) => ValueOption[] | Promise<ValueOption[]>;
|
|
24
|
+
export interface FieldDef {
|
|
25
|
+
/** Canonical name, used in the serialised query (e.g. `artist`). */
|
|
26
|
+
name: string;
|
|
27
|
+
/** Display label (e.g. `Artist`). */
|
|
28
|
+
label: string;
|
|
29
|
+
type: FieldType;
|
|
30
|
+
/** Alternate spellings accepted by the parser (e.g. `by` → assignee). */
|
|
31
|
+
aliases?: string[];
|
|
32
|
+
/** Static options, or omit and supply `provider` for async lookups. */
|
|
33
|
+
options?: ValueOption[];
|
|
34
|
+
provider?: ValueProvider;
|
|
35
|
+
/** Placeholder shown in the value step of the dropdown. */
|
|
36
|
+
valuePlaceholder?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface Schema {
|
|
39
|
+
fields: FieldDef[];
|
|
40
|
+
}
|
|
41
|
+
/** Operators legal for a given field, in catalogue order. */
|
|
42
|
+
export declare function operatorsFor(field: FieldDef): Operator[];
|
|
43
|
+
/** The default operator picked when a user selects a field (YouTrack-like). */
|
|
44
|
+
export declare function defaultOperator(field: FieldDef): Operator;
|
|
45
|
+
export declare function findField(schema: Schema, token: string): FieldDef | undefined;
|
|
46
|
+
export declare function operatorByCode(code: string): Operator | undefined;
|
|
47
|
+
export declare function operatorById(id: OperatorId): Operator | undefined;
|
|
48
|
+
/** Resolve a field's value options (sync wrapper that always returns a promise). */
|
|
49
|
+
export declare function resolveValues(field: FieldDef, query: string): Promise<ValueOption[]>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// Layer 1 — the schema / field registry.
|
|
3
|
+
//
|
|
4
|
+
// The single source of truth that drives BOTH the autocomplete UI and the
|
|
5
|
+
// backend compiler. For each searchable field we declare its type, aliases,
|
|
6
|
+
// the operators it allows and a value provider (static enum or async lookup).
|
|
7
|
+
// Nothing here is UI: it's plain data a host app supplies.
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
9
|
+
// The full operator catalogue. The order here is the order shown in dropdowns.
|
|
10
|
+
export const OPERATORS = [
|
|
11
|
+
{ id: 'contains', label: 'includes', code: ':', types: ['string'] },
|
|
12
|
+
{ id: 'not_contains', label: 'does not include', code: '!:', types: ['string'] },
|
|
13
|
+
{ id: 'eq', label: 'is', code: '=', types: ['enum', 'id', 'number', 'bool', 'date'] },
|
|
14
|
+
{ id: 'ne', label: 'is not', code: '!=', types: ['enum', 'id', 'number', 'bool', 'date'] },
|
|
15
|
+
{ id: 'gt', label: 'greater than', code: '>', types: ['number', 'date'] },
|
|
16
|
+
{ id: 'gte', label: 'greater or equal', code: '>=', types: ['number', 'date'] },
|
|
17
|
+
{ id: 'lt', label: 'less than', code: '<', types: ['number', 'date'] },
|
|
18
|
+
{ id: 'lte', label: 'less or equal', code: '<=', types: ['number', 'date'] },
|
|
19
|
+
{ id: 'in', label: 'any of', code: 'in', types: ['enum', 'id'], arity: 'many' },
|
|
20
|
+
{ id: 'range', label: 'in range', code: '..', types: ['date', 'number'], arity: 'two' },
|
|
21
|
+
];
|
|
22
|
+
/** Operators legal for a given field, in catalogue order. */
|
|
23
|
+
export function operatorsFor(field) {
|
|
24
|
+
return OPERATORS.filter((op) => op.types.includes(field.type));
|
|
25
|
+
}
|
|
26
|
+
/** The default operator picked when a user selects a field (YouTrack-like). */
|
|
27
|
+
export function defaultOperator(field) {
|
|
28
|
+
return operatorsFor(field)[0];
|
|
29
|
+
}
|
|
30
|
+
export function findField(schema, token) {
|
|
31
|
+
const t = token.toLowerCase();
|
|
32
|
+
return schema.fields.find((f) => f.name.toLowerCase() === t || f.aliases?.some((a) => a.toLowerCase() === t));
|
|
33
|
+
}
|
|
34
|
+
export function operatorByCode(code) {
|
|
35
|
+
return OPERATORS.find((op) => op.code === code);
|
|
36
|
+
}
|
|
37
|
+
export function operatorById(id) {
|
|
38
|
+
return OPERATORS.find((op) => op.id === id);
|
|
39
|
+
}
|
|
40
|
+
/** Resolve a field's value options (sync wrapper that always returns a promise). */
|
|
41
|
+
export async function resolveValues(field, query) {
|
|
42
|
+
if (field.provider)
|
|
43
|
+
return field.provider(query);
|
|
44
|
+
const opts = field.options ?? [];
|
|
45
|
+
const q = query.toLowerCase();
|
|
46
|
+
return q ? opts.filter((o) => o.label.toLowerCase().includes(q)) : opts;
|
|
47
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Schema } from './schema';
|
|
2
|
+
export type SuggestKind = 'field' | 'operator' | 'value';
|
|
3
|
+
export interface Suggestion {
|
|
4
|
+
/** What to show on the row. */
|
|
5
|
+
label: string;
|
|
6
|
+
/** Secondary, dimmed line. */
|
|
7
|
+
hint?: string;
|
|
8
|
+
/** Right-aligned monospace token (operator code / field name). */
|
|
9
|
+
code?: string;
|
|
10
|
+
/** The string to splice into the query when accepted. */
|
|
11
|
+
insert: string;
|
|
12
|
+
/** Caret position after the insert (absolute index). */
|
|
13
|
+
caret: number;
|
|
14
|
+
/** When true, keep the dropdown open and advance to the next step. */
|
|
15
|
+
advance?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface SuggestState {
|
|
18
|
+
kind: SuggestKind;
|
|
19
|
+
/** [start, end) of the token region these suggestions replace. */
|
|
20
|
+
span: [number, number];
|
|
21
|
+
items: Suggestion[];
|
|
22
|
+
}
|
|
23
|
+
/** The whitespace-delimited token under the caret (quotes count as non-ws). */
|
|
24
|
+
export declare function activeToken(s: string, pos: number): {
|
|
25
|
+
start: number;
|
|
26
|
+
end: number;
|
|
27
|
+
raw: string;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Compute the suggestion state for the caret. Value lookups are async (they may
|
|
31
|
+
* hit the network), so this returns a promise. `null` means "no dropdown".
|
|
32
|
+
*/
|
|
33
|
+
export declare function suggest(schema: Schema, s: string, pos: number): Promise<SuggestState | null>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// Autocomplete engine — the SECOND consumer of the schema (the first is the
|
|
3
|
+
// compiler). Given the raw string + caret position it figures out which of the
|
|
4
|
+
// three YouTrack-style steps we're in (field → operator → value) and produces
|
|
5
|
+
// the suggestion list + the text edit to apply when one is accepted.
|
|
6
|
+
//
|
|
7
|
+
// This is pure logic, deliberately UI-free so the Svelte organism stays dumb.
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
9
|
+
import { findField, operatorsFor, resolveValues, } from './schema';
|
|
10
|
+
// Longest first so `>=`/`!:` win over `>`/`:`.
|
|
11
|
+
const OP_CODES = ['!:', '!=', '>=', '<=', ':', '=', '>', '<'];
|
|
12
|
+
/** The whitespace-delimited token under the caret (quotes count as non-ws). */
|
|
13
|
+
export function activeToken(s, pos) {
|
|
14
|
+
let start = pos;
|
|
15
|
+
while (start > 0 && !/\s/.test(s[start - 1]))
|
|
16
|
+
start--;
|
|
17
|
+
let end = pos;
|
|
18
|
+
while (end < s.length && !/\s/.test(s[end]))
|
|
19
|
+
end++;
|
|
20
|
+
return { start, end, raw: s.slice(start, end) };
|
|
21
|
+
}
|
|
22
|
+
/** Split a token into field / operator-code / value parts (earliest op wins). */
|
|
23
|
+
function splitToken(raw) {
|
|
24
|
+
let best = -1;
|
|
25
|
+
let bestCode = null;
|
|
26
|
+
for (const code of OP_CODES) {
|
|
27
|
+
const idx = raw.indexOf(code);
|
|
28
|
+
if (idx === -1)
|
|
29
|
+
continue;
|
|
30
|
+
// earliest position wins; on a tie the longer code wins (handled by order).
|
|
31
|
+
if (best === -1 || idx < best) {
|
|
32
|
+
best = idx;
|
|
33
|
+
bestCode = code;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (bestCode === null)
|
|
37
|
+
return { field: raw, opCode: null, value: '' };
|
|
38
|
+
return {
|
|
39
|
+
field: raw.slice(0, best),
|
|
40
|
+
opCode: bestCode,
|
|
41
|
+
value: raw.slice(best + bestCode.length),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function unquote(v) {
|
|
45
|
+
return v.replace(/^["']|["']$/g, '');
|
|
46
|
+
}
|
|
47
|
+
function quoteIfNeeded(v) {
|
|
48
|
+
return /[\s,()]/.test(v) ? `"${v}"` : v;
|
|
49
|
+
}
|
|
50
|
+
function fieldSuggestions(schema, frag, span) {
|
|
51
|
+
const q = frag.toLowerCase();
|
|
52
|
+
const items = schema.fields
|
|
53
|
+
.filter((f) => !q ||
|
|
54
|
+
f.name.toLowerCase().includes(q) ||
|
|
55
|
+
f.label.toLowerCase().includes(q) ||
|
|
56
|
+
f.aliases?.some((a) => a.toLowerCase().includes(q)))
|
|
57
|
+
.map((f) => {
|
|
58
|
+
const op = operatorsFor(f)[0];
|
|
59
|
+
const insert = `${f.name}${op.code}`;
|
|
60
|
+
return {
|
|
61
|
+
label: f.label,
|
|
62
|
+
hint: f.aliases?.length ? `aka ${f.aliases.join(', ')}` : f.type,
|
|
63
|
+
code: f.name,
|
|
64
|
+
insert,
|
|
65
|
+
caret: span[0] + insert.length,
|
|
66
|
+
advance: true,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
return { kind: 'field', span, items };
|
|
70
|
+
}
|
|
71
|
+
function operatorSuggestions(field, span) {
|
|
72
|
+
const items = operatorsFor(field).map((op) => {
|
|
73
|
+
const insert = `${field.name}${op.code}`;
|
|
74
|
+
return {
|
|
75
|
+
label: op.label,
|
|
76
|
+
code: op.code,
|
|
77
|
+
insert,
|
|
78
|
+
caret: span[0] + insert.length,
|
|
79
|
+
advance: true,
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
return { kind: 'operator', span, items };
|
|
83
|
+
}
|
|
84
|
+
function buildValueState(field, opCode, span, options) {
|
|
85
|
+
const items = options.map((o) => {
|
|
86
|
+
const insert = `${field.name}${opCode}${quoteIfNeeded(o.value)}`;
|
|
87
|
+
return {
|
|
88
|
+
label: o.label,
|
|
89
|
+
hint: o.hint,
|
|
90
|
+
insert,
|
|
91
|
+
caret: span[0] + insert.length,
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
return { kind: 'value', span, items };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Compute the suggestion state for the caret. Value lookups are async (they may
|
|
98
|
+
* hit the network), so this returns a promise. `null` means "no dropdown".
|
|
99
|
+
*/
|
|
100
|
+
export async function suggest(schema, s, pos) {
|
|
101
|
+
const { start, end, raw } = activeToken(s, pos);
|
|
102
|
+
const span = [start, end];
|
|
103
|
+
// Empty caret position (between tokens or empty input) → offer all fields.
|
|
104
|
+
if (raw === '')
|
|
105
|
+
return fieldSuggestions(schema, '', span);
|
|
106
|
+
const { field: fieldFrag, opCode, value } = splitToken(raw);
|
|
107
|
+
const field = findField(schema, fieldFrag);
|
|
108
|
+
// No operator typed yet.
|
|
109
|
+
if (opCode === null) {
|
|
110
|
+
// Exact field match → it's time to pick an operator.
|
|
111
|
+
if (field && fieldFrag.toLowerCase() === field.name.toLowerCase()) {
|
|
112
|
+
return operatorSuggestions(field, span);
|
|
113
|
+
}
|
|
114
|
+
// Otherwise we're still completing the field name.
|
|
115
|
+
return fieldSuggestions(schema, fieldFrag, span);
|
|
116
|
+
}
|
|
117
|
+
// Operator present but field unknown → nothing useful to suggest.
|
|
118
|
+
if (!field)
|
|
119
|
+
return null;
|
|
120
|
+
// Operator present → suggest values (async; filtered by what's typed).
|
|
121
|
+
const opts = await resolveValues(field, unquote(value));
|
|
122
|
+
if (opts.length === 0)
|
|
123
|
+
return null;
|
|
124
|
+
return buildValueState(field, opCode, span, opts);
|
|
125
|
+
}
|
package/package.json
CHANGED