Carl Heinrich Hancke
Carl Heinrich Hancke

Reputation: 2800

WPF editable master-detail with DataGrid updating on save

I'm very new to WPF, so I thought I'd start with something simple: A window that allows users to manage users. The Window contains a DataGrid along with several input controls to add or edit users. When a user selects a record in the grid, the data is bound to the input controls. The user can then make the required changes & click the "Save" button to persist the changes.

What's happening however, is that as soon as a user makes a change in one of the input controls, the corresponding data in the DataGrid gets updated as well, before the "Save" button was clicked. I would like the DataGrid to only be updated once the user clicks "Save".

Here is the XAML for the view:

<Window x:Class="LearnWPF.Views.AdminUser"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vms="clr-namespace:LearnWPF.ViewModels"
        Title="User Administration" Height="400" Width="450" 
        ResizeMode="NoResize">
    <Window.DataContext>
        <vms:UserViewModel />
    </Window.DataContext>

    <StackPanel>
        <GroupBox x:Name="grpDetails" Header="User Details" DataContext="{Binding CurrentUser, Mode=OneWay}">
            <Grid>
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="*" />
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition />
                    <ColumnDefinition />
                </Grid.ColumnDefinitions>

                <Label Grid.Column="0" Grid.Row="0">First Name:</Label>
                <TextBox Grid.Column="1" Grid.Row="0" Style="{StaticResource TextBox}" Text="{Binding FirstName}"></TextBox>

                <Label Grid.Column="0" Grid.Row="1">Surname:</Label>
                <TextBox Grid.Column="1" Grid.Row="1" Style="{StaticResource TextBox}" Text="{Binding LastName}"></TextBox>

                <Label Grid.Column="0" Grid.Row="2">Username:</Label>
                <TextBox Grid.Column="1" Grid.Row="2" Style="{StaticResource TextBox}" Text="{Binding Username}"></TextBox>

                <Label Grid.Column="0" Grid.Row="3">Password:</Label>
                <PasswordBox Grid.Column="1" Grid.Row="3" Style="{StaticResource PasswordBox}"></PasswordBox>

                <Label Grid.Column="0" Grid.Row="4">Confirm Password:</Label>
                <PasswordBox Grid.Column="1" Grid.Row="4" Style="{StaticResource PasswordBox}"></PasswordBox>
            </Grid>
        </GroupBox>

        <StackPanel Orientation="Horizontal">
            <Button Style="{StaticResource Button}" Command="{Binding SaveCommand}" CommandParameter="{Binding CurrentUser}">Save</Button>
            <Button Style="{StaticResource Button}">Cancel</Button>
        </StackPanel>

        <DataGrid x:Name="grdUsers" AutoGenerateColumns="False" CanUserAddRows="False" CanUserResizeRows="False"
                  Style="{StaticResource DataGrid}" ItemsSource="{Binding Users}" SelectedItem="{Binding CurrentUser, Mode=OneWayToSource}">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Full Name" IsReadOnly="True" Binding="{Binding FullName}" Width="2*"></DataGridTextColumn>
                <DataGridTextColumn Header="Username" IsReadOnly="True" Binding="{Binding Username}" Width="*"></DataGridTextColumn>
            </DataGrid.Columns>
        </DataGrid>
    </StackPanel>
</Window>

The Model has nothing special in it (the base class merely implements the INotifyPropertyChanged interface & firing the associated event):

public class UserModel : PropertyChangedBase
{
    private int _id;
    public int Id
    {
        get { return _id; }
        set
        {
            _id = value;
            RaisePropertyChanged("Id");
        }
    }

    private string _firstName;
    public string FirstName
    {
        get { return _firstName; }
        set
        {
            _firstName = value;
            RaisePropertyChanged("FirstName");
            RaisePropertyChanged("FullName");
        }
    }

    private string _lastName;
    public string LastName
    {
        get { return _lastName; }
        set
        {
            _lastName = value;
            RaisePropertyChanged("LastName");
            RaisePropertyChanged("FullName");
        }
    }

    private string _username;
    public string Username
    {
        get { return _username; }
        set
        {
            _username = value;
            RaisePropertyChanged("Username");
        }
    }

    public string FullName
    {
        get { return String.Format("{0} {1}", FirstName, LastName); }
    }
}

The ViewModel (IRemoteStore provides access to the underlying record store):

public class UserViewModel : PropertyChangedBase
{
    private IRemoteStore _remoteStore = Bootstrapper.RemoteDataStore;
    private ICommand _saveCmd;

    public UserViewModel()
    {
        Users = new ObservableCollection<UserModel>();
        foreach (var user in _remoteStore.GetUsers()) {
            Users.Add(user);
        }

        _saveCmd = new SaveCommand<UserModel>((model) => {
            Users[Users.IndexOf(Users.First(e => e.Id == model.Id))] = model;
        });
    }

    public ICommand SaveCommand
    {
        get { return _saveCmd; }
    }

    public ObservableCollection<UserModel> Users { get; set; }

    private UserModel _currentUser;
    public UserModel CurrentUser
    {
        get { return _currentUser; }
        set
        {
            _currentUser = value;
            RaisePropertyChanged("CurrentUser");
        }
    }
}

And for the sake of completeness, here's the implementation of my Save ICommand (this doesn't actually persist anything yet, as I wanted to get the databinding working correctly first):

public class SaveCommand<T> : ICommand
{
    private readonly Action<T> _saved;

    public SaveCommand(Action<T> saved)
    {
        _saved = saved;
    }

    public bool CanExecute(object parameter)
    {
        return true;
    }

    public event EventHandler CanExecuteChanged;

    public void Execute(object parameter)
    {
        _saved((T)parameter);
    }
}

As is apparent, I'm looking to implement this using a pure MVVM pattern. I've tried setting the bindings in the DataGrid to OneWay, but this causes changes to not be reflected in the grid (although new entries do get added).

I've also had a look at this SO question, which suggested using a "selected" property on the ViewModel. My original implementation, as posted above, already had such a property (called "CurrentUser"), but with the current binding configuration, the grid is still updated as users make changes.

Any assistance would be greatly appreciated, as I've been bumping my head against this issue for several hours now. If I've left anything out, please comment & I will update the post. Thank you.

Upvotes: 3

Views: 1638

Answers (1)

Eido95
Eido95

Reputation: 1383

Thank you for providing that much of code, it was much easier for me to understand your question.

First, I'll explain your current "User input -> Data grid" flow bellow:

When you are typing, for example, text inside Username: TextBox, the text that you are typing is eventually, at some point, backed inside the TextBox.Text property value, in our case, is the current UserModel.Username property, because they are binded and he is the property value:

Text="{Binding UserName}"></TextBox>

The fact that they are binded means that no matter when you set UserModel.Username property, PropertyChanged is raised and notifies for a change:

private string _username;
public string Username
{
    get { return _username; }
    set
    {
        _username = value;
        RaisePropertyChanged("Username"); // notification
    }
}

When PropertyChanged is raises, it notifies the changes for all UserModel.Username subscribers, in our case, one of the DataGrid.Columns, is a subscriber.

<DataGridTextColumn Header="Username" IsReadOnly="True" Binding="{Binding Username}" Width="*"></DataGridTextColumn>

The problem with the flow above starts at the place where you back the user input text. What you need is a place to back your user input text without setting it directly to the current UserModel.Username property, because if it will, it will start the flow I described above.

I would like the DataGrid to only be updated once the user clicks "Save"

My solution for your question is instead of directly backing the TextBoxes texts inside the current UserModel, backing the texts inside a temporary place, so when you click on "Save", it will copy the text from there to the current UserModel, and the properties set accessor inside CopyTo method will automatically update the DataGrid.

I made the following changes for your code, the rest left the same:

View

<GroupBox x:Name="GroupBoxDetails" Header="User Details" DataContext="{Binding Path=TemporarySelectedUser, Mode=TwoWay, UpdateSourceTrigger=LostFocus}">
...
<Button Content="Save"
                    Command="{Binding Path=SaveCommand}"
                    CommandParameter="{Binding Path=TemporarySelectedUser}"/> // CommandParameter is optional if you'll use SaveCommand with no input members.

ViewModel

...
public UserModel TemporarySelectedUser { get; private set; }
...
TemporarySelectedUser = new UserModel(); // once in the constructor.
...
private UserModel _currentUser;
public UserModel CurrentUser
{
    get { return _currentUser; }
    set
    {
        _currentUser = value;

        if (value != null)
            value.CopyTo(TemporarySelectedUser);

        RaisePropertyChanged("CurrentUser");
    }
}
...
private ICommand _saveCommand;
public ICommand SaveCommand
{
    get
    {
        return _saveCommand ??
                (_saveCommand = new Command<UserModel>(SaveExecute));
    }
}

public void SaveExecute(UserModel updatedUser)
{
    UserModel oldUser = Users[
        Users.IndexOf(
            Users.First(value => value.Id == updatedUser.Id))
        ];
    // updatedUser is always TemporarySelectedUser.
    updatedUser.CopyTo(oldUser);
}
...

Model

public void CopyTo(UserModel target)
{
    if (target == null)
        throw new ArgumentNullException();

    target.FirstName = this.FirstName;
    target.LastName = this.LastName;
    target.Username = this.Username;
    target.Id = this.Id;
}

User input --text input--> Temporary user --click Save--> Updates user and UI.

Its seems that your MVVM approach is View-First, one of many "View-First" approach guidelines is for each View you should create a corresponding ViewModel. So, it would be more "accurate" to rename your ViewModel after the View its abstracting, e.g. rename UserViewModel to AdminUserViewModel.

Also, You can rename your SaveCommand to Command because it is answers the whole command pattern solution, rather then the special "saving" case.

I would suggest you to use one of the MVVM frameworks (MVVMLight is my recommendation) as best practice for MVVM study, there are plenty out there.

Hope it helped.

Upvotes: 2

Related Questions