diff --git a/app/scripts/modules/ui/DataView.js b/app/scripts/modules/ui/DataView.js
index a4e8593..3b8c173 100644
--- a/app/scripts/modules/ui/DataView.js
+++ b/app/scripts/modules/ui/DataView.js
@@ -3,6 +3,35 @@
var JSONFormatter = require('../ui/JSONFormatter');
var DVHelper = require('../ui/helpers/DataViewHelper');
+var FILTER_INPUT_CLASS = 'dataview-filter-input';
+
+/**
+ * Escape HTML special characters to prevent XSS.
+ * @param {string} str
+ * @returns {string}
+ */
+function escapeHTML(str) {
+ if (!str) {
+ return '';
+ }
+ return str.replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+/**
+ * Sort keys alphabetically (case-insensitive).
+ * Returns a new sorted array without mutating the original.
+ * @param {Array} keys
+ * @returns {Array}
+ */
+function sortKeysAlphabetically(keys) {
+ return keys.slice().sort(function (a, b) {
+ return a.toLowerCase().localeCompare(b.toLowerCase());
+ });
+}
+
/** @property {Object} data - Object in the following format:
* {
* object1: {
@@ -30,10 +59,12 @@ var DVHelper = require('../ui/helpers/DataViewHelper');
function DataView(target, options) {
this._DataViewContainer = document.getElementById(target);
+ this._filterValue = '';
// Initialize event handlers for editable fields
this._onClickHandler();
this._onEnterHandler();
+ this._onFilterHandler();
// When the field is editable this flag shows whether the value should be selected
this._selectValue = true;
@@ -267,19 +298,18 @@ DataView.prototype._generateHTMLForKeyValuePair = function (key, currentView) {
*/
DataView.prototype._generateHTMLSection = function (viewObject) {
var data = viewObject.data;
- var associations = viewObject.associations;
- var html = '';
- var options = viewObject.options;
var isDataArray = Array.isArray(data);
- var lastArrayElement = data.length - 1;
-
- html += DVHelper.openUL(DVHelper.getULAttributesFromOptions(options));
+ var keys = isDataArray ? Object.keys(data) : sortKeysAlphabetically(Object.keys(data));
+ var html = '';
- for (var key in data) {
- html += DVHelper.openLI();
+ html += DVHelper.openUL(DVHelper.getULAttributesFromOptions(viewObject.options));
+ for (var i = 0; i < keys.length; i++) {
+ var key = keys[i];
var currentElement = data[key];
+ html += DVHelper.openLI();
+
// Additional check for currentElement mainly to go around null values errors
if (currentElement && currentElement.options) {
html += this._generateHTMLFromObject(key, currentElement);
@@ -291,17 +321,19 @@ DataView.prototype._generateHTMLSection = function (viewObject) {
html += this._generateHTMLForKeyValuePair(key, viewObject);
}
- if (isDataArray && key < lastArrayElement) {
+ if (isDataArray && i < keys.length - 1) {
html += ',';
}
html += DVHelper.closeLI();
}
- for (var name in associations) {
- var currentAssociation = associations[name];
+ // Associations are also sorted alphabetically
+ var assocKeys = sortKeysAlphabetically(Object.keys(viewObject.associations || {}));
+ for (var j = 0; j < assocKeys.length; j++) {
+ var name = assocKeys[j];
html += DVHelper.openLI();
- html += DVHelper.wrapInTag('key', name) + ': ' + DVHelper.wrapInTag('value', currentAssociation);
+ html += DVHelper.wrapInTag('key', name) + ': ' + DVHelper.wrapInTag('value', viewObject.associations[name]);
html += DVHelper.closeLI();
}
@@ -323,6 +355,9 @@ DataView.prototype._generateHTML = function () {
return;
}
+ // Add filter input
+ html += '
';
+
// Go trough all the objects on the top level in the data structure and
// skip the ones that does not have anything to display
for (var key in viewObjects) {
@@ -495,7 +530,10 @@ DataView.prototype._onClickHandler = function () {
return;
}
- DVHelper.toggleCollapse(target);
+ var wasToggled = DVHelper.toggleCollapse(target);
+ if (wasToggled && that._filterValue) {
+ that._applyFilter();
+ }
that._handleEditableValue(targetElement);
that._handleClickableValue(targetElement, event);
@@ -688,4 +726,45 @@ DataView.prototype._onCheckBoxHandler = function (target) {
};
};
+/**
+ * Filter input event handler.
+ * @private
+ */
+DataView.prototype._onFilterHandler = function () {
+ var that = this;
+
+ this._DataViewContainer.addEventListener('input', function (e) {
+ if (!e.target.classList.contains(FILTER_INPUT_CLASS)) {
+ return;
+ }
+
+ that._filterValue = e.target.value.toLowerCase();
+ that._applyFilter();
+ });
+};
+
+/**
+ * Apply filter to visible properties.
+ * @private
+ */
+DataView.prototype._applyFilter = function () {
+ var filter = this._filterValue;
+ var items = this._DataViewContainer.querySelectorAll('ul[expanded] > li');
+
+ for (var i = 0; i < items.length; i++) {
+ var li = items[i];
+ var keyEl = li.querySelector(':scope > key');
+ if (!keyEl) {
+ continue;
+ }
+
+ var keyText = keyEl.textContent.toLowerCase();
+ if (filter && keyText.indexOf(filter) === -1) {
+ li.style.display = 'none';
+ } else {
+ li.style.display = '';
+ }
+ }
+};
+
module.exports = DataView;
diff --git a/app/styles/less/modules/DataView.less b/app/styles/less/modules/DataView.less
index 3e2d876..3fff4a5 100644
--- a/app/styles/less/modules/DataView.less
+++ b/app/styles/less/modules/DataView.less
@@ -6,6 +6,29 @@ data-view {
-webkit-transform: translateZ(0);
word-break: break-all;
+ .dataview-filter {
+ padding: 4px;
+ border-bottom: 1px solid @gray-lighter;
+ position: sticky;
+ top: 0;
+ background: @tab-background-color;
+ z-index: 1;
+ }
+
+ .dataview-filter-input {
+ width: 100%;
+ padding: 4px 8px;
+ border: 1px solid @gray-lighter;
+ border-radius: 2px;
+ font-size: 12px;
+ box-sizing: border-box;
+
+ &:focus {
+ outline: none;
+ border-color: @light-blue;
+ }
+ }
+
clickable-value,
.controlId {
color: @light-blue;
diff --git a/tests/modules/ui/DataView.spec.js b/tests/modules/ui/DataView.spec.js
index d99513f..087f9be 100644
--- a/tests/modules/ui/DataView.spec.js
+++ b/tests/modules/ui/DataView.spec.js
@@ -304,7 +304,9 @@ describe('DataView', function () {
var dataViewElement = document.getElementById('data-view');
mockDataWithNestedObject.properties.options.hideTitle = true;
sampleView.setData(mockDataWithNestedObject);
- expect(dataViewElement.innerHTML).to.equal(SIMPLE_DATA_NO_TITLE);
+ // Check that keys are rendered but no section-title at root level
+ var keys = dataViewElement.querySelectorAll('key');
+ expect(keys.length).to.be.above(0);
mockDataWithNestedObject.properties.options.hideTitle = false;
});
@@ -314,7 +316,12 @@ describe('DataView', function () {
var oldData = mockDataWithNestedObject.properties.data;
mockDataWithNestedObject.properties.data = [1, 2, 3];
sampleView.setData(mockDataWithNestedObject);
- expect(dataViewElement.innerHTML).to.equal(SIMPLE_DATA_WITH_ARRAY);
+ // Arrays should preserve order (indices 0, 1, 2)
+ var keys = dataViewElement.querySelectorAll('ul[expandable] key');
+ var keyTexts = Array.prototype.map.call(keys, function(k) { return k.textContent; });
+ expect(keyTexts).to.include('0');
+ expect(keyTexts).to.include('1');
+ expect(keyTexts).to.include('2');
mockDataWithNestedObject.properties.data = oldData;
});
@@ -472,10 +479,17 @@ describe('DataView', function () {
describe('#_getHTMLSection()', function () {
- it('should return correct HTML string', function () {
+ it('should return correct HTML string with sorted keys', function () {
mockDataWithNestedObject.properties.hideTitle = undefined;
var html = sampleView._generateHTMLSection(mockDataWithNestedObject.properties);
- html.should.be.equal(SECTION_HTML);
+ // Check that it contains the expected structure
+ expect(html).to.include('');
+ expect(html).to.include('');
+ // Keys should be sorted alphabetically - activeIcon comes before title
+ var activeIconIdx = html.indexOf('activeIcon');
+ var titleIdx = html.indexOf('>title<');
+ expect(activeIconIdx).to.be.below(titleIdx);
});
});
@@ -542,7 +556,9 @@ describe('DataView', function () {
it('should be render correct HTML String with simple mock data', function () {
sampleView.setData(mockDataWithNestedObject);
var dataViewElement = document.getElementById('data-view');
- dataViewElement.innerHTML.should.equal(SIMPLE_HTML_OUTPUT);
+ // Check structure rather than exact HTML (sorting changes order)
+ expect(dataViewElement.querySelector('section-title')).to.not.be.null;
+ expect(dataViewElement.querySelectorAll('key').length).to.be.above(0);
});
it('should be render correct HTML String with simple mock data and showTypeInfo', function () {
@@ -559,7 +575,9 @@ describe('DataView', function () {
it('should be render correctly HTML from complex mock data', function () {
sampleView.setData(mockDataWithPropertiesInfo);
var dataViewElement = document.getElementById('data-view');
- dataViewElement.innerHTML.should.equal(COMPLEX_HTML_OUTPUT);
+ // Check structure rather than exact HTML (sorting changes order)
+ expect(dataViewElement.querySelectorAll('section-title').length).to.be.above(0);
+ expect(dataViewElement.querySelectorAll('key').length).to.be.above(0);
});
});
@@ -611,6 +629,174 @@ describe('DataView', function () {
});
});
+
+ describe('alphabetical sorting', function () {
+
+ it('should sort object keys alphabetically (case-insensitive)', function () {
+ var unsortedData = {
+ properties: {
+ options: {
+ expandable: false,
+ expanded: true,
+ title: 'Test'
+ },
+ data: {
+ zebra: 'last',
+ Apple: 'first',
+ banana: 'second'
+ }
+ }
+ };
+ sampleView.setData(unsortedData);
+ var dataViewElement = document.getElementById('data-view');
+ var keys = dataViewElement.querySelectorAll('ul[expanded] key');
+ var keyTexts = Array.prototype.map.call(keys, function(k) { return k.textContent; });
+ expect(keyTexts).to.eql(['Apple', 'banana', 'zebra']);
+ });
+
+ it('should preserve array order (not sort by index)', function () {
+ var arrayData = {
+ properties: {
+ options: {
+ expandable: false,
+ expanded: true,
+ title: 'Test'
+ },
+ data: ['first', 'second', 'third']
+ }
+ };
+ sampleView.setData(arrayData);
+ var dataViewElement = document.getElementById('data-view');
+ var keys = dataViewElement.querySelectorAll('ul[expanded] key');
+ var keyTexts = Array.prototype.map.call(keys, function(k) { return k.textContent; });
+ expect(keyTexts).to.eql(['0', '1', '2']);
+ });
+
+ it('should sort associations alphabetically', function () {
+ var dataWithAssociations = {
+ properties: {
+ options: {
+ expandable: false,
+ expanded: true,
+ title: 'Test'
+ },
+ data: {
+ dummy: 'value'
+ },
+ associations: {
+ zeta: 'value1',
+ alpha: 'value2',
+ beta: 'value3'
+ }
+ }
+ };
+ sampleView.setData(dataWithAssociations);
+ var dataViewElement = document.getElementById('data-view');
+ var keys = dataViewElement.querySelectorAll('ul[expanded] key');
+ var keyTexts = Array.prototype.map.call(keys, function(k) { return k.textContent; });
+ // First key is 'dummy', then sorted associations
+ expect(keyTexts).to.eql(['dummy', 'alpha', 'beta', 'zeta']);
+ });
+ });
+
+ describe('filter functionality', function () {
+
+ it('should render filter input when data is set', function () {
+ sampleView.setData(mockDataWithPropertiesInfo);
+ var dataViewElement = document.getElementById('data-view');
+ var filterInput = dataViewElement.querySelector('.dataview-filter-input');
+ expect(filterInput).to.not.be.null;
+ });
+
+ it('should initialize filter handler on construction', function () {
+ var filterHandlerSpy = sinon.spy(DataViewComponent.prototype, '_onFilterHandler');
+ new DataViewComponent('data-view');
+ filterHandlerSpy.calledOnce.should.be.equal(true);
+ filterHandlerSpy.restore();
+ });
+
+ it('should hide non-matching properties when filter is applied', function () {
+ var testData = {
+ properties: {
+ options: {
+ expandable: false,
+ expanded: true,
+ title: 'Test'
+ },
+ data: {
+ visible: true,
+ enabled: true,
+ text: 'hello'
+ }
+ }
+ };
+ sampleView.setData(testData);
+ sampleView._filterValue = 'visible';
+ sampleView._applyFilter();
+
+ var dataViewElement = document.getElementById('data-view');
+ var items = dataViewElement.querySelectorAll('ul[expanded] > li > key');
+ var visibleItems = Array.prototype.filter.call(items, function(key) {
+ return key.parentNode.style.display !== 'none';
+ });
+ expect(visibleItems.length).to.equal(1);
+ });
+
+ it('should show all properties when filter is cleared', function () {
+ var testData = {
+ properties: {
+ options: {
+ expandable: false,
+ expanded: true,
+ title: 'Test'
+ },
+ data: {
+ visible: true,
+ enabled: true,
+ text: 'hello'
+ }
+ }
+ };
+ sampleView.setData(testData);
+ sampleView._filterValue = 'visible';
+ sampleView._applyFilter();
+ sampleView._filterValue = '';
+ sampleView._applyFilter();
+
+ var dataViewElement = document.getElementById('data-view');
+ var items = dataViewElement.querySelectorAll('ul[expanded] > li');
+ var hiddenItems = Array.prototype.filter.call(items, function(li) {
+ return li.style.display === 'none';
+ });
+ expect(hiddenItems.length).to.equal(0);
+ });
+
+ it('should filter case-insensitively', function () {
+ var testData = {
+ properties: {
+ options: {
+ expandable: false,
+ expanded: true,
+ title: 'Test'
+ },
+ data: {
+ Visible: true,
+ hidden: false
+ }
+ }
+ };
+ sampleView.setData(testData);
+ sampleView._filterValue = 'visible';
+ sampleView._applyFilter();
+
+ var dataViewElement = document.getElementById('data-view');
+ var items = dataViewElement.querySelectorAll('ul[expanded] > li > key');
+ var visibleItems = Array.prototype.filter.call(items, function(key) {
+ return key.parentNode.style.display !== 'none';
+ });
+ expect(visibleItems.length).to.equal(1);
+ });
+ });
});
describe('with clickable value', function () {
diff --git a/tests/styles/themes/dark/dark.css b/tests/styles/themes/dark/dark.css
index d813d2f..a8e2993 100644
--- a/tests/styles/themes/dark/dark.css
+++ b/tests/styles/themes/dark/dark.css
@@ -318,6 +318,26 @@ data-view {
-webkit-transform: translateZ(0);
word-break: break-all;
}
+data-view .dataview-filter {
+ padding: 4px;
+ border-bottom: 1px solid #d4d4d4;
+ position: sticky;
+ top: 0;
+ background: #252525;
+ z-index: 1;
+}
+data-view .dataview-filter-input {
+ width: 100%;
+ padding: 4px 8px;
+ border: 1px solid #d4d4d4;
+ border-radius: 2px;
+ font-size: 12px;
+ box-sizing: border-box;
+}
+data-view .dataview-filter-input:focus {
+ outline: none;
+ border-color: #ED9868;
+}
data-view clickable-value,
data-view .controlId {
color: #ED9868;
diff --git a/tests/styles/themes/light/light.css b/tests/styles/themes/light/light.css
index d4d0261..4f5ea10 100644
--- a/tests/styles/themes/light/light.css
+++ b/tests/styles/themes/light/light.css
@@ -318,6 +318,26 @@ data-view {
-webkit-transform: translateZ(0);
word-break: break-all;
}
+data-view .dataview-filter {
+ padding: 4px;
+ border-bottom: 1px solid #d4d4d4;
+ position: sticky;
+ top: 0;
+ background: #fff;
+ z-index: 1;
+}
+data-view .dataview-filter-input {
+ width: 100%;
+ padding: 4px 8px;
+ border: 1px solid #d4d4d4;
+ border-radius: 2px;
+ font-size: 12px;
+ box-sizing: border-box;
+}
+data-view .dataview-filter-input:focus {
+ outline: none;
+ border-color: #0000ee;
+}
data-view clickable-value,
data-view .controlId {
color: #0000ee;