diff --git a/frontend/src/array-utils.js b/frontend/src/array-utils.js index 3b17500b..e60ea16d 100644 --- a/frontend/src/array-utils.js +++ b/frontend/src/array-utils.js @@ -1,7 +1,6 @@ 'use strict'; const { inspect } = require('node-inspect-extracted'); -const deepEqual = require('./_util/deepEqual'); /** * Format a value for display in array views @@ -59,9 +58,68 @@ function formatItemValue(item, key) { return String(value); } +/** + * True when every element is a plain object (empty array counts as eligible). + */ +function isArrayOfObjects(arr) { + return Array.isArray(arr) && arr.every(item => isObjectItem(item)); +} + +/** + * Sorted union of keys across plain object elements. + */ +function unionKeysForArrayOfObjects(arr) { + if (!Array.isArray(arr)) { + return []; + } + const set = new Set(); + for (const item of arr) { + if (!isObjectItem(item)) { + continue; + } + for (const k of Object.keys(item)) { + set.add(k); + } + } + return Array.from(set).sort((a, b) => a.localeCompare(b)); +} + +/** + * Flat text for substring search over one array element (field names + values, or whole item). + * @param {*} item + * @returns {string} + */ +function searchTextForArrayItem(item) { + if (item == null) { + return String(item); + } + if (isObjectItem(item)) { + return getItemKeys(item) + .map(k => `${k} ${formatItemValue(item, k)}`) + .join(' \n '); + } + return formatValue(item); +} + +/** + * Case-insensitive substring match against an array element. + * @param {*} item + * @param {string} normalizedQuery lowercased trimmed query; empty matches all + */ +function arrayItemMatchesSearch(item, normalizedQuery) { + if (normalizedQuery == null || normalizedQuery === '') { + return true; + } + return searchTextForArrayItem(item).toLowerCase().includes(normalizedQuery); +} + module.exports = { formatValue, isObjectItem, getItemKeys, - formatItemValue + formatItemValue, + isArrayOfObjects, + unionKeysForArrayOfObjects, + searchTextForArrayItem, + arrayItemMatchesSearch }; diff --git a/frontend/src/detail-array/detail-array.html b/frontend/src/detail-array/detail-array.html index 400c2f3a..c2ff6e05 100644 --- a/frontend/src/detail-array/detail-array.html +++ b/frontend/src/detail-array/detail-array.html @@ -3,23 +3,83 @@ Empty array -
+
+
+
+ + +
+ + {{ filteredArrayRows.length }} of {{ arrayValue.length }} shown + +
+
-
{{ index >= 1000 ? '1k+' : index }}
-
-
- {{ key }}: - {{ arrayUtils.formatItemValue(item, key) }} + v-if="filteredArrayRows.length === 0" + class="rounded-md border border-dashed border-edge py-6 text-center text-xs text-content-tertiary"> + No items match "{{ arraySearchQuery.trim() }}". +
+ +
+ + + + + + + + + + + + + +
# + {{ key }} +
{{ row.index }} + {{ Object.prototype.hasOwnProperty.call(row.item, key) ? arrayUtils.formatItemValue(row.item, key) : '' }} +
+
+ +
+
+
{{ row.index >= 1000 ? '1k+' : row.index }}
+
+
+ {{ key }}: + {{ arrayUtils.formatItemValue(row.item, key) }} +
+
{{ arrayUtils.formatValue(row.item) }}
-
{{ arrayUtils.formatValue(item) }}
diff --git a/frontend/src/detail-array/detail-array.js b/frontend/src/detail-array/detail-array.js index 637fed6c..a6197eb7 100644 --- a/frontend/src/detail-array/detail-array.js +++ b/frontend/src/detail-array/detail-array.js @@ -1,15 +1,46 @@ 'use strict'; const template = require('./detail-array.html'); +const { isArrayOfObjects, unionKeysForArrayOfObjects, arrayItemMatchesSearch } = require('../array-utils'); module.exports = app => app.component('detail-array', { template: template, - props: ['value'], + props: { + value: {}, + /** 'list' | 'table' — table applies to arrays of plain objects */ + viewMode: { + type: String, + default: 'list' + } + }, data() { return { - arrayValue: [] + arrayValue: [], + arraySearchQuery: '' }; }, + computed: { + effectiveViewMode() { + return this.viewMode === 'table' ? 'table' : 'list'; + }, + showAsTable() { + return this.effectiveViewMode === 'table' && isArrayOfObjects(this.arrayValue); + }, + tableColumnKeys() { + return unionKeysForArrayOfObjects(this.arrayValue); + }, + arraySearchNormalized() { + return (this.arraySearchQuery || '').trim().toLowerCase(); + }, + filteredArrayRows() { + if (!Array.isArray(this.arrayValue)) { + return []; + } + return this.arrayValue + .map((item, index) => ({ item, index })) + .filter(({ item }) => arrayItemMatchesSearch(item, this.arraySearchNormalized)); + } + }, methods: { initializeArray() { if (this.value == null) { diff --git a/frontend/src/document-details/document-property/document-property.html b/frontend/src/document-details/document-property/document-property.html index 2295d1c5..62d30b3c 100644 --- a/frontend/src/document-details/document-property/document-property.html +++ b/frontend/src/document-details/document-property/document-property.html @@ -18,6 +18,24 @@ {{path.path}} ({{(path.instance || 'unknown').toLowerCase()}}) +
+ + +
diff --git a/frontend/src/document-details/document-property/document-property.js b/frontend/src/document-details/document-property/document-property.js index 71a47381..50a9bce3 100644 --- a/frontend/src/document-details/document-property/document-property.js +++ b/frontend/src/document-details/document-property/document-property.js @@ -11,6 +11,7 @@ const appendCSS = require('../../appendCSS'); appendCSS(require('./document-property.css')); const UNSET = Symbol('unset'); +const { isArrayOfObjects } = require('../../array-utils'); module.exports = app => app.component('document-property', { template, @@ -21,6 +22,8 @@ module.exports = app => app.component('document-property', { renderedValue: UNSET, isCollapsed: false, // Start uncollapsed by default isValueExpanded: false, // Track if the value is expanded + /** 'list' | 'table' for array-of-objects display and editing */ + arrayDetailViewMode: 'list', detailViewMode: 'text', copyButtonLabel: 'Copy', copyResetTimeoutId: null, @@ -124,6 +127,28 @@ module.exports = app => app.component('document-property', { isMultiPolygon() { const value = this.getValueForPath(this.path.path); return this.isGeoJsonGeometry && value.type === 'MultiPolygon'; + }, + arrayPathCurrentValue() { + if (!this.path || this.path.instance !== 'Array') { + return null; + } + if (this.editting) { + return this.getEditValueForPath(this.path); + } + return this.getValueForPath(this.path.path); + }, + canUseArrayTableView() { + const v = this.arrayPathCurrentValue; + return Array.isArray(v) && isArrayOfObjects(v); + }, + propertyDetailViewMode() { + if (this.isDatePath) { + return this.dateViewMode; + } + if (this.path?.instance === 'Array') { + return this.arrayDetailViewMode; + } + return this.detailViewMode; } }, watch: { @@ -140,9 +165,28 @@ module.exports = app => app.component('document-property', { if (newValue && this.isGeoJsonGeometry) { this.detailViewMode = 'map'; } + }, + canUseArrayTableView(can) { + if (!can && this.arrayDetailViewMode === 'table') { + this.arrayDetailViewMode = 'list'; + } + }, + 'path.path'() { + this.arrayDetailViewMode = 'list'; } }, methods: { + setArrayDetailViewMode(mode) { + this.arrayDetailViewMode = mode; + if (mode === 'table') { + if (this.needsTruncation && !this.isValueExpanded) { + this.isValueExpanded = true; + } + if (this.isCollapsed) { + this.isCollapsed = false; + } + } + }, setDetailViewMode(mode) { this.detailViewMode = mode; @@ -213,6 +257,7 @@ module.exports = app => app.component('document-property', { if (path.instance === 'Array') { props.path = path; props.schemaPaths = this.schemaPaths; + props.viewMode = this.arrayDetailViewMode === 'table' ? 'table' : 'json'; } return props; }, diff --git a/frontend/src/edit-array/edit-array.html b/frontend/src/edit-array/edit-array.html index 30ead65f..bde27fde 100644 --- a/frontend/src/edit-array/edit-array.html +++ b/frontend/src/edit-array/edit-array.html @@ -1,9 +1,144 @@
- -
\ No newline at end of file +
+ +
+
+
+
+
+ +
+ + +
+
+

+ Each cell is JSON. Clear a cell to remove that field from the row. +

+
+

{{ newColumnKeyError }}

+
+ +
+
+
+ + +
+ + {{ filteredTableRows.length }} of {{ arrayValue.length }} shown + +
+
+ +
+ No rows yet. Use + Row or enter a field name and choose Add field. +
+ +
+ No rows match "{{ arraySearchQuery.trim() }}". +
+ +
+ + + + + + + + + + + + + +
+ Actions + + {{ key }} +
+
+ #{{ entry.index }} + + + +
+
+ +
+
+
+
diff --git a/frontend/src/edit-array/edit-array.js b/frontend/src/edit-array/edit-array.js index ac5388b9..7ae1c485 100644 --- a/frontend/src/edit-array/edit-array.js +++ b/frontend/src/edit-array/edit-array.js @@ -2,6 +2,7 @@ const template = require('./edit-array.html'); const { BSON } = require('mongodb/lib/bson'); +const { isArrayOfObjects, unionKeysForArrayOfObjects, arrayItemMatchesSearch } = require('../array-utils'); const ObjectId = new Proxy(BSON.ObjectId, { apply(target, thisArg, argumentsList) { @@ -12,15 +13,44 @@ const ObjectId = new Proxy(BSON.ObjectId, { module.exports = app => app.component('edit-array', { template: template, - props: ['value'], + props: { + value: {}, + path: {}, + schemaPaths: {}, + /** 'json' (ACE) or 'table' for arrays of plain objects */ + viewMode: { + type: String, + default: 'json' + } + }, data() { return { - arrayValue: [] + arrayValue: [], + newColumnKeyInput: '', + newColumnKeyError: '', + arraySearchQuery: '' }; }, computed: { arrayStr() { return JSON.stringify(this.arrayValue, null, 2); + }, + useTableEditor() { + return this.viewMode === 'table' && isArrayOfObjects(this.arrayValue); + }, + tableColumnKeys() { + return unionKeysForArrayOfObjects(this.arrayValue); + }, + arraySearchNormalized() { + return (this.arraySearchQuery || '').trim().toLowerCase(); + }, + filteredTableRows() { + if (!Array.isArray(this.arrayValue)) { + return []; + } + return this.arrayValue + .map((item, index) => ({ item, index })) + .filter(({ item }) => arrayItemMatchesSearch(item, this.arraySearchNormalized)); } }, methods: { @@ -51,6 +81,78 @@ module.exports = app => app.component('edit-array', { } catch (err) { this.$emit('error', err); } + }, + serializeCell(value) { + if (value === undefined) { + return ''; + } + return JSON.stringify(value); + }, + onCellChange(rowIndex, key, raw) { + const row = this.arrayValue[rowIndex]; + if (row == null || typeof row !== 'object') { + return; + } + const trimmed = raw.trim(); + if (trimmed === '') { + delete row[key]; + this.emitUpdate(); + return; + } + try { + row[key] = JSON.parse(trimmed); + this.emitUpdate(); + } catch (err) { + this.$emit('error', err); + } + }, + addRow() { + const row = {}; + for (const k of this.tableColumnKeys) { + row[k] = null; + } + this.arrayValue.push(row); + this.emitUpdate(); + }, + addColumn() { + const key = (this.newColumnKeyInput || '').trim(); + this.newColumnKeyError = ''; + if (!key) { + this.newColumnKeyError = 'Enter a column name.'; + return; + } + if (this.tableColumnKeys.includes(key)) { + this.newColumnKeyError = 'That column already exists.'; + return; + } + if (!Array.isArray(this.arrayValue)) { + this.arrayValue = []; + } + if (this.arrayValue.length === 0) { + this.arrayValue.push({ [key]: null }); + } else { + for (const row of this.arrayValue) { + if (row != null && typeof row === 'object' && !Array.isArray(row) && row.constructor === Object) { + row[key] = null; + } + } + } + this.newColumnKeyInput = ''; + this.emitUpdate(); + }, + removeRow(index) { + this.arrayValue.splice(index, 1); + this.emitUpdate(); + }, + moveRow(index, delta) { + const j = index + delta; + if (j < 0 || j >= this.arrayValue.length) { + return; + } + const copy = this.arrayValue[index]; + this.arrayValue.splice(index, 1); + this.arrayValue.splice(j, 0, copy); + this.emitUpdate(); } }, mounted() {