/** * Copyright (c) 2020-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 { Button, Checkbox, Chip, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, IconButton, InputAdornment, InputLabel, MenuItem, Select, TextField, ToggleButton, ToggleButtonGroup, Tooltip, Typography, } from '@mui/material'; import { LegendMarketplacePage } from '../LegendMarketplacePage.js'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ProductSubscription, Subscription, SubscriptionRequest, } from '@finos/legend-server-marketplace'; import { DataGrid, type DataGridApi, type DataGridCellRendererParams, type DataGridColumnDefinition, } from '@finos/legend-lego/data-grid'; import { useLegendMarketplaceBaseStore } from '../../application/providers/LegendMarketplaceFrameworkProvider.js'; import { flowResult } from 'mobx'; import { ChevronDownIcon, ChevronUpIcon, clsx, TimesIcon, UserSearchInput, } from '@finos/legend-art'; import { assertErrorThrown, debounce, type GeneratorFn, LegendUser, } from '@finos/legend-shared'; import { useLegendMarketplaceSubscriptionsStore, withLegendMarketplaceSubscriptionsStore, } from '../../application/providers/LegendMarketplaceSubscriptionsStoreProvider.js'; import { LegendMarketplaceTelemetryHelper } from '../../__lib__/LegendMarketplaceTelemetryHelper.js'; const SEARCH_DEBOUNCE_MS = 300; // A sentinel that cannot collide with a real carrier vendor or item type name. // Unlike this app's other 'All' enum members (e.g. `VendorDataProviderType.ALL`), // which enumerate a small, closed set of values we control, these filter options // are built from live, arbitrary backend data (see `carrierVendorOptions`/ // `itemTypeOptions` below) — so a vendor or item type literally named "All" // would otherwise silently be treated as "no filter". const ALL_FILTER_OPTION = '__ALL_FILTER_OPTION__'; const formatFilterOptionLabel = (option: string): string => option === ALL_FILTER_OPTION ? 'All' : option; const PERMISSION_ID_LABEL = 'Permission ID'; type SubscriptionGridRow = Subscription & { permissionGroupKey: string; permissionGroupLabel: string; isSelected: boolean; }; type SubscriptionGridNode = DataGridCellRendererParams['node']; // -------------------------------------------------------------------------- // Confirmation dialog shown before executing a batch cancellation // -------------------------------------------------------------------------- const CancellationConfirmationDialog = (props: { open: boolean; selectedSubscriptions: Subscription[]; isLoading: boolean; onClose: () => void; onConfirm: () => void; }): React.ReactNode => { const { open, selectedSubscriptions, isLoading, onClose, onConfirm } = props; return ( Confirm Cancellation The following {selectedSubscriptions.length} subscription(s) will be cancelled:
    {selectedSubscriptions.map((sub) => (
  • {sub.carrierVendor} — {sub.serviceName} ( {sub.itemName})
  • ))}
); }; // -------------------------------------------------------------------------- // KPI summary bar — total count, total monthly cost, breakdown by item type // -------------------------------------------------------------------------- const SubscriptionKpiBar = (props: { subscriptions: Subscription[]; totalMonthlyCost: number; showAnnualCost: boolean; onCostToggle: (annual: boolean) => void; }): React.ReactNode => { const { subscriptions, totalMonthlyCost, showAnnualCost, onCostToggle } = props; const displayedCost = showAnnualCost ? totalMonthlyCost * 12 : totalMonthlyCost; const countByType = useMemo(() => { const typeMap = new Map(); for (const sub of subscriptions) { typeMap.set(sub.itemName, (typeMap.get(sub.itemName) ?? 0) + 1); } return typeMap; }, [subscriptions]); return (
Total Subscriptions {subscriptions.length}
Total Cost
${displayedCost.toLocaleString()} { if (newValue !== null) { onCostToggle(newValue === 'annual'); } }} className="legend-marketplace-subscriptions-kpi-bar__cost-segment" > Monthly Annual
Categories
{Array.from(countByType.entries()).map(([type, count]) => ( {type}: {count} ))}
); }; // -------------------------------------------------------------------------- // Main subscriptions page // -------------------------------------------------------------------------- export const LegendMarketplaceSubscriptions = withLegendMarketplaceSubscriptionsStore( observer(() => { const marketplaceStore = useLegendMarketplaceBaseStore(); const subscriptionStore = useLegendMarketplaceSubscriptionsStore(); const [userSearchEnabled, setUserSearchEnabled] = useState(false); const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); // Raw value reflects what the user typed; activeSearchText is the debounced value // used for filtering so we don't re-filter on every keystroke. const [rawSearchText, setRawSearchText] = useState(''); const [activeSearchText, setActiveSearchText] = useState(''); const [carrierVendorFilter, setCarrierVendorFilter] = useState(ALL_FILTER_OPTION); const [itemTypeFilter, setItemTypeFilter] = useState(ALL_FILTER_OPTION); // Shared toggle — controls both the KPI cost card and the grid cost column. const [showAnnualCost, setShowAnnualCost] = useState(false); // true = all groups expanded (default), false = all collapsed, null = mixed const [allGroupsExpanded, setAllGroupsExpanded] = useState< boolean | null >(true); const gridApiRef = useRef | null>(null); const hasLoggedPageViewRef = useRef(false); const initialUser = marketplaceStore.applicationStore.identityService.currentUser; // Shared error-handling wrapper for MobX flows (mirrors // `LegendMarketplaceYourOrders.tsx`'s `executeFlowSafely`) so failures // are surfaced consistently instead of each call site repeating its own // `flowResult(...).catch(...)`. const executeFlowSafely = useCallback( (flowFn: () => GeneratorFn) => { flowResult(flowFn()).catch((error: unknown) => { assertErrorThrown(error); marketplaceStore.applicationStore.alertUnhandledError(error); }); }, [marketplaceStore.applicationStore], ); const fetchSubscriptions = useCallback( (user: string): void => { executeFlowSafely(() => subscriptionStore.fetchSubscription(user)); }, [executeFlowSafely, subscriptionStore], ); // Debounce text search to avoid filtering on every keystroke. const debouncedSetActiveSearch = useMemo( () => debounce( (text: string) => setActiveSearchText(text), SEARCH_DEBOUNCE_MS, ), [], ); useEffect( () => () => { debouncedSetActiveSearch.cancel(); }, [debouncedSetActiveSearch], ); const handleSearchChange = useCallback( (text: string): void => { setRawSearchText(text); debouncedSetActiveSearch(text); }, [debouncedSetActiveSearch], ); const handleClearSearch = useCallback((): void => { setRawSearchText(''); debouncedSetActiveSearch.cancel(); setActiveSearchText(''); }, [debouncedSetActiveSearch]); // Search text and dropdown filters are scoped to whichever user's // subscriptions are being viewed, so switching the target user must // reset them rather than carrying them over (and potentially filtering // the new user's grid down to zero rows). const resetSearchAndFilters = useCallback((): void => { setRawSearchText(''); debouncedSetActiveSearch.cancel(); setActiveSearchText(''); setCarrierVendorFilter(ALL_FILTER_OPTION); setItemTypeFilter(ALL_FILTER_OPTION); }, [debouncedSetActiveSearch]); const handleExpandCollapseAll = useCallback((): void => { const api = gridApiRef.current; if (!api) { return; } const shouldExpand = allGroupsExpanded !== true; if (shouldExpand) { api.expandAll(); setAllGroupsExpanded(true); } else { api.collapseAll(); setAllGroupsExpanded(false); } }, [allGroupsExpanded]); // Keyboard shortcuts: Ctrl+Shift+E = expand all, Ctrl+Shift+C = collapse all useEffect(() => { const handleKeyDown = (e: KeyboardEvent): void => { if (e.ctrlKey && e.shiftKey && e.code === 'KeyE') { e.preventDefault(); gridApiRef.current?.expandAll(); setAllGroupsExpanded(true); } else if (e.ctrlKey && e.shiftKey && e.code === 'KeyC') { e.preventDefault(); gridApiRef.current?.collapseAll(); setAllGroupsExpanded(false); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, []); const handleCancelSubscriptionClick = useCallback((): void => { setIsConfirmDialogOpen(true); }, []); const handleConfirmCancellation = useCallback((): void => { // Built solely from `selectedSubscriptions`: checking a Permission ID // row already adds its currently visible/filtered addons to this list // (see `handleSubscriptionCheckboxChange`), so re-deriving addons // here from the full, unfiltered `subscriptionFeeds` would silently // include rows the user never saw (and never selected) in the // confirmation dialog whenever an active search/filter hid them. const orderItems: Record = {}; subscriptionStore.selectedSubscriptions.forEach((s) => { const item: ProductSubscription = { providerName: s.carrierVendor, productName: s.serviceName, category: s.itemName, price: s.price, servicepriceId: s.servicepriceId ?? 0, model: s.model, }; if (s.permId in orderItems) { orderItems[s.permId]?.push(item); } else { orderItems[s.permId] = [item]; } }); const cancellationRequest: SubscriptionRequest = { ordered_by: initialUser, kerberos: subscriptionStore.selectedUser.id, order_items: orderItems, }; executeFlowSafely(() => subscriptionStore.cancelSubscription(cancellationRequest), ); setIsConfirmDialogOpen(false); }, [executeFlowSafely, subscriptionStore, initialUser]); useEffect(() => { executeFlowSafely(() => subscriptionStore.refresh()); }, [executeFlowSafely, subscriptionStore]); useEffect(() => { if (hasLoggedPageViewRef.current) { return; } const selectedUserId = subscriptionStore.selectedUser.id; const currentUserId = marketplaceStore.applicationStore.identityService.currentUser; const isTargetUser = selectedUserId ? selectedUserId !== currentUserId : false; LegendMarketplaceTelemetryHelper.logEvent_ViewSubscriptionsPage( marketplaceStore.applicationStore.telemetryService, isTargetUser, ); hasLoggedPageViewRef.current = true; }, [marketplaceStore, subscriptionStore.selectedUser.id]); // Unique dropdown options derived from all feeds (unfiltered) so selections // are always available regardless of the current active filters. const carrierVendorOptions = useMemo( () => [ ALL_FILTER_OPTION, ...Array.from( new Set( subscriptionStore.subscriptionFeeds.map((s) => s.carrierVendor), ), ).sort((a, b) => a.localeCompare(b)), ], [subscriptionStore.subscriptionFeeds], ); const itemTypeOptions = useMemo( () => [ ALL_FILTER_OPTION, ...Array.from( new Set(subscriptionStore.subscriptionFeeds.map((s) => s.itemName)), ).sort((a, b) => a.localeCompare(b)), ], [subscriptionStore.subscriptionFeeds], ); // True when at least one subscription has a non-empty cost code; used to // hide the Cost Code column when no data populates it. const hasCostCodeData = useMemo( () => subscriptionStore.subscriptionFeeds.some((s) => !!s.costCode), [subscriptionStore.subscriptionFeeds], ); // Filtered and pre-sorted data passed to the grid. // Sort order: Carrier Vendor → Product → Service (ascending). // AG Grid's built-in column sort controls any subsequent user-driven re-sorts. const filteredSubscriptions = useMemo(() => { const normalizedSearch = activeSearchText.trim().toLowerCase(); // Assigns a sort priority to a row when a search is active: // 0 = serviceName match (highest), 1 = carrierVendor, 2 = sourceVendor. const matchScore = (sub: Subscription): number => { if (sub.serviceName.toLowerCase().includes(normalizedSearch)) { return 0; } if (sub.carrierVendor.toLowerCase().includes(normalizedSearch)) { return 1; } return 2; }; return [...subscriptionStore.subscriptionFeeds] .filter((sub) => { if ( carrierVendorFilter !== ALL_FILTER_OPTION && sub.carrierVendor !== carrierVendorFilter ) { return false; } if ( itemTypeFilter !== ALL_FILTER_OPTION && sub.itemName !== itemTypeFilter ) { return false; } if (!normalizedSearch) { return true; } return ( sub.serviceName.toLowerCase().includes(normalizedSearch) || sub.carrierVendor.toLowerCase().includes(normalizedSearch) || sub.sourceVendor.toLowerCase().includes(normalizedSearch) ); }) .sort((a, b) => { // When a search is active, rows with a higher-priority match // (serviceName > carrierVendor > sourceVendor) float to the top. if (normalizedSearch) { const scoreDiff = matchScore(a) - matchScore(b); if (scoreDiff !== 0) { return scoreDiff; } } // Standard grouping sort: vendor → permId → Permission ID first → service. const vendorCmp = a.carrierVendor.localeCompare(b.carrierVendor); if (vendorCmp !== 0) { return vendorCmp; } const permIdCmp = a.permId - b.permId; if (permIdCmp !== 0) { return permIdCmp; } const aIsPermId = a.itemName === PERMISSION_ID_LABEL; const bIsPermId = b.itemName === PERMISSION_ID_LABEL; if (aIsPermId !== bIsPermId) { return aIsPermId ? -1 : 1; } return a.serviceName.localeCompare(b.serviceName); }); }, [ subscriptionStore.subscriptionFeeds, activeSearchText, carrierVendorFilter, itemTypeFilter, ]); // Recomputed only when the underlying (unfiltered) feed changes, not on // every search/filter keystroke, since it derives purely from // `subscriptionFeeds`. const permissionGroupLabelByVendorAndPermId = useMemo(() => { const labelsByKey = new Map(); for (const sub of subscriptionStore.subscriptionFeeds) { const key = `${sub.carrierVendor}::${sub.permId}`; const isPermissionIdRow = sub.itemName === PERMISSION_ID_LABEL; const current = labelsByKey.get(key); const preferredLabel = (sub.model || sub.serviceName || '').trim(); if (!current && preferredLabel) { labelsByKey.set(key, preferredLabel); } if (isPermissionIdRow && preferredLabel) { labelsByKey.set(key, preferredLabel); } } return labelsByKey; }, [subscriptionStore.subscriptionFeeds]); // O(1) membership lookup for the per-row "selected" state, instead of a // linear scan of `selectedSubscriptions` per rendered row/cell. const selectedSubscriptionIds = useMemo( () => new Set(subscriptionStore.selectedSubscriptions.map((s) => s.id)), [subscriptionStore.selectedSubscriptions], ); const groupedSubscriptions = useMemo(() => { return filteredSubscriptions.map((sub) => { const key = `${sub.carrierVendor}::${sub.permId}`; const permissionGroupLabel = permissionGroupLabelByVendorAndPermId.get(key) ?? `${PERMISSION_ID_LABEL} ${sub.permId}`; return { ...sub, permissionGroupKey: `${sub.permId}::${permissionGroupLabel}`, permissionGroupLabel, isSelected: selectedSubscriptionIds.has(sub.id), }; }); }, [ filteredSubscriptions, permissionGroupLabelByVendorAndPermId, selectedSubscriptionIds, ]); const formatCurrency = useCallback((amount: number): string => { return `$${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; }, []); const getDisplayedCost = useCallback( (annualAmount: number): number => showAnnualCost ? annualAmount : annualAmount / 12, [showAnnualCost], ); const getGroupDisplayedCost = useCallback( (node: SubscriptionGridNode): number => { const totalAnnualAmount = (node.allLeafChildren ?? []).reduce( (sum, childNode) => sum + Number(childNode.data?.annualAmount ?? 0), 0, ); return getDisplayedCost(totalAnnualAmount); }, [getDisplayedCost], ); const shouldSuppressPermissionSubtotal = useCallback( (node: SubscriptionGridNode): boolean => { if (!node.group || node.level !== 1) { return false; } const leafCount = node.allLeafChildren?.length ?? 0; const vendorGroupNode = node.parent; if (!vendorGroupNode) { return false; } const permissionGroupCount = ( vendorGroupNode.childrenAfterGroup ?? [] ).filter((childNode) => childNode.group === true).length; return permissionGroupCount === 1 && leafCount === 1; }, [], ); const renderGroupLabel = useCallback( (params: DataGridCellRendererParams) => { const node = params.node; if (!node.group) { return ''; } const itemCount = node.allLeafChildren?.length ?? 0; if (node.level === 0) { return `${String(node.key)} (${itemCount})`; } const permissionGroupLabel = node.allLeafChildren?.[0]?.data?.permissionGroupLabel ?? String(node.key); const itemLabel = itemCount === 1 ? 'item' : 'items'; return `${permissionGroupLabel} (${itemCount} ${itemLabel})`; }, [], ); const autoGroupColumnDef = useMemo( () => ({ minWidth: 330, headerName: '', suppressHeaderMenuButton: true, cellRendererParams: { suppressCount: true, innerRenderer: renderGroupLabel, }, }), [renderGroupLabel], ); const handleSubscriptionCheckboxChange = useCallback( (subscription: Subscription | null | undefined, checked: boolean) => { if (!subscription) { return; } // Toggling a Permission ID row also toggles its associated addons, // scoped to whatever is currently visible/filtered so it matches // what the user actually sees in the grid. const associatedAddons = subscription.itemName === PERMISSION_ID_LABEL ? filteredSubscriptions.filter( (sub) => sub.permId === subscription.permId && sub.itemName !== PERMISSION_ID_LABEL, ) : []; if (checked) { subscriptionStore.addSelectedSubscriptions(subscription); associatedAddons.forEach((addon) => { subscriptionStore.addSelectedSubscriptions(addon); }); } else { subscriptionStore.removeSelectedSubscription(subscription); associatedAddons.forEach((addon) => { subscriptionStore.removeSelectedSubscription(addon); }); } }, [subscriptionStore, filteredSubscriptions], ); const columnDefs: DataGridColumnDefinition[] = useMemo( () => [ { // Hidden — used only to define the row group; the group header row // renders the carrier vendor name with an expand/collapse toggle. headerName: 'Carrier Vendor', field: 'carrierVendor', rowGroup: true, hide: true, suppressHeaderMenuButton: true, }, { // Hidden — second grouping level to show subtotals for each Permission ID. headerName: 'Permission ID Group', field: 'permissionGroupKey', rowGroup: true, hide: true, suppressHeaderMenuButton: true, }, { minWidth: 150, headerName: 'Product', field: 'model', sortable: true, suppressHeaderMenuButton: true, flex: 1, tooltipField: 'model', // Visual indentation mirrors the OwnedTerminalDetailModal pattern: // add-ons are shown with a coloured accent bar and left-padding. cellRenderer: ( params: DataGridCellRendererParams, ) => { if (params.node.group) { return null; } const isAddon = params.data?.itemName !== PERMISSION_ID_LABEL; return (
{isAddon && (
)} {params.data?.model}
); }, }, { minWidth: 120, headerName: 'Source Vendor', field: 'sourceVendor', sortable: true, suppressHeaderMenuButton: true, flex: 1, tooltipField: 'sourceVendor', }, { minWidth: 130, headerName: 'Item Type', field: 'itemName', sortable: true, suppressHeaderMenuButton: true, flex: 1, tooltipField: 'itemName', cellRenderer: ( params: DataGridCellRendererParams, ) => { if (params.node.group) { return null; } return (
); }, }, { minWidth: 180, headerName: 'Service', field: 'serviceName', sortable: true, suppressHeaderMenuButton: true, flex: 1, tooltipField: 'serviceName', }, { minWidth: 130, headerName: showAnnualCost ? 'Annual Cost (USD)' : 'Monthly Cost (USD)', field: 'annualAmount', headerClass: 'legend-marketplace-subscriptions-content__col-header--right', sortable: true, suppressHeaderMenuButton: true, flex: 1, tooltipField: 'annualAmount', valueGetter: (params) => { const node = params.node; if (!node) { return 0; } if (node.group) { return getGroupDisplayedCost(node); } return getDisplayedCost(Number(params.data?.annualAmount ?? 0)); }, cellRenderer: ( params: DataGridCellRendererParams, ) => { const node = params.node; const displayValue = Number(params.value ?? 0); if (!Number.isFinite(displayValue)) { return null; } if (node.group) { if ( node.level === 1 && shouldSuppressPermissionSubtotal(node) ) { return null; } const groupClassName = node.level === 0 ? 'legend-marketplace-subscriptions-content__cost-value legend-marketplace-subscriptions-content__cost-value--vendor' : 'legend-marketplace-subscriptions-content__cost-value legend-marketplace-subscriptions-content__cost-value--permission'; return ( {formatCurrency(displayValue)} ); } return ( {formatCurrency(displayValue)} ); }, }, { minWidth: 100, headerName: 'Cost Code', field: 'costCode', sortable: true, suppressHeaderMenuButton: true, flex: 1, tooltipField: 'costCode', hide: !hasCostCodeData, }, { minWidth: 140, headerName: 'Cancel Subscription', headerClass: 'legend-marketplace-subscriptions-content__col-header--right', cellClass: 'legend-marketplace-subscriptions-content__col-cell--right', suppressHeaderMenuButton: true, flex: 1, cellRenderer: ( params: DataGridCellRendererParams, ) => { const rowData = params.data; if (params.node.group || !rowData) { return null; } return (
{ handleSubscriptionCheckboxChange( rowData, e.target.checked, ); }} />
); }, }, ], [ hasCostCodeData, showAnnualCost, handleSubscriptionCheckboxChange, formatCurrency, getDisplayedCost, getGroupDisplayedCost, shouldSuppressPermissionSubtotal, ], ); const hasSelections = subscriptionStore.selectedSubscriptions.length > 0; const isCancelInProgress = subscriptionStore.cancelSubscriptionState.isInProgress; return (
{/* Header: title + subtitle on the left, action buttons on the right */}
Subscriptions Manage your active Market Data subscriptions.
{userSearchEnabled ? (
{ resetSearchAndFilters(); if (_user.id) { subscriptionStore.setSelectedUser(_user); fetchSubscriptions(_user.id); } else { subscriptionStore.resetSelectedUser(); fetchSubscriptions(initialUser); } }} userSearchService={marketplaceStore.userSearchService} label="Search user" required={true} variant="outlined" fullWidth={true} /> { setUserSearchEnabled(false); resetSearchAndFilters(); const currentUser = new LegendUser(); currentUser.id = initialUser; subscriptionStore.setSelectedUser(currentUser); fetchSubscriptions(initialUser); }} className="legend-marketplace-subscriptions-content__user-search-clear-btn" >
) : ( )} {/* Wrap in a so the Tooltip still fires when the button is disabled */}
{/* KPI bar — hidden while data is loading */} {!subscriptionStore.fetchSubscriptionState.isInProgress && ( )} {/* Search + filter toolbar */} {!subscriptionStore.fetchSubscriptionState.isInProgress && (
handleSearchChange(e.target.value)} slotProps={{ input: { endAdornment: rawSearchText ? ( ) : undefined, }, }} /> Carrier Vendor Item Type
)} {subscriptionStore.fetchSubscriptionState.isInProgress ? ( ) : ( <> {/* Expand / Collapse all control bar */}
params.data.id} // Group rows by carrier vendor (L1) and permission ID (L2). groupDisplayType="singleColumn" groupDefaultExpanded={-1} rowGroupPanelShow="never" getRowClass={(params) => { if (params.node.group !== true) { return 'legend-marketplace-subscriptions-content__row--item'; } return params.node.level === 0 ? 'legend-marketplace-subscriptions-content__row--group-vendor' : 'legend-marketplace-subscriptions-content__row--group-permission'; }} onGridReady={(params) => { gridApiRef.current = params.api; }} onRowGroupOpened={() => { const api = gridApiRef.current; if (!api) { return; } const groupNodes = api .getRenderedNodes() .filter((node) => node.group === true); const hasExpanded = groupNodes.some( (node) => node.expanded, ); const hasCollapsed = groupNodes.some( (node) => !node.expanded, ); if (hasExpanded && hasCollapsed) { setAllGroupsExpanded(null); } else if (hasExpanded) { setAllGroupsExpanded(true); } else { setAllGroupsExpanded(false); } }} />
)}
setIsConfirmDialogOpen(false)} onConfirm={handleConfirmCancellation} />
); }), );