Detect unsaved changes in USD session

One would have assumed that detecting changes made in any of the hosted apps within a USD session should be something pretty straightforward, right?

It turns out to be that no, it’s not as easy… USD 4.1 introduced some functionality that is still buggy and it doesn’t detect these pending changes, so I had to implement something to accomplish this.

USD AgentDesktop is a composite WPF application, so it’s got different areas where different pieces of information are displayed. When a session is created it is stored in the DataContext of SessionTabsControl, in turn this control hosts a variety of different tabs (hosted control). I find it interesting that Session Tabs aren’t stored in an ObservableCollection, hence one could be notified when an item is removed or added to this collection. This is important because it would then be impossible to detect when new tabs are created (hosted control) inside a specific session.

In order to implement this functionality a combination of both, WPF (Visual C#) and JavaScript  was required.  This functionality was implemented in a new class SessionHelper that’s contained in the customization file. The lifecycle of the Session in USD seems to work in a funny way, because the Id of the changes during its lifecycle hence it’s not the same when the Session Tab is rendered to when the data coming from Dynamics 365 has been completely pulled. The reason for this is that I needed to store the Session ID as the Key in a dictionary but since I couldn’t rely on the Session ID I then used the SessionTab itself as the key. If a session was added to this dictionary nothing to be done, but if a new session was created then this session is added.

Why is this so important? It’s important because in the event of having unsaved changes I needed to display a MessageBox for the corresponding tab (hosted control) to prompt users on whether they wanted to save their changes or not. The buttons ( X ) in both parent tab (Session) and inner or children tabs (hosted control) have been bound to a command responsible to handle the button click. This dictionary allows me to hold a reference to Session –> Event handlers (Button) click for all tabs (for both parent and children). So,  I replace the bound command for my event handler, effectively.

 /// <summary>
        /// Initializes this instance.
        /// </summary>
        /// <returns></returns>
        private async Task Initialize() {
            await Task.Run(() => {
                _tmrSessionCheck.Enabled = true;

                // Fetch SessionId when session is created (CustomerId is set once session has been initialized)
                _sessions.SessionShowEvent += a => {
                    OnSessionOpened?.Invoke(this, (AgentDesktopSession)a);
                };

                // Every 2.5 seconds we fetch (if any) recently created tabs (Sessions)
                _tmrSessionCheck.Elapsed += (a, b) => {
                    _tmrSessionCheck.Enabled = false;

                    Application.Current.Dispatcher.Invoke(() => {
                        var sessionTabs = (Utilities.FindChild<SessionTabsControl>(Application.Current?.MainWindow, null)
                            ?.Content as StackPanel)?.Children?.Cast<UIElement>()?.FirstOrDefault() as TabControl;

                        sessionTabs?.Dispatcher.Invoke(() => {
                            var tabs = sessionTabs.Items.Cast<TabItem>()?.ToList();
                            tabs?.ForEach(_ => {
                                var dockPanel = Utilities.FindChild<DockPanel>(_);
                                var closeButton = Utilities.FindChild<Button>(dockPanel);
                                if (!_sessionTabs.ContainsKey(_) && closeButton != null) {
                                    closeButton.Command = null; // Let's override Command with our event handler
                                    closeButton.Click += CloseButton_Click;
                                    ((SessionTabsControl)_.DataContext).SessionClosed += SelectedTab_SessionClosed;
                                    _mutex.WaitOne();

                                    // We register Tab that contains session in DataContext (as key), the value is a tuple that has got three values
                                    // which are Contact/Account button (inner tab) and a dictionary that holds the buttons in recently created tabs
                                    _sessionTabs.Add(_,
                                        new Tuple<SessionTabsControl, Button, Dictionary<Button, object>>(
                                            (SessionTabsControl)_.DataContext, closeButton, new Dictionary<Button, object>()));

                                    _mutex.ReleaseMutex();
                                    ListenToEventsFromChildren(_); // Let's listen to events coming from children (This is to locate close button)
                                    OnSessionRegistered?.Invoke(this, ((SessionTabsControl)_.DataContext).localSession);
                                }
                            });
                        });
                    });
                    _tmrSessionCheck.Enabled = true;
                };
            });
        }

The use of a Mutex is required to protect and ensure that the dictionary will be modified by one thread at a time. Since the Sessions are not stored in a ObservableCollection as mentioned earlier, it’s required to listen to events when a child tab (hosted control) is added thus to replace the command for the button click event handler. The only event that’s raised and notifies us about a hosted control having been  added to the SessionTab is SizeChanged, therefore we rely on the EventManager to filter that event only as shown below

      /// <summary>
        /// Listens to events from children.
        /// </summary>
        /// <param name="selectedTab">The selected tab.</param>
        private void ListenToEventsFromChildren(TabItem selectedTab) {
            var mainPanel = Utilities.FindChild<USDTabPanel>(Application.Current.MainWindow, MainPanel);
            var headerPanel = Utilities.FindChild<TabPanel>(mainPanel, HeaderPanel);
            var type = headerPanel.GetType();

            for (var baseType = type; baseType != null; baseType = baseType.BaseType) {
                var routedEvents = EventManager.GetRoutedEventsForOwner(baseType);
                if (routedEvents != null) {
                    Array.ForEach(routedEvents, _ => {
                        EventManager.RegisterClassHandler(baseType, _, new RoutedEventHandler((a, b) => {
                            TabItemHeader header;
                            if ((header = a as TabItemHeader) != null && b.RoutedEvent.ToString().Contains(SizeChangedEvent)) {
                                var tabItem = header.FindParentByType<TabItem>();
                                var dockPanel = Utilities.FindChild<DockPanel>(tabItem);
                                var closeButton = Utilities.FindChild<Button>(dockPanel);

                                // Have we replaced command in recently created tab for our event handler?
                                if (_sessionTabs.ContainsKey(selectedTab) && closeButton != null &&
                                    !_sessionTabs[selectedTab].Item3.ContainsKey(closeButton)) {
                                    var isMainEntityTab = selectedTab.ToString().ToLower().Contains("session");
                                    _mutex.WaitOne();
                                    closeButton.Command = null; // Let's override Command with our event handler
                                    closeButton.Click += CloseButton_Click;
                                    _sessionTabs[selectedTab].Item3.Add(closeButton, isMainEntityTab);
                                    _mutex.ReleaseMutex();
                                }
                            }
                        }), false);
                    });
                }
            }
        }

To recap what’s been done so far it’s to replace bound command to button click by our custom event handler that will run and check for unsaved changes. We need to store the event handler for the buttons that are created otherwise we’ll be leaking memory, because event handles occupy memory. Some developers they prefer to use a lambda when setting an event handler, and this is supported but they don’t provide means or ways to unsubscribe from when destroying the object that was holding that reference.

When any button ( X ) is clicked our USD can react accordingly. If the Session was clicked then it’s closed otherwise we’ll close the current Tab (hosted control).

        /// <summary>
        /// Handles the Click event of the CloseButton control.
        /// </summary>
        /// <param name="sender">The source of the event.</param>
        /// <param name="e">The <see cref="RoutedEventArgs"/> instance containing the event data.</param>
        private void CloseButton_Click(object sender, RoutedEventArgs e) {
            // Is it the Session or a tab we're closing?
            var isSessionOrTab = ((FrameworkElement)((Button)sender).Parent).Parent is Grid;

            // Get a reference to Session button
            var sessionButton = _sessionTabs?.FirstOrDefault(_ => _.Value.Item2 == (Button)sender);

            // Get a reference to hosted control in question
            var findHostedControl = _sessionTabs?.FirstOrDefault(_ => _.Value.Item2 == (Button)sender || _.Value.Item3.ContainsKey((Button)sender));
            var container = findHostedControl?.Value?.Item1;

            // Since we've changed way landing page is hosted, it's very likely that session can be closed
            // and then any external (non-hosted in session) tabs to be dismissed separately. Since Session tab (container)
            // does not exist, we'll just process click of these external (non-hosted tabs) without performing any session
            // termination tasks
            if (container == null) {
                CloseTabFunc(sender);
                return;
            }

            // Fire event to run javascript and check for changes
            container?.FireEvent(OnPageIsDirtyEvent, new Dictionary<string, string> { { "useCustomCheck", "true" } });

            // Let's fetch return value from Javascript function that was called upon firing event before
            var desktopCustomer = ((AgentDesktopSession)container.GetActiveSession()).Customer.DesktopCustomer as DynamicsCustomerRecord;

            // Get replacement variables returned by action calls
            var dirtyPages = desktopCustomer?.CapturedReplacementVariables?.FirstOrDefault(_ => string.Equals(ReplacementVariableReturn,
                    _.Key, StringComparison.OrdinalIgnoreCase)).Value?
                .Where(r => r.Key.ToLower().Contains(ReturnedVariableName)).ToList();

            // What pages are dirty?
            var isPageDirty = dirtyPages?.Where(_ => _.Value.value.ToLower().Contains("true")).ToList();
            var currentTab = Utilities.FindChild<TabItemHeader>(((Button)sender).Parent);
            var isCurrentTabDirty = currentTab != null && isPageDirty?.FirstOrDefault(_ => _.Key.ToLower().Contains(currentTab?.TabTitle?.ToLower())).Value != null;

            // Are there any dirty pages or is the current tab dirty?
            if (isCurrentTabDirty || (object.Equals(sender, sessionButton?.Value?.Item2) && isPageDirty?.Count > 0)) {
                var message = string.Format(PromptWithoutSavingMessage, (isSessionOrTab ? new object[] { "current tab" } : new object[] { "Session" }));
                var result = MessageBox.Show($"{ message } {GetDirtyPagesName(dirtyPages)}",
                                             PromptConfirmation, MessageBoxButton.YesNo);
                e.Handled = result == MessageBoxResult.No ? true : false;

                if (result == MessageBoxResult.Yes)
                    CloseTabFunc(sender, sessionButton);
            } else
                CloseTabFunc(sender, sessionButton);
        }

If selected tab (this applies to both, session tab and hosted controls) doesn’t have any unsaved changes then we don’t do anything but we rebind the command that was bound originally, otherwise we’ll fire an event that in turn runs an action call for all hosted apps and it’ll return a Boolean to indicate whether the pages has got unsaved changes or not. This is parsed and retrieved by querying  the CapturedReplacement variables.

There’s a generic method responsible to handle the button click (event handler) but also to release event handlers for all the buttons in the tabs (hosted control) that might exist within the Session.

     /// <summary>
        /// Function to handle closing tabs (with or without pending changes)
        /// </summary>
        /// <param name="sender">The sender.</param>
        /// <param name="sessionButton">The session button.</param>
        private void CloseTabFunc(object sender, KeyValuePair<TabItem, Tuple<SessionTabsControl, Button, Dictionary<Button, object>>>? sessionButton = null) {
            if (sessionButton?.Key != null) {
                var isMainEntityTab = _sessionTabs[sessionButton?.Key].Item3?.FirstOrDefault(r => r.Key == (Button)sender);

                // Was the session or account/contact tab clicked?
                if (((FrameworkElement)sender).DataContext is SessionTabsControl || (isMainEntityTab.HasValue &&
                                                                                     isMainEntityTab.Value.Value is bool &&
                                                                                     (bool)isMainEntityTab.Value.Value)) {
                    if (_sessionTabs.ContainsKey(sessionButton?.Key)) {
                        _sessionTabs[sessionButton?.Key].Item2.Click -= CloseButton_Click;
                        _sessionTabs[sessionButton?.Key].Item3?.Keys.ToList().ForEach(r => r.Click -= CloseButton_Click);
                        _mutex.WaitOne();
                        sessionButton?.Value.Item1?.FireRequestAction(new RequestActionEventArgs(TargetedControl, ActionToFire, string.Empty));
                        _sessionTabs.Remove(sessionButton?.Key);
                        _mutex.ReleaseMutex();
                    }
                }
            } else { // We'll handle internal tabs (non account or contact)
                var tabContainer = ((DependencyObject)sender).FindParentByType<TabControl>();
                var parent = ((DependencyObject)sender).FindParentByType<TabItem>() as USDTab;
                ((Button)sender).Click -= CloseButton_Click;
                ((Button)sender).Command = (ICommand)ActionCommands.CloseControl;
            }

            // Fire event to clear $Return replacement variable and isPageDirty flag       
            var crmGlobalManager = ApplicationManager.Current?.AppHost?.GetApplications()?.FirstOrDefault(_ => string.Equals(_.ApplicationName.Replace(" ", string.Empty),
                                     "CrmGlobalManager", StringComparison.OrdinalIgnoreCase)) as CRMGlobalManager;

            crmGlobalManager?.FireEvent(OnClearPageIsDirtyEvent);
        }

There are a couple of helper methods that I use to traverse up or down the VisualTree to find the elements (in this case, DockPanel and Buttons) to replace the default commands with our event handler.

Leave a Reply

Your email address will not be published. Required fields are marked *