@dxos/react-ui-query 0.12.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/LICENSE +105 -0
- package/README.md +5 -0
- package/dist/lib/index.mjs +637 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/types/src/components/QueryEditor/QueryEditor.d.ts +37 -0
- package/dist/types/src/components/QueryEditor/QueryEditor.d.ts.map +1 -0
- package/dist/types/src/components/QueryEditor/QueryEditor.stories.d.ts +42 -0
- package/dist/types/src/components/QueryEditor/QueryEditor.stories.d.ts.map +1 -0
- package/dist/types/src/components/QueryEditor/autocomplete.d.ts +8 -0
- package/dist/types/src/components/QueryEditor/autocomplete.d.ts.map +1 -0
- package/dist/types/src/components/QueryEditor/autocomplete.test.d.ts +2 -0
- package/dist/types/src/components/QueryEditor/autocomplete.test.d.ts.map +1 -0
- package/dist/types/src/components/QueryEditor/index.d.ts +3 -0
- package/dist/types/src/components/QueryEditor/index.d.ts.map +1 -0
- package/dist/types/src/components/QueryEditor/query-extension.d.ts +28 -0
- package/dist/types/src/components/QueryEditor/query-extension.d.ts.map +1 -0
- package/dist/types/src/components/QueryEditor/query-extension.test.d.ts +2 -0
- package/dist/types/src/components/QueryEditor/query-extension.test.d.ts.map +1 -0
- package/dist/types/src/components/QueryForm/Picker.d.ts +15 -0
- package/dist/types/src/components/QueryForm/Picker.d.ts.map +1 -0
- package/dist/types/src/components/QueryForm/QueryForm.d.ts +15 -0
- package/dist/types/src/components/QueryForm/QueryForm.d.ts.map +1 -0
- package/dist/types/src/components/QueryForm/QueryForm.stories.d.ts +26 -0
- package/dist/types/src/components/QueryForm/QueryForm.stories.d.ts.map +1 -0
- package/dist/types/src/components/QueryForm/index.d.ts +2 -0
- package/dist/types/src/components/QueryForm/index.d.ts.map +1 -0
- package/dist/types/src/components/QueryForm/query.d.ts +6 -0
- package/dist/types/src/components/QueryForm/query.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +3 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +2 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useQueryBuilder.d.ts +6 -0
- package/dist/types/src/hooks/useQueryBuilder.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +3 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +13 -0
- package/dist/types/src/translations.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +78 -0
- package/src/components/QueryEditor/QueryEditor.stories.tsx +165 -0
- package/src/components/QueryEditor/QueryEditor.tsx +136 -0
- package/src/components/QueryEditor/autocomplete.test.ts +46 -0
- package/src/components/QueryEditor/autocomplete.ts +76 -0
- package/src/components/QueryEditor/index.ts +7 -0
- package/src/components/QueryEditor/query-extension.test.ts +176 -0
- package/src/components/QueryEditor/query-extension.ts +532 -0
- package/src/components/QueryForm/Picker.tsx +47 -0
- package/src/components/QueryForm/QueryForm.stories.tsx +72 -0
- package/src/components/QueryForm/QueryForm.tsx +77 -0
- package/src/components/QueryForm/index.ts +5 -0
- package/src/components/QueryForm/query.ts +91 -0
- package/src/components/index.ts +6 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useQueryBuilder.ts +25 -0
- package/src/index.ts +6 -0
- package/src/translations.ts +22 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
import { EditorState, Prec, RangeSetBuilder, StateField } from "@codemirror/state";
|
|
2
|
+
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { QueryBuilder, QueryDSL, findTagByToken, formatTag } from "@dxos/echo-query";
|
|
4
|
+
import { Select, useThemeMode, useTranslation } from "@dxos/react-ui";
|
|
5
|
+
import { Editor, createMenuGroup } from "@dxos/react-ui-editor";
|
|
6
|
+
import { createBasicExtensions, createThemeExtensions, focus, focusField, keymap, staticCompletion, typeahead } from "@dxos/ui-editor";
|
|
7
|
+
import { translationKey } from "#translations";
|
|
8
|
+
import { Filter, Obj, Query, Type } from "@dxos/echo";
|
|
9
|
+
import { HighlightStyle, LRLanguage, LanguageSupport, syntaxHighlighting, syntaxTree } from "@codemirror/language";
|
|
10
|
+
import { Decoration, EditorView, WidgetType } from "@codemirror/view";
|
|
11
|
+
import { styleTags, tags } from "@lezer/highlight";
|
|
12
|
+
import JSON5 from "json5";
|
|
13
|
+
import { Domino } from "@dxos/ui";
|
|
14
|
+
import { getHashHue, getStyles, mx } from "@dxos/ui-theme";
|
|
15
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
16
|
+
import * as Option from "effect/Option";
|
|
17
|
+
import * as Match from "effect/Match";
|
|
18
|
+
//#region src/components/QueryEditor/autocomplete.ts
|
|
19
|
+
/**
|
|
20
|
+
* Tag labels as they must appear in the document. Selecting a completion REPLACES the whole trigger
|
|
21
|
+
* range, `#` included, and inserts the item verbatim — so an unprefixed label would silently drop the
|
|
22
|
+
* `#` and leave text that no longer parses as a tag.
|
|
23
|
+
*/
|
|
24
|
+
var tagCompletions = (tags) => Object.values(tags).map((tag) => formatTag(tag.label));
|
|
25
|
+
/**
|
|
26
|
+
* Whether the caret sits in a tag the user is still typing: a `#` at the start of the current word,
|
|
27
|
+
* with only label characters between it and `pos`.
|
|
28
|
+
*/
|
|
29
|
+
var isTypingTag = (text, pos) => {
|
|
30
|
+
let index = pos;
|
|
31
|
+
while (index > 0 && /[a-zA-Z0-9_-]/.test(text[index - 1])) index--;
|
|
32
|
+
return index > 0 && text[index - 1] === "#";
|
|
33
|
+
};
|
|
34
|
+
var completions = ({ db, tags }) => {
|
|
35
|
+
const parser = QueryDSL.Parser.configure({ strict: false });
|
|
36
|
+
return ({ state, pos }) => {
|
|
37
|
+
const text = state.sliceDoc();
|
|
38
|
+
const { node } = parser.parse(text).cursorAt(pos, -1);
|
|
39
|
+
if (tags && isTypingTag(text, pos) && node.type.id !== QueryDSL.Node.String) return tagCompletions(tags);
|
|
40
|
+
switch (node.parent?.type.id) {
|
|
41
|
+
case QueryDSL.Node.TypeFilter: {
|
|
42
|
+
let range;
|
|
43
|
+
if (node?.type.id === QueryDSL.Node.Identifier) range = {
|
|
44
|
+
from: node.from,
|
|
45
|
+
to: node.to
|
|
46
|
+
};
|
|
47
|
+
else if (node?.type.name === ":") range = {
|
|
48
|
+
from: node.from + 1,
|
|
49
|
+
to: node.to
|
|
50
|
+
};
|
|
51
|
+
if (range) return (db ? [...db.graph.registry.list().filter(Type.isType)] : []).map((schema) => Type.getTypename(schema));
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
case QueryDSL.Node.TagFilter: if (tags) return tagCompletions(tags);
|
|
55
|
+
}
|
|
56
|
+
return [];
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/components/QueryEditor/query-extension.ts
|
|
61
|
+
/**
|
|
62
|
+
* Create a CodeMirror extension for the query language with syntax highlighting.
|
|
63
|
+
*/
|
|
64
|
+
var query = ({ tags } = {}) => {
|
|
65
|
+
return [
|
|
66
|
+
new LanguageSupport(queryLanguage),
|
|
67
|
+
syntaxHighlighting(queryHighlightStyle),
|
|
68
|
+
decorations({ tags }),
|
|
69
|
+
spacing,
|
|
70
|
+
typeahead({ onComplete: ({ line }) => {
|
|
71
|
+
if (line.split(/\s+/).filter(Boolean).length > 0) return staticCompletion([
|
|
72
|
+
"type:",
|
|
73
|
+
"AND",
|
|
74
|
+
"OR",
|
|
75
|
+
"NOT"
|
|
76
|
+
])({ line });
|
|
77
|
+
} }),
|
|
78
|
+
focus,
|
|
79
|
+
styles
|
|
80
|
+
];
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* The extension's decorations for a given state.
|
|
84
|
+
*
|
|
85
|
+
* Exported as a pure function of `(state, options)` so the atomicity rules — which govern whether a
|
|
86
|
+
* tag can still be edited — are testable without mounting an `EditorView`.
|
|
87
|
+
*/
|
|
88
|
+
var buildQueryDecorations = (state, { tags } = {}) => {
|
|
89
|
+
{
|
|
90
|
+
const hasFocus = state.field(focusField);
|
|
91
|
+
const isInside = (node) => {
|
|
92
|
+
const range = intersectRanges(state.selection.main, node);
|
|
93
|
+
return hasFocus && range && (state.selection.main.from > 0 || range.to - range.from > 0);
|
|
94
|
+
};
|
|
95
|
+
const collected = [];
|
|
96
|
+
const deco = { add: (from, to, value) => collected.push({
|
|
97
|
+
from,
|
|
98
|
+
to,
|
|
99
|
+
deco: value
|
|
100
|
+
}) };
|
|
101
|
+
const tagged = [];
|
|
102
|
+
syntaxTree(state).iterate({ enter: (node) => {
|
|
103
|
+
if (node.type.id === QueryDSL.Node.Tag) tagged.push({
|
|
104
|
+
from: node.from,
|
|
105
|
+
to: node.to
|
|
106
|
+
});
|
|
107
|
+
switch (node.type.name) {
|
|
108
|
+
case "(":
|
|
109
|
+
case ")":
|
|
110
|
+
deco.add(node.from, node.to, Decoration.mark({ class: "px-1" }));
|
|
111
|
+
break;
|
|
112
|
+
case "=": deco.add(node.from, node.to, Decoration.mark({ class: "text-fg-subtle" }));
|
|
113
|
+
}
|
|
114
|
+
switch (node.type.id) {
|
|
115
|
+
case QueryDSL.Node.TypeFilter: {
|
|
116
|
+
if (isInside(node)) break;
|
|
117
|
+
const identifier = node.node.getChild(QueryDSL.Node.Identifier);
|
|
118
|
+
if (identifier) deco.add(node.from, node.to, Decoration.widget({ widget: new TypeWidget(state.sliceDoc(identifier.from, identifier.to)) }));
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case QueryDSL.Node.TagFilter: {
|
|
122
|
+
const tagNode = node.node.getChild(QueryDSL.Node.Tag);
|
|
123
|
+
if (tagNode) {
|
|
124
|
+
const token = state.sliceDoc(tagNode.from, tagNode.to);
|
|
125
|
+
const match = findTagByToken(tags, token);
|
|
126
|
+
const label = match?.tag.label ?? token.slice(1);
|
|
127
|
+
const hue = match?.tag.hue ?? getHashHue(match?.key ?? label);
|
|
128
|
+
deco.add(node.from, node.to, Decoration.replace({
|
|
129
|
+
widget: new TagWidget(label, hue),
|
|
130
|
+
atomic: true
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case QueryDSL.Node.ObjectLiteral:
|
|
136
|
+
if (isInside(node)) break;
|
|
137
|
+
try {
|
|
138
|
+
const props = JSON5.parse(state.sliceDoc(node.from, node.to));
|
|
139
|
+
if (props) deco.add(node.from, node.to, Decoration.widget({ widget: new ObjectWidget(props) }));
|
|
140
|
+
} catch {}
|
|
141
|
+
break;
|
|
142
|
+
case QueryDSL.Node.Not:
|
|
143
|
+
case QueryDSL.Node.And:
|
|
144
|
+
case QueryDSL.Node.Or:
|
|
145
|
+
deco.add(node.from, node.to, Decoration.mark({
|
|
146
|
+
class: "pe-1 uppercase",
|
|
147
|
+
atomic: true
|
|
148
|
+
}));
|
|
149
|
+
break;
|
|
150
|
+
case QueryDSL.Node.ArrowRight:
|
|
151
|
+
case QueryDSL.Node.ArrowLeft: deco.add(node.from, node.to, Decoration.widget({
|
|
152
|
+
widget: new SymbolWidget(node.type.id === QueryDSL.Node.ArrowRight ? "→" : "←"),
|
|
153
|
+
atomic: true
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
} });
|
|
157
|
+
for (const range of bareTagRanges(state, tagged)) collected.push({
|
|
158
|
+
from: range.from,
|
|
159
|
+
to: range.to,
|
|
160
|
+
deco: Decoration.mark({ class: mx("rounded-xs px-0.5", getStyles(getHashHue("")).surface) })
|
|
161
|
+
});
|
|
162
|
+
const builder = new RangeSetBuilder();
|
|
163
|
+
collected.sort((a, b) => a.from - b.from || b.to - a.to);
|
|
164
|
+
for (const { from, to, deco } of collected) builder.add(from, to, deco);
|
|
165
|
+
return builder.finish();
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* Decorations
|
|
170
|
+
*/
|
|
171
|
+
var decorations = ({ tags }) => {
|
|
172
|
+
return [StateField.define({
|
|
173
|
+
create: (state) => buildQueryDecorations(state, { tags }),
|
|
174
|
+
update: (deco, tr) => {
|
|
175
|
+
if (tr.docChanged || tr.newSelection) return buildQueryDecorations(tr.state, { tags });
|
|
176
|
+
return deco;
|
|
177
|
+
},
|
|
178
|
+
provide: (field) => [EditorView.decorations.from(field), EditorView.atomicRanges.of((view) => {
|
|
179
|
+
const builder = new RangeSetBuilder();
|
|
180
|
+
const cursor = view.state.field(field).iter();
|
|
181
|
+
while (cursor.value) {
|
|
182
|
+
if (cursor.value.spec.atomic) builder.add(cursor.from, cursor.to, cursor.value);
|
|
183
|
+
cursor.next();
|
|
184
|
+
}
|
|
185
|
+
return builder.finish();
|
|
186
|
+
})]
|
|
187
|
+
})];
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* Keeps typed text from being glued onto a tag chip.
|
|
191
|
+
*
|
|
192
|
+
* Two rules, both applied as follow-up changes so the user's own transaction is left intact:
|
|
193
|
+
* 1. Text typed immediately before a tag is separated from it by a space.
|
|
194
|
+
* 2. An insertion leaves a trailing space at the end of the document, so there is always somewhere to
|
|
195
|
+
* type that is not adjacent to a chip. Deletions are exempt, or backspace at the end of the
|
|
196
|
+
* document would only ever delete a space this rule puts straight back.
|
|
197
|
+
*/
|
|
198
|
+
var spacing = EditorState.transactionFilter.of((tr) => {
|
|
199
|
+
if (!tr.docChanged) return tr;
|
|
200
|
+
const tagStarts = /* @__PURE__ */ new Set();
|
|
201
|
+
syntaxTree(tr.startState).iterate({ enter: (node) => {
|
|
202
|
+
if (node.type.id === QueryDSL.Node.Tag) tagStarts.add(node.from);
|
|
203
|
+
} });
|
|
204
|
+
let inserting = false;
|
|
205
|
+
let separator;
|
|
206
|
+
tr.changes.iterChanges((fromA, toA, _fromB, toB, inserted) => {
|
|
207
|
+
if (!inserted.length) return;
|
|
208
|
+
inserting = true;
|
|
209
|
+
if (tagStarts.has(toA) && !/\s$/.test(inserted.toString())) separator = toB;
|
|
210
|
+
});
|
|
211
|
+
const specs = [tr];
|
|
212
|
+
let text = tr.newDoc.toString();
|
|
213
|
+
if (separator !== void 0) {
|
|
214
|
+
specs.push({
|
|
215
|
+
changes: {
|
|
216
|
+
from: separator,
|
|
217
|
+
insert: " "
|
|
218
|
+
},
|
|
219
|
+
sequential: true
|
|
220
|
+
});
|
|
221
|
+
text = text.slice(0, separator) + " " + text.slice(separator);
|
|
222
|
+
}
|
|
223
|
+
if (inserting && text.length > 0 && !/\s$/.test(text)) specs.push({
|
|
224
|
+
changes: {
|
|
225
|
+
from: text.length,
|
|
226
|
+
insert: " "
|
|
227
|
+
},
|
|
228
|
+
sequential: true
|
|
229
|
+
});
|
|
230
|
+
return specs;
|
|
231
|
+
});
|
|
232
|
+
var lineHeight = "30px";
|
|
233
|
+
/** The outer box vertically aligns the inner text with content in the outer div. */
|
|
234
|
+
var CHIP_OUTER = "inline-flex h-[28px] align-middle";
|
|
235
|
+
var CHIP_INNER = "inline-flex h-[26px] border rounded-xs";
|
|
236
|
+
var container = (classNames, ...children) => {
|
|
237
|
+
const inner = Domino.of("span").classNames(mx(CHIP_INNER, classNames)).append(...children);
|
|
238
|
+
return Domino.of("span").classNames(CHIP_OUTER).append(inner).root;
|
|
239
|
+
};
|
|
240
|
+
/** A tag chip's parts, keyed by the nesting {@link container} produces. */
|
|
241
|
+
var chipClasses = (hue) => {
|
|
242
|
+
const { bg, fg, surface } = getStyles(hue);
|
|
243
|
+
return {
|
|
244
|
+
hash: mx("flex items-center px-1 text-black text-xs", bg),
|
|
245
|
+
label: mx("flex items-center px-1 text-sm", surface, fg)
|
|
246
|
+
};
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* TypeKeyword:Identifier
|
|
250
|
+
*/
|
|
251
|
+
var TypeWidget = class extends WidgetType {
|
|
252
|
+
_identifier;
|
|
253
|
+
constructor(_identifier) {
|
|
254
|
+
super();
|
|
255
|
+
this._identifier = _identifier;
|
|
256
|
+
}
|
|
257
|
+
ignoreEvent() {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
eq(other) {
|
|
261
|
+
return this._identifier === other._identifier;
|
|
262
|
+
}
|
|
263
|
+
toDOM() {
|
|
264
|
+
const label = this._identifier.split(/\W/).at(-1);
|
|
265
|
+
return container("border-sky-500", Domino.of("span").classNames(mx("flex items-center px-1 text-black text-xs bg-sky-500")).text("type"), Domino.of("span").classNames(mx("flex items-center px-1 text-fg-muted")).text(label));
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
/**
|
|
269
|
+
* Tag
|
|
270
|
+
*/
|
|
271
|
+
var TagWidget = class extends WidgetType {
|
|
272
|
+
_str;
|
|
273
|
+
_hue;
|
|
274
|
+
constructor(_str, _hue) {
|
|
275
|
+
super();
|
|
276
|
+
this._str = _str;
|
|
277
|
+
this._hue = _hue;
|
|
278
|
+
}
|
|
279
|
+
eq(other) {
|
|
280
|
+
return this._str === other._str;
|
|
281
|
+
}
|
|
282
|
+
toDOM() {
|
|
283
|
+
const { hash, label } = chipClasses(this._hue);
|
|
284
|
+
return container(getStyles(this._hue).border, Domino.of("span").classNames(hash).text("#"), Domino.of("span").classNames(label).text(this._str));
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
/**
|
|
288
|
+
* { type: "value" }
|
|
289
|
+
*/
|
|
290
|
+
var ObjectWidget = class extends WidgetType {
|
|
291
|
+
_props;
|
|
292
|
+
_entries;
|
|
293
|
+
_json;
|
|
294
|
+
constructor(_props) {
|
|
295
|
+
super();
|
|
296
|
+
this._props = _props;
|
|
297
|
+
this._entries = Object.entries(this._props);
|
|
298
|
+
this._json = JSON.stringify(this._props);
|
|
299
|
+
}
|
|
300
|
+
ignoreEvent() {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
eq(other) {
|
|
304
|
+
return this._json === other._json;
|
|
305
|
+
}
|
|
306
|
+
toDOM() {
|
|
307
|
+
return container("border-separator divide-x divide-separator", ...this._entries.map(([key, value]) => {
|
|
308
|
+
const keyEl = Domino.of("span").classNames("flex items-center px-1 text-fg-muted text-xs bg-modal-surface first:rounded-l-xs").text(key);
|
|
309
|
+
const valueEl = Domino.of("span").classNames("flex items-center px-1 text-fg-muted").text(value);
|
|
310
|
+
return Domino.of("span").classNames("inline-flex items-stretch").append(keyEl, valueEl);
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
/**
|
|
315
|
+
* Symbol
|
|
316
|
+
*/
|
|
317
|
+
var SymbolWidget = class extends WidgetType {
|
|
318
|
+
_str;
|
|
319
|
+
constructor(_str) {
|
|
320
|
+
super();
|
|
321
|
+
this._str = _str;
|
|
322
|
+
}
|
|
323
|
+
eq(other) {
|
|
324
|
+
return this._str === other._str;
|
|
325
|
+
}
|
|
326
|
+
toDOM() {
|
|
327
|
+
return Domino.of("span").text(this._str).root;
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
var styles = EditorView.theme({
|
|
331
|
+
".cm-line": { lineHeight },
|
|
332
|
+
".cm-scroller": {
|
|
333
|
+
alignItems: "center",
|
|
334
|
+
minHeight: "2.5rem"
|
|
335
|
+
},
|
|
336
|
+
"@media (pointer: fine)": { ".cm-scroller": { minHeight: "2rem" } }
|
|
337
|
+
});
|
|
338
|
+
/**
|
|
339
|
+
* Define syntax highlighting tags for the query language.
|
|
340
|
+
*/
|
|
341
|
+
var queryHighlighting = styleTags({
|
|
342
|
+
"Not And Or": tags.keyword,
|
|
343
|
+
"TypeKeyword": tags.attributeName,
|
|
344
|
+
"String": tags.string,
|
|
345
|
+
"Number": tags.number,
|
|
346
|
+
"Boolean": tags.bool,
|
|
347
|
+
"Null": tags.null,
|
|
348
|
+
"Identifier": tags.variableName,
|
|
349
|
+
"PropertyPath": tags.propertyName,
|
|
350
|
+
"{ }": tags.brace,
|
|
351
|
+
"[ ]": tags.squareBracket,
|
|
352
|
+
"( )": tags.paren,
|
|
353
|
+
":": tags.definitionOperator,
|
|
354
|
+
",": tags.separator,
|
|
355
|
+
".": tags.derefOperator
|
|
356
|
+
});
|
|
357
|
+
/**
|
|
358
|
+
* Create the query language with the parser and highlighting.
|
|
359
|
+
*/
|
|
360
|
+
var queryLanguage = LRLanguage.define({
|
|
361
|
+
parser: QueryDSL.Parser.configure({
|
|
362
|
+
props: [queryHighlighting],
|
|
363
|
+
strict: false
|
|
364
|
+
}),
|
|
365
|
+
languageData: { commentTokens: { line: "//" } }
|
|
366
|
+
});
|
|
367
|
+
/**
|
|
368
|
+
* Define a custom highlight style for the query language.
|
|
369
|
+
*/
|
|
370
|
+
var queryHighlightStyle = HighlightStyle.define([
|
|
371
|
+
{
|
|
372
|
+
tag: tags.keyword,
|
|
373
|
+
class: "text-blue-text"
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
tag: tags.string,
|
|
377
|
+
class: "text-orange-text"
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
tag: tags.number,
|
|
381
|
+
class: "text-green-text"
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
tag: tags.bool,
|
|
385
|
+
class: "text-green-text"
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
tag: tags.null,
|
|
389
|
+
class: "text-neutral-text"
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
tag: tags.attributeName,
|
|
393
|
+
class: "text-blue-text"
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
tag: tags.variableName,
|
|
397
|
+
class: "text-teal-text"
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
tag: tags.propertyName,
|
|
401
|
+
class: "text-teal-text"
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
tag: tags.definitionOperator,
|
|
405
|
+
class: "text-fg-subtle"
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
tag: tags.separator,
|
|
409
|
+
class: "text-fg-subtle"
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
tag: tags.derefOperator,
|
|
413
|
+
class: "text-fg-subtle"
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
tag: tags.brace,
|
|
417
|
+
class: "text-fg-subtle"
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
tag: tags.squareBracket,
|
|
421
|
+
class: "text-fg-subtle"
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
tag: tags.operator,
|
|
425
|
+
class: "text-fg-subtle"
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
tag: tags.paren,
|
|
429
|
+
class: "text-amber-text"
|
|
430
|
+
}
|
|
431
|
+
]);
|
|
432
|
+
function intersectRanges(a, b) {
|
|
433
|
+
const start = Math.max(a.from, b.from);
|
|
434
|
+
const end = Math.min(a.to, b.to);
|
|
435
|
+
return start <= end ? {
|
|
436
|
+
from: start,
|
|
437
|
+
to: end
|
|
438
|
+
} : null;
|
|
439
|
+
}
|
|
440
|
+
/** Label characters the grammar admits after `#` (`Tag { "#" $[a-zA-Z0-9_\-]+ }`). */
|
|
441
|
+
var TAG_LABEL_CHAR = /[a-zA-Z0-9_-]/;
|
|
442
|
+
/**
|
|
443
|
+
* Ranges of an in-progress tag — a `#` the parser has not yet accepted as a {@link QueryDSL.Node.Tag},
|
|
444
|
+
* because the grammar requires at least one label character after it. Skips offsets the tree already
|
|
445
|
+
* claimed, and any `#` inside a string, where it is content rather than a tag.
|
|
446
|
+
*/
|
|
447
|
+
var bareTagRanges = (state, tagged) => {
|
|
448
|
+
const ranges = [];
|
|
449
|
+
const text = state.sliceDoc();
|
|
450
|
+
const covered = (index) => tagged.some(({ from, to }) => index >= from && index < to);
|
|
451
|
+
for (let index = text.indexOf("#"); index !== -1; index = text.indexOf("#", index + 1)) {
|
|
452
|
+
if (covered(index) || syntaxTree(state).resolveInner(index, 1).type.id === QueryDSL.Node.String) continue;
|
|
453
|
+
let end = index + 1;
|
|
454
|
+
while (end < text.length && TAG_LABEL_CHAR.test(text[end])) end++;
|
|
455
|
+
ranges.push({
|
|
456
|
+
from: index,
|
|
457
|
+
to: end
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
return ranges;
|
|
461
|
+
};
|
|
462
|
+
//#endregion
|
|
463
|
+
//#region src/components/QueryEditor/QueryEditor.tsx
|
|
464
|
+
/**
|
|
465
|
+
* Query editor with decorations and autocomplete.
|
|
466
|
+
*/
|
|
467
|
+
var QueryEditor = forwardRef(({ db, tags, value, readonly, numItems = 8, onChange, onFilterChange, ...props }, forwardedRef) => {
|
|
468
|
+
const { t } = useTranslation(translationKey);
|
|
469
|
+
const getOptions = useMemo(() => completions({
|
|
470
|
+
db,
|
|
471
|
+
tags
|
|
472
|
+
}), [db, tags]);
|
|
473
|
+
const getMenu = useCallback(async (context) => [createMenuGroup({ items: getOptions(context) })], [getOptions]);
|
|
474
|
+
const builder = useMemo(() => onFilterChange ? new QueryBuilder(tags) : void 0, [onFilterChange, tags]);
|
|
475
|
+
const handleChange = useCallback((doc) => {
|
|
476
|
+
onChange?.(doc);
|
|
477
|
+
if (builder) onFilterChange?.(builder.build(doc));
|
|
478
|
+
}, [
|
|
479
|
+
onChange,
|
|
480
|
+
onFilterChange,
|
|
481
|
+
builder
|
|
482
|
+
]);
|
|
483
|
+
const tagsKey = useMemo(() => Object.values(tags ?? {}).map((tag) => `${tag.id}:${tag.label}:${tag.hue ?? ""}`).join(), [tags]);
|
|
484
|
+
const tagsRef = useRef(tags);
|
|
485
|
+
tagsRef.current = tags;
|
|
486
|
+
const themeMode = useThemeMode();
|
|
487
|
+
const extensions = useMemo(() => [
|
|
488
|
+
createBasicExtensions({
|
|
489
|
+
readOnly: readonly,
|
|
490
|
+
lineWrapping: false,
|
|
491
|
+
placeholder: t("query-editor.placeholder"),
|
|
492
|
+
bracketMatching: false
|
|
493
|
+
}),
|
|
494
|
+
createThemeExtensions({
|
|
495
|
+
themeMode,
|
|
496
|
+
slots: { scroller: { className: "scrollbar-none" } }
|
|
497
|
+
}),
|
|
498
|
+
query({ tags: tagsRef.current }),
|
|
499
|
+
Prec.highest(keymap.of([{
|
|
500
|
+
key: "Enter",
|
|
501
|
+
run: () => {
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
}]))
|
|
505
|
+
], [
|
|
506
|
+
readonly,
|
|
507
|
+
themeMode,
|
|
508
|
+
tagsKey
|
|
509
|
+
]);
|
|
510
|
+
return /* @__PURE__ */ jsx(Editor.Root, {
|
|
511
|
+
ref: forwardedRef,
|
|
512
|
+
extensions,
|
|
513
|
+
numItems,
|
|
514
|
+
trigger: ["#"],
|
|
515
|
+
activateOnDelimiters: [":"],
|
|
516
|
+
triggerKey: "Ctrl-Space",
|
|
517
|
+
getMenu,
|
|
518
|
+
children: /* @__PURE__ */ jsx(Editor.View, {
|
|
519
|
+
...props,
|
|
520
|
+
initialValue: value,
|
|
521
|
+
onChange: handleChange,
|
|
522
|
+
selectionEnd: true
|
|
523
|
+
})
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
//#endregion
|
|
527
|
+
//#region src/components/QueryForm/Picker.tsx
|
|
528
|
+
var NULL = "__NULL__";
|
|
529
|
+
var Picker = ({ placeholder, values, value, onChange }) => {
|
|
530
|
+
const { t } = useTranslation(translationKey);
|
|
531
|
+
const sorted = useMemo(() => values?.sort(({ label: a }, { label: b }) => a.localeCompare(b)) ?? [], [values]);
|
|
532
|
+
return /* @__PURE__ */ jsxs(Select.Root, {
|
|
533
|
+
items: [{
|
|
534
|
+
value: NULL,
|
|
535
|
+
label: t("picker-none.label")
|
|
536
|
+
}, ...sorted],
|
|
537
|
+
value: [value ?? NULL],
|
|
538
|
+
onValueChange: ({ value: [value] }) => onChange?.(value === NULL ? null : value),
|
|
539
|
+
children: [/* @__PURE__ */ jsx(Select.Trigger, { placeholder: placeholder ?? t("picker-select.label") }), /* @__PURE__ */ jsx(Select.Content, { children: /* @__PURE__ */ jsxs(Select.ItemGroup, { children: [/* @__PURE__ */ jsx(Select.Item, { item: {
|
|
540
|
+
value: NULL,
|
|
541
|
+
label: t("picker-none.label")
|
|
542
|
+
} }), sorted.map(({ value, label }) => /* @__PURE__ */ jsx(Select.Item, { item: {
|
|
543
|
+
value,
|
|
544
|
+
label
|
|
545
|
+
} }, value))] }) })]
|
|
546
|
+
});
|
|
547
|
+
};
|
|
548
|
+
//#endregion
|
|
549
|
+
//#region src/components/QueryForm/query.ts
|
|
550
|
+
var extractTypename = (query) => {
|
|
551
|
+
return Match.value(query).pipe(Match.withReturnType(), Match.when({ type: "select" }, (q) => extractTypenameFromFilter(q.filter)), Match.when({ type: "filter" }, (q) => {
|
|
552
|
+
const selectionTypename = extractTypename(q.selection);
|
|
553
|
+
const filterTypename = extractTypenameFromFilter(q.filter);
|
|
554
|
+
return Option.isSome(selectionTypename) ? selectionTypename : filterTypename;
|
|
555
|
+
}), Match.when({ type: "options" }, (q) => extractTypename(q.query)), Match.when({ type: "from" }, (q) => extractTypename(q.query)), Match.when({ type: "order" }, (q) => extractTypename(q.query)), Match.when({ type: "limit" }, (q) => extractTypename(q.query)), Match.orElse(() => Option.none()));
|
|
556
|
+
};
|
|
557
|
+
var extractTag = (query) => {
|
|
558
|
+
return Match.value(query).pipe(Match.withReturnType(), Match.when({ type: "select" }, (q) => extractTagFromFilter(q.filter)), Match.when({ type: "filter" }, (q) => {
|
|
559
|
+
const selectionTag = extractTag(q.selection);
|
|
560
|
+
const filterTag = extractTagFromFilter(q.filter);
|
|
561
|
+
return Option.isSome(filterTag) ? filterTag : selectionTag;
|
|
562
|
+
}), Match.when({ type: "options" }, (q) => extractTag(q.query)), Match.when({ type: "from" }, (q) => extractTag(q.query)), Match.when({ type: "order" }, (q) => extractTag(q.query)), Match.when({ type: "limit" }, (q) => extractTag(q.query)), Match.orElse(() => Option.none()));
|
|
563
|
+
};
|
|
564
|
+
var extractTypenameFromFilter = (filter) => {
|
|
565
|
+
return Match.value(filter).pipe(Match.withReturnType(), Match.when({ type: "object" }, (f) => Option.fromNullishOr(f.typename)), Match.when({ type: "and" }, (f) => f.filters.reduce((acc, filterItem) => Option.isSome(acc) ? acc : extractTypenameFromFilter(filterItem), Option.none())), Match.when({ type: "or" }, (f) => f.filters.reduce((acc, filterItem) => Option.isSome(acc) ? acc : extractTypenameFromFilter(filterItem), Option.none())), Match.orElse(() => Option.none()));
|
|
566
|
+
};
|
|
567
|
+
var extractTagFromFilter = (filter) => {
|
|
568
|
+
return Match.value(filter).pipe(Match.withReturnType(), Match.when({ type: "tag" }, (f) => Option.some(f.tag)), Match.when({ type: "and" }, (f) => f.filters.reduce((acc, filterItem) => Option.isSome(acc) ? acc : extractTagFromFilter(filterItem), Option.none())), Match.when({ type: "or" }, (f) => f.filters.reduce((acc, filterItem) => Option.isSome(acc) ? acc : extractTagFromFilter(filterItem), Option.none())), Match.orElse(() => Option.none()));
|
|
569
|
+
};
|
|
570
|
+
//#endregion
|
|
571
|
+
//#region src/components/QueryForm/QueryForm.tsx
|
|
572
|
+
var QueryForm = ({ classNames, initialQuery, types, tags, onChange }) => {
|
|
573
|
+
const { t } = useTranslation(translationKey);
|
|
574
|
+
const initialType = initialQuery ? Option.getOrUndefined(extractTypename(initialQuery)) : void 0;
|
|
575
|
+
const initialTag = initialQuery ? Option.getOrUndefined(extractTag(initialQuery)) : void 0;
|
|
576
|
+
const [type, setType] = useState(initialType ?? null);
|
|
577
|
+
const [tag, setTag] = useState(initialTag ?? null);
|
|
578
|
+
const tagOptions = useMemo(() => tags?.map((tag) => ({
|
|
579
|
+
value: Obj.getURI(tag),
|
|
580
|
+
label: tag.label
|
|
581
|
+
})), [tags]);
|
|
582
|
+
const handleChange = useCallback(({ type, tag }) => {
|
|
583
|
+
const typeFilter = type ? Filter.type(type) : null;
|
|
584
|
+
const tagFilter = tag ? Filter.tag(tag) : null;
|
|
585
|
+
const combined = typeFilter && tagFilter ? Filter.and(typeFilter, tagFilter) : typeFilter ?? tagFilter ?? Filter.nothing();
|
|
586
|
+
onChange?.(Query.select(combined));
|
|
587
|
+
}, [onChange]);
|
|
588
|
+
const handleTypeChange = useCallback((value) => {
|
|
589
|
+
const uri = value;
|
|
590
|
+
setType(uri);
|
|
591
|
+
handleChange({
|
|
592
|
+
type: uri,
|
|
593
|
+
tag
|
|
594
|
+
});
|
|
595
|
+
}, [handleChange, tag]);
|
|
596
|
+
const handleTagChange = useCallback((tag) => {
|
|
597
|
+
setTag(tag);
|
|
598
|
+
handleChange({
|
|
599
|
+
type,
|
|
600
|
+
tag
|
|
601
|
+
});
|
|
602
|
+
}, [handleChange, type]);
|
|
603
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
604
|
+
className: mx("grid grid-cols-2 gap-2", classNames),
|
|
605
|
+
children: [types && /* @__PURE__ */ jsx(Picker, {
|
|
606
|
+
placeholder: t("picker-type.placeholder"),
|
|
607
|
+
values: types,
|
|
608
|
+
value: type,
|
|
609
|
+
onChange: handleTypeChange
|
|
610
|
+
}), tags && /* @__PURE__ */ jsx(Picker, {
|
|
611
|
+
placeholder: t("picker-tag.placeholder"),
|
|
612
|
+
values: tagOptions,
|
|
613
|
+
value: tag,
|
|
614
|
+
onChange: handleTagChange
|
|
615
|
+
})]
|
|
616
|
+
});
|
|
617
|
+
};
|
|
618
|
+
//#endregion
|
|
619
|
+
//#region src/hooks/useQueryBuilder.ts
|
|
620
|
+
/**
|
|
621
|
+
* Creates a filter from a query string.
|
|
622
|
+
*/
|
|
623
|
+
var useQueryBuilder = (query) => {
|
|
624
|
+
const builder = useMemo(() => new QueryBuilder(), []);
|
|
625
|
+
const [filter, setFilter] = useState(Filter.everything());
|
|
626
|
+
useEffect(() => {
|
|
627
|
+
if (query) {
|
|
628
|
+
const { filter } = builder.build(query);
|
|
629
|
+
setFilter(filter ?? Filter.everything());
|
|
630
|
+
} else setFilter(Filter.everything());
|
|
631
|
+
}, [builder, query]);
|
|
632
|
+
return filter;
|
|
633
|
+
};
|
|
634
|
+
//#endregion
|
|
635
|
+
export { QueryEditor, QueryForm, useQueryBuilder };
|
|
636
|
+
|
|
637
|
+
//# sourceMappingURL=index.mjs.map
|