/*! * SAPUI5 * Copyright (c) 2025 SAP SE or an SAP affiliate company. All rights reserved. */ import type SearchItem from "sap/f/gen/ui5/webcomponents_fiori/dist/SearchItem"; import type Search from "sap/f/gen/ui5/webcomponents_fiori/dist/Search"; import Control from "sap/ui/core/Control"; import BindingMode from "sap/ui/model/BindingMode"; import SuggestionType, { isSearchSuggestion, Suggestion } from "../../suggestions/SuggestionType"; import { ProgramError } from "../../error/errors"; import type SearchModel from "../../SearchModel"; import * as SearchHelper from "../../SearchHelper"; import UIEvents, { IUiEventParametersSearchFinishedPublic } from "../../UIEvents"; import EventBus from "sap/ui/core/EventBus"; import { loadWebComponents, WebComponents } from "./WebCompLoader"; import Image from "sap/m/Image"; import Filter from "sap/ui/model/Filter"; import FilterOperator from "sap/ui/model/FilterOperator"; import type { DataSource } from "../../sinaNexTS/sina/DataSource"; import i18n from "../../i18n"; import Container from "sap/ushell/Container"; export async function createWebCompSearchFieldGroupBindings(webCompSearchFieldGroup: Search): Promise { // load webcomponents const webComponents = await loadWebComponents(); // create alias because types do not work const webCompSearchFieldGroupTmp = webCompSearchFieldGroup as Control; // check whether scopeValue property exists // (it was introduced in ui5 web components 2.18.0) // >=2.18.0 -> scopeValue of search control contains selected data source id // <2.18.0 -> selected property of each scope item is set to true/false depending on whether it is selected const scopeValuePropertyExists = !!webCompSearchFieldGroupTmp.getMetadata().getProperty("scopeValue"); // enable clear icon // eslint-disable-next-line @typescript-eslint/no-explicit-any (webCompSearchFieldGroupTmp as any).setShowClearIcon(true); // disable typeahed (webCompSearchFieldGroupTmp as any).setNoTypeahead(true); // add methods addMethods(webCompSearchFieldGroup, webComponents); // bind enabled webCompSearchFieldGroupTmp.bindProperty("blocked", { parts: [ { path: "/initializingObjSearch", }, ], formatter: (initializingObjSearch) => initializingObjSearch, }); // bind scopeValue if (scopeValuePropertyExists) { webCompSearchFieldGroupTmp.bindProperty("scopeValue", { path: "/uiFilter/dataSource/id", mode: BindingMode.OneWay, }); } // bind placeholder webCompSearchFieldGroupTmp.bindProperty("placeholder", { path: "/searchTermPlaceholder", mode: BindingMode.OneWay, }); // bind datasources dropdown const searchScopeProperties = { text: "{labelPlural}", // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; if (scopeValuePropertyExists) { searchScopeProperties.value = "{id}"; } else { searchScopeProperties.selected = { parts: ["/uiFilter/dataSource/id", "id"], formatter: (selectedKey: string, key: string) => { return selectedKey === key; }, }; } webCompSearchFieldGroupTmp.bindAggregation("scopes", { filters: [ new Filter({ path: "/businessObjSearchEnabled", operator: FilterOperator.EQ, value1: true, }), ], path: "/dataSources", // eslint-disable-next-line @typescript-eslint/no-explicit-any template: new (webComponents.SearchScope as any)(searchScopeProperties) as Control, }); // bind datasource change webCompSearchFieldGroupTmp.attachEvent("scopeChange", (event) => { const dataSource = event.mParameters.scope.getBindingContext().getObject(); const model = webCompSearchFieldGroupTmp.getModel() as SearchModel; model.setDataSource(dataSource, false); setTimeout(() => { webCompSearchFieldGroupTmp.focus(); }, 200); }); // bind input box value (two-way) webCompSearchFieldGroupTmp.bindProperty("value", { path: "/uiFilter/searchTerm", mode: BindingMode.TwoWay, }); // bind suggestion items webCompSearchFieldGroupTmp.bindAggregation("items", { path: "/suggestions", factory: (sId, oContext) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return (webCompSearchFieldGroup as any).createSuggestionItem(sId, oContext); }, }); // bind suggestion popup open /*webCompSearchFieldGroupTmp.bindProperty("open", { parts: ["/suggestions/length"], formatter: (length) => length > 0, });*/ // bind illustrated message webCompSearchFieldGroupTmp.bindAggregation("illustration", { path: "/illustratedMessage", // eslint-disable-next-line @typescript-eslint/no-explicit-any template: new (webComponents.IllustratedMessage as any)({ titleText: "{titleText}", subtitleText: "{subtitleText}", name: "{illustrationName}", // eslint-disable-next-line @typescript-eslint/no-explicit-any actions: new (webComponents.Button as any)({ text: "{buttonText}", click: () => { const url = webCompSearchFieldGroupTmp .getModel() .getProperty("/illustratedMessage/0/url"); if (url) { window.open(url, "_blank", "noopener,noreferrer"); } }, }), }).addStyleClass("searchIllustratedMessage") as Control, }); // bind busy indicator webCompSearchFieldGroupTmp.bindProperty("loading", { path: "/isBusySuggestions", }); // register to suggestion events webCompSearchFieldGroupTmp.attachEvent("input", () => { // console.log("xx fetch sugg", (webCompSearchFieldGroupTmp as any).getValue(), x, y); // eslint-disable-next-line @typescript-eslint/no-explicit-any (webCompSearchFieldGroup as any).triggerSuggestions(); }); // register to search events webCompSearchFieldGroupTmp.attachEvent("search", (event) => { const suggestionControl = event.getParameter("item"); if (suggestionControl /*&& suggestionControl.getMetadata*/) { // console.log("xx sug selected"); let suggestion; try { suggestion = suggestionControl.getBindingContext().getObject(); // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { // console.log("xx suggestion object error", e); return; } // console.log("xx", suggestion); // prevent writing suggestion term to input box after this callback has finished // input box content is handle in suggestionItemSelected dependend on suggestions type event.preventDefault(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (webCompSearchFieldGroup as any).handleSuggestionItemSelected(suggestion); return; } // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((webCompSearchFieldGroupTmp as any).getValue().length > 0) { // for length=0 -> search field is collapsed, no need for search // console.log("xx search", (webCompSearchFieldGroupTmp as any).getValue()); // eslint-disable-next-line @typescript-eslint/no-explicit-any (webCompSearchFieldGroup as any).triggerSearch(); } }); } export function addMethods(searchFieldGroup, webComponents: WebComponents) { Object.assign(searchFieldGroup, { createSuggestionItem(sId, oContext) { const suggestion = oContext.getObject(); switch (suggestion.uiSuggestionType) { case SuggestionType.Header: { // eslint-disable-next-line @typescript-eslint/no-explicit-any const headerItem = new (webComponents.SearchItemGroup as any)({ headerText: suggestion.label, }); headerItem.addStyleClass("searchSuggestion").addStyleClass("searchHeaderSuggestion"); return headerItem; } case SuggestionType.DataSource: { // eslint-disable-next-line @typescript-eslint/no-explicit-any const dataSourceItem = new (webComponents.SearchItem as any)({ text: (suggestion.dataSource as DataSource).labelPlural ?? (suggestion.dataSource as DataSource).label, scopeName: "", }); dataSourceItem .addStyleClass("searchSuggestion") .addStyleClass("searchDataSourceSuggestion"); return dataSourceItem; } case SuggestionType.App: { // eslint-disable-next-line @typescript-eslint/no-explicit-any const appItem = new (webComponents.SearchItem as any)({ text: suggestion.title, icon: suggestion.icon, description: suggestion.subtitle, scopeName: "", }); const appTypeClass = suggestion.title?.includes("combinedAppSuggestion") ? "searchCombinedAppSuggestion" : "searchAppSuggestion"; appItem.addStyleClass("searchSuggestion").addStyleClass(appTypeClass); return appItem; } case SuggestionType.SearchTermData: case SuggestionType.SearchTermHistory: case SuggestionType.SearchTermAI: { if (suggestion.isShowMoreApps) { return this.createShowMoreAppsSuggestion(suggestion); } // eslint-disable-next-line @typescript-eslint/no-explicit-any const searchTermItem = new (webComponents.SearchItem as any)({ text: suggestion.searchTerm, scopeName: "", }); const searchTermTypeClass = suggestion.uiSuggestionType === SuggestionType.SearchTermData ? "searchBOSuggestion" : suggestion.uiSuggestionType === SuggestionType.SearchTermAI ? "searchAISuggestion" : "searchHistorySuggestion"; searchTermItem.addStyleClass("searchSuggestion").addStyleClass(searchTermTypeClass); return searchTermItem; } case SuggestionType.Object: { // eslint-disable-next-line @typescript-eslint/no-explicit-any const objectSuggestionItem = new (webComponents.SearchItem as any)({ text: suggestion.label1, description: suggestion.label2, scopeName: "", }); objectSuggestionItem .addStyleClass("searchSuggestion") .addStyleClass("searchObjectSuggestion"); if (suggestion.imageUrl) { if (suggestion.imageUrl.startsWith("sap-icon://")) { objectSuggestionItem.setIcon(suggestion.imageUrl); } else if (suggestion.imageExists) { // in case of URL image -> do not use icon but image aggregation // eslint-disable-next-line @typescript-eslint/no-explicit-any const imageAvatar = new (webComponents.Avatar as any)({ shape: suggestion.imageIsCircular ? // @ts-expect-error AvatarShape enum available at runtime but not in type declarations webComponents.Ui5WebComponents.AvatarShape.Circle : // @ts-expect-error AvatarShape enum available at runtime but not in type declarations webComponents.Ui5WebComponents.AvatarShape.Square, // @ts-expect-error AvatarSize enum available at runtime but not in type declarations size: webComponents.Ui5WebComponents.AvatarSize.XS, }); imageAvatar.addAggregation( "image", new Image({ src: suggestion.imageUrl, decorative: false, }) ); objectSuggestionItem.addAggregation("image", imageAvatar); } } return objectSuggestionItem; } case SuggestionType.Search: { // eslint-disable-next-line @typescript-eslint/no-explicit-any const recentItem = new (webComponents.SearchItem as any)({ text: "search suggestion:" + suggestion.label, scopeName: "", }); recentItem.addStyleClass("searchSuggestion").addStyleClass("searchRecentSuggestion"); return recentItem; } default: throw new ProgramError(null, "Unknown suggestion type: " + suggestion.uiSuggestionType); } }, createShowMoreAppsSuggestion(suggestion): SearchItem { const model = this.getModel() as SearchModel; // eslint-disable-next-line @typescript-eslint/no-explicit-any const showMoreItem = new (webComponents.SearchItem as any)({ text: i18n.getText("showAllNApps2", [suggestion.totalCount]), }); // workaround because there is no suggestions event for SearchItemShowMore Object.assign(showMoreItem, { onAfterRendering: () => { if (!showMoreItem.eshRegistered) { showMoreItem.getDomRef().addEventListener("click", () => { model.abortSuggestions(); model.setSearchBoxTerm(model.getLastSearchTerm(), false); // restore search term TODO comment suggestion.titleNavigation.performNavigation(); }); showMoreItem.getDomRef().addEventListener("keydown", (event) => { if (event.keyCode !== 13) { return; } model.abortSuggestions(); model.setSearchBoxTerm(model.getLastSearchTerm(), false); // restore search term TODO comment suggestion.titleNavigation.performNavigation(); }); showMoreItem.eshRegistered = true; } }, }); return showMoreItem; }, handleSuggestionItemSelected(suggestion) { const model = this.getModel() as SearchModel; model.abortSuggestions(); switch (suggestion.uiSuggestionType) { case SuggestionType.App: this.handleAppSuggestionItemSelected(suggestion); break; case SuggestionType.Search: case SuggestionType.SearchTermData: case SuggestionType.SearchTermHistory: case SuggestionType.SearchTermAI: case SuggestionType.Object: model.setSearchBoxTerm(model.getLastSearchTerm(), false); // restore search term TODO comment if (isSearchSuggestion(suggestion)) { suggestion.titleNavigation.performNavigation(); } break; case SuggestionType.DataSource: model.setDataSource(suggestion.dataSource, false); model.setSearchBoxTerm("", false); setTimeout(() => { this.focus(); }, 200); break; default: break; // log error? } }, handleAppSuggestionItemSelected(suggestion: Suggestion) { const model = this.getModel() as SearchModel; let targetURL = suggestion.url; if (targetURL[0] === "#") { if ( targetURL.indexOf("#Action-search") === 0 && (targetURL === SearchHelper.getHashFromUrl() || targetURL === decodeURIComponent(SearchHelper.getHashFromUrl())) ) { // ugly workaround // in case the app suggestion points to the search app with query identical to current query // --> do noting except: restore query term + focus again the first item in the result list model.setSearchBoxTerm(model.query.filter.searchTerm, false); model.setDataSource(model.query.filter.dataSource, false); model.notifySubscribers(UIEvents.ESHSearchFinished, null); EventBus.getInstance().publish(UIEvents.ESHSearchFinished, { searchTerm: model.getLastSearchTerm(), } as IUiEventParametersSearchFinishedPublic); return; } if (window["hasher"]) { if (targetURL[1] === window.hasher.prependHash) { // hasher preprends a "prependHash" character between "#" and the rest. // so we remove the same character to have the desired string in the end after hasher changed it // this avoids a wrong url if the application does not use window.hasher.getHash which removes prependHash again targetURL = targetURL.slice(0, 1) + targetURL.slice(2); } window["hasher"].setHash(targetURL); } else { window.location.href = targetURL; } model.setSearchBoxTerm("", false); } else { // special logging: only for urls started via suggestions // (apps/urls started via click ontile have logger in tile click handler) this.logRecentActivity(suggestion); window.open(targetURL, "_blank", "noopener,noreferrer"); model.setSearchBoxTerm("", false); } }, triggerSuggestions() { const model = this.getModel() as SearchModel; model.setProperty("/illustratedMessage", []); // reset previous illustrated message if ( model.getSearchBoxTerm().toLowerCase().indexOf("/n") !== 0 && model.getSearchBoxTerm().toLowerCase().indexOf("/o") !== 0 && (model.getSearchBoxTerm().length > 0 || model.config.bRecentSearches) ) { model.doSuggestion(); } else { model.abortSuggestions(); } }, triggerSearch() { // auto-start app // it is necessay to do this in search input (and not in search model) because otherwise navigating back from the app to the // search UI would result in a repeated navigation to the app const model = this.getModel() as SearchModel; SearchHelper.subscribeOnlyOnce( "triggerSearch", UIEvents.ESHSearchFinished, function () { (this.getModel() as SearchModel).autoStartApp(); }, this ); // special behaviour for S/4 -> replace empty search term with "*" if (this.getValue().trim() === "" && model.config.isUshell) { this.setValue("*"); } for (const handler of model.searchTermHandlers) { const searchTermWasHandled = handler.handleSearchTerm(this.getValue()); if (searchTermWasHandled) { searchTermWasHandled.catch(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (this as any).setOpen(true); // make sure that illustrated message is visible in case there is an error message to show therein }); return; // no further handling necessary } } this.navigateToSearchApp(); }, navigateToSearchApp() { const model = this.getModel() as SearchModel; model.abortSuggestions(); model.invalidateQuery(); if (SearchHelper.isSearchAppActive() || !model.config.isUshell) { // app running -> just fire query model.fireSearchQuery(); } else { // app not running -> start via hash // change hash: // - do not use Searchhelper.hasher here // - this is starting the search app from outside model.resetSearchResultItemMemory(); const sHash = model.createSearchNavigationTargetCurrentState({ updateUrl: true, }).targetUrl; window.location.hash = sHash; } }, logRecentActivity(suggestion: Suggestion): void { // load ushell deps lazy only in case of FLP sap.ui.require(["sap/ushell/Config", "sap/ushell/services/AppType"], function (Config, AppType) { // ToDo 'require' const bLogRecentActivity = Config.last("/core/shell/enableRecentActivity") && Config.last("/core/shell/enableRecentActivityLogging"); if (bLogRecentActivity) { const oRecentEntry = { title: suggestion.title, appType: AppType.URL, url: suggestion.url, appId: suggestion.url, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any (Container.getRenderer("fiori2") as any).logRecentActivity(oRecentEntry); } }); }, }); }