Search code examples
c#winformsdatagridview

DataGridViewComboBoxCell with different entries, as selected in another DataGridViewComboBoxCell


I've been struggling with a problem of having two DataGridViewComboBoxCell columns in a datagrid. Upon changing an item in column 1 in given row, I want the corresponding cell in column 2 to change its droplist items. I attached very simple script below for such a dialog, see the attached image.

By default column 2 has no entries. Now when you do the following sequence

  1. In column 1 selectect "1", this triggers col1Changed event and updates cell items in column 2
  2. In column 2, select "item 1".
  3. Try to select something in column 1

This triggers the following exception:

The following exception occurred in the DataGridView: System.ArgumentException: DataGridViewComboBoxCell value is not valid. To replace this default dialog please handle the DataError event.

How can I avoid this exception?

Thanks

See my code for this problem below:

void initaliseGrid()
{
    string[] itemsCol1 = new string[] { "1", "2", "3" };
    DataGridViewComboBoxColumn col1 = new DataGridViewComboBoxColumn();
    col1.DropDownWidth = 200;
    col1.Width = 200;
    col1.MaxDropDownItems = 3;
    col1.HeaderText = "col 1";
    col1.ReadOnly = false;
    dataGridView1.Columns.Insert(0, col1);
    col1.Items.AddRange(itemsCol1);

    DataGridViewComboBoxColumn col2 = new DataGridViewComboBoxColumn();
    col2.DropDownWidth = 200;
    col2.Width = 200;
    col2.MaxDropDownItems = 3;
    col2.HeaderText = "col 2";
    col2.ReadOnly = true;
    dataGridView1.Columns.Insert(1, col2);

}

int col1Idx = 0;
int col2Idx = 1;

private void dataGridView1_EditingControlShowing(object sender, DataGridViewEditingControlShowingEventArgs e)
{
    // Check if the curent cell is combobox
    if (dataGridView1.CurrentCell.ColumnIndex == colqIdx && e.Control is ComboBox)
    {
        ComboBox comboBox = e.Control as ComboBox;
        // Add an event when combobox index is changed
        comboBox.SelectedIndexChanged += col1Changed;
    }
}

private void col1Changed(object sender, EventArgs e)
{
    var currentcell = dataGridView1.CurrentCellAddress;
    if (currentcell.X == col1Idx)
    {
        // Read the selected item
        string selectedItem = ((ComboBox)sender).SelectedItem.ToString();
        // Get the cellfrom column 2
        DataGridViewComboBoxCell comboBox = (DataGridViewComboBoxCell)dataGridView1.Rows[currentcell.X].Cells[col2Idx];
        if (selectedItem == "1")
        {
            comboBox.Items.Clear();
            comboBox.Items.Add("item1");
            comboBox.Items.Add("item2");
            comboBox.ReadOnly = false;
        }
        else if (selectedItem == "2")
        {
            comboBox.Items.Clear();
            comboBox.Items.Add("item3");
            comboBox.Items.Add("item4");
            comboBox.ReadOnly = false;
        }
        else
        {
            comboBox.Items.Clear();
            comboBox.ReadOnly = true;
        }
    }
}

enter image description here


Solution

  • I was able to easily reproduce your DataError exception and the root cause is that whenever the DataGridView refreshes, it tries to synchronize the displayed value with one of the available items in the combo box. But if you change the available options in the combo box, it can no longer find the displayed value and throws the DataError exception.

    As I understand it, You'd like to have different options for different index values, something like this for example:

        enum OptionsOne { Select, Bumble, Twinkle, }
        enum OptionsTwo { Select, Whisker, Quibble, }
        enum OptionsThree { Select, Wobble, Flutter, }
    

    different options


    To handle the Refresh issue, you can experiment with using binding to suppress any change to the Option value if the incoming value isn't a currently available option. Here's an example for the bound record class:

    class Record : INotifyPropertyChanged
    {
        static int _recordCount = 1;
        public string Description { get; set; } = $"Record {_recordCount++}";
    
        // By initially setting index to '1' and Available options
        // to `OptionsOne` every record is born in sync with itself.
        public int Index
        {
            get => _index;
            set
            {
                if (!Equals(_index, value))
                {
                    _index = value;
                    switch (Index)
                    {
                        case 1: AvailableOptions = Enum.GetNames(typeof(OptionsOne)); break;
                        case 2: AvailableOptions = Enum.GetNames(typeof(OptionsTwo)); break;
                        case 3: AvailableOptions = Enum.GetNames(typeof(OptionsThree)); break;
                    }
                    if (Option is string currentOption && !AvailableOptions.Contains(currentOption))
                    {
                        Option = AvailableOptions[0];
                        OnPropertyChanged(nameof(Option));
                    }
                    OnPropertyChanged();
                }
            }
        }
        int _index = 1;
    
        [Browsable(false)]
        public string[] AvailableOptions { get; set; } = Enum.GetNames(typeof(OptionsOne));
        public string? Option
        {
            get => _option;
            set
            {
                if (!Equals(_option, value))
                {
                    if (Option is string currentOption && AvailableOptions.Contains(value))
                    {
                        _option = value;
                        OnPropertyChanged();
                    }
                }
            }
        }
        string? _option = $"{OptionsOne.Select}";
    
        public virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    
        public event PropertyChangedEventHandler? PropertyChanged;
    }
    

    successful selection


    A secondary issue is that you might change the Index value, and now the currently selected value is no longer a valid available choice, so another aspect of this binding is that the record will reset to the safe "common" value of Select if that occurs.

    successful redirection


    The only thing that remains to be done is making sure the available selections track the selected item. This can be done in a handler for the CurrentCellChanged event as shown in this initialization routine:

    public partial class MainForm : Form
    {
        public MainForm() => InitializeComponent();
        protected override void OnLoad(EventArgs e)
        {
            int replaceIndex;
            DataGridViewComboBoxColumn cbCol;
    
            base.OnLoad(e);
            dataGridView.DataSource = Records; 
            dataGridView.Columns[nameof(Record.Description)].AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill;
    
            // Index column
            cbCol = new DataGridViewComboBoxColumn
            {
                Name = nameof(Record.Index),
                DataSource = new int[] {1,2,3},
                DataPropertyName = nameof(Record.Index),
            };
            replaceIndex = dataGridView.Columns[nameof(Record.Index)].Index;
            dataGridView.Columns.RemoveAt(replaceIndex);
            dataGridView.Columns.Insert(replaceIndex, cbCol);
    
            // Option column
            cbCol = new DataGridViewComboBoxColumn
            {
                Name = nameof(Record.Option),
                DataSource = Enum.GetNames<OptionsOne>(),
                DataPropertyName = nameof(Record.Option),
            };
            replaceIndex = dataGridView.Columns[nameof(Record.Option)].Index;
            dataGridView.Columns.RemoveAt(replaceIndex);
            dataGridView.Columns.Insert(replaceIndex, cbCol);
            dataGridView.CurrentCellChanged += (sender, e) =>
            {
                var cbCol = ((DataGridViewComboBoxColumn)dataGridView.Columns[nameof(Record.Option)]);
                if (dataGridView.CurrentCell is DataGridViewComboBoxCell cbCell && cbCell.ColumnIndex == cbCol.Index)
                {
                    var record = Records[dataGridView.CurrentCell.RowIndex];
                    cbCell.DataSource = record.AvailableOptions;
                }
            };
            dataGridView.DataError += (sender, e) =>
            {
                Debug.Fail("We don't expect this to happen anymore!");
            };
            // Consider 'not' allowing the record to be dirty after a CB select.
            dataGridView.CurrentCellDirtyStateChanged += (sender, e) =>
            {
                if(dataGridView.CurrentCell is DataGridViewComboBoxCell)
                {
                    if (dataGridView.IsCurrentCellDirty)
                    {
                        BeginInvoke(()=> dataGridView.EndEdit(DataGridViewDataErrorContexts.Commit));
                    }
                }
            };
            Records.Add(new Record());
            Records.Add(new Record());
            Records.Add(new Record());
        }
        BindingList<Record> Records { get; } = new BindingList<Record>();
    }
    

    One more subtlety. In your original code, changing a CB selection is going to make the record dirty and uncommitted. By handling the CurrentCellDirtyStateChanged event you can go ahead and commit immediately if desired.

    immediate commit