File: Components\Pages\IPageWithSessionAndUrlState.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Dashboard.Components.Layout;
using Microsoft.AspNetCore.Components;
 
namespace Aspire.Dashboard.Components.Pages;
 
/// <summary>
/// Represents a page that can contain state both in the url and in localstorage.
/// Navigating back to the page will restore the previous page state
/// </summary>
/// <typeparam name="TViewModel">The view model containing live state</typeparam>
/// <typeparam name="TSerializableViewModel">A serializable version of <typeparamref name="TViewModel"/> that will be saved in session storage and restored from</typeparam>
public interface IPageWithSessionAndUrlState<TViewModel, TSerializableViewModel>
    where TSerializableViewModel : class
{
    /// <summary>
    /// The base relative path of the page (ie, Metrics for /Metrics)
    /// </summary>
    public string BasePath { get; }
 
    /// <summary>
    /// The key to save page state to
    /// </summary>
    public string SessionStorageKey { get; }
 
    public NavigationManager NavigationManager { get; }
    public ISessionStorage SessionStorage { get; }
 
    /// <summary>
    /// The view model containing live state, to be instantiated in OnInitialized.
    /// </summary>
    public TViewModel PageViewModel { get; set; }
 
    /// <summary>
    /// Computes the initial view model state based on query param values
    /// </summary>
    public Task UpdateViewModelFromQueryAsync(TViewModel viewModel);
 
    /// <summary>
    /// Translates the <param name="serializable">serializable form of the view model</param> to a relative URL associated
    /// with that state
    /// </summary>
    public string GetUrlFromSerializableViewModel(TSerializableViewModel serializable);
 
    /// <summary>
    /// Maps <typeparamref name="TViewModel"/> to <typeparamref name="TSerializableViewModel"/>, which should contain simple types.
    /// </summary>
    public TSerializableViewModel ConvertViewModelToSerializable();
}
 
public static class PageExtensions
{
    /// <summary>
    /// Called after a change in the view model that will affect the url associated with new page state
    /// to navigate to the new url and save new state in localstorage.
    /// <param name="page"></param>
    /// <param name="layout"></param>
    /// <param name="waitToApplyMobileChange">Whether we should avoid applying this change immediately on mobile, and instead
    /// only once the toolbar has been closed.</param>
    /// </summary>
    public static async Task AfterViewModelChangedAsync<TViewModel, TSerializableViewModel>(this IPageWithSessionAndUrlState<TViewModel, TSerializableViewModel> page, AspirePageContentLayout? layout, bool waitToApplyMobileChange) where TSerializableViewModel : class
    {
        // if the mobile filter dialog is open, we want to wait until the dialog is closed to apply all changes
        // we should only apply the last invocation, as TViewModel will be up-to-date
        if (layout is not null && !layout.ViewportInformation.IsDesktop && waitToApplyMobileChange)
        {
            layout.DialogCloseListeners[nameof(AfterViewModelChangedAsync)] = SetStateAndNavigateAsync;
            return;
        }
 
        await SetStateAndNavigateAsync();
        return;
 
        async Task SetStateAndNavigateAsync()
        {
            var serializableViewModel = page.ConvertViewModelToSerializable();
            var pathWithParameters = page.GetUrlFromSerializableViewModel(serializableViewModel);
 
            page.NavigationManager.NavigateTo(pathWithParameters);
            await page.SessionStorage.SetAsync(page.SessionStorageKey, serializableViewModel).ConfigureAwait(false);
        }
    }
 
    /// <summary>
    /// If first visiting the page then initialize page state from storage and redirect using page state.
    /// </summary>
    /// <returns>
    /// A value indicating whether there was a page redirect. Further page initialization should check the return value
    /// and wait until parameters are updated if there was a page redirect.
    /// </returns>
    public static async Task<bool> InitializeViewModelAsync<TViewModel, TSerializableViewModel>(this IPageWithSessionAndUrlState<TViewModel, TSerializableViewModel> page) where TSerializableViewModel : class
    {
        if (string.Equals(page.BasePath, page.NavigationManager.ToBaseRelativePath(page.NavigationManager.Uri)))
        {
            var result = await page.SessionStorage.GetAsync<TSerializableViewModel>(page.SessionStorageKey).ConfigureAwait(false);
            if (result is { Success: true, Value: not null })
            {
                var newUrl = page.GetUrlFromSerializableViewModel(result.Value).ToString();
 
                // Don't navigate if the URL redirects to itself.
                if (newUrl != "/" + page.BasePath)
                {
                    // Replace the initial address with this navigation.
                    // We do this because the visit to "/{BasePath}" then redirect to the final address is automatic from the user perspective.
                    // Replacing the visit to "/{BasePath}" is good because we want to take the user back to where they started, not an intermediary address.
                    page.NavigationManager.NavigateTo(newUrl, new NavigationOptions { ReplaceHistoryEntry = true });
                    return true;
                }
            }
        }
 
        ArgumentNullException.ThrowIfNull(page.PageViewModel, nameof(page.PageViewModel));
        await page.UpdateViewModelFromQueryAsync(page.PageViewModel);
        return false;
    }
}