Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions frontend/src/array-utils.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
};
88 changes: 74 additions & 14 deletions frontend/src/detail-array/detail-array.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,83 @@
Empty array
</div>

<div v-else class="mt-2">
<div v-else>
<div class="mb-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<div class="relative min-w-0 flex-1">
<input
v-model="arraySearchQuery"
type="search"
autocomplete="off"
aria-label="Search in this array"
placeholder="Search in this array…"
class="w-full rounded-md border border-edge bg-surface py-1.5 pl-2 pr-8 text-xs text-content placeholder:text-content-tertiary focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
<button
v-if="arraySearchNormalized"
type="button"
class="absolute right-1 top-1/2 -translate-y-1/2 rounded px-1.5 py-0.5 text-[11px] text-content-tertiary hover:bg-slate-100 hover:text-content"
title="Clear search"
@click="arraySearchQuery = ''">
Clear
</button>
</div>
<span v-if="arraySearchNormalized" class="shrink-0 text-xs text-content-tertiary">
{{ filteredArrayRows.length }} of {{ arrayValue.length }} shown
</span>
</div>

<div
v-for="(item, index) in arrayValue"
:key="index"
:title="'Index: ' + index"
class="mb-1.5 last:mb-0 py-2.5 px-3 pl-4 bg-transparent border-l-[3px] border-l-blue-500 rounded-none transition-all duration-200 cursor-pointer relative hover:bg-slate-50 hover:border-l-blue-600 hover:shadow-lg hover:-translate-y-0.5">
<div class="absolute -left-3 top-1/2 -translate-y-1/2 w-5 h-5 bg-blue-500 text-white rounded-full flex items-center justify-center text-[10px] font-semibold font-mono z-10 hover:bg-blue-600">{{ index >= 1000 ? '1k+' : index }}</div>
<div v-if="arrayUtils.isObjectItem(item)" class="flex flex-col gap-1 mt-1 px-2">
<div
v-for="key in arrayUtils.getItemKeys(item)"
:key="key"
class="flex items-start gap-2 text-xs font-mono">
<span class="font-semibold text-gray-600 flex-shrink-0 min-w-[80px]">{{ key }}:</span>
<span class="text-gray-800 break-words whitespace-pre-wrap flex-1">{{ arrayUtils.formatItemValue(item, key) }}</span>
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() }}".
</div>

<div v-else-if="showAsTable" class="mt-2 overflow-x-auto rounded-md border border-edge">
<table class="min-w-full text-left text-xs font-mono text-content">
<thead class="bg-slate-100 text-content-secondary border-b border-edge">
<tr>
<th scope="col" class="sticky left-0 z-[1] bg-slate-100 px-2 py-2 font-semibold border-r border-edge w-10 text-center">#</th>
<th
v-for="key in tableColumnKeys"
:key="key"
scope="col"
class="px-3 py-2 font-semibold whitespace-nowrap border-r border-edge last:border-r-0">
{{ key }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-edge bg-surface">
<tr v-for="row in filteredArrayRows" :key="row.index" class="hover:bg-slate-50/80">
<td class="sticky left-0 z-[1] bg-inherit px-2 py-2 text-center text-content-tertiary border-r border-edge">{{ row.index }}</td>
<td
v-for="key in tableColumnKeys"
:key="key"
class="px-3 py-2 align-top break-words whitespace-pre-wrap max-w-md border-r border-edge last:border-r-0">
{{ Object.prototype.hasOwnProperty.call(row.item, key) ? arrayUtils.formatItemValue(row.item, key) : '' }}
</td>
</tr>
</tbody>
</table>
</div>

<div v-else class="mt-2">
<div
v-for="row in filteredArrayRows"
:key="row.index"
:title="'Index: ' + row.index"
class="mb-1.5 last:mb-0 py-2.5 px-3 pl-4 bg-transparent border-l-[3px] border-l-blue-500 rounded-none transition-all duration-200 cursor-pointer relative hover:bg-slate-50 hover:border-l-blue-600 hover:shadow-lg hover:-translate-y-0.5">
<div class="absolute -left-3 top-1/2 -translate-y-1/2 w-5 h-5 bg-blue-500 text-white rounded-full flex items-center justify-center text-[10px] font-semibold font-mono z-10 hover:bg-blue-600">{{ row.index >= 1000 ? '1k+' : row.index }}</div>
<div v-if="arrayUtils.isObjectItem(row.item)" class="flex flex-col gap-1 mt-1 px-2">
<div
v-for="key in arrayUtils.getItemKeys(row.item)"
:key="key"
class="flex items-start gap-2 text-xs font-mono">
<span class="font-semibold text-gray-600 flex-shrink-0 min-w-[80px]">{{ key }}:</span>
<span class="text-gray-800 break-words whitespace-pre-wrap flex-1">{{ arrayUtils.formatItemValue(row.item, key) }}</span>
</div>
</div>
<div v-else class="text-xs py-1.5 px-2 font-mono text-gray-800 break-words whitespace-pre-wrap mt-1">{{ arrayUtils.formatValue(row.item) }}</div>
</div>
<div v-else class="text-xs py-1.5 px-2 font-mono text-gray-800 break-words whitespace-pre-wrap mt-1">{{ arrayUtils.formatValue(item) }}</div>
</div>
</div>
</div>
35 changes: 33 additions & 2 deletions frontend/src/detail-array/detail-array.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,24 @@
</svg>
<span class="font-medium text-content">{{path.path}}</span>
<span class="ml-2 text-sm text-content-tertiary">({{(path.instance || 'unknown').toLowerCase()}})</span>
<div v-if="path.instance === 'Array' && canUseArrayTableView" class="ml-3 inline-flex items-center rounded-full bg-gray-200 p-0.5 text-xs font-semibold">
<button
type="button"
class="rounded-full px-2.5 py-0.5 transition"
:class="arrayDetailViewMode === 'list' ? 'bg-blue-600 text-white shadow' : 'text-content-secondary hover:text-content'"
:style="arrayDetailViewMode === 'list' ? 'color: white !important; background-color: #2563eb !important;' : ''"
@click.stop="setArrayDetailViewMode('list')">
List
</button>
<button
type="button"
class="rounded-full px-2.5 py-0.5 transition"
:class="arrayDetailViewMode === 'table' ? 'bg-blue-600 text-white shadow' : 'text-content-secondary hover:text-content'"
:style="arrayDetailViewMode === 'table' ? 'color: white !important; background-color: #2563eb !important;' : ''"
@click.stop="setArrayDetailViewMode('table')">
Table
</button>
</div>
<div v-if="isGeoJsonGeometry" class="ml-3 inline-flex items-center gap-2">
<div class="inline-flex items-center rounded-full bg-gray-200 p-0.5 text-xs font-semibold">
<button
Expand Down Expand Up @@ -202,7 +220,7 @@
<component
:is="getComponentForPath(path)"
:value="rawValue"
:viewMode="isDatePath ? dateViewMode : detailViewMode"
:viewMode="propertyDetailViewMode"
@updated="renderedValue = $event"></component>
<button
@click="toggleValueExpansion"
Expand All @@ -219,7 +237,7 @@
<component
:is="getComponentForPath(path)"
:value="rawValue"
:viewMode="isDatePath ? dateViewMode : detailViewMode"
:viewMode="propertyDetailViewMode"
@updated="renderedValue = $event"></component>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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: {
Expand All @@ -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;

Expand Down Expand Up @@ -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;
},
Expand Down
Loading
Loading