Skip to content

Commit aa19c17

Browse files
Refactor PasswordBoxAssist.Password to use behaviors (issue 2930) (#2932)
* Rewrite current UI test and add new UI test to test failing scenario * Refactor PasswordBoxAssist to use behavior * Refactor PasswordBox reveal style TextBox focus- and text selection code The old focus code did not work that well because keyboard focus was not really possible to set via the Style. With the introduction of behaviors, we can now improve that a lot. * Undo wrong change in TestBase * Whitespace commit to force re-run * Increase delay in test Test runs perfectly locally, so I don't understand why it fails in the pipeline * Rollback of increased test delay * Update MaterialDesignThemes.Wpf/Behaviors/PasswordBoxRevealTextBoxBehavior.cs Co-authored-by: Kevin B <[email protected]> * Update MaterialDesignThemes.Wpf/Behaviors/PasswordBoxRevealTextBoxBehavior.cs Co-authored-by: Kevin B <[email protected]> * Extract PasswordBoxBehavior into its own class * Cache PropertyInfos and MethodInfos in PasswordBoxRevealTextBoxBehavior * Selecting minimum required version of dependency and updating nuspec I am unsure whether we can go down to an even older version - potentially supporting a broader audience. * Convert Password binding to opt-in feature Previously, the attached property was updated based on the PasswordChanged event regardless of whether the user had setup a binding or not. Now it is only wired up if there is a binding, or the "reveal" style has been applied (where it is a necessary evil). * File scoped namespace * Behavior classes renames and change from internal to public * Use latest version of Xaml.Behaviors and update nuspec file * Attempting fix of pipeline * Adding screenshots for debugging * Attempt at fixing failing UI test * Adding more screenshots for debugging * Activate test window on loaded event * Yet another attempt at getting test window shown * Removing debugging screenshots Co-authored-by: Kevin B <[email protected]> Co-authored-by: Kevin Bost <[email protected]>
1 parent 04a56c4 commit aa19c17

15 files changed

+473
-74
lines changed

Directory.packages.props

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<PackageVersion Include="Microsoft.NETCore.Platforms" Version="7.0.0" />
1616
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
1717
<PackageVersion Include="Microsoft.Toolkit.MVVM" Version="7.1.2" />
18+
<PackageVersion Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
1819
<PackageVersion Include="Newtonsoft.Json" Version="13.0.1" />
1920
<PackageVersion Include="Shouldly" Version="4.1.0" />
2021
<PackageVersion Include="ShowMeTheXAML" Version="2.0.0" />
@@ -26,4 +27,4 @@
2627
<PackageVersion Include="xunit.runner.visualstudio" Version="2.4.5" />
2728
<PackageVersion Include="Xunit.StaFact" Version="1.1.11" />
2829
</ItemGroup>
29-
</Project>
30+
</Project>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<UserControl x:Class="MaterialDesignThemes.UITests.Samples.PasswordBox.BoundPasswordBox"
2+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
3+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
4+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
5+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
6+
xmlns:local="clr-namespace:MaterialDesignThemes.UITests.Samples.PasswordBox"
7+
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
8+
mc:Ignorable="d"
9+
d:DataContext="{d:DesignInstance local:BoundPasswordBoxViewModel, IsDesignTimeCreatable=False}"
10+
d:DesignHeight="450" d:DesignWidth="800">
11+
<Grid>
12+
<PasswordBox x:Name="PasswordBox"
13+
Width="400"
14+
HorizontalAlignment="Center"
15+
VerticalAlignment="Center"
16+
Style="{StaticResource {x:Type PasswordBox}}"
17+
materialDesign:PasswordBoxAssist.Password="{Binding Password, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
18+
</Grid>
19+
</UserControl>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
namespace MaterialDesignThemes.UITests.Samples.PasswordBox;
2+
3+
public partial class BoundPasswordBox
4+
{
5+
6+
7+
public string? ViewModelPassword
8+
{
9+
get => ((BoundPasswordBoxViewModel) DataContext).Password;
10+
set => ((BoundPasswordBoxViewModel) DataContext).Password = value;
11+
}
12+
13+
14+
private bool _useRevealStyle;
15+
public bool UseRevealStyle
16+
{
17+
get => _useRevealStyle;
18+
set
19+
{
20+
_useRevealStyle = value;
21+
if (_useRevealStyle)
22+
{
23+
PasswordBox.Style = (Style)PasswordBox.FindResource("MaterialDesignFloatingHintRevealPasswordBox");
24+
}
25+
else
26+
{
27+
PasswordBox.ClearValue(StyleProperty);
28+
}
29+
30+
}
31+
}
32+
33+
public BoundPasswordBox()
34+
{
35+
DataContext = new BoundPasswordBoxViewModel();
36+
InitializeComponent();
37+
}
38+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using CommunityToolkit.Mvvm.ComponentModel;
2+
3+
namespace MaterialDesignThemes.UITests.Samples.PasswordBox;
4+
5+
internal partial class BoundPasswordBoxViewModel : ObservableObject
6+
{
7+
[ObservableProperty]
8+
private string? _password;
9+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<Window x:Class="MaterialDesignThemes.UITests.Samples.PasswordBox.BoundPasswordBoxWindow"
2+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
3+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
4+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
5+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
6+
xmlns:local="clr-namespace:MaterialDesignThemes.UITests.Samples.PasswordBox"
7+
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
8+
mc:Ignorable="d"
9+
Height="450"
10+
Width="800"
11+
Title="BoundPasswordBoxWindow"
12+
Background="{DynamicResource MaterialDesignPaper}"
13+
FontFamily="{materialDesign:MaterialDesignFont}"
14+
TextElement.FontSize="13"
15+
TextElement.FontWeight="Regular"
16+
TextElement.Foreground="{DynamicResource MaterialDesignBody}"
17+
TextOptions.TextFormattingMode="Ideal"
18+
TextOptions.TextRenderingMode="Auto"
19+
WindowStartupLocation="CenterScreen"
20+
Loaded="BoundPasswordBoxWindow_OnLoaded">
21+
<local:BoundPasswordBox />
22+
</Window>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace MaterialDesignThemes.UITests.Samples.PasswordBox;
2+
3+
public partial class BoundPasswordBoxWindow
4+
{
5+
public BoundPasswordBoxWindow() => InitializeComponent();
6+
7+
private void BoundPasswordBoxWindow_OnLoaded(object sender, RoutedEventArgs e)
8+
{
9+
Activate();
10+
Topmost = true;
11+
Topmost = false;
12+
Focus();
13+
}
14+
}

MaterialDesignThemes.UITests/WPF/PasswordBoxes/PasswordBoxTests.cs

Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.ComponentModel;
2+
using MaterialDesignThemes.UITests.Samples.PasswordBox;
23

34
namespace MaterialDesignThemes.UITests.WPF.PasswordBoxes
45
{
@@ -83,42 +84,36 @@ await Wait.For(async () =>
8384
}
8485

8586
[Fact]
86-
[Description("PR 2828")]
87+
[Description("PR 2828 and Issue 2930")]
8788
public async Task RevealPasswordBox_WithBoundPasswordProperty_RespectsThreeWayBinding()
8889
{
8990
await using var recorder = new TestRecorder(App);
9091

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

105-
// Act 1 (Update in VM updates PasswordBox and RevealPasswordTextBox)
106-
await boundPasswordTextBox.SendKeyboardInput($"1");
107-
string? boundText1 = await boundPasswordTextBox.GetProperty<string>(TextBox.TextProperty);
100+
// Act 1 (Update in PasswordBox updates VM and RevealPasswordTextBox)
101+
await passwordBox.SendKeyboardInput($"1");
102+
string? boundText1 = await userControl.GetProperty<string>(nameof(BoundPasswordBox.ViewModelPassword));
108103
string? password1 = await passwordBox.GetProperty<string>(nameof(PasswordBox.Password));
109104
string? clearTextPassword1 = await clearTextPasswordTextBox.GetProperty<string>(TextBox.TextProperty);
110105

111-
// Act 2 (Update in PasswordBox updates VM and RevealPasswordTextBox)
112-
await passwordBox.SendKeyboardInput($"2");
113-
string? boundText2 = await boundPasswordTextBox.GetProperty<string>(TextBox.TextProperty);
114-
string? password2 = await passwordBox.GetProperty<string>(nameof(PasswordBox.Password));
115-
string? clearTextPassword2 = await clearTextPasswordTextBox.GetProperty<string>(TextBox.TextProperty);
116-
117106
// Act 2 (Update in RevealPasswordTextBox updates PasswordBox and VM)
118107
await revealPasswordButton.LeftClick();
119108
await Task.Delay(50); // Wait for the "clear text TextBox" to become visible
120-
await clearTextPasswordTextBox.SendKeyboardInput($"3");
121-
string? boundText3 = await boundPasswordTextBox.GetProperty<string>(TextBox.TextProperty);
109+
await clearTextPasswordTextBox.SendKeyboardInput($"2");
110+
string? boundText2 = await userControl.GetProperty<string>(nameof(BoundPasswordBox.ViewModelPassword));
111+
string? password2 = await passwordBox.GetProperty<string>(nameof(PasswordBox.Password));
112+
string? clearTextPassword2 = await clearTextPasswordTextBox.GetProperty<string>(TextBox.TextProperty);
113+
114+
// Act 3 (Update in VM updates PasswordBox and RevealPasswordTextBox)
115+
await userControl.SetProperty(nameof(BoundPasswordBox.ViewModelPassword), "3");
116+
string? boundText3 = await userControl.GetProperty<string>(nameof(BoundPasswordBox.ViewModelPassword));
122117
string? password3 = await passwordBox.GetProperty<string>(nameof(PasswordBox.Password));
123118
string? clearTextPassword3 = await clearTextPasswordTextBox.GetProperty<string>(TextBox.TextProperty);
124119

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

130-
Assert.Equal("21", boundText2);
131-
Assert.Equal("21", password2);
132-
Assert.Equal("21", clearTextPassword2);
125+
Assert.Equal("12", boundText2);
126+
Assert.Equal("12", password2);
127+
Assert.Equal("12", clearTextPassword2);
133128

134-
Assert.Equal("321", boundText3);
135-
Assert.Equal("321", password3);
136-
Assert.Equal("321", clearTextPassword3);
129+
Assert.Equal("3", boundText3);
130+
Assert.Equal("3", password3);
131+
Assert.Equal("3", clearTextPassword3);
137132

138133
recorder.Success();
139134
}
135+
136+
[Fact]
137+
[Description("Issue 2930")]
138+
public async Task PasswordBox_WithBoundPasswordProperty_RespectsBinding()
139+
{
140+
await using var recorder = new TestRecorder(App);
141+
142+
await App.InitializeWithMaterialDesign();
143+
IWindow window = await App.CreateWindow<BoundPasswordBoxWindow>();
144+
var userControl = await window.GetElement<BoundPasswordBox>();
145+
await userControl.SetProperty(nameof(BoundPasswordBox.UseRevealStyle), false);
146+
var passwordBox = await userControl.GetElement<PasswordBox>("PasswordBox");
147+
148+
// Act 1 (Update in PasswordBox updates VM)
149+
await passwordBox.SendKeyboardInput($"1");
150+
string? boundText1 = await userControl.GetProperty<string>(nameof(BoundPasswordBox.ViewModelPassword));
151+
string? password1 = await passwordBox.GetProperty<string>(nameof(PasswordBox.Password));
152+
153+
// Act 2 (Update in VM updates PasswordBox)
154+
await userControl.SetProperty(nameof(BoundPasswordBox.ViewModelPassword), "2");
155+
string? boundText2 = await userControl.GetProperty<string>(nameof(BoundPasswordBox.ViewModelPassword));
156+
string? password2 = await passwordBox.GetProperty<string>(nameof(PasswordBox.Password));
157+
158+
// Assert
159+
Assert.Equal("1", boundText1);
160+
Assert.Equal("1", password1);
161+
162+
Assert.Equal("2", boundText2);
163+
Assert.Equal("2", password2);
164+
165+
recorder.Success();
166+
}
140167
}
141168
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using Microsoft.Xaml.Behaviors;
2+
3+
namespace MaterialDesignThemes.Wpf;
4+
5+
public class BehaviorCollection : FreezableCollection<Behavior>
6+
{
7+
protected override Freezable CreateInstanceCore() => new BehaviorCollection();
8+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using Microsoft.Xaml.Behaviors;
2+
3+
namespace MaterialDesignThemes.Wpf.Behaviors;
4+
5+
internal class PasswordBoxBehavior : Behavior<PasswordBox>
6+
{
7+
private void PasswordBoxLoaded(object sender, RoutedEventArgs e) => PasswordBoxAssist.SetPassword(AssociatedObject, AssociatedObject.Password);
8+
9+
protected override void OnAttached()
10+
{
11+
base.OnAttached();
12+
AssociatedObject.Loaded += PasswordBoxLoaded;
13+
}
14+
15+
protected override void OnDetaching()
16+
{
17+
if (AssociatedObject != null)
18+
{
19+
AssociatedObject.Loaded -= PasswordBoxLoaded;
20+
}
21+
base.OnDetaching();
22+
}
23+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
using System.Reflection;
2+
using System.Windows.Documents;
3+
using Microsoft.Xaml.Behaviors;
4+
5+
namespace MaterialDesignThemes.Wpf.Behaviors;
6+
7+
internal class PasswordBoxRevealTextBoxBehavior : Behavior<TextBox>
8+
{
9+
private static readonly DependencyProperty SelectionProperty = DependencyProperty.RegisterAttached(
10+
"Selection", typeof(TextSelection), typeof(PasswordBoxRevealTextBoxBehavior), new UIPropertyMetadata(default(TextSelection)));
11+
private static void SetSelection(DependencyObject obj, TextSelection? value) => obj.SetValue(SelectionProperty, value);
12+
private static TextSelection? GetSelection(DependencyObject obj) => (TextSelection?)obj.GetValue(SelectionProperty);
13+
14+
internal static readonly DependencyProperty PasswordBoxProperty = DependencyProperty.Register(
15+
nameof(PasswordBox), typeof(PasswordBox), typeof(PasswordBoxRevealTextBoxBehavior), new PropertyMetadata(default(PasswordBox)));
16+
17+
internal PasswordBox? PasswordBox
18+
{
19+
get => (PasswordBox) GetValue(PasswordBoxProperty);
20+
set => SetValue(PasswordBoxProperty, value);
21+
}
22+
23+
private static PropertyInfo SelectionPropertyInfo { get; }
24+
private static MethodInfo SelectMethodInfo { get; }
25+
private static MethodInfo GetStartMethodInfo { get; }
26+
private static MethodInfo GetEndMethodInfo { get; }
27+
private static PropertyInfo GetOffsetPropertyInfo { get; }
28+
29+
static PasswordBoxRevealTextBoxBehavior()
30+
{
31+
SelectionPropertyInfo = typeof(PasswordBox).GetProperty("Selection", BindingFlags.NonPublic | BindingFlags.Instance) ?? throw new InvalidOperationException("Did not find 'Selection' property on PasswordBox");
32+
SelectMethodInfo = typeof(PasswordBox).GetMethod("Select", BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException("Did not find 'Select' method on PasswordBox");
33+
Type iTextRange = typeof(PasswordBox).Assembly.GetType("System.Windows.Documents.ITextRange") ?? throw new InvalidOperationException("Failed to find ITextRange");
34+
GetStartMethodInfo = iTextRange.GetProperty("Start")?.GetGetMethod() ?? throw new InvalidOperationException($"Failed to find 'Start' property on {iTextRange.FullName}");
35+
GetEndMethodInfo = iTextRange.GetProperty("End")?.GetGetMethod() ?? throw new InvalidOperationException($"Failed to find 'End' property on {iTextRange.FullName}");
36+
Type passwordTextPointer = typeof(PasswordBox).Assembly.GetType("System.Windows.Controls.PasswordTextPointer") ?? throw new InvalidOperationException("Failed to find PasswordTextPointer");
37+
GetOffsetPropertyInfo = passwordTextPointer.GetProperty("Offset", BindingFlags.NonPublic | BindingFlags.Instance) ?? throw new InvalidOperationException("Failed to find 'Offset' property on PasswordTextPointer");
38+
}
39+
40+
protected override void OnAttached()
41+
{
42+
base.OnAttached();
43+
AssociatedObject.IsVisibleChanged += AssociatedObjectOnIsVisibleChanged;
44+
if (PasswordBox != null)
45+
{
46+
var selection = SelectionPropertyInfo.GetValue(PasswordBox, null) as TextSelection;
47+
SetSelection(AssociatedObject, selection);
48+
}
49+
}
50+
51+
protected override void OnDetaching()
52+
{
53+
base.OnDetaching();
54+
if (AssociatedObject != null)
55+
{
56+
AssociatedObject.ClearValue(SelectionProperty);
57+
AssociatedObject.IsVisibleChanged -= AssociatedObjectOnIsVisibleChanged;
58+
}
59+
}
60+
61+
private void AssociatedObjectOnIsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
62+
{
63+
if (AssociatedObject.IsVisible)
64+
{
65+
AssociatedObject.SelectionLength = 0;
66+
var selection = GetPasswordBoxSelection();
67+
AssociatedObject.SelectionStart = selection.SelectionStart;
68+
AssociatedObject.SelectionLength = selection.SelectionEnd;
69+
Keyboard.Focus(AssociatedObject);
70+
}
71+
else if (PasswordBox != null)
72+
{
73+
SetPasswordBoxSelection(AssociatedObject.SelectionStart, AssociatedObject.SelectionLength);
74+
Keyboard.Focus(PasswordBox);
75+
}
76+
}
77+
78+
private PasswordBoxSelection GetPasswordBoxSelection()
79+
{
80+
var selection = GetSelection(AssociatedObject);
81+
object? start = GetStartMethodInfo.Invoke(selection, null);
82+
object? end = GetEndMethodInfo.Invoke(selection, null);
83+
int? startValue = GetOffsetPropertyInfo.GetValue(start, null) as int?;
84+
int? endValue = GetOffsetPropertyInfo.GetValue(end, null) as int?;
85+
int selectionStart = startValue ?? 0;
86+
int selectionLength = 0;
87+
if (endValue.HasValue)
88+
{
89+
selectionLength = endValue.Value - selectionStart;
90+
}
91+
return new PasswordBoxSelection(selectionStart, selectionLength);
92+
}
93+
94+
private void SetPasswordBoxSelection(int selectionStart, int selectionLength) => SelectMethodInfo.Invoke(PasswordBox, new object[] { selectionStart, selectionLength });
95+
96+
private record struct PasswordBoxSelection(int SelectionStart, int SelectionEnd);
97+
}

0 commit comments

Comments
 (0)