/*! * SAPUI5 * Copyright (c) 2025 SAP SE or an SAP affiliate company. All rights reserved. */ import Context from "sap/ui/model/Context"; import JSONModel from "sap/ui/model/json/JSONModel"; import SearchModel from "./SearchModel"; import { ResultSet, ResultSetItem, ResultSetItemAttributeChangeData, ResultSetItemData, } from "./ResultSetApi"; import { clonePublic } from "./sinaNexTS/sina/cloneUtil"; import { FormattedResultItem } from "./SearchResultFormatter"; import UIEvents from "./UIEvents"; export interface $PublicSearchModelSettings { modelName: string; internalSearchModel: SearchModel; } /** * @namespace sap.esh.search.ui */ export default class PublicSearchModel extends JSONModel { private internalSearchModel: SearchModel; public static defaultModelName = "publicSearchModel"; public static publicSearchModelPropertyPath: Array<{ sourcePath: string | RegExp; targetPath?: string | ((sPath: string) => string); }> = [ // when changing this list, make sure to keep QUnit test in sync: PublicSearchModel.spec.js { sourcePath: "/results" }, { sourcePath: /^\/results\/.*\/selected$/, // RegExp: Starts with '/results' and ends with '/selected' targetPath: (sPath) => sPath.replace("/results/", "/results/items/"), }, { sourcePath: /^\/tableRows\/.*\/selected$/, // RegExp: Starts with '/tableRows' and ends with '/selected' targetPath: (sPath) => sPath.replace("/tableRows/", "/results/items/"), }, { sourcePath: /^\/results\/.*\/selectionEnabled$/, // RegExp: Starts with '/results' and ends with '/selectionEnabled' targetPath: (sPath) => sPath.replace("/results/", "/results/items/"), }, { sourcePath: /^\/tableRows\/.*\/selectionEnabled$/, // RegExp: Starts with '/tableRows' and ends with '/selectionEnabled' targetPath: (sPath) => sPath.replace("/tableRows/", "/results/items/"), }, { sourcePath: /^\/results\/.*\/customItemStyleClass$/, // RegExp: Starts with '/results' and ends with '/customItemStyleClass' targetPath: (sPath) => sPath.replace("/results/", "/results/items/"), }, { sourcePath: /^\/tableRows\/.*\/customItemStyleClass$/, // RegExp: Starts with '/tableRows' and ends with '/customItemStyleClass' targetPath: (sPath) => sPath.replace("/tableRows/", "/results/items/"), }, { sourcePath: "/count" }, { sourcePath: "/config" }, { sourcePath: "/singleSelectionSelected" }, { sourcePath: "/multiSelectionSelected" }, { sourcePath: "/multiSelectionObjects" }, { sourcePath: "/resultviewSelectionVisibility" }, { sourcePath: "/hierarchyNodePaths" }, { sourcePath: "/pendingDataSourceChange" }, ]; public modelName: string; // name used to bind to controls (see SearchCompositeControl) constructor(settings: Partial<$PublicSearchModelSettings>) { super(); this.setSizeLimit(1000); // see also size limit for private model this.modelName = settings.modelName; this.internalSearchModel = settings.internalSearchModel; } public setPropertyFromInternalModel( internalModel: SearchModel, sPath: string, oValue: unknown, oContext?: Context, bAsyncUpdate?: boolean ) { let propertyValue; let absolutePath = sPath; if (oContext) { absolutePath = `${oContext.getPath()}/${sPath}`; } if (absolutePath === "/results") { const fnDataToBeCloned = (item) => { if (item["type"] === "appcontainer") { return item.tiles; } else { return item.sinaItem; } }; // search results let searchResults: ResultSet; try { searchResults = { items: (oValue as FormattedResultItem[]).map((searchResultItem, index) => { // clone the attributes of 'sinaItem' (unformatted result item) const clonedData = clonePublic( fnDataToBeCloned(searchResultItem) ) as ResultSetItemData; const resultSetItem: ResultSetItem = { key: searchResultItem.key, data: clonedData, title: searchResultItem.title, selected: searchResultItem.selected, setSelected: (select: boolean): void => { internalModel.setProperty(`/results/${index}/selected`, select); internalModel.notifySubscribers(UIEvents.ESHPublicModelChanged, { changedPropertyIds: ["selected"], }); }, selectionEnabled: searchResultItem.selectionEnabled, setSelectionEnabled: (enable: boolean): void => { internalModel.setProperty(`/results/${index}/selectionEnabled`, enable); internalModel.notifySubscribers(UIEvents.ESHPublicModelChanged, { changedPropertyIds: ["selectionEnabled"], }); }, customItemStyleClass: searchResultItem.customItemStyleClass, // css class to be added to result item (table, grid, list) setCustomItemStyleClass: (customItemStyleClass: string): void => { internalModel.setProperty( `/results/${index}/customItemStyleClass`, customItemStyleClass ); internalModel.notifySubscribers(UIEvents.ESHPublicModelChanged, { changedPropertyIds: ["customItemStyleClass"], }); }, setAttributeData: (data: ResultSetItemAttributeChangeData): void => { if (!data || !data.attributeId) { throw new Error( "ResultSetItem.setAttributeData: 'attributeId' is required." ); } else if ( data.iconUrl === undefined && data.tooltip === undefined && data.value === undefined ) { throw new Error( "ResultSetItem.setAttributeData: At least 'iconUrl' or 'tooltip' or 'value' must be provided." ); } // update internal search model const internalResults = internalModel.getProperty(`/results/`); const internalResultItemKey = internalModel.getProperty( `/results/${index}/` ).key; const internalResultItem = internalResults.filter( (intItem) => intItem.key === internalResultItemKey )[0]; const internalItemAttribute = internalResultItem.attributesMap[data.attributeId]; if (typeof internalItemAttribute === "undefined") { throw new Error( `ResultSetItem.setAttributeData: Attribute with id '${data.attributeId}' not found (internalResultItem.attributesMap).` ); } let internalCell; if (typeof internalResultItem.cells !== "undefined") { const filteredCells = internalResultItem.cells.filter( (intCell) => intCell.attributeId === data.attributeId ); if (!filteredCells) { throw new Error( `ResultSetItem.setAttributeData: Attribute with id '${data.attributeId}' not found (internalResultItem.cells).` ); } if (filteredCells.length > 0) { internalCell = filteredCells[0]; } } const tableRows = internalModel.getProperty("/tableRows"); const tableRow = tableRows.filter( (row) => row.key === internalResultItem.key )[0]; let tableRowAttribute; if (tableRow) { tableRowAttribute = tableRow.itemattributes.filter( (attr) => attr.key === data.attributeId )[0]; } let itemAttribute; if (typeof internalResultItem.itemattributes !== "undefined") { itemAttribute = internalResultItem.itemattributes.filter( (attr) => attr.key === data.attributeId )[0]; } if (data.iconUrl !== undefined) { internalItemAttribute.iconUrl = data.iconUrl; if (internalItemAttribute.defaultNavigationTarget) { internalItemAttribute.defaultNavigationTarget.icon = data.iconUrl; } if (internalCell) { internalCell.icon = data.iconUrl; } if (tableRowAttribute) { tableRowAttribute.iconUrl = data.iconUrl; } } if (data.tooltip !== undefined) { if (internalItemAttribute.defaultNavigationTarget) { internalItemAttribute.defaultNavigationTarget.tooltip = data.tooltip; } if (internalItemAttribute.tooltip !== undefined) { internalItemAttribute.tooltip = data.tooltip; } if (internalCell) { internalCell.tooltip = data.tooltip; } if (itemAttribute) { itemAttribute.tooltip = data.tooltip; } } if (data.value !== undefined) { internalItemAttribute.value = data.value; } internalModel.setProperty("/results", internalResults, undefined, true); internalModel.refresh(true); this.refresh(true); // notify subscribers about the change (subscribers will invalidate the binding paths) internalModel.notifySubscribers(UIEvents.ESHPublicModelChanged, { changedPropertyIds: ["attributeData"], }); }, }; return resultSetItem; }), }; } catch (error) { throw new Error("Public Model could not be updated. Error: " + error + ")"); } propertyValue = searchResults; } if (!propertyValue) { propertyValue = oValue; } this.setPropertyInternal(absolutePath, propertyValue, undefined, bAsyncUpdate); if (sPath.endsWith("selected")) { // path ends with 'selected' or '/selected' internalModel.notifySubscribers(UIEvents.ESHPublicModelChanged, { changedPropertyIds: ["selected"], }); } if (sPath === "/results" && Array.isArray(oValue) && oValue.length > 0) { // make sure we also set custom data (DOM) for enabled items internalModel.notifySubscribers(UIEvents.ESHPublicModelChanged, { changedPropertyIds: ["selected", "customItemStyleClass", "selectionEnabled"], }); } } setPropertyInternal(sPath: string, oValue: unknown, oContext?: Context, bAsyncUpdate?: boolean): boolean { let absolutePath = sPath; let success; if (oContext) { absolutePath = `${oContext.getPath()}/${sPath}`; } const publicProperty = this.isPublicProperty(absolutePath); if (publicProperty.length > 0) { if (typeof publicProperty[0]?.targetPath !== "undefined") { // property shall be renamed for public model if (typeof publicProperty[0]?.targetPath === "function") { success = super.setProperty( publicProperty[0]?.targetPath(absolutePath), oValue, undefined, bAsyncUpdate ); } else { success = super.setProperty( publicProperty[0]?.targetPath, oValue, oContext, bAsyncUpdate ); } } else { // default success = super.setProperty(sPath, oValue, oContext, bAsyncUpdate); } // update 'multiSelectionObjects', ... of internal model if (absolutePath.startsWith("/results/") && absolutePath.endsWith("/selected")) { // console.log("SELECTION: publ model, set 'selected' property (path: '/results/.../selected')"); this.internalSearchModel.updateMultiSelectionSelected(); // ToDo, leads event 'selectionChanged' being fired. It can happen the event is fired twice because of multiple calls of updateMultiSelectionSelected after selection has changed } } else { // this property is private and shall not be added to public search model } return success; } setProperty(sPath: string, oValue: unknown, oContext?: Context, bAsyncUpdate?: boolean): boolean { // in general we need to make sure this model is only updated by ELISA SearchModel !!! // Changeable properties: // - /results/items[...]/selected // - /results/items[...]/selectionEnabled // - /results/items[...]/customItemStyleClass let absolutePath = sPath; if (oContext) { absolutePath = `${oContext.getPath()}/${sPath}`; } if (absolutePath.startsWith("/results/items/") && absolutePath.endsWith("/selected")) { const successPublicModel = super.setProperty(sPath, oValue, oContext, bAsyncUpdate); // selected: Checkbox (item) is set (if visible -> see selection modes) const propertyPathSearchModel = `/results/${absolutePath .replace("/results/items/", "") .replace("/selected", "")}/selected`; const successInternalModel = this.internalSearchModel.setPropertyInternal( propertyPathSearchModel, oValue, undefined, bAsyncUpdate, false ); /* console.log( "SELECTION: publ model, set 'selected' property (path: '/results/items/.../selected')" ); */ this.internalSearchModel.updateMultiSelectionSelected(); this.internalSearchModel.notifySubscribers(UIEvents.ESHPublicModelChanged, { changedPropertyIds: ["selected"], }); return successPublicModel && successInternalModel; } else if (absolutePath.startsWith("/results/items/") && absolutePath.endsWith("/selectionEnabled")) { const successPublicModel = super.setProperty(sPath, oValue, oContext, bAsyncUpdate); // selectionEnabled: Checkbox (item) is enabled (if visible -> see selection modes) const propertyPathSearchModel = `/results/${absolutePath .replace("/results/items/", "") .replace("/selectionEnabled", "")}/selectionEnabled`; const successInternalModel = this.internalSearchModel.setPropertyInternal( propertyPathSearchModel, oValue, undefined, bAsyncUpdate, false ); this.internalSearchModel.notifySubscribers(UIEvents.ESHPublicModelChanged, { changedPropertyIds: ["selectionEnabled"], }); return successPublicModel && successInternalModel; } else if ( absolutePath.startsWith("/results/items/") && absolutePath.endsWith("/customItemStyleClass") ) { const successPublicModel = super.setProperty(sPath, oValue, oContext, bAsyncUpdate); const propertyPathSearchModel = `/results/${absolutePath .replace("/results/items/", "") .replace("/customItemStyleClass", "")}/customItemStyleClass`; const successInternalModel = this.internalSearchModel.setPropertyInternal( propertyPathSearchModel, oValue, undefined, bAsyncUpdate, false ); this.internalSearchModel.notifySubscribers(UIEvents.ESHPublicModelChanged, { changedPropertyIds: ["customItemStyleClass"], }); return successPublicModel && successInternalModel; } else { throw new Error( `The public search model '${this.modelName}' is read-only but for properties "/results/items//selected", "/results/items//selectionEnabled" and "/results/items//customItemStyleClass". You are changing property '${absolutePath}'.` ); } } isPendingDataSourceChangeWithMessage(showMessage = true): boolean { return this.internalSearchModel.isPendingDataSourceChangeWithMessage(showMessage); } isPublicProperty(sPath): Array<{ sourcePath: string | RegExp; targetPath?: string | ((sPath: string) => string); }> { const publicProperty = PublicSearchModel.publicSearchModelPropertyPath.filter((prop) => { if (prop.sourcePath instanceof RegExp) { return sPath.match(prop.sourcePath); } else { return prop.sourcePath === sPath; } }); return publicProperty; } }