Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor PasswordBoxAssist.Password to use behaviors (issue 2930) #2932

Merged
merged 23 commits into from
Nov 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e9388bf
Rewrite current UI test and add new UI test to test failing scenario
nicolaihenriksen Nov 4, 2022
4d9c233
Refactor PasswordBoxAssist to use behavior
nicolaihenriksen Nov 4, 2022
90471ba
Refactor PasswordBox reveal style TextBox focus- and text selection code
nicolaihenriksen Nov 4, 2022
0a9f4f8
Undo wrong change in TestBase
nicolaihenriksen Nov 4, 2022
bc349fa
Whitespace commit to force re-run
nicolaihenriksen Nov 4, 2022
bd746b4
Increase delay in test
nicolaihenriksen Nov 4, 2022
b282e6c
Rollback of increased test delay
nicolaihenriksen Nov 4, 2022
dda88f8
Update MaterialDesignThemes.Wpf/Behaviors/PasswordBoxRevealTextBoxBeh…
nicolaihenriksen Nov 5, 2022
7d32386
Update MaterialDesignThemes.Wpf/Behaviors/PasswordBoxRevealTextBoxBeh…
nicolaihenriksen Nov 5, 2022
e1ddcf8
Extract PasswordBoxBehavior into its own class
nicolaihenriksen Nov 5, 2022
847d6ab
Cache PropertyInfos and MethodInfos in PasswordBoxRevealTextBoxBehavior
nicolaihenriksen Nov 5, 2022
417c69c
Selecting minimum required version of dependency and updating nuspec
nicolaihenriksen Nov 5, 2022
4b78de6
Convert Password binding to opt-in feature
nicolaihenriksen Nov 6, 2022
3fdb918
File scoped namespace
nicolaihenriksen Nov 6, 2022
e89ce21
Behavior classes renames and change from internal to public
nicolaihenriksen Nov 6, 2022
21d7373
Use latest version of Xaml.Behaviors and update nuspec file
nicolaihenriksen Nov 6, 2022
da46c37
Attempting fix of pipeline
Keboo Nov 7, 2022
8124883
Adding screenshots for debugging
Keboo Nov 7, 2022
fac1639
Attempt at fixing failing UI test
nicolaihenriksen Nov 7, 2022
b52a9ce
Adding more screenshots for debugging
nicolaihenriksen Nov 7, 2022
100bad6
Activate test window on loaded event
nicolaihenriksen Nov 7, 2022
bfbd3d5
Yet another attempt at getting test window shown
nicolaihenriksen Nov 7, 2022
afbc730
Removing debugging screenshots
nicolaihenriksen Nov 7, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Directory.packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<PackageVersion Include="Microsoft.NETCore.Platforms" Version="6.0.6" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageVersion Include="Microsoft.Toolkit.MVVM" Version="7.1.2" />
<PackageVersion Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.1" />
<PackageVersion Include="Shouldly" Version="4.1.0" />
<PackageVersion Include="ShowMeTheXAML" Version="2.0.0" />
Expand All @@ -26,4 +27,4 @@
<PackageVersion Include="xunit.runner.visualstudio" Version="2.4.5" />
<PackageVersion Include="Xunit.StaFact" Version="1.1.11" />
</ItemGroup>
</Project>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<UserControl x:Class="MaterialDesignThemes.UITests.Samples.PasswordBox.BoundPasswordBox"
nicolaihenriksen marked this conversation as resolved.
Show resolved Hide resolved
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:MaterialDesignThemes.UITests.Samples.PasswordBox"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance local:BoundPasswordBoxViewModel, IsDesignTimeCreatable=False}"
d:DesignHeight="450" d:DesignWidth="800">
<Grid>
<PasswordBox x:Name="PasswordBox"
Width="400"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Style="{StaticResource {x:Type PasswordBox}}"
materialDesign:PasswordBoxAssist.Password="{Binding Password, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</Grid>
</UserControl>
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
namespace MaterialDesignThemes.UITests.Samples.PasswordBox;

public partial class BoundPasswordBox
{


public string? ViewModelPassword
{
get => ((BoundPasswordBoxViewModel) DataContext).Password;
set => ((BoundPasswordBoxViewModel) DataContext).Password = value;
}


private bool _useRevealStyle;
public bool UseRevealStyle
{
get => _useRevealStyle;
set
{
_useRevealStyle = value;
if (_useRevealStyle)
{
PasswordBox.Style = (Style)PasswordBox.FindResource("MaterialDesignFloatingHintRevealPasswordBox");
}
else
{
PasswordBox.ClearValue(StyleProperty);
}

}
}

public BoundPasswordBox()
{
DataContext = new BoundPasswordBoxViewModel();
InitializeComponent();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using CommunityToolkit.Mvvm.ComponentModel;

namespace MaterialDesignThemes.UITests.Samples.PasswordBox;

internal partial class BoundPasswordBoxViewModel : ObservableObject
{
[ObservableProperty]
private string? _password;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Window x:Class="MaterialDesignThemes.UITests.Samples.PasswordBox.BoundPasswordBoxWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:MaterialDesignThemes.UITests.Samples.PasswordBox"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
mc:Ignorable="d"
Height="450"
Width="800"
Title="BoundPasswordBoxWindow"
Background="{DynamicResource MaterialDesignPaper}"
FontFamily="{materialDesign:MaterialDesignFont}"
TextElement.FontSize="13"
TextElement.FontWeight="Regular"
TextElement.Foreground="{DynamicResource MaterialDesignBody}"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="Auto"
WindowStartupLocation="CenterScreen"
Loaded="BoundPasswordBoxWindow_OnLoaded">
<local:BoundPasswordBox />
</Window>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace MaterialDesignThemes.UITests.Samples.PasswordBox;

public partial class BoundPasswordBoxWindow
{
public BoundPasswordBoxWindow() => InitializeComponent();

private void BoundPasswordBoxWindow_OnLoaded(object sender, RoutedEventArgs e)
{
Activate();
Topmost = true;
Topmost = false;
Focus();
}
}
85 changes: 56 additions & 29 deletions MaterialDesignThemes.UITests/WPF/PasswordBoxes/PasswordBoxTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel;
using MaterialDesignThemes.UITests.Samples.PasswordBox;

namespace MaterialDesignThemes.UITests.WPF.PasswordBoxes
{
Expand Down Expand Up @@ -83,42 +84,36 @@ await Wait.For(async () =>
}

[Fact]
[Description("PR 2828")]
[Description("PR 2828 and Issue 2930")]
nicolaihenriksen marked this conversation as resolved.
Show resolved Hide resolved
public async Task RevealPasswordBox_WithBoundPasswordProperty_RespectsThreeWayBinding()
{
await using var recorder = new TestRecorder(App);

var grid = await LoadXaml<Grid>(@"
<Grid Margin=""30"">
<StackPanel Orientation=""Vertical"">
<TextBox x:Name=""BoundPassword"" />
<PasswordBox x:Name=""PasswordBox""
materialDesign:PasswordBoxAssist.Password=""{Binding ElementName=BoundPassword, Path=Text, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}""
Style=""{StaticResource MaterialDesignFloatingHintRevealPasswordBox}""/>
</StackPanel>
</Grid>");
var boundPasswordTextBox = await grid.GetElement<TextBox>("BoundPassword"); // Serves as the "VM" in this test
var passwordBox = await grid.GetElement<PasswordBox>("PasswordBox");
await App.InitializeWithMaterialDesign();
IWindow window = await App.CreateWindow<BoundPasswordBoxWindow>();
var userControl = await window.GetElement<BoundPasswordBox>();
await userControl.SetProperty(nameof(BoundPasswordBox.UseRevealStyle), true);
var passwordBox = await userControl.GetElement<PasswordBox>("PasswordBox");
var clearTextPasswordTextBox = await passwordBox.GetElement<TextBox>("RevealPasswordTextBox");
var revealPasswordButton = await passwordBox.GetElement<ToggleButton>("RevealPasswordButton");

// Act 1 (Update in VM updates PasswordBox and RevealPasswordTextBox)
await boundPasswordTextBox.SendKeyboardInput($"1");
string? boundText1 = await boundPasswordTextBox.GetProperty<string>(TextBox.TextProperty);
// Act 1 (Update in PasswordBox updates VM and RevealPasswordTextBox)
await passwordBox.SendKeyboardInput($"1");
string? boundText1 = await userControl.GetProperty<string>(nameof(BoundPasswordBox.ViewModelPassword));
string? password1 = await passwordBox.GetProperty<string>(nameof(PasswordBox.Password));
string? clearTextPassword1 = await clearTextPasswordTextBox.GetProperty<string>(TextBox.TextProperty);

// Act 2 (Update in PasswordBox updates VM and RevealPasswordTextBox)
await passwordBox.SendKeyboardInput($"2");
string? boundText2 = await boundPasswordTextBox.GetProperty<string>(TextBox.TextProperty);
string? password2 = await passwordBox.GetProperty<string>(nameof(PasswordBox.Password));
string? clearTextPassword2 = await clearTextPasswordTextBox.GetProperty<string>(TextBox.TextProperty);

// Act 2 (Update in RevealPasswordTextBox updates PasswordBox and VM)
await revealPasswordButton.LeftClick();
await Task.Delay(50); // Wait for the "clear text TextBox" to become visible
await clearTextPasswordTextBox.SendKeyboardInput($"3");
string? boundText3 = await boundPasswordTextBox.GetProperty<string>(TextBox.TextProperty);
await clearTextPasswordTextBox.SendKeyboardInput($"2");
string? boundText2 = await userControl.GetProperty<string>(nameof(BoundPasswordBox.ViewModelPassword));
string? password2 = await passwordBox.GetProperty<string>(nameof(PasswordBox.Password));
string? clearTextPassword2 = await clearTextPasswordTextBox.GetProperty<string>(TextBox.TextProperty);

// Act 3 (Update in VM updates PasswordBox and RevealPasswordTextBox)
await userControl.SetProperty(nameof(BoundPasswordBox.ViewModelPassword), "3");
string? boundText3 = await userControl.GetProperty<string>(nameof(BoundPasswordBox.ViewModelPassword));
string? password3 = await passwordBox.GetProperty<string>(nameof(PasswordBox.Password));
string? clearTextPassword3 = await clearTextPasswordTextBox.GetProperty<string>(TextBox.TextProperty);

Expand All @@ -127,15 +122,47 @@ public async Task RevealPasswordBox_WithBoundPasswordProperty_RespectsThreeWayBi
Assert.Equal("1", password1);
Assert.Equal("1", clearTextPassword1);

Assert.Equal("21", boundText2);
Assert.Equal("21", password2);
Assert.Equal("21", clearTextPassword2);
Assert.Equal("12", boundText2);
Assert.Equal("12", password2);
Assert.Equal("12", clearTextPassword2);

Assert.Equal("321", boundText3);
Assert.Equal("321", password3);
Assert.Equal("321", clearTextPassword3);
Assert.Equal("3", boundText3);
Assert.Equal("3", password3);
Assert.Equal("3", clearTextPassword3);

recorder.Success();
}

[Fact]
[Description("Issue 2930")]
public async Task PasswordBox_WithBoundPasswordProperty_RespectsBinding()
{
await using var recorder = new TestRecorder(App);

await App.InitializeWithMaterialDesign();
IWindow window = await App.CreateWindow<BoundPasswordBoxWindow>();
var userControl = await window.GetElement<BoundPasswordBox>();
await userControl.SetProperty(nameof(BoundPasswordBox.UseRevealStyle), false);
var passwordBox = await userControl.GetElement<PasswordBox>("PasswordBox");

// Act 1 (Update in PasswordBox updates VM)
await passwordBox.SendKeyboardInput($"1");
string? boundText1 = await userControl.GetProperty<string>(nameof(BoundPasswordBox.ViewModelPassword));
string? password1 = await passwordBox.GetProperty<string>(nameof(PasswordBox.Password));

// Act 2 (Update in VM updates PasswordBox)
await userControl.SetProperty(nameof(BoundPasswordBox.ViewModelPassword), "2");
string? boundText2 = await userControl.GetProperty<string>(nameof(BoundPasswordBox.ViewModelPassword));
string? password2 = await passwordBox.GetProperty<string>(nameof(PasswordBox.Password));

// Assert
Assert.Equal("1", boundText1);
Assert.Equal("1", password1);

Assert.Equal("2", boundText2);
Assert.Equal("2", password2);

recorder.Success();
}
}
}
8 changes: 8 additions & 0 deletions MaterialDesignThemes.Wpf/BehaviorCollection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Microsoft.Xaml.Behaviors;

namespace MaterialDesignThemes.Wpf;

public class BehaviorCollection : FreezableCollection<Behavior>
{
protected override Freezable CreateInstanceCore() => new BehaviorCollection();
}
23 changes: 23 additions & 0 deletions MaterialDesignThemes.Wpf/Behaviors/PasswordBoxBehavior.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Microsoft.Xaml.Behaviors;

namespace MaterialDesignThemes.Wpf.Behaviors;

internal class PasswordBoxBehavior : Behavior<PasswordBox>
{
private void PasswordBoxLoaded(object sender, RoutedEventArgs e) => PasswordBoxAssist.SetPassword(AssociatedObject, AssociatedObject.Password);

protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.Loaded += PasswordBoxLoaded;
}

protected override void OnDetaching()
{
if (AssociatedObject != null)
{
AssociatedObject.Loaded -= PasswordBoxLoaded;
}
base.OnDetaching();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System.Reflection;
using System.Windows.Documents;
using Microsoft.Xaml.Behaviors;

namespace MaterialDesignThemes.Wpf.Behaviors;

internal class PasswordBoxRevealTextBoxBehavior : Behavior<TextBox>
{
private static readonly DependencyProperty SelectionProperty = DependencyProperty.RegisterAttached(
"Selection", typeof(TextSelection), typeof(PasswordBoxRevealTextBoxBehavior), new UIPropertyMetadata(default(TextSelection)));
private static void SetSelection(DependencyObject obj, TextSelection? value) => obj.SetValue(SelectionProperty, value);
private static TextSelection? GetSelection(DependencyObject obj) => (TextSelection?)obj.GetValue(SelectionProperty);

internal static readonly DependencyProperty PasswordBoxProperty = DependencyProperty.Register(
nameof(PasswordBox), typeof(PasswordBox), typeof(PasswordBoxRevealTextBoxBehavior), new PropertyMetadata(default(PasswordBox)));

internal PasswordBox? PasswordBox
{
get => (PasswordBox) GetValue(PasswordBoxProperty);
set => SetValue(PasswordBoxProperty, value);
}

private static PropertyInfo SelectionPropertyInfo { get; }
private static MethodInfo SelectMethodInfo { get; }
private static MethodInfo GetStartMethodInfo { get; }
private static MethodInfo GetEndMethodInfo { get; }
private static PropertyInfo GetOffsetPropertyInfo { get; }

static PasswordBoxRevealTextBoxBehavior()
{
SelectionPropertyInfo = typeof(PasswordBox).GetProperty("Selection", BindingFlags.NonPublic | BindingFlags.Instance) ?? throw new InvalidOperationException("Did not find 'Selection' property on PasswordBox");
SelectMethodInfo = typeof(PasswordBox).GetMethod("Select", BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException("Did not find 'Select' method on PasswordBox");
Type iTextRange = typeof(PasswordBox).Assembly.GetType("System.Windows.Documents.ITextRange") ?? throw new InvalidOperationException("Failed to find ITextRange");
GetStartMethodInfo = iTextRange.GetProperty("Start")?.GetGetMethod() ?? throw new InvalidOperationException($"Failed to find 'Start' property on {iTextRange.FullName}");
GetEndMethodInfo = iTextRange.GetProperty("End")?.GetGetMethod() ?? throw new InvalidOperationException($"Failed to find 'End' property on {iTextRange.FullName}");
Type passwordTextPointer = typeof(PasswordBox).Assembly.GetType("System.Windows.Controls.PasswordTextPointer") ?? throw new InvalidOperationException("Failed to find PasswordTextPointer");
GetOffsetPropertyInfo = passwordTextPointer.GetProperty("Offset", BindingFlags.NonPublic | BindingFlags.Instance) ?? throw new InvalidOperationException("Failed to find 'Offset' property on PasswordTextPointer");
}

protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.IsVisibleChanged += AssociatedObjectOnIsVisibleChanged;
if (PasswordBox != null)
{
var selection = SelectionPropertyInfo.GetValue(PasswordBox, null) as TextSelection;
SetSelection(AssociatedObject, selection);
}
}

protected override void OnDetaching()
{
base.OnDetaching();
if (AssociatedObject != null)
{
AssociatedObject.ClearValue(SelectionProperty);
AssociatedObject.IsVisibleChanged -= AssociatedObjectOnIsVisibleChanged;
}
}

private void AssociatedObjectOnIsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (AssociatedObject.IsVisible)
{
AssociatedObject.SelectionLength = 0;
var selection = GetPasswordBoxSelection();
AssociatedObject.SelectionStart = selection.SelectionStart;
AssociatedObject.SelectionLength = selection.SelectionEnd;
Keyboard.Focus(AssociatedObject);
}
else if (PasswordBox != null)
{
SetPasswordBoxSelection(AssociatedObject.SelectionStart, AssociatedObject.SelectionLength);
Keyboard.Focus(PasswordBox);
}
}

private PasswordBoxSelection GetPasswordBoxSelection()
{
var selection = GetSelection(AssociatedObject);
object? start = GetStartMethodInfo.Invoke(selection, null);
object? end = GetEndMethodInfo.Invoke(selection, null);
int? startValue = GetOffsetPropertyInfo.GetValue(start, null) as int?;
int? endValue = GetOffsetPropertyInfo.GetValue(end, null) as int?;
int selectionStart = startValue ?? 0;
int selectionLength = 0;
if (endValue.HasValue)
{
selectionLength = endValue.Value - selectionStart;
}
return new PasswordBoxSelection(selectionStart, selectionLength);
}

private void SetPasswordBoxSelection(int selectionStart, int selectionLength) => SelectMethodInfo.Invoke(PasswordBox, new object[] { selectionStart, selectionLength });

private record struct PasswordBoxSelection(int SelectionStart, int SelectionEnd);
}
Loading