collabmd 0.1.32 → 0.1.34
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/client/assets/{drawio-editor-Br4qyh3r.js → drawio-editor-BMTwP5mD.js} +1 -1
- package/dist/client/assets/{drawioEditor-ClmnTx5J.js → drawioEditor-D6clB5OH.js} +2 -2
- package/dist/client/assets/{editor-session-Cf-iV5-3.js → editor-session-Dzy_85Wp.js} +1 -1
- package/dist/client/assets/{excalidraw-editor-CXAxVhlw.js → excalidraw-editor-CAP9F8CB.js} +3 -3
- package/dist/client/assets/{excalidrawEditor-DoH_kMdu.js → excalidrawEditor-DD4AvzYP.js} +2 -2
- package/dist/client/assets/{exportDocument-9wTrBZNS.css → exportDocument-BbkN85c4.css} +1 -1
- package/dist/client/assets/index-BIt5EZXz.css +1 -0
- package/dist/client/assets/{index-C8QZYHvV.js → index-BLrtj9YM.js} +2 -2
- package/dist/client/assets/main-CfSHaGes.js +1368 -0
- package/dist/client/assets/{vault-api-client-Bf9tB_GW.js → vault-api-client-p7h7cXKM.js} +1 -1
- package/dist/client/drawio-editor.html +1 -1
- package/dist/client/excalidraw-editor.html +1 -1
- package/dist/client/export-document.html +2 -2
- package/dist/client/index.html +2 -2
- package/package.json +6 -5
- package/src/client/application/app-shell/git-feature.js +44 -36
- package/src/client/application/app-shell/ui-feature-shell.js +84 -63
- package/src/client/bootstrap/collabmd-app-shell.js +6 -0
- package/src/client/infrastructure/editor-session.js +4 -0
- package/src/client/infrastructure/editor-view-adapter.js +23 -0
- package/src/client/infrastructure/vault-api-client.js +48 -0
- package/src/client/presentation/bases-preview-controller.js +1388 -28
- package/src/client/presentation/comments-panel.js +50 -5
- package/src/client/presentation/excalidraw-embed-controller.js +4 -1
- package/src/client/presentation/file-history-view-controller.js +9 -1
- package/src/client/presentation/git-panel-controller.js +101 -91
- package/src/client/styles/components/scrollbars.css +5 -0
- package/src/client/styles/features/preview-markdown.css +468 -23
- package/src/server/domain/backlink-index.js +202 -11
- package/src/server/domain/bases/base-definition.js +138 -11
- package/src/server/domain/bases/base-expression-runtime.js +54 -17
- package/src/server/domain/bases/base-index-snapshot-store.js +167 -20
- package/src/server/domain/bases/base-query-metadata.js +242 -0
- package/src/server/domain/bases/base-query-results.js +18 -8
- package/src/server/domain/bases/base-query-service.js +360 -49
- package/src/server/domain/bases/base-transform.js +116 -0
- package/src/server/infrastructure/http/create-request-handler.js +63 -28
- package/src/server/infrastructure/http/create-vault-api-command-handler.js +275 -256
- package/src/server/infrastructure/http/create-vault-api-query-handler.js +258 -184
- package/src/server/infrastructure/persistence/vault-file-store.js +68 -12
- package/dist/client/assets/index-CxSbUTs2.css +0 -1
- package/dist/client/assets/main-BjHHMlv0.js +0 -1249
- /package/dist/client/assets/{exportDocument-Bia2hI25.js → exportDocument-Zfwq1XVx.js} +0 -0
|
@@ -17,6 +17,18 @@ function looksLikeExternalImageUrl(value = '') {
|
|
|
17
17
|
return /^https?:\/\/.+\.(?:png|jpe?g|webp|gif|svg)(?:[?#].*)?$/iu.test(String(value ?? '').trim());
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
function isPlainObject(value) {
|
|
21
|
+
return Object.prototype.toString.call(value) === '[object Object]';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createEmptyFilterGroup(conjunction = 'and') {
|
|
25
|
+
return {
|
|
26
|
+
children: [],
|
|
27
|
+
conjunction,
|
|
28
|
+
type: 'group',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
function getCardImageCell(view, columns, row) {
|
|
21
33
|
const preferred = view.image
|
|
22
34
|
|| columns.find((column) => ['cover', 'image'].includes(String(column.id ?? '').toLowerCase()))?.id
|
|
@@ -178,9 +190,852 @@ function renderViewTabs(result) {
|
|
|
178
190
|
)).join('');
|
|
179
191
|
}
|
|
180
192
|
|
|
181
|
-
function
|
|
193
|
+
function getMeta(result) {
|
|
194
|
+
return result?.meta ?? {};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function getAvailableProperties(result) {
|
|
198
|
+
return getMeta(result).availableProperties ?? [];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function getVisiblePropertyIds(result) {
|
|
202
|
+
const explicitOrder = getMeta(result).activeViewConfig?.order;
|
|
203
|
+
if (Array.isArray(explicitOrder) && explicitOrder.length > 0) {
|
|
204
|
+
return [...explicitOrder];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return Array.isArray(result?.columns)
|
|
208
|
+
? result.columns.map((column) => column.id).filter(Boolean)
|
|
209
|
+
: [];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function findPropertyMeta(result, propertyId = '') {
|
|
213
|
+
return getAvailableProperties(result).find((entry) => entry.id === propertyId) ?? null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getPropertyFilterOperators(result, propertyId = '') {
|
|
217
|
+
return findPropertyMeta(result, propertyId)?.filterOperators
|
|
218
|
+
?? ['is', 'is not', 'contains', 'does not contain', 'is empty', 'is not empty'];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function getEditableViewConfig(result) {
|
|
222
|
+
const config = getMeta(result).activeViewConfig ?? {};
|
|
223
|
+
return {
|
|
224
|
+
filters: config.filters ?? null,
|
|
225
|
+
groupBy: config.groupBy ?? null,
|
|
226
|
+
order: Array.isArray(config.order) ? [...config.order] : [],
|
|
227
|
+
sort: Array.isArray(config.sort) ? config.sort.map((entry) => ({ ...entry })) : [],
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isStandaloneBaseEntry(entry) {
|
|
232
|
+
return Boolean(
|
|
233
|
+
entry?.payload?.path
|
|
234
|
+
&& entry?.payload?.sourcePath
|
|
235
|
+
&& entry.payload.path === entry.payload.sourcePath,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function buildToolbarButton(label, panel, entry, result) {
|
|
240
|
+
const active = entry.ui?.openPanel === panel;
|
|
241
|
+
const editable = Boolean(getMeta(result).editable);
|
|
242
|
+
return `<button type="button" class="${escapeHtml(buttonClassNames({ variant: active ? 'primary' : 'secondary', pill: true, extra: ['bases-toolbar-btn', active ? 'is-active' : ''] }))}" data-base-panel="${escapeHtml(panel)}"${editable ? '' : ' disabled'}>${escapeHtml(label)}</button>`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function pruneFilterGroupByProperty(group, propertyId = '') {
|
|
246
|
+
if (!group || group.type !== 'group' || !propertyId) {
|
|
247
|
+
return group;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const children = (group.children ?? []).flatMap((child) => {
|
|
251
|
+
if (child?.type === 'group') {
|
|
252
|
+
const nextGroup = pruneFilterGroupByProperty(child, propertyId);
|
|
253
|
+
return nextGroup ? [nextGroup] : [];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return child?.propertyId === propertyId ? [] : [child];
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
...group,
|
|
261
|
+
children,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function createPropertyValuesCacheKey(entry, propertyId = '', result = entry?.result) {
|
|
266
|
+
const rawFilters = getMeta(result).activeViewConfig?.filters ?? null;
|
|
267
|
+
const parsedFilters = parseFilterNode(rawFilters);
|
|
268
|
+
const cacheFilters = propertyId && parsedFilters
|
|
269
|
+
? compileFilterGroup(pruneFilterGroupByProperty(parsedFilters, propertyId), result)
|
|
270
|
+
: rawFilters;
|
|
271
|
+
return JSON.stringify({
|
|
272
|
+
filters: cacheFilters,
|
|
273
|
+
path: entry?.payload?.path ?? '',
|
|
274
|
+
source: entry?.payload?.source ?? null,
|
|
275
|
+
sourcePath: entry?.payload?.sourcePath ?? '',
|
|
276
|
+
view: result?.view?.id ?? entry?.payload?.view ?? '',
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function getCachedPropertyValueOptions(entry, propertyId = '', result = entry?.result) {
|
|
281
|
+
const cachedEntry = entry?.propertyValueOptions?.get(propertyId) ?? null;
|
|
282
|
+
if (!cachedEntry) {
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return cachedEntry.cacheKey === createPropertyValuesCacheKey(entry, propertyId, result)
|
|
287
|
+
? (cachedEntry.values ?? [])
|
|
288
|
+
: [];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function renderActionButtons(result, entry) {
|
|
292
|
+
return [
|
|
293
|
+
buildToolbarButton('Sort', 'sort', entry, result),
|
|
294
|
+
buildToolbarButton('Filter', 'filter', entry, result),
|
|
295
|
+
buildToolbarButton('Properties', 'properties', entry, result),
|
|
296
|
+
].join('');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function createSelectOptions(result, {
|
|
300
|
+
allowBlank = false,
|
|
301
|
+
selected = '',
|
|
302
|
+
} = {}) {
|
|
303
|
+
const options = [];
|
|
304
|
+
if (allowBlank) {
|
|
305
|
+
options.push(`<option value=""${selected ? '' : ' selected'}>Property</option>`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
getAvailableProperties(result).forEach((property) => {
|
|
309
|
+
options.push(`<option value="${escapeHtml(property.id)}"${property.id === selected ? ' selected' : ''}>${escapeHtml(property.label)}</option>`);
|
|
310
|
+
});
|
|
311
|
+
return options.join('');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function renderSortPanel(result) {
|
|
315
|
+
const viewConfig = getEditableViewConfig(result);
|
|
316
|
+
const groupDirections = findPropertyMeta(result, viewConfig.groupBy?.property)?.sortDirections
|
|
317
|
+
?? [
|
|
318
|
+
{ id: 'asc', label: 'A → Z' },
|
|
319
|
+
{ id: 'desc', label: 'Z → A' },
|
|
320
|
+
];
|
|
321
|
+
const sorts = viewConfig.sort ?? [];
|
|
322
|
+
|
|
182
323
|
return `
|
|
183
|
-
<section class="bases-
|
|
324
|
+
<section class="bases-panel-card">
|
|
325
|
+
<div class="bases-panel-section">
|
|
326
|
+
<div class="bases-panel-title">Group by</div>
|
|
327
|
+
<div class="bases-sort-row">
|
|
328
|
+
<select class="${escapeHtml(inputClassNames({ extra: 'bases-select bases-sort-property' }))}" data-base-group-by-property>
|
|
329
|
+
${createSelectOptions(result, { allowBlank: true, selected: viewConfig.groupBy?.property ?? '' })}
|
|
330
|
+
</select>
|
|
331
|
+
<select class="${escapeHtml(inputClassNames({ extra: 'bases-select bases-sort-direction' }))}" data-base-group-by-direction${viewConfig.groupBy?.property ? '' : ' disabled'}>
|
|
332
|
+
${groupDirections.map((direction) => `<option value="${escapeHtml(direction.id)}"${direction.id === (viewConfig.groupBy?.direction ?? 'asc') ? ' selected' : ''}>${escapeHtml(direction.label)}</option>`).join('')}
|
|
333
|
+
</select>
|
|
334
|
+
<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-clear-group-by${viewConfig.groupBy?.property ? '' : ' disabled'}>Clear</button>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
<div class="bases-panel-section">
|
|
338
|
+
<div class="bases-panel-title">Sort by</div>
|
|
339
|
+
<div class="bases-sort-list">
|
|
340
|
+
${sorts.map((sortConfig, index) => {
|
|
341
|
+
const propertyMeta = findPropertyMeta(result, sortConfig.property);
|
|
342
|
+
const directions = propertyMeta?.sortDirections ?? [
|
|
343
|
+
{ id: 'asc', label: 'A → Z' },
|
|
344
|
+
{ id: 'desc', label: 'Z → A' },
|
|
345
|
+
];
|
|
346
|
+
return `
|
|
347
|
+
<div class="bases-sort-row">
|
|
348
|
+
<select class="${escapeHtml(inputClassNames({ extra: 'bases-select bases-sort-property' }))}" data-base-sort-property="${index}">
|
|
349
|
+
${createSelectOptions(result, { selected: sortConfig.property })}
|
|
350
|
+
</select>
|
|
351
|
+
<select class="${escapeHtml(inputClassNames({ extra: 'bases-select bases-sort-direction' }))}" data-base-sort-direction="${index}">
|
|
352
|
+
${directions.map((direction) => `<option value="${escapeHtml(direction.id)}"${direction.id === sortConfig.direction ? ' selected' : ''}>${escapeHtml(direction.label)}</option>`).join('')}
|
|
353
|
+
</select>
|
|
354
|
+
<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-sort-move="${index}:up"${index === 0 ? ' disabled' : ''}>↑</button>
|
|
355
|
+
<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-sort-move="${index}:down"${index === sorts.length - 1 ? ' disabled' : ''}>↓</button>
|
|
356
|
+
<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-sort-delete="${index}">Delete</button>
|
|
357
|
+
</div>
|
|
358
|
+
`;
|
|
359
|
+
}).join('')}
|
|
360
|
+
</div>
|
|
361
|
+
<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-sort-add>Add sort</button>
|
|
362
|
+
</div>
|
|
363
|
+
</section>
|
|
364
|
+
`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function encodePath(path = []) {
|
|
368
|
+
return path.join('.');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function decodePath(value = '') {
|
|
372
|
+
return String(value ?? '')
|
|
373
|
+
.split('.')
|
|
374
|
+
.filter((segment) => segment !== '')
|
|
375
|
+
.map((segment) => Number.parseInt(segment, 10))
|
|
376
|
+
.filter(Number.isFinite);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function escapeExpressionString(value = '') {
|
|
380
|
+
return JSON.stringify(String(value ?? ''));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function formatExpressionLiteral(value = '', valueType = 'text', operator = 'is') {
|
|
384
|
+
const normalized = String(value ?? '').trim();
|
|
385
|
+
if (!normalized && operator !== 'is' && operator !== 'is not') {
|
|
386
|
+
return '""';
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (valueType === 'boolean') {
|
|
390
|
+
return normalized === 'false' ? 'false' : 'true';
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (valueType === 'number') {
|
|
394
|
+
return Number.isFinite(Number(normalized))
|
|
395
|
+
? String(Number(normalized))
|
|
396
|
+
: escapeExpressionString(normalized);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (valueType === 'date') {
|
|
400
|
+
return escapeExpressionString(normalized);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return escapeExpressionString(normalized);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function compileFilterRule(rule, propertyMeta = null) {
|
|
407
|
+
if (!rule?.propertyId || !rule?.operator) {
|
|
408
|
+
return '';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const propertyId = rule.propertyId;
|
|
412
|
+
const valueType = propertyMeta?.valueType ?? 'text';
|
|
413
|
+
|
|
414
|
+
switch (rule.operator) {
|
|
415
|
+
case 'contains':
|
|
416
|
+
return `${propertyId}.contains(${formatExpressionLiteral(rule.value, valueType, rule.operator)})`;
|
|
417
|
+
case 'does not contain':
|
|
418
|
+
return `!${propertyId}.contains(${formatExpressionLiteral(rule.value, valueType, rule.operator)})`;
|
|
419
|
+
case 'starts with':
|
|
420
|
+
return `${propertyId}.startsWith(${formatExpressionLiteral(rule.value, valueType, rule.operator)})`;
|
|
421
|
+
case 'ends with':
|
|
422
|
+
return `${propertyId}.endsWith(${formatExpressionLiteral(rule.value, valueType, rule.operator)})`;
|
|
423
|
+
case 'is':
|
|
424
|
+
return `${propertyId} == ${formatExpressionLiteral(rule.value, valueType, rule.operator)}`;
|
|
425
|
+
case 'is not':
|
|
426
|
+
return `${propertyId} != ${formatExpressionLiteral(rule.value, valueType, rule.operator)}`;
|
|
427
|
+
case '>':
|
|
428
|
+
case '>=':
|
|
429
|
+
case '<':
|
|
430
|
+
case '<=':
|
|
431
|
+
return `${propertyId} ${rule.operator} ${formatExpressionLiteral(rule.value, valueType, rule.operator)}`;
|
|
432
|
+
case 'is empty':
|
|
433
|
+
return `${propertyId}.isEmpty()`;
|
|
434
|
+
case 'is not empty':
|
|
435
|
+
return `!${propertyId}.isEmpty()`;
|
|
436
|
+
default:
|
|
437
|
+
return '';
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function compileFilterGroup(group, result) {
|
|
442
|
+
if (!group || group.type !== 'group') {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const compiledChildren = (group.children ?? [])
|
|
447
|
+
.map((child) => {
|
|
448
|
+
if (child?.type === 'group') {
|
|
449
|
+
return compileFilterGroup(child, result);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const propertyMeta = findPropertyMeta(result, child?.propertyId);
|
|
453
|
+
return compileFilterRule(child, propertyMeta);
|
|
454
|
+
})
|
|
455
|
+
.filter(Boolean);
|
|
456
|
+
|
|
457
|
+
if (compiledChildren.length === 0) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (group.conjunction === 'not') {
|
|
462
|
+
return {
|
|
463
|
+
not: compiledChildren,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
[group.conjunction]: compiledChildren,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function deserializeExpressionValue(value = '') {
|
|
473
|
+
const normalized = String(value ?? '').trim();
|
|
474
|
+
if (!normalized) {
|
|
475
|
+
return '';
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (normalized === 'true' || normalized === 'false') {
|
|
479
|
+
return normalized;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (normalized === 'null') {
|
|
483
|
+
return '';
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (/^-?\d+(?:\.\d+)?$/u.test(normalized)) {
|
|
487
|
+
return normalized;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if ((normalized.startsWith('"') && normalized.endsWith('"')) || (normalized.startsWith('\'') && normalized.endsWith('\''))) {
|
|
491
|
+
return normalized.slice(1, -1).replace(/\\(["'])/gu, '$1');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return normalized;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function parseFilterRule(expression = '') {
|
|
498
|
+
const text = String(expression ?? '').trim();
|
|
499
|
+
const patterns = [
|
|
500
|
+
[/^!([A-Za-z0-9_.]+)\.isEmpty\(\)$/u, (_, propertyId) => ({ operator: 'is not empty', propertyId, type: 'rule', value: '' })],
|
|
501
|
+
[/^([A-Za-z0-9_.]+)\.isEmpty\(\)$/u, (_, propertyId) => ({ operator: 'is empty', propertyId, type: 'rule', value: '' })],
|
|
502
|
+
[/^!([A-Za-z0-9_.]+)\.contains\((.+)\)$/u, (_, propertyId, value) => ({ operator: 'does not contain', propertyId, type: 'rule', value: deserializeExpressionValue(value) })],
|
|
503
|
+
[/^([A-Za-z0-9_.]+)\.contains\((.+)\)$/u, (_, propertyId, value) => ({ operator: 'contains', propertyId, type: 'rule', value: deserializeExpressionValue(value) })],
|
|
504
|
+
[/^([A-Za-z0-9_.]+)\.startsWith\((.+)\)$/u, (_, propertyId, value) => ({ operator: 'starts with', propertyId, type: 'rule', value: deserializeExpressionValue(value) })],
|
|
505
|
+
[/^([A-Za-z0-9_.]+)\.endsWith\((.+)\)$/u, (_, propertyId, value) => ({ operator: 'ends with', propertyId, type: 'rule', value: deserializeExpressionValue(value) })],
|
|
506
|
+
[/^([A-Za-z0-9_.]+)\s*(==|!=|>=|<=|>|<)\s*(.+)$/u, (_, propertyId, operator, value) => ({
|
|
507
|
+
operator: {
|
|
508
|
+
'!=': 'is not',
|
|
509
|
+
'==': 'is',
|
|
510
|
+
'<': '<',
|
|
511
|
+
'<=': '<=',
|
|
512
|
+
'>': '>',
|
|
513
|
+
'>=': '>=',
|
|
514
|
+
}[operator] ?? operator,
|
|
515
|
+
propertyId,
|
|
516
|
+
type: 'rule',
|
|
517
|
+
value: deserializeExpressionValue(value),
|
|
518
|
+
})],
|
|
519
|
+
];
|
|
520
|
+
|
|
521
|
+
for (const [pattern, build] of patterns) {
|
|
522
|
+
const match = text.match(pattern);
|
|
523
|
+
if (match) {
|
|
524
|
+
return build(...match);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function parseFilterNode(filterNode) {
|
|
532
|
+
if (!filterNode) {
|
|
533
|
+
return createEmptyFilterGroup('and');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (typeof filterNode === 'string') {
|
|
537
|
+
const rule = parseFilterRule(filterNode);
|
|
538
|
+
return rule
|
|
539
|
+
? {
|
|
540
|
+
children: [rule],
|
|
541
|
+
conjunction: 'and',
|
|
542
|
+
type: 'group',
|
|
543
|
+
}
|
|
544
|
+
: null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (!isPlainObject(filterNode)) {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (Array.isArray(filterNode.and)) {
|
|
552
|
+
const children = filterNode.and.map((child) => parseFilterNode(child)).flatMap((child) => (
|
|
553
|
+
child?.type === 'group' && child.conjunction === 'and' && child.children?.length === 1 && child.children[0]?.type === 'rule'
|
|
554
|
+
? child.children
|
|
555
|
+
: [child]
|
|
556
|
+
));
|
|
557
|
+
return children.every(Boolean)
|
|
558
|
+
? { children, conjunction: 'and', type: 'group' }
|
|
559
|
+
: null;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (Array.isArray(filterNode.or)) {
|
|
563
|
+
const children = filterNode.or.map((child) => parseFilterNode(child)).flatMap((child) => (
|
|
564
|
+
child?.type === 'group' && child.conjunction === 'and' && child.children?.length === 1 && child.children[0]?.type === 'rule'
|
|
565
|
+
? child.children
|
|
566
|
+
: [child]
|
|
567
|
+
));
|
|
568
|
+
return children.every(Boolean)
|
|
569
|
+
? { children, conjunction: 'or', type: 'group' }
|
|
570
|
+
: null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (filterNode.not != null) {
|
|
574
|
+
const notChildren = Array.isArray(filterNode.not)
|
|
575
|
+
? filterNode.not
|
|
576
|
+
: (() => {
|
|
577
|
+
const parsed = parseFilterNode(filterNode.not);
|
|
578
|
+
if (!parsed) {
|
|
579
|
+
return [];
|
|
580
|
+
}
|
|
581
|
+
return parsed.type === 'group' && parsed.conjunction === 'or'
|
|
582
|
+
? (parsed.children ?? [])
|
|
583
|
+
: [parsed];
|
|
584
|
+
})();
|
|
585
|
+
const children = notChildren.flatMap((child) => {
|
|
586
|
+
const parsedChild = child?.type ? child : parseFilterNode(child);
|
|
587
|
+
if (!parsedChild) {
|
|
588
|
+
return [null];
|
|
589
|
+
}
|
|
590
|
+
return parsedChild.type === 'group' && parsedChild.conjunction === 'and' && parsedChild.children?.length === 1 && parsedChild.children[0]?.type === 'rule'
|
|
591
|
+
? parsedChild.children
|
|
592
|
+
: [parsedChild];
|
|
593
|
+
});
|
|
594
|
+
return children.every(Boolean)
|
|
595
|
+
? { children, conjunction: 'not', type: 'group' }
|
|
596
|
+
: null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function serializeRawFilterText(filters) {
|
|
603
|
+
if (!filters) {
|
|
604
|
+
return '';
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return typeof filters === 'string'
|
|
608
|
+
? filters
|
|
609
|
+
: JSON.stringify(filters, null, 2);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function parseRawFilterText(text = '') {
|
|
613
|
+
const normalized = String(text ?? '').trim();
|
|
614
|
+
if (!normalized) {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (normalized.startsWith('{') || normalized.startsWith('[')) {
|
|
619
|
+
return JSON.parse(normalized);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return normalized;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function getNodeAtPath(group, path = []) {
|
|
626
|
+
let current = group;
|
|
627
|
+
for (const index of path) {
|
|
628
|
+
current = current?.children?.[index];
|
|
629
|
+
}
|
|
630
|
+
return current ?? null;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function updateNodeAtPath(group, path = [], updater) {
|
|
634
|
+
if (path.length === 0) {
|
|
635
|
+
return updater(group);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const [head, ...rest] = path;
|
|
639
|
+
return {
|
|
640
|
+
...group,
|
|
641
|
+
children: (group.children ?? []).map((child, index) => (
|
|
642
|
+
index === head
|
|
643
|
+
? updateNodeAtPath(child, rest, updater)
|
|
644
|
+
: child
|
|
645
|
+
)),
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function removeNodeAtPath(group, path = []) {
|
|
650
|
+
if (path.length === 0) {
|
|
651
|
+
return group;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const parentPath = path.slice(0, -1);
|
|
655
|
+
const removeIndex = path[path.length - 1];
|
|
656
|
+
return updateNodeAtPath(group, parentPath, (node) => ({
|
|
657
|
+
...node,
|
|
658
|
+
children: (node.children ?? []).filter((_, index) => index !== removeIndex),
|
|
659
|
+
}));
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function addNodeAtPath(group, path = [], node) {
|
|
663
|
+
return updateNodeAtPath(group, path, (target) => ({
|
|
664
|
+
...target,
|
|
665
|
+
children: [...(target.children ?? []), node],
|
|
666
|
+
}));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function getPropertyOptionsMarkup(result, selected = '') {
|
|
670
|
+
return createSelectOptions(result, { allowBlank: true, selected });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function getPropertyValueOptionText(option = {}) {
|
|
674
|
+
return String(option?.text ?? option?.value ?? '').trim();
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function getBuilderFilterState(entry, result) {
|
|
678
|
+
return entry.ui.builderFilter
|
|
679
|
+
?? parseFilterNode(getEditableViewConfig(result).filters)
|
|
680
|
+
?? createEmptyFilterGroup('and');
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function buildFilterValuePresentation(result, rule, path, entry) {
|
|
684
|
+
const encodedPath = encodePath(path);
|
|
685
|
+
const operators = getPropertyFilterOperators(result, rule.propertyId);
|
|
686
|
+
const suggestionQuery = String(rule.value ?? '').trim().toLowerCase();
|
|
687
|
+
const propertyValueOptions = getCachedPropertyValueOptions(entry, rule.propertyId, result)
|
|
688
|
+
.map((option) => ({
|
|
689
|
+
count: option?.count,
|
|
690
|
+
text: getPropertyValueOptionText(option),
|
|
691
|
+
}))
|
|
692
|
+
.filter((option, index, items) => (
|
|
693
|
+
option.text
|
|
694
|
+
&& items.findIndex((candidate) => candidate.text === option.text) === index
|
|
695
|
+
));
|
|
696
|
+
const hasExactSuggestionMatch = propertyValueOptions.some((option) => (
|
|
697
|
+
option.text.toLowerCase() === suggestionQuery
|
|
698
|
+
));
|
|
699
|
+
const matchingValueSuggestions = propertyValueOptions.filter((option) => (
|
|
700
|
+
!suggestionQuery || option.text.toLowerCase().includes(suggestionQuery)
|
|
701
|
+
));
|
|
702
|
+
const valueSuggestions = !suggestionQuery || hasExactSuggestionMatch
|
|
703
|
+
? propertyValueOptions
|
|
704
|
+
: matchingValueSuggestions.length > 0
|
|
705
|
+
? matchingValueSuggestions
|
|
706
|
+
: propertyValueOptions;
|
|
707
|
+
const suggestionsEnabled = valueSuggestions.length > 0 && !['is empty', 'is not empty'].includes(rule.operator);
|
|
708
|
+
const suggestionListId = `bases-filter-suggestions-${escapeHtml(entry.key)}-${escapeHtml(encodedPath.replace(/\./g, '-'))}`;
|
|
709
|
+
const valueInputClasses = inputClassNames({
|
|
710
|
+
extra: suggestionsEnabled
|
|
711
|
+
? 'bases-filter-value bases-filter-value--suggestions'
|
|
712
|
+
: 'bases-filter-value',
|
|
713
|
+
});
|
|
714
|
+
return {
|
|
715
|
+
encodedPath,
|
|
716
|
+
operators,
|
|
717
|
+
suggestionListId,
|
|
718
|
+
suggestionsEnabled,
|
|
719
|
+
valueInputClasses,
|
|
720
|
+
valueSuggestions,
|
|
721
|
+
valueDisabled: ['is empty', 'is not empty'].includes(rule.operator),
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function renderFilterSuggestionList(presentation, currentValue = '') {
|
|
726
|
+
if (!presentation.suggestionsEnabled) {
|
|
727
|
+
return '';
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return `
|
|
731
|
+
<div class="bases-filter-suggestion-list" id="${presentation.suggestionListId}" role="listbox">
|
|
732
|
+
${presentation.valueSuggestions.slice(0, 8).map((option) => `
|
|
733
|
+
<button
|
|
734
|
+
type="button"
|
|
735
|
+
class="bases-filter-suggestion"
|
|
736
|
+
data-base-filter-suggestion="${escapeHtml(presentation.encodedPath)}"
|
|
737
|
+
data-base-filter-suggestion-value="${escapeHtml(option.text)}"
|
|
738
|
+
role="option"
|
|
739
|
+
aria-selected="${option.text === String(currentValue ?? '') ? 'true' : 'false'}"
|
|
740
|
+
>
|
|
741
|
+
<span class="bases-filter-suggestion-label">${escapeHtml(option.text)}</span>
|
|
742
|
+
${option.count == null ? '' : `<span class="bases-filter-suggestion-meta">${escapeHtml(String(option.count))}</span>`}
|
|
743
|
+
</button>
|
|
744
|
+
`).join('')}
|
|
745
|
+
</div>
|
|
746
|
+
`;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function renderFilterValueCombobox(result, rule, path, entry) {
|
|
750
|
+
const presentation = buildFilterValuePresentation(result, rule, path, entry);
|
|
751
|
+
return `
|
|
752
|
+
<div class="bases-filter-value-combobox${presentation.suggestionsEnabled ? ' has-suggestions' : ''}">
|
|
753
|
+
<input class="${escapeHtml(presentation.valueInputClasses)}" type="text" value="${escapeHtml(rule.value ?? '')}" data-base-filter-value="${escapeHtml(presentation.encodedPath)}"${presentation.suggestionsEnabled ? ` aria-controls="${presentation.suggestionListId}" aria-haspopup="listbox" aria-autocomplete="list" autocomplete="off"` : ''}${presentation.valueDisabled ? ' disabled' : ''}>
|
|
754
|
+
${renderFilterSuggestionList(presentation, rule.value ?? '')}
|
|
755
|
+
</div>
|
|
756
|
+
`;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function renderFilterRule(result, rule, path, entry) {
|
|
760
|
+
const presentation = buildFilterValuePresentation(result, rule, path, entry);
|
|
761
|
+
|
|
762
|
+
return `
|
|
763
|
+
<div class="bases-filter-row">
|
|
764
|
+
<select class="${escapeHtml(inputClassNames({ extra: 'bases-select bases-filter-property' }))}" data-base-filter-property="${escapeHtml(presentation.encodedPath)}">
|
|
765
|
+
${getPropertyOptionsMarkup(result, rule.propertyId)}
|
|
766
|
+
</select>
|
|
767
|
+
<select class="${escapeHtml(inputClassNames({ extra: 'bases-select bases-filter-operator' }))}" data-base-filter-operator="${escapeHtml(presentation.encodedPath)}">
|
|
768
|
+
${presentation.operators.map((operator) => `<option value="${escapeHtml(operator)}"${operator === rule.operator ? ' selected' : ''}>${escapeHtml(operator)}</option>`).join('')}
|
|
769
|
+
</select>
|
|
770
|
+
${renderFilterValueCombobox(result, rule, path, entry)}
|
|
771
|
+
<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-filter-remove="${escapeHtml(presentation.encodedPath)}">Delete</button>
|
|
772
|
+
</div>
|
|
773
|
+
`;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function syncFilterValueCombobox(entry, result, path, { fallbackToFullRender = true } = {}) {
|
|
777
|
+
const shell = findShellElement(entry);
|
|
778
|
+
const panelSlot = shell?.querySelector?.('[data-base-panel-slot]') ?? null;
|
|
779
|
+
const encodedPath = encodePath(path);
|
|
780
|
+
const input = panelSlot?.querySelector?.(`[data-base-filter-value="${encodedPath}"]`) ?? null;
|
|
781
|
+
const combobox = input?.parentElement?.classList?.contains('bases-filter-value-combobox')
|
|
782
|
+
? input.parentElement
|
|
783
|
+
: null;
|
|
784
|
+
const rule = getNodeAtPath(getBuilderFilterState(entry, result), path);
|
|
785
|
+
|
|
786
|
+
if (!input || !combobox || !rule || rule.type !== 'rule' || typeof input.insertAdjacentHTML !== 'function') {
|
|
787
|
+
if (fallbackToFullRender) {
|
|
788
|
+
updateShellContent(entry, result);
|
|
789
|
+
}
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const presentation = buildFilterValuePresentation(result, rule, path, entry);
|
|
794
|
+
combobox.classList.toggle('has-suggestions', presentation.suggestionsEnabled);
|
|
795
|
+
input.className = presentation.valueInputClasses;
|
|
796
|
+
input.disabled = presentation.valueDisabled;
|
|
797
|
+
if (input.value !== String(rule.value ?? '')) {
|
|
798
|
+
input.value = String(rule.value ?? '');
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (presentation.suggestionsEnabled) {
|
|
802
|
+
input.setAttribute('aria-controls', presentation.suggestionListId);
|
|
803
|
+
input.setAttribute('aria-haspopup', 'listbox');
|
|
804
|
+
input.setAttribute('aria-autocomplete', 'list');
|
|
805
|
+
input.setAttribute('autocomplete', 'off');
|
|
806
|
+
} else {
|
|
807
|
+
input.removeAttribute('aria-controls');
|
|
808
|
+
input.removeAttribute('aria-haspopup');
|
|
809
|
+
input.removeAttribute('aria-autocomplete');
|
|
810
|
+
input.removeAttribute('autocomplete');
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const suggestionList = combobox.querySelector('.bases-filter-suggestion-list');
|
|
814
|
+
suggestionList?.remove?.();
|
|
815
|
+
if (presentation.suggestionsEnabled) {
|
|
816
|
+
input.insertAdjacentHTML('afterend', renderFilterSuggestionList(presentation, rule.value ?? ''));
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
return true;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function forEachFilterRule(node, callback, path = []) {
|
|
823
|
+
if (!node) {
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (node.type === 'rule') {
|
|
828
|
+
callback(node, path);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
(node.children ?? []).forEach((child, index) => {
|
|
833
|
+
forEachFilterRule(child, callback, [...path, index]);
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function renderFilterGroup(result, group, path, entry, { isRoot = false } = {}) {
|
|
838
|
+
const encodedPath = encodePath(path);
|
|
839
|
+
return `
|
|
840
|
+
<div class="bases-filter-group"${isRoot ? ' data-base-filter-root' : ''}>
|
|
841
|
+
<div class="bases-filter-group-header">
|
|
842
|
+
<select class="${escapeHtml(inputClassNames({ extra: 'bases-select bases-filter-conjunction' }))}" data-base-filter-conjunction="${escapeHtml(encodedPath)}">
|
|
843
|
+
${['and', 'or', 'not'].map((conjunction) => `<option value="${conjunction}"${group.conjunction === conjunction ? ' selected' : ''}>${escapeHtml({
|
|
844
|
+
and: 'All the following are true',
|
|
845
|
+
not: 'None of the following are true',
|
|
846
|
+
or: 'Any of the following are true',
|
|
847
|
+
}[conjunction])}</option>`).join('')}
|
|
848
|
+
</select>
|
|
849
|
+
${isRoot ? '' : `<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-filter-remove="${escapeHtml(encodedPath)}">Delete group</button>`}
|
|
850
|
+
</div>
|
|
851
|
+
<div class="bases-filter-children">
|
|
852
|
+
${(group.children ?? []).map((child, index) => (
|
|
853
|
+
child?.type === 'group'
|
|
854
|
+
? renderFilterGroup(result, child, [...path, index], entry)
|
|
855
|
+
: renderFilterRule(result, child, [...path, index], entry)
|
|
856
|
+
)).join('')}
|
|
857
|
+
</div>
|
|
858
|
+
<div class="bases-filter-actions-row">
|
|
859
|
+
<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-filter-add-rule="${escapeHtml(encodedPath)}">Add filter</button>
|
|
860
|
+
<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact' }))}" data-base-filter-add-group="${escapeHtml(encodedPath)}">Add filter group</button>
|
|
861
|
+
</div>
|
|
862
|
+
</div>
|
|
863
|
+
`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function renderFilterPanel(result, entry) {
|
|
867
|
+
const rawFilters = getEditableViewConfig(result).filters;
|
|
868
|
+
const parsed = entry.ui.builderFilter ?? parseFilterNode(rawFilters);
|
|
869
|
+
if (entry.ui.builderFilter == null) {
|
|
870
|
+
entry.ui.builderFilter = parsed;
|
|
871
|
+
}
|
|
872
|
+
const mode = parsed ? (entry.ui.filterMode ?? 'builder') : 'advanced';
|
|
873
|
+
const rawText = entry.ui.rawFilterText ?? serializeRawFilterText(rawFilters);
|
|
874
|
+
|
|
875
|
+
return `
|
|
876
|
+
<section class="bases-panel-card">
|
|
877
|
+
<div class="bases-panel-title-row">
|
|
878
|
+
<div class="bases-panel-title">This view</div>
|
|
879
|
+
${parsed ? `
|
|
880
|
+
<div class="bases-toggle-row">
|
|
881
|
+
<button type="button" class="${escapeHtml(buttonClassNames({ variant: mode === 'builder' ? 'primary' : 'secondary', size: 'compact' }))}" data-base-filter-mode="builder">Builder</button>
|
|
882
|
+
<button type="button" class="${escapeHtml(buttonClassNames({ variant: mode === 'advanced' ? 'primary' : 'secondary', size: 'compact' }))}" data-base-filter-mode="advanced">Advanced</button>
|
|
883
|
+
</div>
|
|
884
|
+
` : ''}
|
|
885
|
+
</div>
|
|
886
|
+
${mode === 'advanced'
|
|
887
|
+
? `
|
|
888
|
+
<label class="bases-filter-advanced-label">
|
|
889
|
+
<textarea class="${escapeHtml(inputClassNames({ extra: 'bases-filter-advanced' }))}" data-base-filter-advanced>${escapeHtml(rawText)}</textarea>
|
|
890
|
+
</label>
|
|
891
|
+
<div class="bases-filter-actions-row">
|
|
892
|
+
<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'primary', size: 'compact' }))}" data-base-filter-save-advanced>Save filter</button>
|
|
893
|
+
</div>
|
|
894
|
+
`
|
|
895
|
+
: renderFilterGroup(result, parsed ?? createEmptyFilterGroup('and'), [], entry, { isRoot: true })}
|
|
896
|
+
</section>
|
|
897
|
+
`;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function renderPropertiesPanel(result, entry) {
|
|
901
|
+
const propertySearch = String(entry.ui.propertySearch ?? '').trim().toLowerCase();
|
|
902
|
+
const visibleIds = new Set(getVisiblePropertyIds(result));
|
|
903
|
+
const properties = getAvailableProperties(result).filter((property) => (
|
|
904
|
+
!propertySearch
|
|
905
|
+
|| property.label.toLowerCase().includes(propertySearch)
|
|
906
|
+
|| property.id.toLowerCase().includes(propertySearch)
|
|
907
|
+
));
|
|
908
|
+
|
|
909
|
+
return `
|
|
910
|
+
<section class="bases-panel-card">
|
|
911
|
+
<label class="bases-properties-search">
|
|
912
|
+
<input class="${escapeHtml(inputClassNames({ extra: 'bases-properties-search-input' }))}" type="search" value="${escapeHtml(entry.ui.propertySearch ?? '')}" placeholder="Find property" data-base-properties-search>
|
|
913
|
+
</label>
|
|
914
|
+
<div class="bases-properties-list" data-base-properties-list>
|
|
915
|
+
${properties.map((property) => `
|
|
916
|
+
<label class="bases-property-option">
|
|
917
|
+
<input type="checkbox" data-base-property-toggle="${escapeHtml(property.id)}"${visibleIds.has(property.id) ? ' checked' : ''}>
|
|
918
|
+
<span class="bases-property-option-label">${escapeHtml(property.label)}</span>
|
|
919
|
+
<span class="bases-property-option-meta">${escapeHtml(property.kind)}</span>
|
|
920
|
+
</label>
|
|
921
|
+
`).join('')}
|
|
922
|
+
</div>
|
|
923
|
+
</section>
|
|
924
|
+
`;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function capturePanelState(panelSlot) {
|
|
928
|
+
const propertiesList = panelSlot?.querySelector?.('[data-base-properties-list]') ?? null;
|
|
929
|
+
const propertiesSearch = panelSlot?.querySelector?.('[data-base-properties-search]') ?? null;
|
|
930
|
+
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
|
|
931
|
+
let focusedControl = null;
|
|
932
|
+
|
|
933
|
+
if (propertiesSearch && activeElement === propertiesSearch) {
|
|
934
|
+
const selectionStart = typeof propertiesSearch.selectionStart === 'number'
|
|
935
|
+
? propertiesSearch.selectionStart
|
|
936
|
+
: null;
|
|
937
|
+
const selectionEnd = typeof propertiesSearch.selectionEnd === 'number'
|
|
938
|
+
? propertiesSearch.selectionEnd
|
|
939
|
+
: selectionStart;
|
|
940
|
+
focusedControl = {
|
|
941
|
+
selectionEnd,
|
|
942
|
+
selectionStart,
|
|
943
|
+
type: 'properties-search',
|
|
944
|
+
};
|
|
945
|
+
} else if (activeElement?.dataset?.baseFilterValue != null) {
|
|
946
|
+
const filterPath = String(activeElement.dataset.baseFilterValue);
|
|
947
|
+
const filterValue = panelSlot?.querySelector?.(`[data-base-filter-value="${filterPath}"]`) ?? null;
|
|
948
|
+
if (filterValue && activeElement === filterValue) {
|
|
949
|
+
const selectionStart = typeof filterValue.selectionStart === 'number'
|
|
950
|
+
? filterValue.selectionStart
|
|
951
|
+
: null;
|
|
952
|
+
const selectionEnd = typeof filterValue.selectionEnd === 'number'
|
|
953
|
+
? filterValue.selectionEnd
|
|
954
|
+
: selectionStart;
|
|
955
|
+
focusedControl = {
|
|
956
|
+
path: filterPath,
|
|
957
|
+
selectionEnd,
|
|
958
|
+
selectionStart,
|
|
959
|
+
type: 'filter-value',
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
return {
|
|
965
|
+
focusedControl,
|
|
966
|
+
propertiesListScrollTop: propertiesList ? propertiesList.scrollTop : null,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function restorePanelState(panelSlot, panelState) {
|
|
971
|
+
if (!panelState) {
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if (panelState.propertiesListScrollTop != null) {
|
|
976
|
+
const propertiesList = panelSlot?.querySelector?.('[data-base-properties-list]') ?? null;
|
|
977
|
+
if (propertiesList) {
|
|
978
|
+
propertiesList.scrollTop = panelState.propertiesListScrollTop;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (panelState.focusedControl?.type === 'properties-search') {
|
|
983
|
+
const propertiesSearch = panelSlot?.querySelector?.('[data-base-properties-search]') ?? null;
|
|
984
|
+
if (propertiesSearch) {
|
|
985
|
+
propertiesSearch.focus?.();
|
|
986
|
+
if (
|
|
987
|
+
typeof propertiesSearch.setSelectionRange === 'function'
|
|
988
|
+
&& panelState.focusedControl.selectionStart != null
|
|
989
|
+
) {
|
|
990
|
+
propertiesSearch.setSelectionRange(
|
|
991
|
+
panelState.focusedControl.selectionStart,
|
|
992
|
+
panelState.focusedControl.selectionEnd ?? panelState.focusedControl.selectionStart,
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (panelState.focusedControl?.type === 'filter-value' && panelState.focusedControl.path != null) {
|
|
1000
|
+
const filterValue = panelSlot?.querySelector?.(`[data-base-filter-value="${panelState.focusedControl.path}"]`) ?? null;
|
|
1001
|
+
if (filterValue) {
|
|
1002
|
+
filterValue.focus?.();
|
|
1003
|
+
if (
|
|
1004
|
+
typeof filterValue.setSelectionRange === 'function'
|
|
1005
|
+
&& panelState.focusedControl.selectionStart != null
|
|
1006
|
+
) {
|
|
1007
|
+
filterValue.setSelectionRange(
|
|
1008
|
+
panelState.focusedControl.selectionStart,
|
|
1009
|
+
panelState.focusedControl.selectionEnd ?? panelState.focusedControl.selectionStart,
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function renderPanel(result, entry) {
|
|
1017
|
+
if (!entry.ui?.openPanel) {
|
|
1018
|
+
return '';
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (!getMeta(result).editable) {
|
|
1022
|
+
return '<section class="bases-panel-card"><div class="bases-readonly-note">Inline base previews are read-only. Open the .base file to edit its sort, filter, and properties.</div></section>';
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
switch (entry.ui.openPanel) {
|
|
1026
|
+
case 'filter':
|
|
1027
|
+
return renderFilterPanel(result, entry);
|
|
1028
|
+
case 'properties':
|
|
1029
|
+
return renderPropertiesPanel(result, entry);
|
|
1030
|
+
case 'sort':
|
|
1031
|
+
default:
|
|
1032
|
+
return renderSortPanel(result);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function renderShellHtml(result, entry) {
|
|
1037
|
+
return `
|
|
1038
|
+
<section class="bases-shell" data-base-shell-key="${escapeHtml(entry.key)}">
|
|
184
1039
|
<header class="bases-toolbar">
|
|
185
1040
|
<div class="bases-toolbar-main">
|
|
186
1041
|
<div class="${escapeHtml(segmentedControlClassNames({ pill: true, extra: 'bases-tabs' }))}" data-base-tabs>
|
|
@@ -189,16 +1044,18 @@ function renderShellHtml(result, state) {
|
|
|
189
1044
|
<div class="bases-meta" data-base-meta>${escapeHtml(String(result.totalRows ?? 0))} results</div>
|
|
190
1045
|
</div>
|
|
191
1046
|
<div class="bases-toolbar-actions">
|
|
1047
|
+
<div class="bases-toolbar-edit-actions" data-base-action-slot>${renderActionButtons(result, entry)}</div>
|
|
192
1048
|
<label class="bases-search-shell" aria-label="Search this base">
|
|
193
1049
|
<svg class="bases-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
194
1050
|
<circle cx="11" cy="11" r="7"></circle>
|
|
195
1051
|
<path d="m20 20-3.5-3.5"></path>
|
|
196
1052
|
</svg>
|
|
197
|
-
<input class="${escapeHtml(inputClassNames({ extra: 'bases-search-input' }))}" type="search" value="${escapeHtml(
|
|
1053
|
+
<input class="${escapeHtml(inputClassNames({ extra: 'bases-search-input' }))}" type="search" value="${escapeHtml(entry.search)}" placeholder="Search this base" aria-label="Search this base">
|
|
198
1054
|
</label>
|
|
199
1055
|
<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', pill: true, extra: 'bases-export-btn' }))}">Export CSV</button>
|
|
200
1056
|
</div>
|
|
201
1057
|
</header>
|
|
1058
|
+
<div class="bases-panels" data-base-panel-slot>${renderPanel(result, entry)}</div>
|
|
202
1059
|
<div data-base-summary-slot>${renderSummaryBar(result.summaries)}</div>
|
|
203
1060
|
<div class="bases-content" data-base-content>${renderViewBody(result)}</div>
|
|
204
1061
|
</section>
|
|
@@ -241,6 +1098,18 @@ function updateShellContent(entry, result) {
|
|
|
241
1098
|
meta.textContent = `${String(result.totalRows ?? 0)} results`;
|
|
242
1099
|
}
|
|
243
1100
|
|
|
1101
|
+
const actionSlot = shell.querySelector('[data-base-action-slot]');
|
|
1102
|
+
if (actionSlot) {
|
|
1103
|
+
actionSlot.innerHTML = renderActionButtons(result, entry);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const panelSlot = shell.querySelector('[data-base-panel-slot]');
|
|
1107
|
+
if (panelSlot) {
|
|
1108
|
+
const panelState = capturePanelState(panelSlot);
|
|
1109
|
+
panelSlot.innerHTML = renderPanel(result, entry);
|
|
1110
|
+
restorePanelState(panelSlot, panelState);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
244
1113
|
const summarySlot = shell.querySelector('[data-base-summary-slot]');
|
|
245
1114
|
if (summarySlot) {
|
|
246
1115
|
summarySlot.innerHTML = renderSummaryBar(result.summaries);
|
|
@@ -280,19 +1149,57 @@ function parseDownloadFileName(contentDisposition = '') {
|
|
|
280
1149
|
export class BasesPreviewController {
|
|
281
1150
|
constructor({
|
|
282
1151
|
getActiveFilePath = () => '',
|
|
1152
|
+
getSession = () => null,
|
|
283
1153
|
onOpenFile,
|
|
284
1154
|
previewElement,
|
|
1155
|
+
replaceBaseSource = null,
|
|
285
1156
|
toastController,
|
|
286
1157
|
vaultApiClient,
|
|
287
1158
|
}) {
|
|
288
1159
|
this.getActiveFilePath = getActiveFilePath;
|
|
1160
|
+
this.getSession = getSession;
|
|
289
1161
|
this.onOpenFile = onOpenFile;
|
|
290
1162
|
this.previewElement = previewElement;
|
|
1163
|
+
this.replaceBaseSource = replaceBaseSource;
|
|
291
1164
|
this.toastController = toastController;
|
|
292
1165
|
this.vaultApiClient = vaultApiClient;
|
|
293
1166
|
this.entries = new Map();
|
|
294
1167
|
this.searchTimers = new Map();
|
|
295
1168
|
|
|
1169
|
+
this.applyFilterSuggestion = (entry, path, nextValue) => {
|
|
1170
|
+
void this.updateBuilderFilter(entry, (group) => updateNodeAtPath(group, path, (node) => ({
|
|
1171
|
+
...node,
|
|
1172
|
+
value: nextValue,
|
|
1173
|
+
})));
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
this.persistBuilderFilter = (entry) => {
|
|
1177
|
+
const nextGroup = getBuilderFilterState(entry, entry.result);
|
|
1178
|
+
entry.ui.builderFilter = nextGroup;
|
|
1179
|
+
void this.updateViewConfig(entry, (config) => {
|
|
1180
|
+
config.filters = compileFilterGroup(nextGroup, entry.result);
|
|
1181
|
+
return config;
|
|
1182
|
+
});
|
|
1183
|
+
};
|
|
1184
|
+
|
|
1185
|
+
this.handlePointerDown = (event) => {
|
|
1186
|
+
const filterSuggestion = event.target.closest('[data-base-filter-suggestion]');
|
|
1187
|
+
if (!filterSuggestion) {
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const shell = event.target.closest('[data-base-shell-key]');
|
|
1192
|
+
const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
|
|
1193
|
+
if (!entry) {
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
event.preventDefault();
|
|
1198
|
+
const path = decodePath(filterSuggestion.dataset.baseFilterSuggestion || '');
|
|
1199
|
+
const nextValue = filterSuggestion.dataset.baseFilterSuggestionValue || '';
|
|
1200
|
+
this.applyFilterSuggestion(entry, path, nextValue);
|
|
1201
|
+
};
|
|
1202
|
+
|
|
296
1203
|
this.handleClick = (event) => {
|
|
297
1204
|
const openButton = event.target.closest('[data-base-open-file]');
|
|
298
1205
|
if (openButton) {
|
|
@@ -301,58 +1208,378 @@ export class BasesPreviewController {
|
|
|
301
1208
|
return;
|
|
302
1209
|
}
|
|
303
1210
|
|
|
1211
|
+
const shell = event.target.closest('[data-base-shell-key]');
|
|
1212
|
+
const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
|
|
1213
|
+
if (!entry) {
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
304
1217
|
const tab = event.target.closest('.bases-view-tab');
|
|
305
1218
|
if (tab) {
|
|
306
|
-
|
|
1219
|
+
entry.payload.view = tab.dataset.baseView || '';
|
|
1220
|
+
entry.ui.openPanel = '';
|
|
1221
|
+
void this.renderEntry(entry);
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const panelButton = event.target.closest('[data-base-panel]');
|
|
1226
|
+
if (panelButton) {
|
|
1227
|
+
const panel = panelButton.dataset.basePanel || '';
|
|
1228
|
+
entry.ui.openPanel = entry.ui.openPanel === panel ? '' : panel;
|
|
1229
|
+
entry.ui.rawFilterText = serializeRawFilterText(getEditableViewConfig(entry.result).filters);
|
|
1230
|
+
updateShellContent(entry, entry.result);
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const exportButton = event.target.closest('.bases-export-btn');
|
|
1235
|
+
if (exportButton) {
|
|
1236
|
+
void this.exportEntry(entry);
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
if (event.target.closest('[data-base-sort-add]')) {
|
|
1241
|
+
void this.updateViewConfig(entry, (config) => {
|
|
1242
|
+
const used = new Set((config.sort ?? []).map((sort) => sort.property));
|
|
1243
|
+
const nextProperty = getAvailableProperties(entry.result).find((property) => !used.has(property.id))?.id ?? 'file.name';
|
|
1244
|
+
config.sort = [...(config.sort ?? []), { direction: 'asc', property: nextProperty }];
|
|
1245
|
+
return config;
|
|
1246
|
+
});
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const sortDelete = event.target.closest('[data-base-sort-delete]');
|
|
1251
|
+
if (sortDelete) {
|
|
1252
|
+
const index = Number.parseInt(sortDelete.dataset.baseSortDelete || '', 10);
|
|
1253
|
+
void this.updateViewConfig(entry, (config) => {
|
|
1254
|
+
config.sort = (config.sort ?? []).filter((_, sortIndex) => sortIndex !== index);
|
|
1255
|
+
return config;
|
|
1256
|
+
});
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const sortMove = event.target.closest('[data-base-sort-move]');
|
|
1261
|
+
if (sortMove) {
|
|
1262
|
+
const [indexText, direction] = String(sortMove.dataset.baseSortMove || '').split(':');
|
|
1263
|
+
const index = Number.parseInt(indexText, 10);
|
|
1264
|
+
void this.updateViewConfig(entry, (config) => {
|
|
1265
|
+
const nextSorts = [...(config.sort ?? [])];
|
|
1266
|
+
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
|
1267
|
+
if (targetIndex < 0 || targetIndex >= nextSorts.length) {
|
|
1268
|
+
return config;
|
|
1269
|
+
}
|
|
1270
|
+
[nextSorts[index], nextSorts[targetIndex]] = [nextSorts[targetIndex], nextSorts[index]];
|
|
1271
|
+
config.sort = nextSorts;
|
|
1272
|
+
return config;
|
|
1273
|
+
});
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
if (event.target.closest('[data-base-clear-group-by]')) {
|
|
1278
|
+
void this.updateViewConfig(entry, (config) => {
|
|
1279
|
+
config.groupBy = null;
|
|
1280
|
+
return config;
|
|
1281
|
+
});
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const filterModeButton = event.target.closest('[data-base-filter-mode]');
|
|
1286
|
+
if (filterModeButton) {
|
|
1287
|
+
entry.ui.filterMode = filterModeButton.dataset.baseFilterMode || 'builder';
|
|
1288
|
+
if (entry.ui.filterMode === 'advanced') {
|
|
1289
|
+
entry.ui.rawFilterText = serializeRawFilterText(getEditableViewConfig(entry.result).filters);
|
|
1290
|
+
entry.ui.builderFilter = null;
|
|
1291
|
+
}
|
|
1292
|
+
updateShellContent(entry, entry.result);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
if (event.target.closest('[data-base-filter-save-advanced]')) {
|
|
1297
|
+
void this.saveAdvancedFilter(entry);
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
const addFilterRule = event.target.closest('[data-base-filter-add-rule]');
|
|
1302
|
+
if (addFilterRule) {
|
|
1303
|
+
const path = decodePath(addFilterRule.dataset.baseFilterAddRule || '');
|
|
1304
|
+
void this.updateBuilderFilter(entry, (group) => addNodeAtPath(group, path, {
|
|
1305
|
+
operator: 'is',
|
|
1306
|
+
propertyId: getAvailableProperties(entry.result)[0]?.id ?? 'file.name',
|
|
1307
|
+
type: 'rule',
|
|
1308
|
+
value: '',
|
|
1309
|
+
}));
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const addFilterGroup = event.target.closest('[data-base-filter-add-group]');
|
|
1314
|
+
if (addFilterGroup) {
|
|
1315
|
+
const path = decodePath(addFilterGroup.dataset.baseFilterAddGroup || '');
|
|
1316
|
+
void this.updateBuilderFilter(entry, (group) => addNodeAtPath(group, path, createEmptyFilterGroup('and')));
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const removeFilter = event.target.closest('[data-base-filter-remove]');
|
|
1321
|
+
if (removeFilter) {
|
|
1322
|
+
const path = decodePath(removeFilter.dataset.baseFilterRemove || '');
|
|
1323
|
+
void this.updateBuilderFilter(entry, (group) => removeNodeAtPath(group, path));
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const filterSuggestion = event.target.closest('[data-base-filter-suggestion]');
|
|
1328
|
+
if (filterSuggestion) {
|
|
1329
|
+
event.preventDefault?.();
|
|
1330
|
+
if (event.detail != null && event.detail !== 0) {
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
const path = decodePath(filterSuggestion.dataset.baseFilterSuggestion || '');
|
|
1334
|
+
const nextValue = filterSuggestion.dataset.baseFilterSuggestionValue || '';
|
|
1335
|
+
this.applyFilterSuggestion(entry, path, nextValue);
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
this.handleInput = (event) => {
|
|
1340
|
+
const input = event.target.closest('.bases-search-input');
|
|
1341
|
+
if (input) {
|
|
1342
|
+
const shell = input.closest('[data-base-shell-key]');
|
|
307
1343
|
const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
|
|
308
1344
|
if (!entry) {
|
|
309
1345
|
return;
|
|
310
1346
|
}
|
|
311
1347
|
|
|
312
|
-
entry.
|
|
313
|
-
|
|
1348
|
+
entry.search = input.value || '';
|
|
1349
|
+
clearTimeout(this.searchTimers.get(entry.key));
|
|
1350
|
+
this.searchTimers.set(entry.key, setTimeout(() => {
|
|
1351
|
+
entry.payload.search = entry.search;
|
|
1352
|
+
void this.renderEntry(entry);
|
|
1353
|
+
}, 180));
|
|
314
1354
|
return;
|
|
315
1355
|
}
|
|
316
1356
|
|
|
317
|
-
const
|
|
318
|
-
if (
|
|
319
|
-
const shell =
|
|
1357
|
+
const propertiesSearch = event.target.closest('[data-base-properties-search]');
|
|
1358
|
+
if (propertiesSearch) {
|
|
1359
|
+
const shell = propertiesSearch.closest('[data-base-shell-key]');
|
|
320
1360
|
const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
|
|
321
1361
|
if (!entry) {
|
|
322
1362
|
return;
|
|
323
1363
|
}
|
|
324
1364
|
|
|
325
|
-
|
|
1365
|
+
entry.ui.propertySearch = propertiesSearch.value || '';
|
|
1366
|
+
updateShellContent(entry, entry.result);
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
const advancedFilter = event.target.closest('[data-base-filter-advanced]');
|
|
1371
|
+
if (advancedFilter) {
|
|
1372
|
+
const shell = advancedFilter.closest('[data-base-shell-key]');
|
|
1373
|
+
const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
|
|
1374
|
+
if (!entry) {
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
entry.ui.rawFilterText = advancedFilter.value || '';
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
const filterValue = event.target.closest('[data-base-filter-value]');
|
|
1383
|
+
if (filterValue) {
|
|
1384
|
+
const shell = filterValue.closest('[data-base-shell-key]');
|
|
1385
|
+
const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
|
|
1386
|
+
if (!entry) {
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
const path = decodePath(filterValue.dataset.baseFilterValue || '');
|
|
1391
|
+
const parsed = entry.ui.builderFilter
|
|
1392
|
+
?? parseFilterNode(getEditableViewConfig(entry.result).filters)
|
|
1393
|
+
?? createEmptyFilterGroup('and');
|
|
1394
|
+
entry.ui.builderFilter = updateNodeAtPath(parsed, path, (node) => ({
|
|
1395
|
+
...node,
|
|
1396
|
+
value: filterValue.value || '',
|
|
1397
|
+
}));
|
|
1398
|
+
syncFilterValueCombobox(entry, entry.result, path);
|
|
326
1399
|
}
|
|
327
1400
|
};
|
|
328
1401
|
|
|
329
|
-
this.
|
|
330
|
-
const
|
|
331
|
-
|
|
1402
|
+
this.handleChange = (event) => {
|
|
1403
|
+
const shell = event.target.closest('[data-base-shell-key]');
|
|
1404
|
+
const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
|
|
1405
|
+
if (!entry) {
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
const groupByProperty = event.target.closest('[data-base-group-by-property]');
|
|
1410
|
+
if (groupByProperty) {
|
|
1411
|
+
void this.updateViewConfig(entry, (config) => {
|
|
1412
|
+
config.groupBy = groupByProperty.value
|
|
1413
|
+
? {
|
|
1414
|
+
direction: config.groupBy?.direction ?? 'asc',
|
|
1415
|
+
property: groupByProperty.value,
|
|
1416
|
+
}
|
|
1417
|
+
: null;
|
|
1418
|
+
return config;
|
|
1419
|
+
});
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const groupByDirection = event.target.closest('[data-base-group-by-direction]');
|
|
1424
|
+
if (groupByDirection) {
|
|
1425
|
+
void this.updateViewConfig(entry, (config) => {
|
|
1426
|
+
config.groupBy = config.groupBy?.property
|
|
1427
|
+
? {
|
|
1428
|
+
direction: groupByDirection.value || 'asc',
|
|
1429
|
+
property: config.groupBy.property,
|
|
1430
|
+
}
|
|
1431
|
+
: null;
|
|
1432
|
+
return config;
|
|
1433
|
+
});
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const sortProperty = event.target.closest('[data-base-sort-property]');
|
|
1438
|
+
if (sortProperty) {
|
|
1439
|
+
const index = Number.parseInt(sortProperty.dataset.baseSortProperty || '', 10);
|
|
1440
|
+
void this.updateViewConfig(entry, (config) => {
|
|
1441
|
+
config.sort = (config.sort ?? []).map((sortConfig, sortIndex) => (
|
|
1442
|
+
sortIndex === index
|
|
1443
|
+
? { ...sortConfig, property: sortProperty.value }
|
|
1444
|
+
: sortConfig
|
|
1445
|
+
));
|
|
1446
|
+
return config;
|
|
1447
|
+
});
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
const sortDirection = event.target.closest('[data-base-sort-direction]');
|
|
1452
|
+
if (sortDirection) {
|
|
1453
|
+
const index = Number.parseInt(sortDirection.dataset.baseSortDirection || '', 10);
|
|
1454
|
+
void this.updateViewConfig(entry, (config) => {
|
|
1455
|
+
config.sort = (config.sort ?? []).map((sortConfig, sortIndex) => (
|
|
1456
|
+
sortIndex === index
|
|
1457
|
+
? { ...sortConfig, direction: sortDirection.value || 'asc' }
|
|
1458
|
+
: sortConfig
|
|
1459
|
+
));
|
|
1460
|
+
return config;
|
|
1461
|
+
});
|
|
332
1462
|
return;
|
|
333
1463
|
}
|
|
334
1464
|
|
|
335
|
-
const
|
|
1465
|
+
const propertyToggle = event.target.closest('[data-base-property-toggle]');
|
|
1466
|
+
if (propertyToggle) {
|
|
1467
|
+
const propertyId = propertyToggle.dataset.basePropertyToggle || '';
|
|
1468
|
+
void this.updateViewConfig(entry, (config) => {
|
|
1469
|
+
const nextOrder = config.order?.length > 0
|
|
1470
|
+
? [...config.order]
|
|
1471
|
+
: getVisiblePropertyIds(entry.result);
|
|
1472
|
+
const existingIndex = nextOrder.indexOf(propertyId);
|
|
1473
|
+
if (propertyToggle.checked && existingIndex === -1) {
|
|
1474
|
+
nextOrder.push(propertyId);
|
|
1475
|
+
}
|
|
1476
|
+
if (!propertyToggle.checked && existingIndex >= 0) {
|
|
1477
|
+
nextOrder.splice(existingIndex, 1);
|
|
1478
|
+
}
|
|
1479
|
+
config.order = nextOrder;
|
|
1480
|
+
return config;
|
|
1481
|
+
});
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
const filterConjunction = event.target.closest('[data-base-filter-conjunction]');
|
|
1486
|
+
if (filterConjunction) {
|
|
1487
|
+
const path = decodePath(filterConjunction.dataset.baseFilterConjunction || '');
|
|
1488
|
+
void this.updateBuilderFilter(entry, (group) => updateNodeAtPath(group, path, (node) => ({
|
|
1489
|
+
...node,
|
|
1490
|
+
conjunction: filterConjunction.value || 'and',
|
|
1491
|
+
})));
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const filterProperty = event.target.closest('[data-base-filter-property]');
|
|
1496
|
+
if (filterProperty) {
|
|
1497
|
+
const path = decodePath(filterProperty.dataset.baseFilterProperty || '');
|
|
1498
|
+
void (async () => {
|
|
1499
|
+
await this.updateBuilderFilter(entry, (group) => updateNodeAtPath(group, path, (node) => {
|
|
1500
|
+
const nextOperators = getPropertyFilterOperators(entry.result, filterProperty.value);
|
|
1501
|
+
const nextOperator = nextOperators[0] ?? 'is';
|
|
1502
|
+
return {
|
|
1503
|
+
...node,
|
|
1504
|
+
operator: nextOperator,
|
|
1505
|
+
propertyId: filterProperty.value,
|
|
1506
|
+
value: '',
|
|
1507
|
+
};
|
|
1508
|
+
}));
|
|
1509
|
+
await this.ensurePropertyValues(entry, filterProperty.value);
|
|
1510
|
+
})();
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const filterOperator = event.target.closest('[data-base-filter-operator]');
|
|
1515
|
+
if (filterOperator) {
|
|
1516
|
+
const path = decodePath(filterOperator.dataset.baseFilterOperator || '');
|
|
1517
|
+
void this.updateBuilderFilter(entry, (group) => updateNodeAtPath(group, path, (node) => ({
|
|
1518
|
+
...node,
|
|
1519
|
+
operator: filterOperator.value || 'is',
|
|
1520
|
+
})));
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const filterValue = event.target.closest('[data-base-filter-value]');
|
|
1525
|
+
if (filterValue) {
|
|
1526
|
+
this.persistBuilderFilter(entry);
|
|
1527
|
+
}
|
|
1528
|
+
};
|
|
1529
|
+
|
|
1530
|
+
this.handleFocusIn = (event) => {
|
|
1531
|
+
const filterValue = event.target.closest('[data-base-filter-value]');
|
|
1532
|
+
if (!filterValue) {
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
const shell = filterValue.closest('[data-base-shell-key]');
|
|
336
1537
|
const entry = shell ? this.entries.get(shell.dataset.baseShellKey || '') : null;
|
|
337
1538
|
if (!entry) {
|
|
338
1539
|
return;
|
|
339
1540
|
}
|
|
340
1541
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
void this.
|
|
346
|
-
}
|
|
1542
|
+
const path = decodePath(filterValue.dataset.baseFilterValue || '');
|
|
1543
|
+
const parsed = parseFilterNode(getEditableViewConfig(entry.result).filters);
|
|
1544
|
+
const node = parsed ? getNodeAtPath(parsed, path) : null;
|
|
1545
|
+
if (node?.propertyId) {
|
|
1546
|
+
void this.ensurePropertyValues(entry, node.propertyId);
|
|
1547
|
+
}
|
|
347
1548
|
};
|
|
348
1549
|
|
|
1550
|
+
this.previewElement?.addEventListener('pointerdown', this.handlePointerDown);
|
|
349
1551
|
this.previewElement?.addEventListener('click', this.handleClick);
|
|
350
1552
|
this.previewElement?.addEventListener('input', this.handleInput);
|
|
1553
|
+
this.previewElement?.addEventListener('change', this.handleChange);
|
|
1554
|
+
this.previewElement?.addEventListener('focusin', this.handleFocusIn);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
ensureEntryState(entry) {
|
|
1558
|
+
if (!entry.ui) {
|
|
1559
|
+
entry.ui = {
|
|
1560
|
+
builderFilter: null,
|
|
1561
|
+
filterMode: 'builder',
|
|
1562
|
+
openPanel: '',
|
|
1563
|
+
propertySearch: '',
|
|
1564
|
+
rawFilterText: '',
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
if (!Object.prototype.hasOwnProperty.call(entry.ui, 'builderFilter')) {
|
|
1569
|
+
entry.ui.builderFilter = null;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
if (!entry.propertyValueOptions) {
|
|
1573
|
+
entry.propertyValueOptions = new Map();
|
|
1574
|
+
}
|
|
351
1575
|
}
|
|
352
1576
|
|
|
353
1577
|
destroy() {
|
|
1578
|
+
this.previewElement?.removeEventListener('pointerdown', this.handlePointerDown);
|
|
354
1579
|
this.previewElement?.removeEventListener('click', this.handleClick);
|
|
355
1580
|
this.previewElement?.removeEventListener('input', this.handleInput);
|
|
1581
|
+
this.previewElement?.removeEventListener('change', this.handleChange);
|
|
1582
|
+
this.previewElement?.removeEventListener('focusin', this.handleFocusIn);
|
|
356
1583
|
this.searchTimers.forEach((timer) => clearTimeout(timer));
|
|
357
1584
|
this.searchTimers.clear();
|
|
358
1585
|
this.entries.clear();
|
|
@@ -373,14 +1600,22 @@ export class BasesPreviewController {
|
|
|
373
1600
|
sourcePath: placeholder.dataset.baseSourcePath || '',
|
|
374
1601
|
view: placeholder.dataset.baseView || '',
|
|
375
1602
|
};
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
1603
|
+
const entry = existing ?? {
|
|
1604
|
+
key,
|
|
1605
|
+
payload,
|
|
1606
|
+
placeholder,
|
|
1607
|
+
propertyValueOptions: new Map(),
|
|
1608
|
+
requestVersion: 0,
|
|
1609
|
+
result: null,
|
|
1610
|
+
search: '',
|
|
1611
|
+
ui: {
|
|
1612
|
+
builderFilter: null,
|
|
1613
|
+
filterMode: 'builder',
|
|
1614
|
+
openPanel: '',
|
|
1615
|
+
propertySearch: '',
|
|
1616
|
+
rawFilterText: '',
|
|
1617
|
+
},
|
|
1618
|
+
};
|
|
384
1619
|
entry.payload = payload;
|
|
385
1620
|
entry.placeholder = placeholder;
|
|
386
1621
|
this.entries.set(key, entry);
|
|
@@ -400,9 +1635,17 @@ export class BasesPreviewController {
|
|
|
400
1635
|
view: '',
|
|
401
1636
|
},
|
|
402
1637
|
placeholder: renderHost,
|
|
1638
|
+
propertyValueOptions: new Map(),
|
|
403
1639
|
requestVersion: 0,
|
|
404
1640
|
result: null,
|
|
405
1641
|
search: '',
|
|
1642
|
+
ui: {
|
|
1643
|
+
builderFilter: null,
|
|
1644
|
+
filterMode: 'builder',
|
|
1645
|
+
openPanel: '',
|
|
1646
|
+
propertySearch: '',
|
|
1647
|
+
rawFilterText: '',
|
|
1648
|
+
},
|
|
406
1649
|
};
|
|
407
1650
|
entry.placeholder = renderHost;
|
|
408
1651
|
entry.payload.path = filePath;
|
|
@@ -413,6 +1656,7 @@ export class BasesPreviewController {
|
|
|
413
1656
|
}
|
|
414
1657
|
|
|
415
1658
|
async renderEntry(entry) {
|
|
1659
|
+
this.ensureEntryState(entry);
|
|
416
1660
|
if (!entry?.placeholder?.isConnected) {
|
|
417
1661
|
return;
|
|
418
1662
|
}
|
|
@@ -432,6 +1676,10 @@ export class BasesPreviewController {
|
|
|
432
1676
|
return;
|
|
433
1677
|
}
|
|
434
1678
|
entry.result = response.result;
|
|
1679
|
+
entry.ui.builderFilter = parseFilterNode(getEditableViewConfig(entry.result).filters);
|
|
1680
|
+
if (!entry.ui.rawFilterText) {
|
|
1681
|
+
entry.ui.rawFilterText = serializeRawFilterText(getEditableViewConfig(entry.result).filters);
|
|
1682
|
+
}
|
|
435
1683
|
updateShellContent(entry, entry.result);
|
|
436
1684
|
} catch (error) {
|
|
437
1685
|
if (requestVersion !== entry.requestVersion || !entry?.placeholder?.isConnected) {
|
|
@@ -450,6 +1698,7 @@ export class BasesPreviewController {
|
|
|
450
1698
|
}
|
|
451
1699
|
|
|
452
1700
|
async exportEntry(entry) {
|
|
1701
|
+
this.ensureEntryState(entry);
|
|
453
1702
|
try {
|
|
454
1703
|
const response = await this.vaultApiClient.exportBaseCsv({
|
|
455
1704
|
activeFilePath: this.getActiveFilePath?.() ?? '',
|
|
@@ -460,4 +1709,115 @@ export class BasesPreviewController {
|
|
|
460
1709
|
this.toastController?.show?.(error.message || 'Failed to export base CSV');
|
|
461
1710
|
}
|
|
462
1711
|
}
|
|
1712
|
+
|
|
1713
|
+
async ensurePropertyValues(entry, propertyId = '') {
|
|
1714
|
+
this.ensureEntryState(entry);
|
|
1715
|
+
if (!propertyId) {
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
const cacheKey = createPropertyValuesCacheKey(entry, propertyId);
|
|
1720
|
+
const cachedEntry = entry.propertyValueOptions.get(propertyId) ?? null;
|
|
1721
|
+
if (cachedEntry?.cacheKey === cacheKey) {
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
try {
|
|
1726
|
+
const response = await this.vaultApiClient.queryBasePropertyValues({
|
|
1727
|
+
activeFilePath: this.getActiveFilePath?.() ?? '',
|
|
1728
|
+
...entry.payload,
|
|
1729
|
+
propertyId,
|
|
1730
|
+
});
|
|
1731
|
+
entry.propertyValueOptions.set(propertyId, {
|
|
1732
|
+
cacheKey,
|
|
1733
|
+
values: response.result?.values ?? [],
|
|
1734
|
+
});
|
|
1735
|
+
if (entry.result && entry.ui.openPanel === 'filter') {
|
|
1736
|
+
let synced = false;
|
|
1737
|
+
forEachFilterRule(getBuilderFilterState(entry, entry.result), (rule, path) => {
|
|
1738
|
+
if (rule.propertyId === propertyId) {
|
|
1739
|
+
synced = syncFilterValueCombobox(entry, entry.result, path, { fallbackToFullRender: false }) || synced;
|
|
1740
|
+
}
|
|
1741
|
+
});
|
|
1742
|
+
if (!synced) {
|
|
1743
|
+
updateShellContent(entry, entry.result);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
} catch {
|
|
1747
|
+
entry.propertyValueOptions.set(propertyId, {
|
|
1748
|
+
cacheKey,
|
|
1749
|
+
values: [],
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
async applyTransformedSource(entry, transformed) {
|
|
1755
|
+
this.ensureEntryState(entry);
|
|
1756
|
+
if (isStandaloneBaseEntry(entry)) {
|
|
1757
|
+
entry.payload.source = transformed.source;
|
|
1758
|
+
this.replaceBaseSource?.({
|
|
1759
|
+
path: entry.payload.path,
|
|
1760
|
+
source: transformed.source,
|
|
1761
|
+
});
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
await this.vaultApiClient.writeFile({
|
|
1766
|
+
content: transformed.source,
|
|
1767
|
+
path: entry.payload.path,
|
|
1768
|
+
});
|
|
1769
|
+
entry.payload.source = null;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
async updateViewConfig(entry, updater) {
|
|
1773
|
+
this.ensureEntryState(entry);
|
|
1774
|
+
if (!entry?.result || !getMeta(entry.result).editable) {
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
const nextConfig = updater(getEditableViewConfig(entry.result));
|
|
1779
|
+
try {
|
|
1780
|
+
const response = await this.vaultApiClient.transformBase({
|
|
1781
|
+
activeFilePath: this.getActiveFilePath?.() ?? '',
|
|
1782
|
+
mutation: {
|
|
1783
|
+
config: nextConfig,
|
|
1784
|
+
type: 'set-view-config',
|
|
1785
|
+
view: entry.result.view.id,
|
|
1786
|
+
},
|
|
1787
|
+
...entry.payload,
|
|
1788
|
+
});
|
|
1789
|
+
const transformed = response.result;
|
|
1790
|
+
await this.applyTransformedSource(entry, transformed);
|
|
1791
|
+
entry.result = transformed.result;
|
|
1792
|
+
entry.ui.rawFilterText = serializeRawFilterText(getEditableViewConfig(entry.result).filters);
|
|
1793
|
+
updateShellContent(entry, entry.result);
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
this.toastController?.show?.(error.message || 'Failed to update base');
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
async updateBuilderFilter(entry, updater) {
|
|
1800
|
+
this.ensureEntryState(entry);
|
|
1801
|
+
const parsed = entry.ui.builderFilter ?? parseFilterNode(getEditableViewConfig(entry.result).filters) ?? createEmptyFilterGroup('and');
|
|
1802
|
+
const nextGroup = updater(parsed);
|
|
1803
|
+
entry.ui.builderFilter = nextGroup;
|
|
1804
|
+
await this.updateViewConfig(entry, (config) => {
|
|
1805
|
+
config.filters = compileFilterGroup(nextGroup, entry.result);
|
|
1806
|
+
return config;
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
async saveAdvancedFilter(entry) {
|
|
1811
|
+
this.ensureEntryState(entry);
|
|
1812
|
+
try {
|
|
1813
|
+
const parsed = parseRawFilterText(entry.ui.rawFilterText ?? '');
|
|
1814
|
+
entry.ui.builderFilter = parseFilterNode(parsed);
|
|
1815
|
+
await this.updateViewConfig(entry, (config) => {
|
|
1816
|
+
config.filters = parsed;
|
|
1817
|
+
return config;
|
|
1818
|
+
});
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
this.toastController?.show?.(error.message || 'Invalid advanced filter');
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
463
1823
|
}
|