@keenmate/pure-admin-core 2.9.0-rc03 → 2.9.0-rc05
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/README.md +55 -13
- package/dist/css/main.css +554 -7
- package/package.json +3 -1
- package/snippets/buttons.html +51 -0
- package/snippets/cards.html +132 -47
- package/snippets/comparison.html +26 -22
- package/snippets/manifest.json +180 -115
- package/snippets/range-group.html +125 -0
- package/snippets/splitter.html +44 -38
- package/snippets/statistics.html +31 -0
- package/src/js/btn-split-auto-absorb.js +327 -0
- package/src/js/command-palette.js +472 -0
- package/src/js/file-selector.js +1275 -0
- package/src/js/internal/logging.js +121 -0
- package/src/js/logic-tree-renderer.js +303 -0
- package/src/js/modal-dialogs.js +460 -0
- package/src/js/overflow.js +371 -0
- package/src/js/pa-stat-fit.js +184 -0
- package/src/js/range-group.js +663 -0
- package/src/js/search-autocomplete-v2.js +907 -0
- package/src/js/search-autocomplete.js +434 -0
- package/src/js/settings-panel.js +245 -0
- package/src/js/split-button.js +141 -0
- package/src/js/splitter.js +1323 -0
- package/src/js/toast-service.js +302 -0
- package/src/js/tooltips-popovers.js +275 -0
- package/src/js/virtual-scroll.js +143 -0
- package/src/js/virtual-textbox.js +803 -0
- package/src/scss/_core.scss +7 -0
- package/src/scss/core-components/_buttons.scss +44 -0
- package/src/scss/core-components/_cards.scss +95 -6
- package/src/scss/core-components/_overflow.scss +50 -0
- package/src/scss/core-components/_range-group.scss +474 -0
- package/src/scss/core-components/_statistics.scss +163 -0
- package/src/scss/variables/_components.scss +41 -2
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline Query Editor with Syntax Highlighting (V2)
|
|
3
|
+
* Provides inline editable query string with real-time syntax highlighting
|
|
4
|
+
* User can type: :name startswith abcd :price between 1 to 3
|
|
5
|
+
* Different parts get colored backgrounds (fields=blue, operators=gray, values=green)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
class InlineQueryEditor {
|
|
9
|
+
constructor(containerElement, fields, options = {}) {
|
|
10
|
+
this.container = containerElement;
|
|
11
|
+
this.fields = fields;
|
|
12
|
+
this.popup = null;
|
|
13
|
+
this.highlightLayer = null;
|
|
14
|
+
this.inputLayer = null;
|
|
15
|
+
this.activeIndex = -1;
|
|
16
|
+
this.isOpen = false;
|
|
17
|
+
this.autocompleteResults = [];
|
|
18
|
+
this.cursorPosition = 0;
|
|
19
|
+
|
|
20
|
+
// Operator definitions by field type with shortcuts
|
|
21
|
+
this.operators = {
|
|
22
|
+
text: [
|
|
23
|
+
{ value: 'equals', label: 'Equals', shortcuts: ['eq', '=', 'is'], description: 'Exact match' },
|
|
24
|
+
{ value: 'not_equals', label: 'Not Equals', shortcuts: ['neq', 'ne', '!=', 'not'], description: 'Does not match' },
|
|
25
|
+
{ value: 'starts_with', label: 'Starts With', shortcuts: ['sw', 'startswith', 'begins'], description: 'Starts with text' },
|
|
26
|
+
{ value: 'ends_with', label: 'Ends With', shortcuts: ['ew', 'endswith'], description: 'Ends with text' },
|
|
27
|
+
{ value: 'contains', label: 'Contains', shortcuts: ['c', 'has', 'includes'], description: 'Contains text' },
|
|
28
|
+
{ value: 'not_contains', label: 'Not Contains', shortcuts: ['nc', 'notcontains'], description: 'Does not contain' }
|
|
29
|
+
],
|
|
30
|
+
number: [
|
|
31
|
+
{ value: '=', label: 'Equals', shortcuts: ['eq', '==', 'is'], description: 'Exact value' },
|
|
32
|
+
{ value: '!=', label: 'Not Equals', shortcuts: ['neq', 'ne', 'not'], description: 'Different value' },
|
|
33
|
+
{ value: '>', label: 'Greater Than', shortcuts: ['gt', 'over', 'above'], description: 'Greater than value' },
|
|
34
|
+
{ value: '<', label: 'Less Than', shortcuts: ['lt', 'under', 'below'], description: 'Less than value' },
|
|
35
|
+
{ value: 'between', label: 'Between', shortcuts: ['bw', 'btw', 'from'], description: 'Between two values' },
|
|
36
|
+
{ value: '>=', label: 'Greater or Equal', shortcuts: ['gte', 'ge', 'atleast', 'min'], description: 'Greater or equal' },
|
|
37
|
+
{ value: '<=', label: 'Less or Equal', shortcuts: ['lte', 'le', 'atmost', 'max'], description: 'Less or equal' }
|
|
38
|
+
],
|
|
39
|
+
date: [
|
|
40
|
+
{ value: 'equals', label: 'On Date', shortcuts: ['eq', '=', 'on', 'is'], description: 'Exact date' },
|
|
41
|
+
{ value: 'before', label: 'Before', shortcuts: ['bf', '<', 'until'], description: 'Before date' },
|
|
42
|
+
{ value: 'after', label: 'After', shortcuts: ['af', '>', 'since', 'from'], description: 'After date' },
|
|
43
|
+
{ value: 'between', label: 'Between', shortcuts: ['bw', 'btw'], description: 'Date range' },
|
|
44
|
+
{ value: 'in_last', label: 'In Last', shortcuts: ['last', 'past', 'recent'], description: 'In last X days' },
|
|
45
|
+
{ value: 'in_next', label: 'In Next', shortcuts: ['next', 'future', 'upcoming'], description: 'In next X days' }
|
|
46
|
+
]
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Keywords that are part of query syntax but not values
|
|
50
|
+
this.keywords = ['to', 'and', 'or', '(', ')'];
|
|
51
|
+
|
|
52
|
+
this.init();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
init() {
|
|
56
|
+
// Create dual-layer structure
|
|
57
|
+
this.container.classList.add('pa-inline-query-editor');
|
|
58
|
+
this.container.innerHTML = `
|
|
59
|
+
<div class="pa-inline-query-editor__layers">
|
|
60
|
+
<div class="pa-inline-query-editor__highlights" aria-hidden="true"></div>
|
|
61
|
+
<textarea class="pa-inline-query-editor__input"
|
|
62
|
+
placeholder="Type : to search fields..."
|
|
63
|
+
rows="1"
|
|
64
|
+
spellcheck="false"></textarea>
|
|
65
|
+
</div>
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
this.highlightLayer = this.container.querySelector('.pa-inline-query-editor__highlights');
|
|
69
|
+
this.inputLayer = this.container.querySelector('.pa-inline-query-editor__input');
|
|
70
|
+
|
|
71
|
+
// Create popup element
|
|
72
|
+
this.popup = document.createElement('div');
|
|
73
|
+
this.popup.className = 'pa-inline-query-autocomplete';
|
|
74
|
+
this.popup.style.display = 'none';
|
|
75
|
+
document.body.appendChild(this.popup);
|
|
76
|
+
|
|
77
|
+
// Event listeners
|
|
78
|
+
this.inputLayer.addEventListener('input', (e) => this.handleInput(e));
|
|
79
|
+
this.inputLayer.addEventListener('keydown', (e) => this.handleKeydown(e));
|
|
80
|
+
this.inputLayer.addEventListener('scroll', () => this.syncScroll());
|
|
81
|
+
this.inputLayer.addEventListener('click', () => this.updateCursorPosition());
|
|
82
|
+
this.inputLayer.addEventListener('keyup', () => this.updateCursorPosition());
|
|
83
|
+
document.addEventListener('click', (e) => this.handleClickOutside(e));
|
|
84
|
+
|
|
85
|
+
// Auto-grow textarea
|
|
86
|
+
this.inputLayer.addEventListener('input', () => this.autoGrow());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Auto-grow textarea height
|
|
91
|
+
*/
|
|
92
|
+
autoGrow() {
|
|
93
|
+
this.inputLayer.style.height = 'auto';
|
|
94
|
+
this.inputLayer.style.height = this.inputLayer.scrollHeight + 'px';
|
|
95
|
+
this.highlightLayer.style.height = this.inputLayer.scrollHeight + 'px';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Sync scroll position between input and highlight layers
|
|
100
|
+
*/
|
|
101
|
+
syncScroll() {
|
|
102
|
+
this.highlightLayer.scrollTop = this.inputLayer.scrollTop;
|
|
103
|
+
this.highlightLayer.scrollLeft = this.inputLayer.scrollLeft;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Update cursor position
|
|
108
|
+
*/
|
|
109
|
+
updateCursorPosition() {
|
|
110
|
+
this.cursorPosition = this.inputLayer.selectionStart;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Parse query string into tokens with types
|
|
115
|
+
*/
|
|
116
|
+
parseQuery(queryString) {
|
|
117
|
+
const tokens = [];
|
|
118
|
+
let currentPos = 0;
|
|
119
|
+
const text = queryString;
|
|
120
|
+
|
|
121
|
+
// Get all field names and operators for matching
|
|
122
|
+
const allFieldNames = this.fields.map(f => f.name);
|
|
123
|
+
const allOperators = [];
|
|
124
|
+
Object.values(this.operators).forEach(ops => {
|
|
125
|
+
ops.forEach(op => {
|
|
126
|
+
allOperators.push(op.value);
|
|
127
|
+
if (op.shortcuts) {
|
|
128
|
+
op.shortcuts.forEach(s => allOperators.push(s));
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
// Sort operators by length (longest first) to match "starts_with" before "starts"
|
|
133
|
+
allOperators.sort((a, b) => b.length - a.length);
|
|
134
|
+
|
|
135
|
+
while (currentPos < text.length) {
|
|
136
|
+
const remaining = text.substring(currentPos);
|
|
137
|
+
|
|
138
|
+
// Check if we're at the start or after whitespace/parenthesis for field matching
|
|
139
|
+
const prevChar = currentPos > 0 ? text[currentPos - 1] : ' ';
|
|
140
|
+
const isFieldStart = prevChar === ' ' || prevChar === '(' || currentPos === 0;
|
|
141
|
+
|
|
142
|
+
// Match field (starts with : only at start or after whitespace)
|
|
143
|
+
if (isFieldStart) {
|
|
144
|
+
const fieldMatch = remaining.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)/);
|
|
145
|
+
if (fieldMatch) {
|
|
146
|
+
const fieldName = fieldMatch[1];
|
|
147
|
+
tokens.push({
|
|
148
|
+
type: 'field',
|
|
149
|
+
value: ':' + fieldName,
|
|
150
|
+
start: currentPos,
|
|
151
|
+
end: currentPos + fieldMatch[0].length,
|
|
152
|
+
valid: allFieldNames.includes(fieldName)
|
|
153
|
+
});
|
|
154
|
+
currentPos += fieldMatch[0].length;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Match operator (sorted by length, longest first)
|
|
160
|
+
let operatorMatched = false;
|
|
161
|
+
for (const op of allOperators) {
|
|
162
|
+
const opRegex = new RegExp('^' + this.escapeRegex(op) + '(?=\\s|\\)|$)', 'i');
|
|
163
|
+
const opMatch = remaining.match(opRegex);
|
|
164
|
+
if (opMatch) {
|
|
165
|
+
tokens.push({
|
|
166
|
+
type: 'operator',
|
|
167
|
+
value: opMatch[0],
|
|
168
|
+
start: currentPos,
|
|
169
|
+
end: currentPos + opMatch[0].length
|
|
170
|
+
});
|
|
171
|
+
currentPos += opMatch[0].length;
|
|
172
|
+
operatorMatched = true;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (operatorMatched) continue;
|
|
177
|
+
|
|
178
|
+
// Match keyword - parentheses don't need trailing whitespace
|
|
179
|
+
const keywordMatch = remaining.match(/^(\(|\))/);
|
|
180
|
+
if (keywordMatch) {
|
|
181
|
+
tokens.push({
|
|
182
|
+
type: 'keyword',
|
|
183
|
+
value: keywordMatch[0],
|
|
184
|
+
start: currentPos,
|
|
185
|
+
end: currentPos + keywordMatch[0].length
|
|
186
|
+
});
|
|
187
|
+
currentPos += keywordMatch[0].length;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Match logical keywords (to, and, or) - these need trailing whitespace or end
|
|
192
|
+
const logicalKeywordMatch = remaining.match(/^(to|and|or)(?=\s|$)/i);
|
|
193
|
+
if (logicalKeywordMatch) {
|
|
194
|
+
tokens.push({
|
|
195
|
+
type: 'keyword',
|
|
196
|
+
value: logicalKeywordMatch[0],
|
|
197
|
+
start: currentPos,
|
|
198
|
+
end: currentPos + logicalKeywordMatch[0].length
|
|
199
|
+
});
|
|
200
|
+
currentPos += logicalKeywordMatch[0].length;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Match whitespace
|
|
205
|
+
const whitespaceMatch = remaining.match(/^\s+/);
|
|
206
|
+
if (whitespaceMatch) {
|
|
207
|
+
tokens.push({
|
|
208
|
+
type: 'whitespace',
|
|
209
|
+
value: whitespaceMatch[0],
|
|
210
|
+
start: currentPos,
|
|
211
|
+
end: currentPos + whitespaceMatch[0].length
|
|
212
|
+
});
|
|
213
|
+
currentPos += whitespaceMatch[0].length;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Match value with optional time unit suffix (30days, 5weeks, 2months, 1year)
|
|
218
|
+
const valueWithUnitMatch = remaining.match(/^(\d+)(days?|weeks?|months?|years?)(?=\s|\)|$)/i);
|
|
219
|
+
if (valueWithUnitMatch) {
|
|
220
|
+
tokens.push({
|
|
221
|
+
type: 'value',
|
|
222
|
+
value: valueWithUnitMatch[0],
|
|
223
|
+
start: currentPos,
|
|
224
|
+
end: currentPos + valueWithUnitMatch[0].length
|
|
225
|
+
});
|
|
226
|
+
currentPos += valueWithUnitMatch[0].length;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Match value (everything else that's not whitespace or parentheses)
|
|
231
|
+
const valueMatch = remaining.match(/^[^\s()]+/);
|
|
232
|
+
if (valueMatch) {
|
|
233
|
+
tokens.push({
|
|
234
|
+
type: 'value',
|
|
235
|
+
value: valueMatch[0],
|
|
236
|
+
start: currentPos,
|
|
237
|
+
end: currentPos + valueMatch[0].length
|
|
238
|
+
});
|
|
239
|
+
currentPos += valueMatch[0].length;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Safety: advance one character if nothing matched
|
|
244
|
+
currentPos++;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return tokens;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Escape special regex characters
|
|
252
|
+
*/
|
|
253
|
+
escapeRegex(str) {
|
|
254
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Render syntax highlighting
|
|
259
|
+
*/
|
|
260
|
+
renderHighlights() {
|
|
261
|
+
const queryString = this.inputLayer.value;
|
|
262
|
+
const tokens = this.parseQuery(queryString);
|
|
263
|
+
|
|
264
|
+
let html = '';
|
|
265
|
+
|
|
266
|
+
tokens.forEach(token => {
|
|
267
|
+
const escapedValue = this.escapeHtml(token.value);
|
|
268
|
+
|
|
269
|
+
if (token.type === 'field') {
|
|
270
|
+
const validClass = token.valid ? '' : ' pa-inline-query-token--invalid';
|
|
271
|
+
html += `<span class="pa-inline-query-token pa-inline-query-token--field${validClass}">${escapedValue}</span>`;
|
|
272
|
+
} else if (token.type === 'operator') {
|
|
273
|
+
html += `<span class="pa-inline-query-token pa-inline-query-token--operator">${escapedValue}</span>`;
|
|
274
|
+
} else if (token.type === 'value') {
|
|
275
|
+
html += `<span class="pa-inline-query-token pa-inline-query-token--value">${escapedValue}</span>`;
|
|
276
|
+
} else if (token.type === 'keyword') {
|
|
277
|
+
html += `<span class="pa-inline-query-token pa-inline-query-token--keyword">${escapedValue}</span>`;
|
|
278
|
+
} else if (token.type === 'whitespace') {
|
|
279
|
+
html += escapedValue;
|
|
280
|
+
} else {
|
|
281
|
+
html += escapedValue;
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Add trailing space to prevent layout collapse
|
|
286
|
+
html += ' ';
|
|
287
|
+
|
|
288
|
+
this.highlightLayer.innerHTML = html;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Escape HTML
|
|
293
|
+
*/
|
|
294
|
+
escapeHtml(text) {
|
|
295
|
+
const div = document.createElement('div');
|
|
296
|
+
div.textContent = text;
|
|
297
|
+
return div.innerHTML;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Get context at cursor position (what type of suggestion to show)
|
|
302
|
+
*/
|
|
303
|
+
getCursorContext() {
|
|
304
|
+
const queryString = this.inputLayer.value;
|
|
305
|
+
const cursorPos = this.cursorPosition;
|
|
306
|
+
const tokens = this.parseQuery(queryString);
|
|
307
|
+
|
|
308
|
+
// Find token at cursor
|
|
309
|
+
const currentToken = tokens.find(t => cursorPos >= t.start && cursorPos <= t.end);
|
|
310
|
+
const prevToken = tokens.filter(t => t.end <= cursorPos).pop();
|
|
311
|
+
|
|
312
|
+
// If cursor is in a field token or after :
|
|
313
|
+
if (currentToken && currentToken.type === 'field') {
|
|
314
|
+
return { type: 'field', partial: currentToken.value };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// If cursor is right after : (incomplete field) - but only at start or after whitespace/parenthesis
|
|
318
|
+
const beforeCursor = queryString.substring(0, cursorPos);
|
|
319
|
+
if (beforeCursor.endsWith(':')) {
|
|
320
|
+
const beforeColon = cursorPos > 1 ? queryString[cursorPos - 2] : ' ';
|
|
321
|
+
if (beforeColon === ' ' || beforeColon === '(' || cursorPos === 1) {
|
|
322
|
+
return { type: 'field', partial: ':' };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Find the field before the current position
|
|
327
|
+
// We need to look backwards from the current token to find a field
|
|
328
|
+
let fieldToken = null;
|
|
329
|
+
for (let i = tokens.length - 1; i >= 0; i--) {
|
|
330
|
+
const token = tokens[i];
|
|
331
|
+
// Skip the current token if it's an operator or value (we're editing it)
|
|
332
|
+
if (currentToken && token.start >= currentToken.start) {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
// Found a field token before current position
|
|
336
|
+
if (token.type === 'field' && token.valid) {
|
|
337
|
+
fieldToken = token;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
// If we hit another operator or value before finding a field, stop
|
|
341
|
+
if (token.type === 'operator' || token.type === 'value') {
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (fieldToken) {
|
|
347
|
+
// Get the field definition
|
|
348
|
+
const fieldName = fieldToken.value.substring(1);
|
|
349
|
+
const fieldDef = this.fields.find(f => f.name === fieldName);
|
|
350
|
+
|
|
351
|
+
// Check if cursor is in operator position (typing or inside operator)
|
|
352
|
+
if (currentToken && currentToken.type === 'operator') {
|
|
353
|
+
return { type: 'operator', fieldType: fieldDef.type, partial: currentToken.value };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Check if cursor is in a value token that might actually be an incomplete operator
|
|
357
|
+
if (currentToken && currentToken.type === 'value') {
|
|
358
|
+
// This could be an incomplete operator shortcut, show operator suggestions
|
|
359
|
+
return { type: 'operator', fieldType: fieldDef.type, partial: currentToken.value };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// If cursor is right after field (no token at cursor), show all operators
|
|
363
|
+
if (!currentToken || currentToken.type === 'whitespace') {
|
|
364
|
+
return { type: 'operator', fieldType: fieldDef.type, partial: '' };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return { type: 'none' };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Search for autocomplete suggestions
|
|
373
|
+
*/
|
|
374
|
+
searchAutocomplete(context) {
|
|
375
|
+
if (context.type === 'field') {
|
|
376
|
+
const searchTerm = context.partial.startsWith(':')
|
|
377
|
+
? context.partial.substring(1).toLowerCase()
|
|
378
|
+
: context.partial.toLowerCase();
|
|
379
|
+
|
|
380
|
+
return this.fields.filter(field =>
|
|
381
|
+
field.name.toLowerCase().includes(searchTerm)
|
|
382
|
+
).map(field => ({
|
|
383
|
+
value: field.name,
|
|
384
|
+
label: field.name,
|
|
385
|
+
description: `${field.type} field`,
|
|
386
|
+
type: field.type,
|
|
387
|
+
insertValue: ':' + field.name
|
|
388
|
+
}));
|
|
389
|
+
} else if (context.type === 'operator') {
|
|
390
|
+
const operators = this.operators[context.fieldType] || [];
|
|
391
|
+
const searchTerm = context.partial.toLowerCase();
|
|
392
|
+
|
|
393
|
+
return operators.filter(op =>
|
|
394
|
+
searchTerm === '' ||
|
|
395
|
+
op.value.toLowerCase().includes(searchTerm) ||
|
|
396
|
+
op.label.toLowerCase().includes(searchTerm) ||
|
|
397
|
+
(op.shortcuts && op.shortcuts.some(s => s.toLowerCase().includes(searchTerm)))
|
|
398
|
+
).map(op => ({
|
|
399
|
+
...op,
|
|
400
|
+
insertValue: op.value
|
|
401
|
+
}));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return [];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Handle input changes
|
|
409
|
+
*/
|
|
410
|
+
handleInput(e) {
|
|
411
|
+
this.updateCursorPosition();
|
|
412
|
+
this.renderHighlights();
|
|
413
|
+
this.syncScroll();
|
|
414
|
+
|
|
415
|
+
// Check for autocomplete
|
|
416
|
+
const context = this.getCursorContext();
|
|
417
|
+
if (context.type !== 'none') {
|
|
418
|
+
this.autocompleteResults = this.searchAutocomplete(context);
|
|
419
|
+
this.activeIndex = this.autocompleteResults.length > 0 ? 0 : -1;
|
|
420
|
+
|
|
421
|
+
if (this.autocompleteResults.length > 0) {
|
|
422
|
+
this.renderAutocomplete();
|
|
423
|
+
} else {
|
|
424
|
+
this.close();
|
|
425
|
+
}
|
|
426
|
+
} else {
|
|
427
|
+
this.close();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Render autocomplete dropdown
|
|
433
|
+
*/
|
|
434
|
+
renderAutocomplete() {
|
|
435
|
+
if (this.autocompleteResults.length === 0) {
|
|
436
|
+
this.close();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const context = this.getCursorContext();
|
|
441
|
+
const isFieldSearch = context.type === 'field';
|
|
442
|
+
let html = '';
|
|
443
|
+
|
|
444
|
+
this.autocompleteResults.forEach((item, index) => {
|
|
445
|
+
const isActive = index === this.activeIndex;
|
|
446
|
+
const icon = isFieldSearch ? '🔍' : '⚡';
|
|
447
|
+
|
|
448
|
+
html += `
|
|
449
|
+
<div class="pa-inline-query-autocomplete__item ${isActive ? 'pa-inline-query-autocomplete__item--active' : ''}" data-index="${index}">
|
|
450
|
+
<span class="pa-inline-query-autocomplete__item-icon">${icon}</span>
|
|
451
|
+
<div class="pa-inline-query-autocomplete__item-content">
|
|
452
|
+
<span class="pa-inline-query-autocomplete__item-name">${item.label}</span>
|
|
453
|
+
<span class="pa-inline-query-autocomplete__item-type">${item.description}</span>
|
|
454
|
+
</div>
|
|
455
|
+
<span class="pa-inline-query-autocomplete__item-badge">${item.value}</span>
|
|
456
|
+
</div>
|
|
457
|
+
`;
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
this.popup.innerHTML = html;
|
|
461
|
+
|
|
462
|
+
// Add click handlers
|
|
463
|
+
this.popup.querySelectorAll('.pa-inline-query-autocomplete__item').forEach((item, index) => {
|
|
464
|
+
item.addEventListener('click', () => {
|
|
465
|
+
this.selectAutocomplete(this.autocompleteResults[index]);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
this.position();
|
|
470
|
+
this.open();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Select autocomplete item
|
|
475
|
+
*/
|
|
476
|
+
selectAutocomplete(item) {
|
|
477
|
+
if (!item) return;
|
|
478
|
+
|
|
479
|
+
const context = this.getCursorContext();
|
|
480
|
+
const queryString = this.inputLayer.value;
|
|
481
|
+
const tokens = this.parseQuery(queryString);
|
|
482
|
+
const cursorPos = this.cursorPosition;
|
|
483
|
+
|
|
484
|
+
let newValue = queryString;
|
|
485
|
+
let newCursorPos = cursorPos;
|
|
486
|
+
|
|
487
|
+
if (context.type === 'field') {
|
|
488
|
+
// Replace the partial field with selected field
|
|
489
|
+
const currentToken = tokens.find(t => t.type === 'field' && cursorPos >= t.start && cursorPos <= t.end);
|
|
490
|
+
|
|
491
|
+
if (currentToken) {
|
|
492
|
+
// Replace existing field
|
|
493
|
+
newValue = queryString.substring(0, currentToken.start) + item.insertValue + ' ' + queryString.substring(currentToken.end);
|
|
494
|
+
newCursorPos = currentToken.start + item.insertValue.length + 1;
|
|
495
|
+
} else {
|
|
496
|
+
// Insert after :
|
|
497
|
+
const colonPos = queryString.lastIndexOf(':', cursorPos);
|
|
498
|
+
if (colonPos !== -1) {
|
|
499
|
+
newValue = queryString.substring(0, colonPos) + item.insertValue + ' ' + queryString.substring(cursorPos);
|
|
500
|
+
newCursorPos = colonPos + item.insertValue.length + 1;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
} else if (context.type === 'operator') {
|
|
504
|
+
// Find the position to insert operator
|
|
505
|
+
const currentToken = tokens.find(t => cursorPos >= t.start && cursorPos <= t.end);
|
|
506
|
+
|
|
507
|
+
if (currentToken && (currentToken.type === 'operator' || currentToken.type === 'value')) {
|
|
508
|
+
// Replace existing operator or value token (incomplete operator)
|
|
509
|
+
newValue = queryString.substring(0, currentToken.start) + item.insertValue + ' ' + queryString.substring(currentToken.end);
|
|
510
|
+
newCursorPos = currentToken.start + item.insertValue.length + 1;
|
|
511
|
+
} else {
|
|
512
|
+
// Insert at cursor
|
|
513
|
+
newValue = queryString.substring(0, cursorPos) + item.insertValue + ' ' + queryString.substring(cursorPos);
|
|
514
|
+
newCursorPos = cursorPos + item.insertValue.length + 1;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Update input
|
|
519
|
+
this.inputLayer.value = newValue;
|
|
520
|
+
this.inputLayer.setSelectionRange(newCursorPos, newCursorPos);
|
|
521
|
+
this.cursorPosition = newCursorPos;
|
|
522
|
+
|
|
523
|
+
// Update UI
|
|
524
|
+
this.renderHighlights();
|
|
525
|
+
this.close();
|
|
526
|
+
this.inputLayer.focus();
|
|
527
|
+
this.autoGrow();
|
|
528
|
+
|
|
529
|
+
// If we just selected a field, trigger operator autocomplete
|
|
530
|
+
if (context.type === 'field') {
|
|
531
|
+
setTimeout(() => {
|
|
532
|
+
this.updateCursorPosition();
|
|
533
|
+
const newContext = this.getCursorContext();
|
|
534
|
+
if (newContext.type === 'operator') {
|
|
535
|
+
this.autocompleteResults = this.searchAutocomplete(newContext);
|
|
536
|
+
this.activeIndex = this.autocompleteResults.length > 0 ? 0 : -1;
|
|
537
|
+
if (this.autocompleteResults.length > 0) {
|
|
538
|
+
this.renderAutocomplete();
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}, 0);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Handle keyboard navigation
|
|
547
|
+
*/
|
|
548
|
+
handleKeydown(e) {
|
|
549
|
+
// Handle autocomplete navigation
|
|
550
|
+
if (this.autocompleteResults.length > 0 && this.isOpen) {
|
|
551
|
+
if (e.key === 'ArrowUp') {
|
|
552
|
+
e.preventDefault();
|
|
553
|
+
this.navigateAutocompletePrevious();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (e.key === 'ArrowDown') {
|
|
557
|
+
e.preventDefault();
|
|
558
|
+
this.navigateAutocompleteNext();
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (e.key === 'Tab') {
|
|
562
|
+
e.preventDefault();
|
|
563
|
+
this.selectAutocomplete(this.autocompleteResults[this.activeIndex]);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (e.key === 'Enter' && this.activeIndex >= 0) {
|
|
567
|
+
e.preventDefault();
|
|
568
|
+
this.selectAutocomplete(this.autocompleteResults[this.activeIndex]);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Escape to close
|
|
574
|
+
if (e.key === 'Escape') {
|
|
575
|
+
this.close();
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Navigate autocomplete suggestions
|
|
581
|
+
*/
|
|
582
|
+
navigateAutocompletePrevious() {
|
|
583
|
+
if (this.autocompleteResults.length === 0) return;
|
|
584
|
+
|
|
585
|
+
this.activeIndex = this.activeIndex <= 0
|
|
586
|
+
? this.autocompleteResults.length - 1
|
|
587
|
+
: this.activeIndex - 1;
|
|
588
|
+
|
|
589
|
+
this.renderAutocomplete();
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
navigateAutocompleteNext() {
|
|
593
|
+
if (this.autocompleteResults.length === 0) return;
|
|
594
|
+
|
|
595
|
+
this.activeIndex = this.activeIndex >= this.autocompleteResults.length - 1
|
|
596
|
+
? 0
|
|
597
|
+
: this.activeIndex + 1;
|
|
598
|
+
|
|
599
|
+
this.renderAutocomplete();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Position popup below input
|
|
604
|
+
*/
|
|
605
|
+
position() {
|
|
606
|
+
const inputRect = this.inputLayer.getBoundingClientRect();
|
|
607
|
+
this.popup.style.position = 'absolute';
|
|
608
|
+
this.popup.style.left = inputRect.left + 'px';
|
|
609
|
+
this.popup.style.top = (inputRect.bottom + 4) + 'px';
|
|
610
|
+
this.popup.style.minWidth = Math.max(inputRect.width, 250) + 'px';
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Open popup
|
|
615
|
+
*/
|
|
616
|
+
open() {
|
|
617
|
+
this.popup.style.display = 'block';
|
|
618
|
+
this.isOpen = true;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Close popup
|
|
623
|
+
*/
|
|
624
|
+
close() {
|
|
625
|
+
this.popup.style.display = 'none';
|
|
626
|
+
this.isOpen = false;
|
|
627
|
+
this.activeIndex = -1;
|
|
628
|
+
this.autocompleteResults = [];
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Handle click outside
|
|
633
|
+
*/
|
|
634
|
+
handleClickOutside(e) {
|
|
635
|
+
if (this.isOpen && !this.popup.contains(e.target) && !this.container.contains(e.target)) {
|
|
636
|
+
this.close();
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Get parsed query (for external use)
|
|
642
|
+
*/
|
|
643
|
+
getParsedQuery() {
|
|
644
|
+
return this.parseQuery(this.inputLayer.value);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Convert parsed tokens to structured query for server (JSON:API format)
|
|
649
|
+
* Returns nested filter object with logical operators
|
|
650
|
+
*/
|
|
651
|
+
toServerQuery() {
|
|
652
|
+
const tokens = this.parseQuery(this.inputLayer.value);
|
|
653
|
+
|
|
654
|
+
// Build expression tree from tokens
|
|
655
|
+
const filter = this.buildFilterTree(tokens, 0).filter;
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
filter: filter,
|
|
659
|
+
raw: this.inputLayer.value
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Build nested filter tree from flat token list
|
|
665
|
+
* Handles parentheses and logical operators with proper precedence
|
|
666
|
+
*/
|
|
667
|
+
buildFilterTree(tokens, startIndex) {
|
|
668
|
+
const conditions = [];
|
|
669
|
+
let currentLogic = null; // null, 'and', or 'or'
|
|
670
|
+
let i = startIndex;
|
|
671
|
+
|
|
672
|
+
while (i < tokens.length) {
|
|
673
|
+
const token = tokens[i];
|
|
674
|
+
|
|
675
|
+
// Skip whitespace
|
|
676
|
+
if (token.type === 'whitespace') {
|
|
677
|
+
i++;
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Handle opening parenthesis - recursively parse group
|
|
682
|
+
if (token.type === 'keyword' && token.value === '(') {
|
|
683
|
+
const result = this.buildFilterTree(tokens, i + 1);
|
|
684
|
+
conditions.push(result.filter);
|
|
685
|
+
i = result.endIndex + 1; // Skip past closing parenthesis
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Handle closing parenthesis - return current level
|
|
690
|
+
if (token.type === 'keyword' && token.value === ')') {
|
|
691
|
+
return {
|
|
692
|
+
filter: this.combineConditions(conditions, currentLogic),
|
|
693
|
+
endIndex: i
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Handle logical operators (AND, OR)
|
|
698
|
+
if (token.type === 'keyword' && (token.value.toLowerCase() === 'and' || token.value.toLowerCase() === 'or')) {
|
|
699
|
+
const logic = token.value.toLowerCase();
|
|
700
|
+
|
|
701
|
+
if (currentLogic === null) {
|
|
702
|
+
currentLogic = logic;
|
|
703
|
+
} else if (currentLogic !== logic) {
|
|
704
|
+
// Mixed operators at same level - need to restructure
|
|
705
|
+
// Combine existing conditions with current logic, then switch
|
|
706
|
+
const combined = this.combineConditions(conditions, currentLogic);
|
|
707
|
+
conditions.length = 0; // Clear array
|
|
708
|
+
conditions.push(combined);
|
|
709
|
+
currentLogic = logic;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
i++;
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Handle field + operator + value
|
|
717
|
+
if (token.type === 'field' && token.valid) {
|
|
718
|
+
const field = token.value.substring(1); // Remove leading :
|
|
719
|
+
i++;
|
|
720
|
+
|
|
721
|
+
// Skip whitespace after field
|
|
722
|
+
while (i < tokens.length && tokens[i].type === 'whitespace') {
|
|
723
|
+
i++;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Get operator
|
|
727
|
+
if (i < tokens.length && tokens[i].type === 'operator') {
|
|
728
|
+
const operator = this.normalizeOperator(tokens[i].value);
|
|
729
|
+
i++;
|
|
730
|
+
|
|
731
|
+
// Skip whitespace after operator
|
|
732
|
+
while (i < tokens.length && tokens[i].type === 'whitespace') {
|
|
733
|
+
i++;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Get value(s)
|
|
737
|
+
const values = [];
|
|
738
|
+
while (i < tokens.length && (tokens[i].type === 'value' || tokens[i].type === 'keyword')) {
|
|
739
|
+
if (tokens[i].type === 'value') {
|
|
740
|
+
// Remove surrounding quotes if present
|
|
741
|
+
let value = tokens[i].value;
|
|
742
|
+
if ((value.startsWith("'") && value.endsWith("'")) ||
|
|
743
|
+
(value.startsWith('"') && value.endsWith('"'))) {
|
|
744
|
+
value = value.slice(1, -1);
|
|
745
|
+
}
|
|
746
|
+
values.push(value);
|
|
747
|
+
} else if (tokens[i].type === 'keyword' && tokens[i].value.toLowerCase() === 'to') {
|
|
748
|
+
// 'to' keyword in 'between' queries, skip it
|
|
749
|
+
i++;
|
|
750
|
+
while (i < tokens.length && tokens[i].type === 'whitespace') {
|
|
751
|
+
i++;
|
|
752
|
+
}
|
|
753
|
+
continue;
|
|
754
|
+
} else {
|
|
755
|
+
// Stop at logical operators or parentheses
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
i++;
|
|
759
|
+
|
|
760
|
+
// Skip whitespace between values
|
|
761
|
+
while (i < tokens.length && tokens[i].type === 'whitespace') {
|
|
762
|
+
i++;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Stop if we hit another field, operator, or keyword
|
|
766
|
+
if (i < tokens.length &&
|
|
767
|
+
(tokens[i].type === 'field' ||
|
|
768
|
+
tokens[i].type === 'operator' ||
|
|
769
|
+
(tokens[i].type === 'keyword' &&
|
|
770
|
+
(tokens[i].value.toLowerCase() === 'and' ||
|
|
771
|
+
tokens[i].value.toLowerCase() === 'or' ||
|
|
772
|
+
tokens[i].value === ')')))) {
|
|
773
|
+
break;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Create filter condition in JSON:API format
|
|
778
|
+
const value = values.length === 1 ? values[0] : values;
|
|
779
|
+
const condition = {
|
|
780
|
+
[field]: { [operator]: value }
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
conditions.push(condition);
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Unknown or invalid token, skip
|
|
789
|
+
i++;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Return combined result
|
|
793
|
+
return {
|
|
794
|
+
filter: this.combineConditions(conditions, currentLogic),
|
|
795
|
+
endIndex: i
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Combine multiple conditions with a logical operator
|
|
801
|
+
*/
|
|
802
|
+
combineConditions(conditions, logic) {
|
|
803
|
+
if (conditions.length === 0) {
|
|
804
|
+
return {};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (conditions.length === 1) {
|
|
808
|
+
return conditions[0];
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Multiple conditions - wrap in logical operator
|
|
812
|
+
return {
|
|
813
|
+
[logic || 'and']: conditions
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Normalize operator names to JSON:API standard format
|
|
819
|
+
*/
|
|
820
|
+
normalizeOperator(operator) {
|
|
821
|
+
const normalized = operator.toLowerCase();
|
|
822
|
+
|
|
823
|
+
// Map common operators to standard names
|
|
824
|
+
const operatorMap = {
|
|
825
|
+
'=': 'eq',
|
|
826
|
+
'==': 'eq',
|
|
827
|
+
'is': 'eq',
|
|
828
|
+
'equals': 'eq',
|
|
829
|
+
'!=': 'ne',
|
|
830
|
+
'not_equals': 'ne',
|
|
831
|
+
'not': 'ne',
|
|
832
|
+
'neq': 'ne',
|
|
833
|
+
'>': 'gt',
|
|
834
|
+
'greater_than': 'gt',
|
|
835
|
+
'over': 'gt',
|
|
836
|
+
'above': 'gt',
|
|
837
|
+
'<': 'lt',
|
|
838
|
+
'less_than': 'lt',
|
|
839
|
+
'under': 'lt',
|
|
840
|
+
'below': 'lt',
|
|
841
|
+
'>=': 'gte',
|
|
842
|
+
'greater_or_equal': 'gte',
|
|
843
|
+
'atleast': 'gte',
|
|
844
|
+
'min': 'gte',
|
|
845
|
+
'<=': 'lte',
|
|
846
|
+
'less_or_equal': 'lte',
|
|
847
|
+
'atmost': 'lte',
|
|
848
|
+
'max': 'lte',
|
|
849
|
+
'starts_with': 'startswith',
|
|
850
|
+
'sw': 'startswith',
|
|
851
|
+
'begins': 'startswith',
|
|
852
|
+
'ends_with': 'endswith',
|
|
853
|
+
'ew': 'endswith',
|
|
854
|
+
'contains': 'contains',
|
|
855
|
+
'c': 'contains',
|
|
856
|
+
'has': 'contains',
|
|
857
|
+
'includes': 'contains',
|
|
858
|
+
'not_contains': 'notcontains',
|
|
859
|
+
'nc': 'notcontains',
|
|
860
|
+
'between': 'between',
|
|
861
|
+
'bw': 'between',
|
|
862
|
+
'btw': 'between',
|
|
863
|
+
'from': 'between',
|
|
864
|
+
'before': 'before',
|
|
865
|
+
'bf': 'before',
|
|
866
|
+
'until': 'before',
|
|
867
|
+
'after': 'after',
|
|
868
|
+
'af': 'after',
|
|
869
|
+
'since': 'after',
|
|
870
|
+
'in_last': 'inlast',
|
|
871
|
+
'last': 'inlast',
|
|
872
|
+
'past': 'inlast',
|
|
873
|
+
'recent': 'inlast',
|
|
874
|
+
'in_next': 'innext',
|
|
875
|
+
'next': 'innext',
|
|
876
|
+
'future': 'innext',
|
|
877
|
+
'upcoming': 'innext'
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
return operatorMap[normalized] || normalized;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Get query string
|
|
885
|
+
*/
|
|
886
|
+
getValue() {
|
|
887
|
+
return this.inputLayer.value;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Set query string
|
|
892
|
+
*/
|
|
893
|
+
setValue(value) {
|
|
894
|
+
this.inputLayer.value = value;
|
|
895
|
+
this.renderHighlights();
|
|
896
|
+
this.autoGrow();
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Destroy instance
|
|
901
|
+
*/
|
|
902
|
+
destroy() {
|
|
903
|
+
if (this.popup && this.popup.parentNode) {
|
|
904
|
+
this.popup.parentNode.removeChild(this.popup);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|