/** * Copyright (c) 2025-present, Goldman Sachs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { observer } from 'mobx-react-lite'; import { type ChangeEvent, type JSX, type KeyboardEvent, useCallback, useMemo, useRef, useState, } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, IconButton, TextField, InputAdornment, Select, MenuItem, FormControl, InputLabel, Pagination, Divider, CircularProgress, type SelectChangeEvent, } from '@mui/material'; import { CloseIcon, CheckCircleIcon, ArrowRightIcon, WarningIcon, SearchIcon, ArrowUpIcon, ArrowDownIcon, } from '@finos/legend-art'; import { TerminalItemType, RecommendationSource, SortOrder, TerminalResult, type VendorAddonsSearchResponse, } from '@finos/legend-server-marketplace'; import { RecommendedItemsCard } from './RecommendedItemsCard.js'; import { ColumnFilterButton } from '../Filters/ColumnFilterButton.js'; import { useLegendMarketplaceBaseStore } from '../../application/providers/LegendMarketplaceFrameworkProvider.js'; import type { CartStore } from '../../stores/cart/CartStore.js'; import { assertErrorThrown, LogEvent } from '@finos/legend-shared'; import { LEGEND_MARKETPLACE_APP_EVENT } from '../../__lib__/LegendMarketplaceAppEvent.js'; import { flowResult } from 'mobx'; import { LegendMarketplaceTelemetryHelper } from '../../__lib__/LegendMarketplaceTelemetryHelper.js'; interface RecommendedAddOnsModalProps { terminal: TerminalResult | null; recommendedItems: TerminalResult[]; message: string; showModal: boolean; setShowModal: (show: boolean) => void; onViewCart?: () => void; onTerminalSelected?: ( selectedTerminal: TerminalResult, recommendations: TerminalResult[], responseMessage: string, totalCount?: number | null, ) => void; totalCount?: number | null | undefined; overridePermissionId?: number | undefined; overrideModel?: string | null | undefined; } const MAX_DISPLAY_ITEMS_COUNT = 10; const ITEMS_PER_PAGE_LIST = [10, 15, 25, 50]; const SERVER_SEARCH_PAGE_SIZE = 300; const ACTION_STATUS_OWNED = 'Subscribed'; const ACTION_STATUS_IN_CART = 'In Cart'; const ACTION_STATUS_ADD_TO_CART = 'Add to Cart'; const ACTION_STATUS_OPTIONS = [ ACTION_STATUS_OWNED, ACTION_STATUS_IN_CART, ACTION_STATUS_ADD_TO_CART, ]; const getItemActionStatus = ( item: TerminalResult, cartStore: CartStore, locallyAddedItemIds: ReadonlySet, ): string => { if (item.isOwned) { return ACTION_STATUS_OWNED; } if (cartStore.isItemInCart(item.id) || locallyAddedItemIds.has(item.id)) { return ACTION_STATUS_IN_CART; } return ACTION_STATUS_ADD_TO_CART; }; // `locallyAddedItemIds` covers items added via a skip-workflow add-to-cart // path (see RecommendedItemsCard's `isAdded` state) that don't (yet) show up // in `cartStore.isItemInCart`, so the Action filter/status stays in sync with // what the row actually displays. const filterItemsByColumnFilters = ( items: TerminalResult[], categoryFilter: ReadonlySet, actionFilter: ReadonlySet, cartStore: CartStore, locallyAddedItemIds: ReadonlySet, ): TerminalResult[] => items.filter((item) => { if (categoryFilter.size > 0 && !categoryFilter.has(item.category)) { return false; } if ( actionFilter.size > 0 && !actionFilter.has( getItemActionStatus(item, cartStore, locallyAddedItemIds), ) ) { return false; } return true; }); interface ListHeaderProps { headerName: string; categoryOptions: string[]; categoryFilter: ReadonlySet; onCategoryFilterChange: (next: Set) => void; actionOptions: string[]; actionFilter: ReadonlySet; onActionFilterChange: (next: Set) => void; } const ListHeader = (props: ListHeaderProps): JSX.Element => { const { headerName, categoryOptions, categoryFilter, onCategoryFilterChange, actionOptions, actionFilter, onActionFilterChange, } = props; return ( {headerName} Category Price (monthly) Action ); }; const getFilteredAndSortedItems = ( recommendedItems: TerminalResult[], isTerminalAdded: boolean, terminalSearchResults: TerminalResult[] | undefined, searchTerm: string, sortOrder: SortOrder | undefined, ): TerminalResult[] => { let items: TerminalResult[]; if (isTerminalAdded && terminalSearchResults) { items = [...terminalSearchResults]; } else { items = [...recommendedItems]; if (!isTerminalAdded && searchTerm) { const search = searchTerm.toLowerCase(); items = items.filter( (item) => item.productName.toLowerCase().includes(search) || item.providerName.toLowerCase().includes(search) || item.category.toLowerCase().includes(search), ); } } if (sortOrder && !(isTerminalAdded && terminalSearchResults)) { items.sort((a, b) => sortOrder === SortOrder.ASC ? a.price - b.price : b.price - a.price, ); } return items; }; const getModalTitle = ( isPermissionOverride: boolean, isTerminalType: boolean, ): string => { if (isPermissionOverride) { return ''; } return isTerminalType ? 'Item Added Successfully' : 'Unable to Add Item'; }; const getSectionTitle = ( isPermissionOverride: boolean, isTerminalType: boolean, terminal: TerminalResult | null, ): string => { if (isPermissionOverride || isTerminalType) { return `Available Add-Ons for ${terminal?.productName ?? ''}${ terminal?.providerName ? ` by ${terminal.providerName}` : '' }`; } if (terminal) { return `Available Terminals for ${terminal.productName}${ terminal.providerName ? ` by ${terminal.providerName}` : '' }`; } return ''; }; const getEmptyStateMessage = (isTerminalAdded: boolean): string => isTerminalAdded ? 'No add-ons available for this terminal.' : 'No available terminals for this add-on.'; const useVendorAddonSearch = ( terminal: TerminalResult | null, isTerminalAdded: boolean, ): { terminalSearchResults: TerminalResult[] | undefined; searchTotalCount: number | undefined; isSearching: boolean; triggerSearch: (query: string, sort?: SortOrder) => void; resetSearch: () => void; } => { const legendMarketplaceBaseStore = useLegendMarketplaceBaseStore(); const cartUser = legendMarketplaceBaseStore.cartStore.cartUser; const applicationStore = legendMarketplaceBaseStore.applicationStore; const marketplaceServerClient = legendMarketplaceBaseStore.marketplaceServerClient; const [terminalSearchResults, setTerminalSearchResults] = useState< TerminalResult[] | undefined >(undefined); const [searchTotalCount, setSearchTotalCount] = useState( undefined, ); const [isSearching, setIsSearching] = useState(false); const abortControllerRef = useRef(null); const fetchVendorAddons = useCallback( async (query: string, sort?: SortOrder, signal?: AbortSignal) => { if (!terminal || !isTerminalAdded) { return; } setIsSearching(true); try { const response = (await marketplaceServerClient.searchVendorAddons( cartUser, terminal.providerName, { // SERVER_SEARCH_PAGE_SIZE is set high enough to cover all expected results and paginate client-side. page: 1, page_size: SERVER_SEARCH_PAGE_SIZE, search: query, ...(sort ? { sort_by_price: sort } : {}), }, signal, )) as unknown as VendorAddonsSearchResponse; if (!signal?.aborted) { setTerminalSearchResults( response.marketplace_addons.map((item) => TerminalResult.serialization.fromJson(item), ), ); setSearchTotalCount(response.total_count); } } catch (error) { assertErrorThrown(error); if (error.name === 'AbortError') { return; } applicationStore.logService.error( LogEvent.create( LEGEND_MARKETPLACE_APP_EVENT.SEARCH_VENDOR_ADDONS_FAILURE, ), error, ); setTerminalSearchResults(undefined); } finally { if (!signal?.aborted) { setIsSearching(false); } } }, [ terminal, isTerminalAdded, cartUser, marketplaceServerClient, applicationStore.logService, ], ); const triggerSearch = useCallback( (query: string, sort?: SortOrder) => { abortControllerRef.current?.abort(); if (!isTerminalAdded || !query.trim()) { setTerminalSearchResults(undefined); setSearchTotalCount(undefined); setIsSearching(false); return; } const controller = new AbortController(); abortControllerRef.current = controller; fetchVendorAddons(query.trim(), sort, controller.signal).catch( applicationStore.alertUnhandledError, ); }, [isTerminalAdded, fetchVendorAddons, applicationStore.alertUnhandledError], ); const resetSearch = useCallback(() => { setTerminalSearchResults(undefined); setSearchTotalCount(undefined); setIsSearching(false); abortControllerRef.current?.abort(); }, []); return { terminalSearchResults, searchTotalCount, isSearching, triggerSearch, resetSearch, }; }; const isMandatoryItem = (item: TerminalResult): boolean => Boolean(item.isMandatory) && Boolean(item.productName); interface MultiSourceContentProps { cartSourceItems: TerminalResult[]; inventorySourceItems: TerminalResult[]; marketplaceSourceItems: TerminalResult[]; headerName: string; isAssociating: boolean; associatingItemId: number | undefined; onAssociate: (item: TerminalResult) => Promise | boolean; onItemAdded: (itemId: number) => void; categoryOptions: string[]; categoryFilter: ReadonlySet; onCategoryFilterChange: (next: Set) => void; actionOptions: string[]; actionFilter: ReadonlySet; onActionFilterChange: (next: Set) => void; } const MultiSourceContent = (props: MultiSourceContentProps): JSX.Element => { const { cartSourceItems, inventorySourceItems, marketplaceSourceItems, headerName, isAssociating, associatingItemId, onAssociate, onItemAdded, categoryOptions, categoryFilter, onCategoryFilterChange, actionOptions, actionFilter, onActionFilterChange, } = props; const listHeaderFilterProps = { categoryOptions, categoryFilter, onCategoryFilterChange, actionOptions, actionFilter, onActionFilterChange, }; if ( cartSourceItems.length === 0 && inventorySourceItems.length === 0 && marketplaceSourceItems.length === 0 ) { return ( No items match your search criteria. ); } return ( {cartSourceItems.length > 0 && ( From Your Cart Select a terminal from your cart to associate {cartSourceItems.map((item) => ( ))} )} {cartSourceItems.length > 0 && (inventorySourceItems.length > 0 || marketplaceSourceItems.length > 0) && ( )} {inventorySourceItems.length > 0 && ( From Your Inventory Select a terminal from your existing inventory to associate {inventorySourceItems.map((item) => ( ))} )} {(cartSourceItems.length > 0 || inventorySourceItems.length > 0) && marketplaceSourceItems.length > 0 && ( )} {marketplaceSourceItems.length > 0 && ( From Marketplace Explore other available terminal options from the marketplace {marketplaceSourceItems.map((item) => ( ))} )} ); }; const MandatoryAddOnsAlert = (props: { mandatoryAddOns: string[]; }): JSX.Element | null => { const { mandatoryAddOns } = props; if (mandatoryAddOns.length === 0) { return null; } return ( Mandatory Add-On{mandatoryAddOns.length > 1 ? 's' : ''} Included: {mandatoryAddOns.length === 1 ? ( {mandatoryAddOns[0]} Added To Cart ) : ( {mandatoryAddOns.map((name) => ( {name} ))} )} ); }; interface FilterControlsProps { searchTerm: string; sortOrder: SortOrder | undefined; itemsPerPage: number; filteredItemsLength: number; isPermissionOverride: boolean; isTerminalType: boolean; onSearchChange: ( e: ChangeEvent, ) => void; onSearchKeyDown: (e: KeyboardEvent) => void; onSearchAction: () => void; onSortChange: (event: SelectChangeEvent) => void; onItemsPerPageChange: (event: SelectChangeEvent) => void; } const FilterControls = (props: FilterControlsProps): JSX.Element => { const { searchTerm, sortOrder, itemsPerPage, filteredItemsLength, isPermissionOverride, isTerminalType, onSearchChange, onSearchKeyDown, onSearchAction, onSortChange, onItemsPerPageChange, } = props; return ( ), }, }} /> Sort by Price {filteredItemsLength > MAX_DISPLAY_ITEMS_COUNT && ( Items per page )} ); }; export const RecommendedAddOnsModal = observer( (props: RecommendedAddOnsModalProps) => { const { terminal, recommendedItems, message, showModal, setShowModal, onViewCart, onTerminalSelected, totalCount: initialTotalCount, overridePermissionId, overrideModel, } = props; const legendMarketplaceBaseStore = useLegendMarketplaceBaseStore(); const { cartStore, applicationStore } = legendMarketplaceBaseStore; const [searchTerm, setSearchTerm] = useState(''); const [sortOrder, setSortOrder] = useState(); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(15); const [categoryFilter, setCategoryFilter] = useState>( () => new Set(), ); const [actionFilter, setActionFilter] = useState>( () => new Set(), ); // Items added via a skip-workflow add-to-cart path that don't (yet) show // up in cartStore.isItemInCart; keeps the Action filter in sync with what // RecommendedItemsCard actually renders (see getItemActionStatus). const [locallyAddedItemIds, setLocallyAddedItemIds] = useState>( () => new Set(), ); const isTerminalAdded = terminal?.terminalItemType === TerminalItemType.TERMINAL; const isAddOnAssociation = !isTerminalAdded; const isPermissionOverride = overridePermissionId !== undefined; const headerName = isTerminalAdded || isPermissionOverride ? 'Add-On Name' : 'Terminal Name'; // The "Subscribed" status only applies when overriding an existing // permission (managing add-ons for an already-owned terminal); in other // flows recommended items are never owned, so hide it as a filter option. const actionOptions = useMemo( () => isPermissionOverride ? ACTION_STATUS_OPTIONS : ACTION_STATUS_OPTIONS.filter( (status) => status !== ACTION_STATUS_OWNED, ), [isPermissionOverride], ); const { terminalSearchResults, searchTotalCount, isSearching, triggerSearch, resetSearch, } = useVendorAddonSearch(terminal, isTerminalAdded); const hasMultipleSources = useMemo(() => { const hasCartItems = recommendedItems.some( (item) => item.source === RecommendationSource.CART, ); const hasInventoryItems = recommendedItems.some( (item) => item.source === RecommendationSource.INVENTORY, ); const hasMarketplaceItems = recommendedItems.some( (item) => item.source === RecommendationSource.MARKETPLACE, ); return ( [hasCartItems, hasInventoryItems, hasMarketplaceItems].filter(Boolean) .length >= 2 ); }, [recommendedItems]); const { cartSourceItems, inventorySourceItems, marketplaceSourceItems } = useMemo( () => ({ cartSourceItems: recommendedItems.filter( (item) => item.source === RecommendationSource.CART, ), inventorySourceItems: recommendedItems.filter( (item) => item.source === RecommendationSource.INVENTORY, ), marketplaceSourceItems: recommendedItems.filter( (item) => item.source === RecommendationSource.MARKETPLACE, ), }), [recommendedItems], ); const handleSearchAction = useCallback(() => { setCurrentPage(1); LegendMarketplaceTelemetryHelper.logEvent_AddonsPopupSearch( applicationStore.telemetryService, searchTerm, terminal?.productName ?? '', ); triggerSearch(searchTerm, sortOrder); }, [searchTerm, sortOrder, triggerSearch, applicationStore, terminal]); const filteredAndSortedItems = useMemo( () => getFilteredAndSortedItems( recommendedItems, isTerminalAdded, terminalSearchResults, searchTerm, sortOrder, ), [ recommendedItems, isTerminalAdded, terminalSearchResults, searchTerm, sortOrder, ], ); const categoryOptions = useMemo( () => Array.from(new Set(recommendedItems.map((item) => item.category))).sort( (a, b) => a.localeCompare(b), ), [recommendedItems], ); const columnFilteredItems = useMemo( () => filterItemsByColumnFilters( filteredAndSortedItems, categoryFilter, actionFilter, cartStore, locallyAddedItemIds, ), [ filteredAndSortedItems, categoryFilter, actionFilter, cartStore, locallyAddedItemIds, ], ); const columnFilteredCartSourceItems = useMemo( () => filterItemsByColumnFilters( cartSourceItems, categoryFilter, actionFilter, cartStore, locallyAddedItemIds, ), [ cartSourceItems, categoryFilter, actionFilter, cartStore, locallyAddedItemIds, ], ); const columnFilteredInventorySourceItems = useMemo( () => filterItemsByColumnFilters( inventorySourceItems, categoryFilter, actionFilter, cartStore, locallyAddedItemIds, ), [ inventorySourceItems, categoryFilter, actionFilter, cartStore, locallyAddedItemIds, ], ); const columnFilteredMarketplaceSourceItems = useMemo( () => filterItemsByColumnFilters( marketplaceSourceItems, categoryFilter, actionFilter, cartStore, locallyAddedItemIds, ), [ marketplaceSourceItems, categoryFilter, actionFilter, cartStore, locallyAddedItemIds, ], ); const totalPages = Math.ceil(columnFilteredItems.length / itemsPerPage); const mandatoryAddOns = useMemo( () => filteredAndSortedItems .filter(isMandatoryItem) .map((i) => i.productName), [filteredAndSortedItems], ); const paginatedItems = useMemo(() => { const startIndex = (currentPage - 1) * itemsPerPage; const endIndex = startIndex + itemsPerPage; return columnFilteredItems.slice(startIndex, endIndex); }, [columnFilteredItems, currentPage, itemsPerPage]); const closeModal = useCallback(() => { setShowModal(false); setSearchTerm(''); setSortOrder(undefined); setCurrentPage(1); setCategoryFilter(new Set()); setActionFilter(new Set()); setLocallyAddedItemIds(new Set()); resetSearch(); }, [setShowModal, resetSearch]); const handleAssociateTerminal = useCallback( async (selectedTerminal: TerminalResult): Promise => { try { const result = await flowResult( cartStore.associateAddOnToTerminal(selectedTerminal, { ...(overridePermissionId === undefined ? {} : { overridePermissionId }), ...(overrideModel === undefined ? {} : { overrideModel }), }), ); if (!result.success) { return false; } if ( result.recommendations && result.recommendations.length > 0 && onTerminalSelected ) { closeModal(); onTerminalSelected( selectedTerminal, result.recommendations, result.message, result.totalCount, ); return true; } if (result.shouldCloseModal) { closeModal(); } return true; } catch (error) { assertErrorThrown(error); applicationStore.alertUnhandledError(error); return false; } }, [ applicationStore, cartStore, onTerminalSelected, closeModal, overridePermissionId, overrideModel, ], ); const handleSearchTermChange = useCallback( (e: ChangeEvent) => { setSearchTerm(e.target.value); setCurrentPage(1); if (!e.target.value.trim()) { resetSearch(); } }, [resetSearch], ); const handleSearchKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === 'Enter') { handleSearchAction(); } }, [handleSearchAction], ); const handleViewCart = () => { onViewCart?.(); closeModal(); }; const handleSortChange = (event: SelectChangeEvent) => { const newSortOrder = event.target.value ? (event.target.value as SortOrder) : undefined; LegendMarketplaceTelemetryHelper.logEvent_AddonsPopupSort( applicationStore.telemetryService, newSortOrder ?? 'none', ); setSortOrder(newSortOrder); setCurrentPage(1); if (isTerminalAdded && searchTerm.trim() && terminalSearchResults) { triggerSearch(searchTerm, newSortOrder); } }; const handlePageChange = (_event: ChangeEvent, page: number) => { LegendMarketplaceTelemetryHelper.logEvent_AddonsPopupPaginate( applicationStore.telemetryService, page, ); setCurrentPage(page); }; const handleItemsPerPageChange = (event: SelectChangeEvent) => { setItemsPerPage(Number(event.target.value)); setCurrentPage(1); }; const handleCategoryFilterChange = useCallback((next: Set) => { setCategoryFilter(next); setCurrentPage(1); }, []); const handleActionFilterChange = useCallback((next: Set) => { setActionFilter(next); setCurrentPage(1); }, []); const handleItemAdded = useCallback((itemId: number) => { setLocallyAddedItemIds((prev) => prev.has(itemId) ? prev : new Set(prev).add(itemId), ); }, []); if (!showModal) { return null; } const isTerminalType = terminal?.terminalItemType === TerminalItemType.TERMINAL; const modalTitle = getModalTitle(isPermissionOverride, isTerminalType); const sectionTitle = getSectionTitle( isPermissionOverride, isTerminalType, terminal, ); // Pre-compute JSX branches to avoid nested ternary expressions (S3358). const hasActiveColumnFilters = categoryFilter.size > 0 || actionFilter.size > 0; const baseTotalCount = (terminalSearchResults ? searchTotalCount : initialTotalCount) ?? filteredAndSortedItems.length; const displayTotalCount = hasActiveColumnFilters ? columnFilteredItems.length : baseTotalCount; const itemsOrEmpty: JSX.Element = ( <> {columnFilteredItems.length > 0 && ( Showing {(currentPage - 1) * itemsPerPage + 1} -{' '} {Math.min(currentPage * itemsPerPage, columnFilteredItems.length)}{' '} of {displayTotalCount} items )} {columnFilteredItems.length === 0 ? ( No items match your search criteria. ) : ( paginatedItems.map((item) => ( )) )} {totalPages > 1 && ( )} ); const searchContent: JSX.Element = isSearching ? ( Searching... ) : ( itemsOrEmpty ); const nonEmptyContent: JSX.Element = isAddOnAssociation && hasMultipleSources ? ( ) : ( <> {searchContent} ); return ( {isTerminalType ? ( ) : ( )} {modalTitle} {terminal && !isPermissionOverride && ( {message} )} {sectionTitle} {!isPermissionOverride && ( {isTerminalType ? 'Enhance your terminal with these add-ons' : 'You must order a terminal license with this add-on'} )} {recommendedItems.length === 0 ? ( {getEmptyStateMessage(isTerminalAdded)} ) : ( nonEmptyContent )} {onViewCart && (!isAddOnAssociation || isPermissionOverride) && ( )} ); }, );