/*! * SAPUI5 * Copyright (c) 2025 SAP SE or an SAP affiliate company. All rights reserved. */ import JSONModel from "sap/ui/model/json/JSONModel"; import { ResultSetItem } from "../../ResultSetApi"; import errors from "../../error/errors"; import { callConfigExit } from "../../error/callConfigExit"; import SearchBasketSapUiTable from "./SearchBasketSapUiTable"; import SearchBasketSapMTable from "./SearchBasketSapMTable"; import SearchBasketSapMList from "./SearchBasketSapMList"; import SearchModel from "../../SearchModel"; import ErrorHandler from "../../error/ErrorHandler"; import UIEvents from "../../UIEvents"; import { UserEventType } from "../../eventlogging/UserEvents"; import SearchSpreadsheetExport from "../SearchSpreadsheetExport"; import i18n from "../../i18n"; import { EdmType } from "sap/ui/export/library"; export interface SearchBasketApi { getBasketModel: () => SearchBasketModel; } /** * @namespace sap.esh.search.ui */ export default class SearchBasketModel extends JSONModel { private errorHandler: ErrorHandler; constructor(data: object) { super(data); this.errorHandler = ErrorHandler.getInstance(); } /** * Resets the basket data * * @returns {void} */ public resetBasketData(): void { this.setProperty("/rows", []); this.setProperty("/publicItems", []); this.setProperty("/count", 0); this.setProperty("/_sortPath", ""); this.setProperty("/_sortOrder", ""); const oInternalSearchModel = this.getProperty("/internalSearchModel") as SearchModel; if (oInternalSearchModel) { oInternalSearchModel.setProperty("/basketCount", this.getProperty("/count")); oInternalSearchModel.notifySubscribers(UIEvents.ESHBasketChanged, { count: this.getProperty("/count"), }); } } /** * * @param items // result list items (public interface) * @param itemsToAdd // items to add to basket (no matter what their selected state is) * @param keepAllItemsInBasket // keep existing items of basket, just add new items * @returns {void} */ public updateBasketItems( items: Array, itemsToAdd?: Array, keepAllItemsInBasket = false ): void { if (this.getProperty("/_skipSelectionChange")) { return; } this.setProperty("/_skipRowSelectionChange", true); // result list items (public interface) const basketColumns = this.getProperty("/columns"); const basketRows = this.getProperty("/rows"); const basketPublicItems = this.getProperty("/publicItems"); let basketColumnsNew = []; const basketRowsNew = []; const basketPublicItemsNew = []; const basketConfigurator = this.getProperty("/config").basketConfigurator; const basketCustomData = this.getProperty("/customData"); if (basketColumns.length > 0) { basketColumnsNew = basketColumns; } else if (items.length > 0) { const firstItem = items[0]; const firstTitleAttribute = firstItem.data.titleAttributes[0] as any; const label = firstTitleAttribute.attributes ? firstTitleAttribute.attributes[0].attribute.metadata.label : firstTitleAttribute.label; basketColumnsNew.push({ id: firstTitleAttribute.id, label: label, path: "title", selected: true, }); } // take over selected items on current result view let itemExistsInBasket; items.forEach((item) => { itemExistsInBasket = basketRows.length > 0 && basketRows.filter((basketItem) => basketItem.key === item.key).length > 0; if (!itemExistsInBasket && (item.selected || (itemsToAdd && itemsToAdd.includes(item)))) { // new item for basket let adaptedItem = Object.assign({}, item); if (basketConfigurator?.adaptRow) { adaptedItem = callConfigExit( "basketBeforeAddItem (basketConfigurator.adaptRow)", "HAN-AS-INA-UI", () => basketConfigurator.adaptRow(adaptedItem) ); // validate the basket item if (!adaptedItem || !adaptedItem.key) { const oError = new errors.ConfigurationExitError( "basketBeforeAddItem", "HAN-AS-INA-UI", new Error( `Basket item adaptation did not return a valid item.\nCheck implementation of 'config.basketBeforeAddItem'.\nAdapted item: ${adaptedItem}` ) ); throw oError; } } basketRowsNew.push(adaptedItem); basketPublicItemsNew.push({ item }); } }); let basketItemSelected; let basketItemExistsOnResultList; // take over items of basket which are not on current result view any longer basketRows.forEach((basketItem) => { basketItemExistsOnResultList = items.filter((item) => item.key === basketItem.key).length > 0; basketItemSelected = items.filter( (item) => item.key === basketItem.key && (item.selected === true || (itemsToAdd && itemsToAdd.includes(item))) ).length > 0; if (keepAllItemsInBasket || !basketItemExistsOnResultList || basketItemSelected) { basketRowsNew.push(basketItem); } }); // take over items of basket, which also exist on result view basketPublicItems.forEach((publicItem) => { basketItemExistsOnResultList = items.filter((item) => item.key === publicItem.key).length > 0; basketItemSelected = items.filter( (item) => item.key === publicItem.key && (item.selected === true || (itemsToAdd && itemsToAdd.includes(item))) ).length > 0; if (keepAllItemsInBasket || !basketItemExistsOnResultList || basketItemSelected) { basketPublicItemsNew.push({ publicItem }); } }); this.setProperty("/rows", basketRowsNew); this.updateBasketCount(); this.setProperty("/publicItems", basketPublicItemsNew); if (basketConfigurator?.adaptColumns) { const columns = callConfigExit( "basketInit (basketConfigurator.adaptColumns)", "HAN-AS-INA-UI", () => basketConfigurator?.adaptColumns(basketColumnsNew), this.errorHandler ) ?? basketColumnsNew; this.setProperty("/columns", columns); this.setProperty("/config", { basketConfigurator: basketConfigurator, columns: columns, }); } else { this.setProperty("/columns", basketColumnsNew); this.setProperty("/config", {}); } if (basketCustomData) { this.setProperty("/customData", basketCustomData); } this.setProperty("/_skipRowSelectionChange", false); this.refresh(true); const oInternalSearchModel = this.getProperty("/internalSearchModel") as SearchModel; if (oInternalSearchModel) { oInternalSearchModel.setProperty("/basketCount", this.getProperty("/count")); oInternalSearchModel.notifySubscribers(UIEvents.ESHBasketChanged, { count: this.getProperty("/count"), }); } } public syncResultViewSelection( oPublicSearchModelItems, setSelectedOnResultViewItemForUnselectedBasketItems = true ) { const setSkipRowSelectionChange = !this.getProperty("/_skipRowSelectionChange"); if (setSkipRowSelectionChange) { this.setProperty("/_skipRowSelectionChange", true); } const setSkipSelectionChange = !this.getProperty("/_skipSelectionChange"); if (setSkipSelectionChange) { this.setProperty("/_skipSelectionChange", true); } this.getProperty("/rows").forEach((row) => { const itemPublicModel = oPublicSearchModelItems.filter((item) => item.key === row.key); if (itemPublicModel.length > 0) { if (setSelectedOnResultViewItemForUnselectedBasketItems) { // sync after search has finished. To make sure items of the basket are re-selected in the result list itemPublicModel[0].setSelected(true); } else { // sync when leaving the basket view, after items have been deselected in the basket itemPublicModel[0].setSelected(row.selected); } } }); if (setSkipRowSelectionChange) { this.setProperty("/_skipRowSelectionChange", false); } if (setSkipSelectionChange) { this.setProperty("/_skipSelectionChange", false); } } /** * Remove items (rows) from basket. * When sourceControl is provided and basketLinkByResultViewItemSelection is active, * the corresponding result view items are also deselected. * * @public * @since 1.145.0 * @param {Array} itemKeys Keys of result items to be removed from the basket. If not provided, all deselected items will be removed. * @param {SearchBasketSapUiTable | SearchBasketSapMTable | SearchBasketSapMList} [sourceControl] The basket control, required to sync result-view checkboxes in linked mode. */ public removeItemsFromBasket( itemKeys?: Array, sourceControl?: SearchBasketSapUiTable | SearchBasketSapMTable | SearchBasketSapMList ): void { this.setProperty("/_skipRowSelectionChange", true); if (itemKeys) { // mark removed items as deselected before removing so syncSearchCompControl can uncheck them this.setProperty( "/rows", this.getProperty("/rows").map((row) => itemKeys.includes(row.key) ? { ...row, selected: false } : row ) ); if (sourceControl) { this.syncSearchCompControl(sourceControl); } this.setProperty( "/rows", this.getProperty("/rows").filter((row) => !itemKeys.includes(row.key)) ); } else { this.setProperty( "/rows", this.getProperty("/rows").filter((row) => row.selected) ); } this.updateBasketCount(); this.setProperty("/_skipRowSelectionChange", false); const oInternalSearchModel = this.getProperty("/internalSearchModel") as SearchModel; if (oInternalSearchModel) { oInternalSearchModel.setProperty("/basketCount", this.getProperty("/count")); oInternalSearchModel.notifySubscribers(UIEvents.ESHBasketChanged, { count: this.getProperty("/count"), }); } } public syncSearchCompControl( sourceControl: SearchBasketSapUiTable | SearchBasketSapMTable | SearchBasketSapMList ) { const oInternalSearchModel = this.getProperty("/internalSearchModel") as SearchModel; if (!oInternalSearchModel) { return; } // sync result view selection (in case the items of basket and result view shall always be in sync) if (oInternalSearchModel?.config?.basketLinkByResultViewItemSelection) { const searchCompControl = oInternalSearchModel.getSearchCompositeControlInstanceByChildControl(sourceControl); const publicModelItems = searchCompControl.getPublicSearchModel().getProperty("/results/items"); this.syncResultViewSelection(publicModelItems, false); this.updateBasketCount(true); // only selected items } } public selectAllItems() { this.setProperty("/selectAll", true); } /** * Clears all items from the basket. * * @public * @since 1.147.0 * @param {SearchBasketSapUiTable | SearchBasketSapMTable | SearchBasketSapMList} [sourceControl] The basket control, required to sync result-view checkboxes in linked mode. */ public clearBasket( sourceControl?: SearchBasketSapUiTable | SearchBasketSapMTable | SearchBasketSapMList ): void { if (sourceControl) { // deselect all rows in one setProperty call so syncResultViewSelection unchecks them in the result view const deselected = (this.getProperty("/rows") as Array>).map((row) => ({ ...row, selected: false, })); this.setProperty("/rows", deselected); this.syncSearchCompControl(sourceControl); } this.resetBasketData(); } public updateBasketCount(onlySelectedItems = false): number { const basketCount = this.calculateBasketCount(onlySelectedItems); this.setProperty("/count", basketCount); const oInternalSearchModel = this.getProperty("/internalSearchModel") as SearchModel; if (oInternalSearchModel) { oInternalSearchModel.setProperty("/basketCount", this.getProperty("/count")); oInternalSearchModel.notifySubscribers(UIEvents.ESHBasketChanged, { count: this.getProperty("/count"), }); } return basketCount; } public calculateBasketCount(onlySelectedItems = false): number { let basketCount = 0; if (onlySelectedItems) { basketCount = this.getProperty("/rows").filter((row) => row.selected).length; } else { basketCount = this.getProperty("/rows").length; } return basketCount; } public logItemAdd(itemCount: number): void { const oInternalSearchModel = this.getProperty("/internalSearchModel") as SearchModel; oInternalSearchModel?.eventLogger?.logEvent({ type: UserEventType.BASKET_ITEM_ADD, dataSourceKey: oInternalSearchModel.getDataSource().id, itemCount, }); } public logItemRemove(itemCount: number): void { const oInternalSearchModel = this.getProperty("/internalSearchModel") as SearchModel; oInternalSearchModel?.eventLogger?.logEvent({ type: UserEventType.BASKET_ITEM_REMOVE, dataSourceKey: oInternalSearchModel.getDataSource().id, itemCount, }); } // Note: itemCount must be captured before calling clearBasket(), as clearBasket() may update the count internally. public logClear(itemCount: number): void { const oInternalSearchModel = this.getProperty("/internalSearchModel") as SearchModel; oInternalSearchModel?.eventLogger?.logEvent({ type: UserEventType.BASKET_CLEAR, dataSourceKey: oInternalSearchModel.getDataSource().id, itemCount, }); } public logSort(sortPath: string, sortOrder: string): void { const oInternalSearchModel = this.getProperty("/internalSearchModel") as SearchModel; oInternalSearchModel?.eventLogger?.logEvent({ type: UserEventType.BASKET_SORT, dataSourceKey: oInternalSearchModel.getDataSource().id, sortPath, sortOrder, }); } /** * exports the current basket contents to an xlsx file. * builds the column/row data from the basket model and delegates * the actual export (lock/unlock, spreadsheet creation) to SearchSpreadsheetExport.exportData(). */ async exportBasket(model: SearchModel, spreadsheetExport: SearchSpreadsheetExport): Promise { const rows = (this.getProperty("/rows") as Array>) ?? []; const columns = (this.getProperty("/columns") as Array<{ id: string; label: string; path: string; }>) ?? []; const exportColumns = columns.map((col) => ({ label: col.label, property: col.path, type: EdmType.String, })); const exportRows = rows.map((row) => { const exportRow: Record = {}; for (const col of columns) { exportRow[col.path] = row[col.path] ?? ""; } return exportRow; }); await spreadsheetExport.exportData( model, exportColumns, exportRows, i18n.getText("exportBasketFileName") ); } }