From dad37a6b117f6b96f8c84679227568b66711e172 Mon Sep 17 00:00:00 2001 From: Deyan Nenov Date: Wed, 28 Feb 2024 02:34:24 +0000 Subject: [PATCH] Dynamo new homepage implementation (#14879) * initial WIP - initial structure to create a homePage user control replacing the current home page - currently loads graphs to the web app ui * initial changes - incorporated the new home page web app * moved loading to frontend - now the front end will prompt the loading moment using useEffects rather than relying on the component being loaded after the webView2 was done loading * guided tours implemented - implemented guided tours to the landing page * samples backend code - implemented samples backend implementation - reuses existing viewmodel logic * added ShowSampleFilesInFolder - added ShowSampleFilesInFolder existing in the current Home Page experience * local bundle files added to source for testing purposes - added bundle.js and index.html to source control for testing purposes and while implementation takes place * resolving build issues - remove WIP bundle.js to try to resolve building for upstream * replaced .net6 complient API method - net6 build error fix * added guides description - added localization description to user guides - trimming the '_' from the start of the guides names (happens on the back end) * send image author data test - internal mock-up on testing graph image data * set sidebar width relative to user - the sidebar in Dynamo is resizable, so there is no 'default' value - we are setting the initial width based on the current value - will not update live, but will update on the next run (when initializing the StartPage from DynamoView) * remove coupling to sidebar width, implemented Template API - added Template call - removed sidebar width sync between backend and front end - added the ability to grip-resize sidebar in frontend NOT PERSISTED * for review - publish for review * tests added - added test coverage on all interaction logic between the back and the front end * remove old code * npm build implementation - now builds from the latest npm package - webView2 swapped to DynamoWebView2 class * update dynamohome build - the new build was interfering with SplashScreen build * comments - dynamically adding locale to front end - extracted repeated webView2 to DynamoUtilities helper methods (inside the PathHelper) - fixed dynamically loading of the Artifakt font resource - added new public functions to the respective API text files - removed unneeded stopwatch in StartPage.xaml.cs * tests for new helper methods - added tests for the new public helper methods - added null checks handling * checks for valid json dyn input - now correctly checks if the recent file is of valid json input - it assumes that any other format but json is an old dynamo 1.0 format (not checking if xml or anything else) * swap 1.0 for 1.x - swap dynamo 1.0 message in favor of 1.x * update * Update --------- Co-authored-by: Aaron (Qilong) --- src/DynamoCoreWpf/Controls/StartPage.xaml.cs | 78 +- src/DynamoCoreWpf/DynamoCoreWpf.csproj | 58 +- .../Properties/Resources.Designer.cs | 36 + .../Properties/Resources.en-US.resx | 12 + src/DynamoCoreWpf/Properties/Resources.resx | 12 + src/DynamoCoreWpf/PublicAPI.Unshipped.txt | 1 + src/DynamoCoreWpf/Views/Core/DynamoView.xaml | 74 +- .../Views/Core/DynamoView.xaml.cs | 16 +- .../Views/HomePage/HomePage.xaml | 10 + .../Views/HomePage/HomePage.xaml.cs | 559 ++++++++++++++ src/DynamoUtilities/PathHelper.cs | 79 ++ src/DynamoUtilities/PublicAPI.Unshipped.txt | 3 + test/DynamoCoreTests/DynamoCoreTests.csproj | 6 + .../Resources/TestResource.txt | 1 + test/DynamoCoreTests/UtilityTests.cs | 54 ++ test/DynamoCoreWpfTests/HomePageTests.cs | 702 ++++++++++++++++++ .../NotificationsExtensionTests.cs | 2 +- 17 files changed, 1668 insertions(+), 35 deletions(-) create mode 100644 src/DynamoCoreWpf/Views/HomePage/HomePage.xaml create mode 100644 src/DynamoCoreWpf/Views/HomePage/HomePage.xaml.cs create mode 100644 test/DynamoCoreTests/Resources/TestResource.txt create mode 100644 test/DynamoCoreWpfTests/HomePageTests.cs diff --git a/src/DynamoCoreWpf/Controls/StartPage.xaml.cs b/src/DynamoCoreWpf/Controls/StartPage.xaml.cs index f9b7066b1e4..8126d825b2f 100644 --- a/src/DynamoCoreWpf/Controls/StartPage.xaml.cs +++ b/src/DynamoCoreWpf/Controls/StartPage.xaml.cs @@ -15,6 +15,7 @@ using Dynamo.Utilities; using Dynamo.ViewModels; using Dynamo.Wpf.Properties; +using Newtonsoft.Json; using NotificationObject = Dynamo.Core.NotificationObject; namespace Dynamo.UI.Controls @@ -72,6 +73,10 @@ internal StartPageListItem(string caption, string iconPath) public string Caption { get; private set; } public string SubScript { get; set; } public string ToolTip { get; set; } + public string DateModified { get; set; } + public string Description { get; internal set; } + public string Thumbnail { get; set; } + public string Author { get; internal set; } public string ContextData { get; set; } public Action ClickAction { get; set; } @@ -371,27 +376,94 @@ private void RefreshFileList(ObservableCollection files, { try { + // Skip files which were moved or deleted (consistent with Revit behavior) + if (!DynamoUtilities.PathHelper.IsValidPath(filePath)) continue; + var extension = Path.GetExtension(filePath).ToUpper(); // If not extension specified and code reach here, this means this is still a valid file // only without file type. Otherwise, simply take extension substring skipping the 'dot'. - var subScript = extension.IndexOf(".") == 0 ? extension.Substring(1) : ""; + var subScript = extension.StartsWith(".") ? extension.Substring(1) : ""; var caption = Path.GetFileNameWithoutExtension(filePath); + // deserializes the file only once + var jsonObject = DeserializeJsonFile(filePath); + var description = jsonObject != null ? GetGraphDescription(jsonObject) : string.Empty; + var thumbnail = jsonObject != null ? GetGraphThumbnail(jsonObject) : string.Empty; + var author = jsonObject != null ? GetGraphAuthor(jsonObject) : Resources.DynamoXmlFileFormat; + + var date = DynamoUtilities.PathHelper.GetDateModified(filePath); + files.Add(new StartPageListItem(caption) { ContextData = filePath, ToolTip = filePath, SubScript = subScript, - ClickAction = StartPageListItem.Action.FilePath - }); + Description = description, + Thumbnail = thumbnail, + Author = author, + DateModified = date, + ClickAction = StartPageListItem.Action.FilePath, + + }); } catch (ArgumentException ex) { DynamoViewModel.Model.Logger.Log("File path is not valid: " + ex.StackTrace); } + catch (Exception ex) + { + DynamoViewModel.Model.Logger.Log("Error loading the file: " + ex.StackTrace); + } } } + private Dictionary DeserializeJsonFile(string filePath) + { + if (DynamoUtilities.PathHelper.isValidJson(filePath, out string jsonString, out Exception ex)) + { + return JsonConvert.DeserializeObject>(jsonString); + } + else + { + if(ex is JsonReaderException) + { + DynamoViewModel.Model.Logger.Log("File is not a valid json format."); + } + else + { + DynamoViewModel.Model.Logger.Log("File is not valid: " + ex.StackTrace); + } + return null; + } + } + + private const string BASE64PREFIX = "data:image/png;base64,"; + + private string GetGraphThumbnail(Dictionary jsonObject) + { + jsonObject.TryGetValue("Thumbnail", out object thumbnail); + + if (string.IsNullOrEmpty(thumbnail as string)) return string.Empty; + + var base64 = String.Format("{0}{1}", BASE64PREFIX, thumbnail as string); + + return base64; + } + + private string GetGraphDescription(Dictionary jsonObject) + { + jsonObject.TryGetValue("Description", out object description); + + return description as string; + } + + private string GetGraphAuthor(Dictionary jsonObject) + { + jsonObject.TryGetValue("Author", out object author); + + return author as string; + } + private void HandleRegularCommand(StartPageListItem item) { var dvm = this.DynamoViewModel; diff --git a/src/DynamoCoreWpf/DynamoCoreWpf.csproj b/src/DynamoCoreWpf/DynamoCoreWpf.csproj index 3cfb3dfa844..e0503873367 100644 --- a/src/DynamoCoreWpf/DynamoCoreWpf.csproj +++ b/src/DynamoCoreWpf/DynamoCoreWpf.csproj @@ -1,4 +1,4 @@ - + true @@ -50,11 +50,46 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + %(TGZFilesDynamoHome.Filename) + + + + + + + + + + + + + + + @@ -90,6 +125,8 @@ + + @@ -447,6 +484,7 @@ SurveyPopupWindow.xaml + @@ -503,6 +541,9 @@ SplashScreen.xaml + + HomePage.xaml + NodeView.xaml @@ -1558,6 +1599,12 @@ Designer + + + MSBuild:Compile + Designer + + {7858fa8c-475f-4b8e-b468-1f8200778cf8} @@ -1770,6 +1817,9 @@ + + MSBuild:Compile + MSBuild:Compile diff --git a/src/DynamoCoreWpf/Properties/Resources.Designer.cs b/src/DynamoCoreWpf/Properties/Resources.Designer.cs index f56944081ad..6e04b4bb8a8 100644 --- a/src/DynamoCoreWpf/Properties/Resources.Designer.cs +++ b/src/DynamoCoreWpf/Properties/Resources.Designer.cs @@ -2665,6 +2665,15 @@ public static string DynamoViewViewMenuZoomOut { } } + /// + /// Looks up a localized string similar to Dynamo 1.x file format. + /// + public static string DynamoXmlFileFormat { + get { + return ResourceManager.GetString("DynamoXmlFileFormat", resourceCulture); + } + } + /// /// Looks up a localized string similar to Edit Group Title. /// @@ -3245,6 +3254,15 @@ public static string GetStartedGuide { } } + /// + /// Looks up a localized string similar to Start your visual programming journey with this short guide. Here you'll learn some basics about the Dynamo interface and features.. + /// + public static string GetStartedGuideDescription { + get { + return ResourceManager.GetString("GetStartedGuideDescription", resourceCulture); + } + } + /// /// Looks up a localized string similar to The library contains all default functions #(nodes)=https://primer2.dynamobim.org/4_nodes_and_wires of Dynamo, as well as custom nodes you may have loaded. \n\nTo find a node, search the library or browse its categories.. /// @@ -5206,6 +5224,15 @@ public static string OnboardingGuideConnectNodesTitle { } } + /// + /// Looks up a localized string similar to Learn about the basic building blocks of Dynamo. Get hands-on practice working with a graph.. + /// + public static string OnboardingGuideDescription { + get { + return ResourceManager.GetString("OnboardingGuideDescription", resourceCulture); + } + } + /// /// Looks up a localized string similar to Let’s run the graph and see the results of the adjustments you made. Click Run.. /// @@ -6662,6 +6689,15 @@ public static string PackagesGuideDependenciesTitle { } } + /// + /// Looks up a localized string similar to A package is a toolkit of utilities that let you do more with Dynamo, beyond its core functionality. This guide shows how to find, install, and use packages. It installs a sample Autodesk package for you to explore.. + /// + public static string PackagesGuideDescription { + get { + return ResourceManager.GetString("PackagesGuideDescription", resourceCulture); + } + } + /// /// Looks up a localized string similar to To continue the guide and install the sample package, you must accept the Terms of Service. \n\n **Click Continue.** Then in the terms, **click I Accept.** \n\n\n\n. /// diff --git a/src/DynamoCoreWpf/Properties/Resources.en-US.resx b/src/DynamoCoreWpf/Properties/Resources.en-US.resx index a5aa96255df..ea3d8cbeb24 100644 --- a/src/DynamoCoreWpf/Properties/Resources.en-US.resx +++ b/src/DynamoCoreWpf/Properties/Resources.en-US.resx @@ -2619,6 +2619,15 @@ Do you wish to uninstall {1}? Restart {2} to complete the uninstall and try down _User Interface Tour Get Started Dynamo Tour + + Start your visual programming journey with this short guide. Here you'll learn some basics about the Dynamo interface and features. + + + Learn about the basic building blocks of Dynamo. Get hands-on practice working with a graph. + + + A package is a toolkit of utilities that let you do more with Dynamo, beyond its core functionality. This guide shows how to find, install, and use packages. It installs a sample Autodesk package for you to explore. + _Interactive Guides Dynamo Guided Tours @@ -3922,4 +3931,7 @@ In certain complex graphs or host program scenarios, Automatic mode may cause in Enable Paneling nodes Preferences | Features | Experimental | Enable Paneling nodes + + Dynamo 1.x file format + diff --git a/src/DynamoCoreWpf/Properties/Resources.resx b/src/DynamoCoreWpf/Properties/Resources.resx index 38878147b95..d6e033680f6 100644 --- a/src/DynamoCoreWpf/Properties/Resources.resx +++ b/src/DynamoCoreWpf/Properties/Resources.resx @@ -2938,6 +2938,15 @@ Do you wish to uninstall {1}? Restart {2} to complete the uninstall and try down _Interactive Guides Dynamo Guided Tours + + Start your visual programming journey with this short guide. Here you'll learn some basics about the Dynamo interface and features. + + + Learn about the basic building blocks of Dynamo. Get hands-on practice working with a graph. + + + A package is a toolkit of utilities that let you do more with Dynamo, beyond its core functionality. This guide shows how to find, install, and use packages. It installs a sample Autodesk package for you to explore. + The library contains all default functions #(nodes)=https://primer2.dynamobim.org/4_nodes_and_wires of Dynamo, as well as custom nodes you may have loaded. \n\nTo find a node, search the library or browse its categories. @@ -3909,4 +3918,7 @@ In certain complex graphs or host program scenarios, Automatic mode may cause in Enable Paneling nodes Preferences | Features | Experimental | Enable Paneling nodes + + Dynamo 1.x file format + diff --git a/src/DynamoCoreWpf/PublicAPI.Unshipped.txt b/src/DynamoCoreWpf/PublicAPI.Unshipped.txt index a58f458aab6..30e83c97c6d 100644 --- a/src/DynamoCoreWpf/PublicAPI.Unshipped.txt +++ b/src/DynamoCoreWpf/PublicAPI.Unshipped.txt @@ -201,6 +201,7 @@ Dynamo.Controls.DynamoView.ExtensionsCollapsed.get -> bool Dynamo.Controls.DynamoView.InitializeComponent() -> void Dynamo.Controls.DynamoView.LibraryCollapsed.get -> bool Dynamo.Controls.DynamoView.PlacePopup(System.Windows.Size popupSize, System.Windows.Size targetSize, System.Windows.Point offset) -> System.Windows.Controls.Primitives.CustomPopupPlacement[] +Dynamo.Controls.DynamoView.IsNewAppHomeEnabled.get -> bool Dynamo.Controls.ElementGroupToColorConverter Dynamo.Controls.ElementGroupToColorConverter.Convert(object value, System.Type targetType, object parameter, System.Globalization.CultureInfo culture) -> object Dynamo.Controls.ElementGroupToColorConverter.ConvertBack(object value, System.Type targetType, object parameter, System.Globalization.CultureInfo culture) -> object diff --git a/src/DynamoCoreWpf/Views/Core/DynamoView.xaml b/src/DynamoCoreWpf/Views/Core/DynamoView.xaml index 0d4de413d6b..44be7126d45 100644 --- a/src/DynamoCoreWpf/Views/Core/DynamoView.xaml +++ b/src/DynamoCoreWpf/Views/Core/DynamoView.xaml @@ -7,6 +7,7 @@ xmlns:controls="clr-namespace:Dynamo.Controls" xmlns:workspaces="clr-namespace:Dynamo.Graph.Workspaces;assembly=DynamoCore" xmlns:ui="clr-namespace:Dynamo.UI" + xmlns:uiviews="clr-namespace:Dynamo.UI.Views" xmlns:uictrls="clr-namespace:Dynamo.UI.Controls" xmlns:service="clr-namespace:Dynamo.Services" xmlns:viewModels="clr-namespace:Dynamo.ViewModels" @@ -1760,31 +1761,54 @@ Cursor="/DynamoCoreWpf;component/UI/Images/resize_horizontal.cur" /> - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DynamoCoreWpf/Views/Core/DynamoView.xaml.cs b/src/DynamoCoreWpf/Views/Core/DynamoView.xaml.cs index 1a616efccd9..64506622f33 100644 --- a/src/DynamoCoreWpf/Views/Core/DynamoView.xaml.cs +++ b/src/DynamoCoreWpf/Views/Core/DynamoView.xaml.cs @@ -15,7 +15,6 @@ using System.Windows.Media.Imaging; using System.Windows.Threading; using Dynamo.Configuration; -using Dynamo.Core; using Dynamo.Graph; using Dynamo.Graph.Nodes; using Dynamo.Graph.Notes; @@ -24,7 +23,6 @@ using Dynamo.Logging; using Dynamo.Models; using Dynamo.Nodes; -using Dynamo.Nodes.Prompts; using Dynamo.PackageManager; using Dynamo.PackageManager.UI; using Dynamo.Search.SearchElements; @@ -1210,6 +1208,7 @@ private void InitializeStartPage(bool isFirstRun) startPage = new StartPageViewModel(dynamoViewModel, isFirstRun); startPageItemsControl.Items.Add(startPage); + homePage.DataContext = startPage; } } @@ -2022,6 +2021,8 @@ private void WindowClosed(object sender, EventArgs e) this.dynamoViewModel.RequestExportWorkSpaceAsImage -= OnRequestExportWorkSpaceAsImage; this.dynamoViewModel.RequestShorcutToolbarLoaded -= onRequestShorcutToolbarLoaded; + this.homePage.Dispose(); + this.Dispose(); sharedViewExtensionLoadedParams?.Dispose(); this._pkgSearchVM?.Dispose(); @@ -2629,6 +2630,17 @@ public bool ExtensionsCollapsed } } + /// + /// A feature flag controlling the appearance of the Dynamo home navigation page + /// + public bool IsNewAppHomeEnabled + { + get + { + return DynamoModel.FeatureFlags?.CheckFeatureFlag("IsNewAppHomeEnabled", false) ?? false; + } + } + // Check if library is collapsed or expanded and apply appropriate button state private void UpdateLibraryCollapseIcon() { diff --git a/src/DynamoCoreWpf/Views/HomePage/HomePage.xaml b/src/DynamoCoreWpf/Views/HomePage/HomePage.xaml new file mode 100644 index 00000000000..3a0bd04fcac --- /dev/null +++ b/src/DynamoCoreWpf/Views/HomePage/HomePage.xaml @@ -0,0 +1,10 @@ + + + + diff --git a/src/DynamoCoreWpf/Views/HomePage/HomePage.xaml.cs b/src/DynamoCoreWpf/Views/HomePage/HomePage.xaml.cs new file mode 100644 index 00000000000..3fb3fe705f0 --- /dev/null +++ b/src/DynamoCoreWpf/Views/HomePage/HomePage.xaml.cs @@ -0,0 +1,559 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Windows; +using System.Windows.Controls; +using Dynamo.Models; +using Dynamo.UI.Controls; +using Dynamo.Utilities; +using Dynamo.Wpf.UI.GuidedTour; +using Dynamo.Wpf.Utilities; +using DynamoUtilities; +using Microsoft.Web.WebView2.Core; +using Microsoft.Web.WebView2.Wpf; + + +namespace Dynamo.UI.Views +{ + /// + /// Interaction logic for HomePage.xaml + /// + public partial class HomePage : UserControl, IDisposable + { + private static readonly string htmlEmbeddedFile = "Dynamo.Wpf.Packages.DynamoHome.build.index.html"; + private static readonly string jsEmbeddedFile = "Dynamo.Wpf.Packages.DynamoHome.build.index.bundle.js"; + private static readonly string fontStylePath = "Dynamo.Wpf.Views.GuidedTour.HtmlPages.Resources.ArtifaktElement-Regular.woff"; + private static readonly string virtualFolderName = "embeddedFonts"; + private static readonly string fontUrl = $"http://{virtualFolderName}/ArtifaktElement-Regular.woff"; + private static readonly string virtualFolderPath = Path.Combine(Path.GetTempPath(), virtualFolderName); + + private string fontFilePath; + + private StartPageViewModel startPage; + + /// + /// The WebView2 Browser instance used to display splash screen + /// + internal DynamoWebView2 dynWebView; + + internal Action RequestOpenFile; + internal Action RequestShowGuidedTour; + internal Action RequestNewWorkspace; + internal Action RequestOpenWorkspace; + internal Action RequestNewCustomNodeWorkspace; + internal Action RequestApplicationLoaded; + internal Action RequestShowSampleFilesInFolder; + internal Action RequestShowBackupFilesInFolder; + internal Action RequestShowTemplate; + + internal List GuidedTourItems; + + /// + /// A helper tool to let us test flows without relying on side-effects + /// + internal static Action TestHook { get; set; } + + public HomePage() + { + InitializeComponent(); + InitializeGuideTourItems(); + + dynWebView = new DynamoWebView2(); + + dynWebView.Margin = new System.Windows.Thickness(0); // Set margin to zero + dynWebView.ZoomFactor = 1.0; // Set zoom factor (optional) + + HostGrid.Children.Add(dynWebView); + + // Bind event handlers + RequestOpenFile = OpenFile; + RequestShowGuidedTour = StartGuidedTour; + RequestNewWorkspace = NewWorkspace; + RequestOpenWorkspace = OpenWorkspace; + RequestNewCustomNodeWorkspace = NewCustomNodeWorkspace; + RequestShowSampleFilesInFolder = ShowSampleFilesInFolder; + RequestShowBackupFilesInFolder = ShowBackupFilesInFolder; + RequestShowTemplate = ShowTemplate; + RequestApplicationLoaded = ApplicationLoaded; + + DataContextChanged += OnDataContextChanged; + + } + + + private void InitializeGuideTourItems() + { + GuidedTourItems = new List + { + new GuidedTourItem(Wpf.Properties.Resources.GetStartedGuide.TrimStart('_'), + Wpf.Properties.Resources.GetStartedGuideDescription, GuidedTourType.UserInterface.ToString()), + new GuidedTourItem(Wpf.Properties.Resources.OnboardingGuide.TrimStart('_'), + Wpf.Properties.Resources.OnboardingGuideDescription, GuidedTourType.GetStarted.ToString()), + new GuidedTourItem(Wpf.Properties.Resources.PackagesGuide.TrimStart('_'), + Wpf.Properties.Resources.PackagesGuideDescription, GuidedTourType.Packages.ToString()) + }; + } + + private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e) + { + startPage = this.DataContext as StartPageViewModel; + + if (startPage != null) + { + startPage.DynamoViewModel.PropertyChanged += DynamoViewModel_PropertyChanged; + } + } + + private void DynamoViewModel_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if(dynWebView?.CoreWebView2 != null && e.PropertyName.Equals(nameof(startPage.DynamoViewModel.ShowStartPage))) + { + dynWebView.CoreWebView2.ExecuteScriptAsync(@$"window.setShowStartPageChanged('{startPage.DynamoViewModel.ShowStartPage}')"); + } + } + + /// + /// This is used before DynamoModel initialization specifically to get user data dir + /// + /// + private string GetUserDirectory() + { + var version = AssemblyHelper.GetDynamoVersion(); + + var folder = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + return Path.Combine(Path.Combine(folder, "Dynamo", "Dynamo Core"), + String.Format("{0}.{1}", version.Major, version.Minor)); + } + + private async void UserControl_Loaded(object sender, System.Windows.RoutedEventArgs e) + { + string htmlString = string.Empty; + string jsonString = string.Empty; + + // When executing Dynamo as Sandbox or inside any host like Revit, FormIt, Civil3D the WebView2 cache folder will be located in the AppData folder + var userDataDir = new DirectoryInfo(GetUserDirectory()); + PathHelper.CreateFolderIfNotExist(userDataDir.ToString()); + var webBrowserUserDataFolder = userDataDir.Exists ? userDataDir : null; + + dynWebView.CreationProperties = new CoreWebView2CreationProperties + { + UserDataFolder = webBrowserUserDataFolder.FullName + }; + + //ContentRendered ensures that the webview2 component is visible. + try + { + await dynWebView.Initialize(); + + // Set WebView2 settings + this.dynWebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false; + this.dynWebView.CoreWebView2.Settings.IsZoomControlEnabled = false; + this.dynWebView.CoreWebView2.Settings.AreDevToolsEnabled = true; + + // Load the embeded resources + var assembly = Assembly.GetExecutingAssembly(); + + htmlString = PathHelper.LoadEmbeddedResourceAsString(htmlEmbeddedFile, assembly); + jsonString = PathHelper.LoadEmbeddedResourceAsString(jsEmbeddedFile, assembly); + + // Embed the font + PathHelper.ExtractAndSaveEmbeddedFont(fontStylePath, virtualFolderPath, "ArtifaktElement-Regular.woff", assembly); + + // Set up virtual host name to folder mapping + dynWebView.CoreWebView2.SetVirtualHostNameToFolderMapping(virtualFolderName, virtualFolderPath, CoreWebView2HostResourceAccessKind.Allow); + + htmlString = htmlString.Replace("mainJs", jsonString); + htmlString = htmlString.Replace("#fontStyle", fontUrl); + + try + { + dynWebView.NavigateToString(htmlString); + } + catch (Exception ex) + { + this.startPage.DynamoViewModel.Model.Logger.Log(ex.Message); + } + + // Exposing commands to the React front-end + dynWebView.CoreWebView2.AddHostObjectToScript("scriptObject", + new ScriptHomeObject(RequestOpenFile, + RequestNewWorkspace, + RequestOpenWorkspace, + RequestNewCustomNodeWorkspace, + RequestApplicationLoaded, + RequestShowGuidedTour, + RequestShowSampleFilesInFolder, + RequestShowBackupFilesInFolder, + RequestShowTemplate)); + } + catch (ObjectDisposedException ex) + { + this.startPage.DynamoViewModel.Model.Logger.Log(ex.Message); + } + } + + internal async void LoadingDone() + { + SendGuidesData(); + + if (startPage == null) { return; } + + SendSamplesData(); + + var recentFiles = startPage.RecentFiles; + if (recentFiles == null || !recentFiles.Any()) { return; } + + LoadGraphs(recentFiles); + + var userLocale = CultureInfo.CurrentCulture.Name; + + if (dynWebView?.CoreWebView2 != null) + { + await dynWebView.CoreWebView2.ExecuteScriptAsync(@$"window.setLocale('{userLocale}');"); + } + } + + #region FrontEnd Initialization Calls + /// + /// Sends graph data to react app + /// + /// + private async void LoadGraphs(ObservableCollection data) + { + string jsonData = JsonSerializer.Serialize(data); + + if (dynWebView?.CoreWebView2 != null) + { + await dynWebView.CoreWebView2.ExecuteScriptAsync(@$"window.receiveGraphDataFromDotNet({jsonData})"); + } + } + + /// + /// Sends samples data to react app + /// + private async void SendSamplesData() + { + if (!this.startPage.SampleFiles.Any()) return; + + string jsonData = JsonSerializer.Serialize(this.startPage.SampleFiles); + + if (dynWebView?.CoreWebView2 != null) + { + await dynWebView.CoreWebView2.ExecuteScriptAsync(@$"window.receiveSamplesDataFromDotNet({jsonData})"); + } + } + + + /// + /// Sends guided tour data to react app + /// + private async void SendGuidesData() + { + if (!this.GuidedTourItems.Any()) return; + + string jsonData = JsonSerializer.Serialize(this.GuidedTourItems); + + if (dynWebView?.CoreWebView2 != null) + { + await dynWebView.CoreWebView2.ExecuteScriptAsync(@$"window.receiveInteractiveGuidesDataFromDotNet({jsonData})"); + } + } + #endregion + + #region Interactive Guides Commands + internal void ShowGuidedTour(string typeString) + { + if (!Enum.TryParse(typeString, true, out GuidedTourType type)) + { + return; + } + + switch (type) + { + case GuidedTourType.UserInterface: + // This is the LandingPage, so we need to open a blank Workspace before running the Guided Tours + NewWorkspace(); + ShowUserInterfaceGuidedTour(); + break; + case GuidedTourType.GetStarted: + ShowGettingStartedGuidedTour(); + break; + case GuidedTourType.Packages: + // This is the LandingPage, so we need to open a blank Workspace before running the Guided Tours + NewWorkspace(); + ShowPackagesGuidedTour(); + break; + } + } + + private void ShowUserInterfaceGuidedTour() + { + //We pass the root UIElement to the GuidesManager so we can found other child UIElements + try + { + this.startPage.DynamoViewModel.MainGuideManager.LaunchTour(GuidesManager.GetStartedGuideName); + } + catch (Exception) + { + return; + } + } + + private void ShowGettingStartedGuidedTour() + { + try + { + if (this.startPage.DynamoViewModel.ClearHomeWorkspaceInternal()) + { + this.startPage.DynamoViewModel.OpenOnboardingGuideFile(); + this.startPage.DynamoViewModel.MainGuideManager.LaunchTour(GuidesManager.OnboardingGuideName); + } + } + catch (Exception ex) + { + this.startPage.DynamoViewModel.Model.Logger.Log(ex.Message); + this.startPage.DynamoViewModel.Model.Logger.Log(ex.StackTrace); + } + } + + private void ShowPackagesGuidedTour() + { + try + { + this.startPage.DynamoViewModel.MainGuideManager.LaunchTour(GuidesManager.PackagesGuideName); + } + catch (Exception) + { + return; + } + } + + #endregion + + #region Relay Commands + internal void OpenFile(string path) + { + if (String.IsNullOrEmpty(path)) return; + if (DynamoModel.IsTestMode) + { + TestHook?.Invoke(path); + return; + } + + if (this.startPage.DynamoViewModel.OpenCommand.CanExecute(path)) + this.startPage.DynamoViewModel.OpenCommand.Execute(path); + } + + internal void StartGuidedTour(string path) + { + if (String.IsNullOrEmpty(path)) return; + if (DynamoModel.IsTestMode) + { + TestHook?.Invoke(path); + return; + } + + ShowGuidedTour(path); + } + + internal void NewWorkspace() + { + this.startPage?.DynamoViewModel?.NewHomeWorkspaceCommand.Execute(null); + } + + internal void OpenWorkspace() + { + if (DynamoModel.IsTestMode) + { + TestHook?.Invoke(string.Empty); + return; + } + + this.startPage?.DynamoViewModel?.ShowOpenDialogAndOpenResultCommand.Execute(null); + } + + internal void NewCustomNodeWorkspace() + { + if (DynamoModel.IsTestMode) + { + TestHook?.Invoke(string.Empty); + return; + } + + this.startPage?.DynamoViewModel?.ShowNewFunctionDialogCommand.Execute(null); + } + + internal void ShowSampleFilesInFolder() + { + if (this.startPage == null) return; + if (DynamoModel.IsTestMode) + { + TestHook?.Invoke(string.Empty); + return; + } + + Process.Start(new ProcessStartInfo("explorer.exe", "/select," + + this.startPage.SampleFolderPath) + { UseShellExecute = true }); + } + + internal void ShowBackupFilesInFolder() + { + if (this.startPage == null) return; + if (DynamoModel.IsTestMode) + { + TestHook?.Invoke(string.Empty); + return; + } + + Process.Start(new ProcessStartInfo("explorer.exe", this.startPage.DynamoViewModel.Model.PathManager.BackupDirectory) + { UseShellExecute = true }); + } + + internal void ShowTemplate() + { + if (DynamoModel.IsTestMode) + { + TestHook?.Invoke(string.Empty); + return; + } + + // Equivalent to CommandParameter="Template" + this.startPage?.DynamoViewModel?.ShowOpenTemplateDialogCommand.Execute("Template"); + } + + internal void ApplicationLoaded() + { + LoadingDone(); + } + + #endregion + + #region Dispose + public void Dispose() + { + DataContextChanged -= OnDataContextChanged; + if(startPage != null) startPage.DynamoViewModel.PropertyChanged -= DynamoViewModel_PropertyChanged; + + + if (File.Exists(fontFilePath)) + { + File.Delete(fontFilePath); + } + } + #endregion + } + + + [ClassInterface(ClassInterfaceType.AutoDual)] + [ComVisible(true)] + public class ScriptHomeObject + { + readonly Action RequestOpenFile; + readonly Action RequestNewWorkspace; + readonly Action RequestOpenWorkspace; + readonly Action RequestNewCustomNodeWorkspace; + readonly Action RequestApplicationLoaded; + readonly Action RequestShowGuidedTour; + readonly Action RequestShowSampleFilesInFolder; + readonly Action RequestShowBackupFilesInFolder; + readonly Action RequestShowTemplate; + + public ScriptHomeObject(Action requestOpenFile, + Action requestNewWorkspace, + Action requestOpenWorkspace, + Action requestNewCustomNodeWorkspace, + Action requestApplicationLoaded, + Action requestShowGuidedTour, + Action requestShowSampleFilesInFolder, + Action requestShowBackupFilesInFolder, + Action requestShowTemplate) + { + RequestOpenFile = requestOpenFile; + RequestNewWorkspace = requestNewWorkspace; + RequestOpenWorkspace = requestOpenWorkspace; + RequestNewCustomNodeWorkspace = requestNewCustomNodeWorkspace; + RequestApplicationLoaded = requestApplicationLoaded; + RequestShowGuidedTour = requestShowGuidedTour; + RequestShowSampleFilesInFolder = requestShowSampleFilesInFolder; + RequestShowBackupFilesInFolder = requestShowBackupFilesInFolder; + RequestShowTemplate = requestShowTemplate; + + } + + public void OpenFile(string path) + { + RequestOpenFile(path); + } + + public void StartGuidedTour(string path) + { + RequestShowGuidedTour(path); + } + + public void NewWorkspace() + { + RequestNewWorkspace(); + } + + public void OpenWorkspace() + { + RequestOpenWorkspace(); + } + + public void NewCustomNodeWorkspace() + { + RequestNewCustomNodeWorkspace(); + } + + public void ShowSampleFilesInFolder() + { + RequestShowSampleFilesInFolder(); + } + + public void ShowBackupFilesInFolder() + { + RequestShowBackupFilesInFolder(); + } + + + public void ShowTempalte() + { + RequestShowTemplate(); + } + + public void ApplicationLoaded() + { + RequestApplicationLoaded(); + } + + } + + public enum GuidedTourType + { + UserInterface, + GetStarted, + Packages + } + + public class GuidedTourItem + { + public string Name { get; set; } + public string Description { get; set; } + public string Type { get; set; } + + public GuidedTourItem(string name, string description, string type) + { + Name = name; + Description = description; + Type = type; + } + } +} diff --git a/src/DynamoUtilities/PathHelper.cs b/src/DynamoUtilities/PathHelper.cs index cd275bbd095..dd059e4e3c5 100644 --- a/src/DynamoUtilities/PathHelper.cs +++ b/src/DynamoUtilities/PathHelper.cs @@ -2,6 +2,7 @@ using System.Configuration; using System.IO; using System.Linq; +using System.Reflection; using System.Runtime.Versioning; using System.Security.AccessControl; using System.Security.Principal; @@ -65,6 +66,26 @@ public static bool IsValidPath(string filePath) return (!string.IsNullOrEmpty(filePath) && (File.Exists(filePath))); } + /// + /// Utility method to get the last time a file has been modified + /// + /// + /// + public static string GetDateModified(string filePath) + { + FileInfo fileInfo = new(filePath); + + if (fileInfo.Exists) + { + DateTime lastModified = fileInfo.LastWriteTime; + return lastModified.ToString(); + } + else + { + return string.Empty; + } + } + /// /// Check if user has readonly privilege to the folder path. /// @@ -498,5 +519,63 @@ public static string getServiceConfigValues(object o, string serviceKey) } return val; } + + /// + /// Loads embedded resources such as HTML and JS files and returns the content as a string. + /// + /// The resource path to return. + /// The assembly containing the resource. + /// The embedded resource as a string. + public static string LoadEmbeddedResourceAsString(string resourcePath, Assembly assembly) + { + if (string.IsNullOrEmpty(resourcePath)) + throw new ArgumentNullException(nameof(resourcePath), "The resource path cannot be null or empty."); + + if (assembly == null) + throw new ArgumentNullException(nameof(assembly), "The assembly cannot be null."); + + using (Stream stream = assembly.GetManifestResourceStream(resourcePath)) + { + if (stream == null) + throw new FileNotFoundException("The specified resource was not found in the assembly.", resourcePath); + + using (StreamReader reader = new StreamReader(stream)) + { + return reader.ReadToEnd(); + } + } + } + + /// + /// This function will extract the embedded font file and save it to a specified directory + /// + /// The location of the font resource + /// The temporary path to save the resource to + /// The name of the temporary resource file + /// The assembly containing the resource + public static void ExtractAndSaveEmbeddedFont(string resourcePath, string outputPath, string outputFileName, Assembly assembly) + { + if (string.IsNullOrEmpty(resourcePath) || string.IsNullOrEmpty(outputPath) || string.IsNullOrEmpty(outputFileName)) + throw new ArgumentNullException($"One of the input arguments is null or empty."); + + if (assembly == null) + throw new ArgumentNullException($"The assembly cannot be null."); + + using (var stream = assembly.GetManifestResourceStream(resourcePath)) + { + if (stream != null) + { + var fontData = new byte[stream.Length]; + stream.Read(fontData, 0, fontData.Length); + + // Create the output directory if it doesn't exist + Directory.CreateDirectory(outputPath); + + // Write the font file to the output directory + var fontFilePath = Path.Combine(outputPath, outputFileName); + File.WriteAllBytes(fontFilePath, fontData); + } + } + } } } diff --git a/src/DynamoUtilities/PublicAPI.Unshipped.txt b/src/DynamoUtilities/PublicAPI.Unshipped.txt index e4548c95c92..0d32e761145 100644 --- a/src/DynamoUtilities/PublicAPI.Unshipped.txt +++ b/src/DynamoUtilities/PublicAPI.Unshipped.txt @@ -214,7 +214,9 @@ static Dynamo.Utilities.TypeExtensions.ImplementsGeneric(System.Type generic, Sy static Dynamo.Utilities.VersionUtilities.PartialParse(string versionString, int numberOfFields = 3) -> System.Version static DynamoUtilities.CertificateVerification.CheckAssemblyForValidCertificate(string assemblyPath) -> bool static DynamoUtilities.PathHelper.CreateFolderIfNotExist(string folderPath) -> System.Exception +static DynamoUtilities.PathHelper.ExtractAndSaveEmbeddedFont(string resourcePath, string outputPath, string outputFileName, System.Reflection.Assembly assembly) -> void static DynamoUtilities.PathHelper.FileInfoAtPath(string path, out bool fileExists, out string size) -> void +static DynamoUtilities.PathHelper.GetDateModified(string filePath) -> string static DynamoUtilities.PathHelper.GetFileSize(string path) -> string static DynamoUtilities.PathHelper.GetScreenCaptureNameFromPath(string filePath, bool isTimeStampIncluded) -> string static DynamoUtilities.PathHelper.GetServiceBackendAddress(object o, string serviceKey) -> string @@ -226,6 +228,7 @@ static DynamoUtilities.PathHelper.IsReadOnlyPath(string filePath) -> bool static DynamoUtilities.PathHelper.isValidJson(string path, out string fileContents, out System.Exception ex) -> bool static DynamoUtilities.PathHelper.IsValidPath(string filePath) -> bool static DynamoUtilities.PathHelper.isValidXML(string path, out System.Xml.XmlDocument xmlDoc, out System.Exception ex) -> bool +static DynamoUtilities.PathHelper.LoadEmbeddedResourceAsString(string resourcePath, System.Reflection.Assembly assembly) -> string static DynamoUtilities.TypeSwitch.Case(System.Action action) -> DynamoUtilities.TypeSwitch.CaseInfo static DynamoUtilities.TypeSwitch.Case(System.Action action) -> DynamoUtilities.TypeSwitch.CaseInfo static DynamoUtilities.TypeSwitch.Default(System.Action action) -> DynamoUtilities.TypeSwitch.CaseInfo diff --git a/test/DynamoCoreTests/DynamoCoreTests.csproj b/test/DynamoCoreTests/DynamoCoreTests.csproj index 5b5c5d5102a..150bca5f8ef 100644 --- a/test/DynamoCoreTests/DynamoCoreTests.csproj +++ b/test/DynamoCoreTests/DynamoCoreTests.csproj @@ -17,6 +17,9 @@ + + + @@ -130,6 +133,9 @@ + + + diff --git a/test/DynamoCoreTests/Resources/TestResource.txt b/test/DynamoCoreTests/Resources/TestResource.txt new file mode 100644 index 00000000000..829a20fc020 --- /dev/null +++ b/test/DynamoCoreTests/Resources/TestResource.txt @@ -0,0 +1 @@ +Dynamo resource. \ No newline at end of file diff --git a/test/DynamoCoreTests/UtilityTests.cs b/test/DynamoCoreTests/UtilityTests.cs index a65f43b2ddc..e37c627f22f 100644 --- a/test/DynamoCoreTests/UtilityTests.cs +++ b/test/DynamoCoreTests/UtilityTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using System.Xml; using Dynamo.Configuration; using Dynamo.Engine; @@ -837,5 +838,58 @@ public void GenerateSnapshotNameTest() var snapshotNameWithoutTimestamp = PathHelper.GetScreenCaptureNameFromPath(examplePath, false); Assert.AreEqual(snapshotNameWithoutTimestamp, "Add"); } + + [Test] + public void LoadEmbeddedResourceAsString_ReturnsCorrectContent() + { + // Arrange + var expectedContent = "Dynamo resource."; + var resourceName = "Dynamo.Tests.Resources.TestResource.txt"; + + // Act + var assembly = Assembly.GetExecutingAssembly(); + var actualContent = PathHelper.LoadEmbeddedResourceAsString(resourceName, assembly); + + // Assert + Assert.AreEqual(expectedContent, actualContent, "The content loaded from the embedded resource does not match the expected content."); + } + + [Test] + public void ExtractAndSaveEmbeddedFont_CreatesFileWithCorrectContent() + { + // Arrange + var tempDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var outputFileName = "testResourceFile.ttf"; + var assembly = Assembly.GetExecutingAssembly(); + var resourcePath = "Dynamo.Tests.Resources.TestResource.txt"; + + // Act + PathHelper.ExtractAndSaveEmbeddedFont(resourcePath, tempDirectory, outputFileName, assembly); + + var outputFileFullPath = Path.Combine(tempDirectory, outputFileName); + + // Assert + Assert.IsTrue(File.Exists(outputFileFullPath), "The output file was not created."); + + var originalContent = GetEmbeddedResourceContent(resourcePath, assembly); + var extractedContent = File.ReadAllBytes(outputFileFullPath); + Assert.AreEqual(originalContent, extractedContent, "The contents of the extracted file do not match the original."); + + // Clean up + Directory.Delete(tempDirectory, true); + } + + private byte[] GetEmbeddedResourceContent(string resourcePath, Assembly assembly) + { + using (var stream = assembly.GetManifestResourceStream(resourcePath)) + { + if (stream == null) throw new InvalidOperationException("Resource not found."); + + var content = new byte[stream.Length]; + stream.Read(content, 0, content.Length); + return content; + } + } + } } diff --git a/test/DynamoCoreWpfTests/HomePageTests.cs b/test/DynamoCoreWpfTests/HomePageTests.cs new file mode 100644 index 00000000000..d42f274fd53 --- /dev/null +++ b/test/DynamoCoreWpfTests/HomePageTests.cs @@ -0,0 +1,702 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Dynamo.Controls; +using Dynamo.Graph.Workspaces; +using Dynamo.UI.Controls; +using Dynamo.UI.Views; +using Dynamo.ViewModels; +using DynamoCoreWpfTests.Utility; +using Microsoft.Web.WebView2.Core; +using Microsoft.Web.WebView2.Wpf; +using NUnit.Framework; + +namespace DynamoCoreWpfTests +{ + internal class HomePageTests : DynamoTestUIBase + { + #region initialization tests + [Test] + public void GuidedTourItems_InitializationShouldContainExpectedItems() + { + // Arrange + var homePage = new HomePage(); + + // Act - initialization happens in the constructor + + // Assert + Assert.IsNotNull(homePage.GuidedTourItems); + Assert.AreEqual(3, homePage.GuidedTourItems.Count); + Assert.AreEqual(Dynamo.Wpf.Properties.Resources.GetStartedGuide.TrimStart('_'), homePage.GuidedTourItems[0].Name); + Assert.AreEqual(Dynamo.Wpf.Properties.Resources.OnboardingGuide.TrimStart('_'), homePage.GuidedTourItems[1].Name); + Assert.AreEqual(Dynamo.Wpf.Properties.Resources.PackagesGuide.TrimStart('_'), homePage.GuidedTourItems[2].Name); + } + + [Test] + public void ActionDelegates_ShouldBeProperlySetAfterConstruction() + { + // Arrange + var homePage = new HomePage(); + + // Act - Delegates are set in the constructor + + // Assert + Assert.IsNotNull(homePage.RequestOpenFile); + Assert.IsNotNull(homePage.RequestShowGuidedTour); + Assert.IsNotNull(homePage.RequestNewWorkspace); + Assert.IsNotNull(homePage.RequestOpenWorkspace); + Assert.IsNotNull(homePage.RequestNewCustomNodeWorkspace); + Assert.IsNotNull(homePage.RequestApplicationLoaded); + Assert.IsNotNull(homePage.RequestShowSampleFilesInFolder); + Assert.IsNotNull(homePage.RequestShowBackupFilesInFolder); + Assert.IsNotNull(homePage.RequestShowTemplate); + } + #endregion + + #region integration tests + + // A custom script to execute the click event of + private static string SCRIPT(string elementId) + { + return $@"(() => {{ + const optionId = `{elementId}`; + const optionElement = document.getElementById(optionId); + if (optionElement) {{ + optionElement.click(); + return true; // Indicate the click was attempted + }} else {{ + console.log('Option element not found'); + return false; // Indicate failure to find the element + }} + }})();"; + } + + private static string FILE_SCRIPT(string dropdown, int index) + { + var elementId = $"{dropdown}-{index}"; + return SCRIPT(elementId); + } + + private static string SAMPLESFOLDER_SCRIPT() + { + var elementId = $"showSampleFilesLink"; + return SCRIPT(elementId); + } + + internal static string CONTAINER_SCRIPT(string elementId) + { + return $@"(() => {{ + const optionId = `{elementId}`; + const optionElement = document.getElementById(optionId); + const count = optionElement.children.length; // Return the count of elements found + if(count !== undefined) return count; + else return 0; + }})();"; + } + + internal static string CONTAINER_ITEM_CLICK_SCRIPT(string elementId) + { + return $@"(() => {{ + const optionId = `{elementId}`; + const optionElement = document.getElementById(optionId); + if (optionElement && optionElement.children.length > 0) {{ + // Assuming each child div contains one element + const firstChild = optionElement.children[0]; + const anchor = firstChild.querySelector('a'); // Find the tag within the first child + if (anchor) {{ + anchor.click(); + return true; + }} + }} + return false; // Indicate failure or that the tag doesn't exist + }})();"; + } + + [Test] + public void CanClickRecentGraph() + { + // Arrange + var script = CONTAINER_ITEM_CLICK_SCRIPT("graphContainer"); + var filePath = Path.Combine(GetTestDirectory(ExecutingDirectory), @"core\nodeLocationTest.dyn"); + var vm = View.DataContext as DynamoViewModel; + var wasTestHookInvoked = false; + + string receivedPath = null; + HomePage.TestHook = (path) => + { + receivedPath = path; + wasTestHookInvoked = true; + }; + + Assert.IsFalse(wasTestHookInvoked); + + // Create the startPage manually, as it is not created under Test environment + // Manually add 1 recent graph to test with + var startPage = new StartPageViewModel(vm, true); + var homePage = View.homePage; + startPage.RecentFiles.Add(new StartPageListItem(filePath) { ContextData = filePath }); + homePage.DataContext = startPage; + + InitializeWebView2(homePage.dynWebView); + + // Act + var interactCompleted = WaitForBoolInteractionToComplete(homePage, script, (bool?)null); + + // Clean up to avoid failures testing in pipeline + var windoClosed = CloseViewAndCleanup(View); + + // Assert + Assert.IsTrue(interactCompleted, "Was not able to execute the click event."); + Assert.IsTrue(wasTestHookInvoked, "The OpenFile method did not invoke the test hook as expected."); + Assert.AreEqual(filePath, receivedPath, "The command did not return the same filePath"); + Assert.IsTrue(windoClosed, "Dynamo View was not closed correctly."); + } + + [Test] + public void CanClickSampleGraph() + { + // Arrange + var script = CONTAINER_ITEM_CLICK_SCRIPT("samplesContainer"); + var rootName = "root"; + var fileName = "testFile"; + var rootPath = Path.Combine(GetTestDirectory(ExecutingDirectory), @"core\"); + var filePath = Path.Combine(GetTestDirectory(ExecutingDirectory), @"core\nodeLocationTest.dyn"); + var vm = View.DataContext as DynamoViewModel; + var wasTestHookInvoked = false; + string receivedPath = null; + HomePage.TestHook = (path) => + { + receivedPath = path; + wasTestHookInvoked = true; + }; + + Assert.IsFalse(wasTestHookInvoked); + + // Create the startPage manually, as it is not created under Test environment + // Manually add 1 sample to test with + var startPage = new StartPageViewModel(vm, true); + var homePage = View.homePage; + var childEntity = new SampleFileEntry(fileName, filePath); + var rootEntity = new SampleFileEntry(rootName, rootPath); + rootEntity.AddChildSampleFile(childEntity); + startPage.SampleFiles.Add(rootEntity); + homePage.DataContext = startPage; + + InitializeWebView2(homePage.dynWebView); + + // Act + var interactCompleted = WaitForBoolInteractionToComplete(homePage, script, (bool?)null); + + // Clean up to avoid failures testing in pipeline + var windoClosed = CloseViewAndCleanup(View); + + // Assert + Assert.IsTrue(interactCompleted, "Was not able to execute the click event."); + Assert.IsTrue(wasTestHookInvoked, "The OpenFile method did not invoke the test hook as expected."); + Assert.AreEqual(filePath, receivedPath, "The command did not return the same filePath"); + Assert.IsTrue(windoClosed, "Dynamo View was not closed correctly."); + } + + [Test] + public void CanClickTourGuide() + { + // Arrange + var script = CONTAINER_ITEM_CLICK_SCRIPT("guidesContainer"); + var guideType = "UserInterface"; + var vm = View.DataContext as DynamoViewModel; + var wasTestHookInvoked = false; + string receivedType = null; + HomePage.TestHook = (type) => + { + receivedType = type; + wasTestHookInvoked = true; + }; + + Assert.IsFalse(wasTestHookInvoked); + + var homePage = View.homePage; + + InitializeWebView2(homePage.dynWebView); + + // Act + var interactCompleted = WaitForBoolInteractionToComplete(homePage, script, (bool?)null); + + // Clean up to avoid failures testing in pipeline + var windoClosed = CloseViewAndCleanup(View); + + // Assert + Assert.IsTrue(interactCompleted, "Was not able to execute the click event."); + Assert.IsTrue(wasTestHookInvoked, "The StartGuidedTour method did not invoke the test hook as expected."); + Assert.AreEqual(guideType, receivedType, "The command did not return the expected guide type"); + Assert.IsTrue(windoClosed, "Dynamo View was not closed correctly."); + } + + [Test] + public void ReceiveCorrectNumberOfRecentGrphs() + { + // Arrange + var script = CONTAINER_SCRIPT("graphContainer"); + var filePath = Path.Combine(GetTestDirectory(ExecutingDirectory), @"core\nodeLocationTest.dyn"); + var vm = View.DataContext as DynamoViewModel; + + + // Create the startPage manually, as it is not created under Test environment + // Manually add 1 recent graph to test with + var startPage = new StartPageViewModel(vm, true); + var homePage = View.homePage; + startPage.RecentFiles.Add(new StartPageListItem(filePath) { ContextData=filePath }); + homePage.DataContext = startPage; + + InitializeWebView2(homePage.dynWebView); + + // Act + var interactCompleted = WaitForIntInteractionToComplete(homePage, script, -1); + + // Clean up to avoid failures testing in pipeline + var windoClosed = CloseViewAndCleanup(View); + + // Assert + Assert.AreEqual(1, interactCompleted, "Did not receive correct number of recent graphs."); + Assert.IsTrue(windoClosed, "Dynamo View was not closed correctly."); + } + + [Test] + public void ReceiveCorrectNumberOfSamples() + { + // Arrange + var script = CONTAINER_SCRIPT("samplesContainer"); + var rootName = "root"; + var fileName = "testFile"; + var rootPath = Path.Combine(GetTestDirectory(ExecutingDirectory), @"core\"); + var filePath = Path.Combine(GetTestDirectory(ExecutingDirectory), @"core\nodeLocationTest.dyn"); + var vm = View.DataContext as DynamoViewModel; + + + // Create the startPage manually, as it is not created under Test environment + // Manually add 1 sample to test with + // We remove the root folder on the front end side, so make sure there is a root folder to discount for the test + var startPage = new StartPageViewModel(vm, true); + var homePage = View.homePage; + var childEntity = new SampleFileEntry(fileName, filePath); + var rootEntity = new SampleFileEntry(rootName, rootPath); + rootEntity.AddChildSampleFile(childEntity); + startPage.SampleFiles.Add(rootEntity); + homePage.DataContext = startPage; + + InitializeWebView2(homePage.dynWebView); + + // Act + var interactCompleted = WaitForIntInteractionToComplete(homePage, script, -1); + + // Clean up to avoid failures testing in pipeline + var windoClosed = CloseViewAndCleanup(View); + + // Assert + Assert.AreEqual(1, interactCompleted, "Did not receive corrent number of sample files."); + Assert.IsTrue(windoClosed, "Dynamo View was not closed correctly."); + } + + [Test] + public void ReceiveCorrectNumberOfTourGuides() + { + // Arrange + var script = CONTAINER_SCRIPT("guidesContainer"); + var homePage = View.homePage; + + InitializeWebView2(homePage.dynWebView); + + // Act + var interactCompleted = WaitForIntInteractionToComplete(homePage, script, -1); + + // Clean up to avoid failures testing in pipeline + var windoClosed = CloseViewAndCleanup(View); + + // Assert + Assert.AreEqual(3, interactCompleted, "Did not receive corrent number of guides."); + Assert.IsTrue(windoClosed, "Dynamo View was not closed correctly."); + } + + [Test] + public void ReceiveCorrectNumberOfCarouselVideos() + { + // Arrange + var script = CONTAINER_SCRIPT("videoCarousel"); + var homePage = View.homePage; + + InitializeWebView2(homePage.dynWebView); + + // Act + var interactCompleted = WaitForIntInteractionToComplete(homePage, script, -1); + + // Clean up to avoid failures testing in pipeline + var windoClosed = CloseViewAndCleanup(View); + + // Assert + Assert.AreEqual(10, interactCompleted, "Did not receive corrent number of videos."); + Assert.IsTrue(windoClosed, "Dynamo View was not closed correctly."); + } + + [Test] + public void CanRunNewHomeWorkspaceCommandFromHomePage() + { + // Arrange + var dropdown = "newDropdown"; + var index = 0; + var script = FILE_SCRIPT(dropdown, index); + var vm = View.DataContext as DynamoViewModel; + var hasWorkspaceBeenCleared = false; + void Model_WorkspaceCleared(WorkspaceModel model) + { + vm.Model.WorkspaceCleared -= Model_WorkspaceCleared; + hasWorkspaceBeenCleared = true; + } + + // A side effect of running the NewHomeWorkspaceCommand is that the workspace will be cleared + vm.Model.WorkspaceCleared += Model_WorkspaceCleared; + + // Create the startPage manually, as it is not created under Test environment + var startPage = new StartPageViewModel(vm, true); + var homePage = View.homePage; + homePage.DataContext = startPage; + + InitializeWebView2(homePage.dynWebView); + + // Act + var interactCompleted = WaitForBoolInteractionToComplete(homePage, script, (bool?)null); + + // Clean up to avoid failures testing in pipeline + var windoClosed = CloseViewAndCleanup(View); + + // Assert + Assert.IsTrue(interactCompleted, "The NewWorkspace script did not run as expected."); + Assert.IsTrue(hasWorkspaceBeenCleared, "The NewWorkspace method did not trigger the command as expected."); + Assert.IsTrue(windoClosed, "Dynamo View was not closed correctly."); + } + + [Test] + public void CanRunNewCustomNodeCommandFromHomePage() + { + // Arrange + var dropdown = "newDropdown"; + var index = 1; + var script = FILE_SCRIPT(dropdown, index); + var vm = View.DataContext as DynamoViewModel; + var wasTestHookInvoked = false; + + HomePage.TestHook = (arg) => wasTestHookInvoked = true; + Assert.IsFalse(wasTestHookInvoked); + + // Create the startPage manually, as it is not created under Test environment + var startPage = new StartPageViewModel(vm, true); + var homePage = View.homePage; + homePage.DataContext = startPage; + + InitializeWebView2(homePage.dynWebView); + + // Act + var interactCompleted = WaitForBoolInteractionToComplete(homePage, script, (bool?)null); + + // Clean up to avoid failures testing in pipeline + var windoClosed = CloseViewAndCleanup(View); + + // Assert + Assert.IsTrue(interactCompleted, "The OpenWorkspace script did not run as expected."); + Assert.IsTrue(wasTestHookInvoked, "The OpenWorkspace method did not invoke the test hook as expected."); + Assert.IsTrue(windoClosed, "Dynamo View was not closed correctly."); + } + + [Test] + public void CanOpenWorkspaceCommandFromHomePage() + { + // Arrange + var dropdown = "openDropdown"; + var index = 0; + var script = FILE_SCRIPT(dropdown, index); + var vm = View.DataContext as DynamoViewModel; + var wasTestHookInvoked = false; + + HomePage.TestHook = (arg) => wasTestHookInvoked = true; + Assert.IsFalse(wasTestHookInvoked); + + // Create the startPage manually, as it is not created under Test environment + var startPage = new StartPageViewModel(vm, true); + var homePage = View.homePage; + homePage.DataContext = startPage; + + InitializeWebView2(homePage.dynWebView); + + // Act + var interactCompleted = WaitForBoolInteractionToComplete(homePage, script, (bool?)null); + + // Clean up to avoid failures testing in pipeline + var windoClosed = CloseViewAndCleanup(View); + + // Assert + Assert.IsTrue(interactCompleted, "The OpenWorkspace script did not run as expected."); + Assert.IsTrue(wasTestHookInvoked, "The OpenWorkspace method did not invoke the test hook as expected."); + Assert.IsTrue(windoClosed, "Dynamo View was not closed correctly."); + } + + [Test] + public void ShowTemplateCommandFromHomePage() + { + // Arrange + var dropdown = "openDropdown"; + var index = 1; + var script = FILE_SCRIPT(dropdown, index); + var vm = View.DataContext as DynamoViewModel; + var wasTestHookInvoked = false; + + HomePage.TestHook = (arg) => wasTestHookInvoked = true; + Assert.IsFalse(wasTestHookInvoked); + + // Create the startPage manually, as it is not created under Test environment + var startPage = new StartPageViewModel(vm, true); + var homePage = View.homePage; + homePage.DataContext = startPage; + + InitializeWebView2(homePage.dynWebView); + + // Act + var interactCompleted = WaitForBoolInteractionToComplete(homePage, script, (bool?)null); + + // Clean up to avoid failures testing in pipeline + var windoClosed = CloseViewAndCleanup(View); + + + // Assert + Assert.IsTrue(interactCompleted, "The ShowTemplate script did not run as expected."); + Assert.IsTrue(wasTestHookInvoked, "The ShowTemplate method did not invoke the test hook as expected."); + Assert.IsTrue(windoClosed, "Dynamo View was not closed correctly."); + } + + [Test] + public void ShowBackupFolderCommandFromHomePage() + { + // Arrange + var dropdown = "openDropdown"; + var index = 2; + var script = FILE_SCRIPT(dropdown, index); + var vm = View.DataContext as DynamoViewModel; + var wasTestHookInvoked = false; + + HomePage.TestHook = (arg) => wasTestHookInvoked = true; + Assert.IsFalse(wasTestHookInvoked); + + // Create the startPage manually, as it is not created under Test environment + var startPage = new StartPageViewModel(vm, true); + var homePage = View.homePage; + homePage.DataContext = startPage; + + InitializeWebView2(homePage.dynWebView); + + // Act + var interactCompleted = WaitForBoolInteractionToComplete(homePage, script, (bool?)null); + + // Clean up to avoid failures testing in pipeline + var windoClosed = CloseViewAndCleanup(View); + + // Assert + Assert.IsTrue(interactCompleted, "The ShowBackupFolder script did not run as expected."); + Assert.IsTrue(wasTestHookInvoked, "The ShowBackupFolder method did not invoke the test hook as expected."); + Assert.IsTrue(windoClosed, "Dynamo View was not closed correctly."); + } + + [Test] + public void ShowSampleFilesFolderCommandFromHomePage() + { + // Arrange + var script = SAMPLESFOLDER_SCRIPT(); + var vm = View.DataContext as DynamoViewModel; + var wasTestHookInvoked = false; + + HomePage.TestHook = (arg) => wasTestHookInvoked = true; + Assert.IsFalse(wasTestHookInvoked); + + // Create the startPage manually, as it is not created under Test environment + var startPage = new StartPageViewModel(vm, true); + var homePage = View.homePage; + homePage.DataContext = startPage; + + InitializeWebView2(homePage.dynWebView); + + // Act + var interactCompleted = WaitForBoolInteractionToComplete(homePage, script, (bool?)null); + + // Clean up to avoid failures testing in pipeline + var windoClosed = CloseViewAndCleanup(View); + + // Assert + Assert.IsTrue(interactCompleted, "The ShowSampleFilesFolderCommand script did not run as expected."); + Assert.IsTrue(wasTestHookInvoked, "The ShowSampleFilesFolderCommand method did not invoke the test hook as expected."); + Assert.IsTrue(windoClosed, "Dynamo View was not closed correctly."); + } + #endregion + + #region helpers + + /// + /// A helper method to (async) await the initialization of a WebView2 component + /// + /// The WebView2 component to await + /// + internal static void InitializeWebView2(WebView2 web) + { + var navigationCompletedEvent = new ManualResetEvent(false); + DateTime startTime = DateTime.Now; + TimeSpan timeout = TimeSpan.FromSeconds(50); + + void WebView_NavigationCompleted(object sender, CoreWebView2NavigationCompletedEventArgs e) + { + web.NavigationCompleted -= WebView_NavigationCompleted; + navigationCompletedEvent.Set(); // Signal that navigation has completed + } + + web.NavigationCompleted += WebView_NavigationCompleted; + + // Wait until we have initialized dynWebView or until the timeout is reached + while (!navigationCompletedEvent.WaitOne(100)) + { + if (DateTime.Now - startTime > timeout) + { + throw new TimeoutException("WebView2 initialization timed out."); + } + DispatcherUtil.DoEvents(); + } + } + + /// + /// A helper method to make sure the View is closed before we proceed to the next test + /// + /// The DynamoView object + /// + private bool CloseViewAndCleanup(DynamoView view) + { + bool windowClosed = false; + void WindowClosed(object sender, EventArgs e) + { + windowClosed = true; + view.Closed -= WindowClosed; + } + + view.Closed += WindowClosed; + view.Close(); + + return windowClosed; + } + + /// + /// A helper method to synchronously await async method relying on return value change + /// It is vital that the script has a return value which is always different than the initial value to avoid infinite loop + /// + /// The HomePage instance + /// A script expecting a bool? return value different than the initial value + /// The initial value to compare with (different than the return value) + /// + private static bool? WaitForBoolInteractionToComplete(HomePage homePage, string script, bool? initialValue) + { + // Invoke the custom frontend script command we want to assert the funcionality of + var interactCompleted = initialValue; + homePage.Dispatcher.Invoke(async () => + { + interactCompleted = await Interact(homePage.dynWebView, script); + }); + + // Wait for the interaction to complete + while (EqualityComparer.Default.Equals(interactCompleted, initialValue)) + { + DispatcherUtil.DoEvents(); + } + + return interactCompleted; + } + + /// + /// A helper method to synchronously await async method relying on return value change + /// It is vital that the script has a return value which is always different than the initial value to avoid infinite loop + /// + /// The HomePage instance + /// A script expecting an int return value different than the initial value + /// The initial value to compare with (different than the return value) + /// + private static int WaitForIntInteractionToComplete(HomePage homePage, string script, int initialValue) + { + // Invoke the custom frontend script command we want to assert the funcionality of + var interactCompleted = initialValue; + homePage.Dispatcher.Invoke(async () => + { + interactCompleted = await Interact(homePage.dynWebView, script); + }); + + // Wait for the interaction to complete + while (EqualityComparer.Default.Equals(interactCompleted, initialValue)) + { + DispatcherUtil.DoEvents(); + } + + return interactCompleted; + } + + /// + /// A helper async method to await and return the result of a script execution + /// + /// The return type from the script + /// The WebView2 control + /// A javascript script to be executed on the front end + /// + internal static async Task Interact(WebView2 web, string script) + { + try + { + var scriptTask = web.CoreWebView2.ExecuteScriptAsync(script); + var timeoutTask = Task.Delay(TimeSpan.FromSeconds(20)); + var completedTask = await Task.WhenAny(scriptTask, timeoutTask).ConfigureAwait(false); + + if (completedTask == timeoutTask) + { + throw new TimeoutException("Script execution timed out."); + } + + + string resultJson = await scriptTask; // Result is always returned as JSON string from ExecuteScriptAsync + Task.Delay(200).Wait(); // Allow homepage class to catch up + return DeserializeResult(resultJson); + } + catch (Exception) + { + // Return default value for T in case of error + return default(T); + } + } + + /// + /// Deserealizes json based on the provided type + /// + /// Type to deserialize to + /// The json string to deserialize + /// + /// + private static T DeserializeResult(string jsonResult) + { + // Handle deserialization based on the type of T + if (typeof(T) == typeof(bool)) + { + var result = System.Text.Json.JsonSerializer.Deserialize(jsonResult); + return (T)(object)result; // Cast to object first to avoid direct cast compilation error + } + else if (typeof(T) == typeof(int)) + { + var result = System.Text.Json.JsonSerializer.Deserialize(jsonResult); + return (T)(object)result; + } + else + { + throw new ArgumentException("Unsupported type for deserialization."); + } + } + + #endregion + } +} diff --git a/test/DynamoCoreWpfTests/ViewExtensions/NotificationsExtensionTests.cs b/test/DynamoCoreWpfTests/ViewExtensions/NotificationsExtensionTests.cs index 8b14fa9e6bd..47b370d39be 100644 --- a/test/DynamoCoreWpfTests/ViewExtensions/NotificationsExtensionTests.cs +++ b/test/DynamoCoreWpfTests/ViewExtensions/NotificationsExtensionTests.cs @@ -38,7 +38,7 @@ public void PressNotificationButtonAndShowPopup() .FirstOrDefault(p => p.IsOpen); Assert.NotNull(notificationUI); - var webView = notificationUI.FindName("webView"); + var webView = notificationUI.FindName("dynWebView"); Assert.NotNull(webView); }