diff --git a/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/mapping/association/DataAssociationManagementView.js b/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/mapping/association/DataAssociationManagementView.js index 356daf94a8..95f9adf814 100644 --- a/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/mapping/association/DataAssociationManagementView.js +++ b/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/mapping/association/DataAssociationManagementView.js @@ -70,8 +70,15 @@ define([ this.mapping = this.getCurrentMapping(); this.mappingSync = this.getSyncNow(); this.data.numRepresentativeProps = this.getNumRepresentativeProps(); - this.data.sourceProps = _.pluck(this.mapping.properties,"source").slice(0,this.data.numRepresentativeProps); - this.data.targetProps = _.pluck(this.mapping.properties,"target").slice(0,this.data.numRepresentativeProps); + // Only keep properties that actually define a "source"/"target"; a leading + // undefined value would break the sample search query. See discussion #186. + // Use native Array filter/slice (lodash 3 _.first ignores the count arg). + this.data.sourceProps = _.pluck(this.mapping.properties, "source") + .filter(function(source){ return !!source; }) + .slice(0, this.data.numRepresentativeProps); + this.data.targetProps = _.pluck(this.mapping.properties, "target") + .filter(function(target){ return !!target; }) + .slice(0, this.data.numRepresentativeProps); this.data.hideSingleRecordReconButton = mappingUtils.readOnlySituationalPolicy(this.mapping.policies); this.data.reconAvailable = false; diff --git a/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/mapping/behaviors/SingleRecordReconciliationView.js b/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/mapping/behaviors/SingleRecordReconciliationView.js index b0845ad316..f40fc286a4 100644 --- a/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/mapping/behaviors/SingleRecordReconciliationView.js +++ b/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/mapping/behaviors/SingleRecordReconciliationView.js @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2015-2016 ForgeRock AS. + * Portions copyright 2026 3A Systems, LLC. */ define([ @@ -79,7 +80,13 @@ define([ }, setupSearch: function(){ - var autocompleteProps = _.pluck(this.data.mapping.properties,"source").slice(0,this.getNumRepresentativeProps()); + // Only keep properties that actually define a "source"; otherwise the + // first representative property may be undefined and break the sample + // search query (empty _sortKeys= => HTTP 500). See discussion #186. + // Use native Array filter/slice (lodash 3 _.first ignores the count arg). + var autocompleteProps = _.pluck(this.data.mapping.properties, "source") + .filter(function(source){ return !!source; }) + .slice(0, this.getNumRepresentativeProps()); mappingUtils.setupSampleSearch($("#findSampleSource",this.$el), this.data.mapping, autocompleteProps, _.bind(function(item) { conf.globalData.testSyncSource = item; diff --git a/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/mapping/properties/AttributesGridView.js b/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/mapping/properties/AttributesGridView.js index e611f8a899..977004a180 100644 --- a/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/mapping/properties/AttributesGridView.js +++ b/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/mapping/properties/AttributesGridView.js @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. + * Portions copyright 2026 3A Systems, LLC. */ define([ @@ -111,7 +112,13 @@ define([ this.data.mapProps = mapProps; if (this.data.usesDynamicSampleSource) { - let autocompleteProps = _.pluck(this.model.mapping.properties,"source").slice(0, this.model.numRepresentativeProps); + // Only keep properties that actually define a "source"; otherwise the + // first representative property may be undefined and break the sample + // search query (empty _sortKeys= => HTTP 500). See discussion #186. + // Use native Array filter/slice (lodash 3 _.first ignores the count arg). + let autocompleteProps = _.pluck(this.model.mapping.properties, "source") + .filter((source) => !!source) + .slice(0, this.model.numRepresentativeProps); mappingUtils.setupSampleSearch($("#findSampleSource", this.$el), this.model.mapping, autocompleteProps, (item) => { item.IDMSampleMappingName = this.model.mapping.name; diff --git a/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/mapping/util/MappingUtils.js b/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/mapping/util/MappingUtils.js index 55b04ea265..b9278d7e20 100644 --- a/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/mapping/util/MappingUtils.js +++ b/openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/mapping/util/MappingUtils.js @@ -65,11 +65,14 @@ define([ obj.setupSampleSearch = function(el, mapping, autocompleteProps, selectSuccessCallback){ var searchList, - selectedItem; + selectedItem, + // Guard against empty/undefined entries (e.g. properties without a "source"). + cleanProps = _.compact(autocompleteProps), + primaryProp = cleanProps[0]; el.selectize({ - valueField: autocompleteProps[0], - searchField: autocompleteProps, + valueField: primaryProp, + searchField: cleanProps, maxOptions: 10, create: false, onChange: function() { @@ -96,18 +99,20 @@ define([ item: function(item, escape) { selectedItem = item; - return "
" +escape(item[autocompleteProps[0]]) +"
"; + return "
" +escape(item[primaryProp]) +"
"; } }, load: function(query, callback) { - if (!query.length || query.length < 2 || !autocompleteProps.length) { + if (!query.length || query.length < 2 || !cleanProps.length) { return callback(); } - searchDelegate.searchResults(mapping.source, autocompleteProps, query).then(function(response) { - if(response) { + searchDelegate.searchResults(mapping.source, cleanProps, query).then(function(response) { + if(response && response.length) { searchList = response; - callback([response]); + // searchResults already returns an array of records; pass it + // through as-is (selectize expects a flat array of options). + callback(response); } else { searchList = []; diff --git a/openidm-ui/openidm-ui-admin/src/test/qunit/org/forgerock/openidm/ui/admin/mapping/util/MappingUtilsTest.js b/openidm-ui/openidm-ui-admin/src/test/qunit/org/forgerock/openidm/ui/admin/mapping/util/MappingUtilsTest.js index dceb72f5b0..482f438378 100644 --- a/openidm-ui/openidm-ui-admin/src/test/qunit/org/forgerock/openidm/ui/admin/mapping/util/MappingUtilsTest.js +++ b/openidm-ui/openidm-ui-admin/src/test/qunit/org/forgerock/openidm/ui/admin/mapping/util/MappingUtilsTest.js @@ -1,5 +1,63 @@ define([ - "org/forgerock/openidm/ui/admin/mapping/util/MappingUtils" -], function (MappingUtils) { + "jquery", + "sinon", + "org/forgerock/openidm/ui/admin/mapping/util/MappingUtils", + "org/forgerock/openidm/ui/common/delegates/SearchDelegate" +], function ($, sinon, MappingUtils, SearchDelegate) { QUnit.module('MappingUtils Tests'); + + QUnit.test("setupSampleSearch passes a flat array of records to the selectize load callback", function (assert) { + var done = assert.async(), + records = [{ email: "jsanchez@example.com", lastName: "Sanchez", firstName: "Jane" }], + capturedConfig, + selectizeStub = sinon.stub($.fn, "selectize", function (config) { + capturedConfig = config; + return this; + }), + searchStub = sinon.stub(SearchDelegate, "searchResults", function () { + return $.Deferred().resolve(records).promise(); + }); + + try { + MappingUtils.setupSampleSearch( + $(""), + { source: "system/hr/account" }, + ["email", "lastName", "firstName"], + function () {} + ); + + assert.equal(capturedConfig.valueField, "email", "valueField is the first non-empty prop"); + + capturedConfig.load("Sanchez", function (options) { + // Regression test: options must be the flat array of records, not [[...]]. + assert.deepEqual(options, records, "load callback receives a flat array of records"); + done(); + }); + } finally { + selectizeStub.restore(); + searchStub.restore(); + } + }); + + QUnit.test("setupSampleSearch ignores props without a source (compacts valueField/searchField)", function (assert) { + var capturedConfig, + selectizeStub = sinon.stub($.fn, "selectize", function (config) { + capturedConfig = config; + return this; + }); + + try { + MappingUtils.setupSampleSearch( + $(""), + { source: "managed/user" }, + [undefined, "userName", "sn"], + function () {} + ); + + assert.equal(capturedConfig.valueField, "userName", "valueField falls back to first non-empty prop"); + assert.deepEqual(capturedConfig.searchField, ["userName", "sn"], "searchField has no undefined entries"); + } finally { + selectizeStub.restore(); + } + }); }); \ No newline at end of file diff --git a/openidm-ui/openidm-ui-common/src/main/js/org/forgerock/openidm/ui/common/delegates/SearchDelegate.js b/openidm-ui/openidm-ui-common/src/main/js/org/forgerock/openidm/ui/common/delegates/SearchDelegate.js index 7f6a59c626..a8b34987f6 100644 --- a/openidm-ui/openidm-ui-common/src/main/js/org/forgerock/openidm/ui/common/delegates/SearchDelegate.js +++ b/openidm-ui/openidm-ui-common/src/main/js/org/forgerock/openidm/ui/common/delegates/SearchDelegate.js @@ -23,12 +23,41 @@ define([ var obj = new AbstractDelegate(constants.host + "/" + constants.context); + /** + * Builds the search URL for a resource query. + * + * Note: only the first non-empty property is used for "_sortKeys". When the + * supplied props array starts with empty/undefined values (e.g. a mapping + * whose first property has no "source"), an empty "_sortKeys=" must NOT be + * emitted, otherwise the backend (CREST/IDM) returns an HTTP 500 error. + * + * In addition, "_sortKeys" is omitted for system resources ("system/..."), + * because many connectors (e.g. the CSV connector) do not support + * server-side sorting and would fail the whole query. Sample searches are + * capped at a handful of results anyway, so the sort order is not required. + */ + obj.buildSearchUrl = function (resource, props, searchString, comparisonOperator, additionalQuery, maxPageSize) { + var sortKey = _.find(props, function (p) { return !!p; }), + sortableResource = !/^\/?system\//.test(resource), + url = "/" + resource + "?"; + + if (sortKey && sortableResource) { + url += "_sortKeys=" + sortKey + "&"; + } + + // [a,b] => "a or (b)"; [a,b,c] => "a or (b or (c))" + url += "_pageSize=" + maxPageSize + + "&_queryFilter=" + obj.generateQueryFilter(props, searchString, additionalQuery, comparisonOperator); + + return url; + }; + obj.searchResults = function (resource, props, searchString, comparisonOperator, additionalQuery) { var maxPageSize = 10; return this.serviceCall({ "type": "GET", - "url": "/" + resource + "?_sortKeys=" + props[0] + "&_pageSize=" + maxPageSize + "&_queryFilter=" + obj.generateQueryFilter(props, searchString, additionalQuery, comparisonOperator)// [a,b] => "a or (b)"; [a,b,c] => "a or (b or (c))" + "url": obj.buildSearchUrl(resource, props, searchString, comparisonOperator, additionalQuery, maxPageSize) }).then( function (qry) { return _.take(qry.result, maxPageSize);//we never want more than 10 results from search in case _pageSize does not work diff --git a/openidm-ui/openidm-ui-common/src/test/qunit/tests/org/forgerock/openidm/ui/common/delegates/SearchDelegateTest.js b/openidm-ui/openidm-ui-common/src/test/qunit/tests/org/forgerock/openidm/ui/common/delegates/SearchDelegateTest.js index ced34370c9..ec7b7b1b89 100644 --- a/openidm-ui/openidm-ui-common/src/test/qunit/tests/org/forgerock/openidm/ui/common/delegates/SearchDelegateTest.js +++ b/openidm-ui/openidm-ui-common/src/test/qunit/tests/org/forgerock/openidm/ui/common/delegates/SearchDelegateTest.js @@ -1,3 +1,18 @@ +/** + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Portions copyright 2026 3A Systems, LLC. + */ define([ "org/forgerock/openidm/ui/common/delegates/SearchDelegate" ], function (SearchDelegate) { @@ -12,4 +27,44 @@ define([ assert.equal(SearchDelegate.generateQueryFilter(props, search), '(userName sw "test" or (givenName sw "test" or (sn sw "test")))', "Basic Query Filter Generated"); assert.equal(SearchDelegate.generateQueryFilter(props, search, additionalQuery, comparisonOperator), '((userName sw "test" or (givenName sw "test" or (sn sw "test"))) and (true))', "Complex Query Filter Generated"); }); + + QUnit.test("buildSearchUrl uses the first property as _sortKeys", function (assert) { + var url = SearchDelegate.buildSearchUrl("managed/user", ["userName", "sn"], "test", null, null, 10); + + assert.equal( + url, + '/managed/user?_sortKeys=userName&_pageSize=10&_queryFilter=(userName sw "test" or (sn sw "test"))', + "_sortKeys is set to the first property" + ); + }); + + QUnit.test("buildSearchUrl skips empty _sortKeys when the first property is missing (discussion #186)", function (assert) { + // Mapping whose first property has no "source" => leading undefined value. + var url = SearchDelegate.buildSearchUrl("managed/user", [undefined, "sn"], "test", null, null, 10); + + assert.equal(url.indexOf("_sortKeys=&"), -1, "no empty _sortKeys= is emitted"); + assert.equal( + url, + '/managed/user?_sortKeys=sn&_pageSize=10&_queryFilter=(sn sw "test")', + "_sortKeys falls back to the first non-empty property" + ); + }); + + QUnit.test("buildSearchUrl omits _sortKeys entirely when no property is usable", function (assert) { + var url = SearchDelegate.buildSearchUrl("managed/user", [undefined, ""], "test", null, null, 10); + + assert.equal(url.indexOf("_sortKeys"), -1, "no _sortKeys parameter is present at all"); + assert.equal(url.indexOf("?_pageSize=10"), "/managed/user".length, "the query string starts directly with _pageSize"); + }); + + QUnit.test("buildSearchUrl omits _sortKeys for system resources that may not support sorting", function (assert) { + var url = SearchDelegate.buildSearchUrl("system/hr/account", ["email", "lastName"], "Sanchez", null, null, 10); + + assert.equal(url.indexOf("_sortKeys"), -1, "no _sortKeys parameter is sent to a system connector"); + assert.equal( + url, + '/system/hr/account?_pageSize=10&_queryFilter=(email sw "Sanchez" or (lastName sw "Sanchez"))', + "system resource query has no _sortKeys but keeps the query filter" + ); + }); });