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() {