Skip to content

Commit 140a707

Browse files
committed
Auto-apply changes for DictItem and FloatArrayItem editors
When editing dictionaries or arrays in a DataSetEditGroupBox (with Apply button), changes are now automatically applied when the editor dialog is validated. This eliminates the need for users to click both "Save & Close" in the editor and then "Apply" in the dataset layout, providing a more intuitive workflow.
1 parent aa059d4 commit 140a707

4 files changed

Lines changed: 270 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
## Version 3.13.2 ##
44

5+
✨ New features:
6+
7+
* **Auto-apply for DictItem and FloatArrayItem in DataSetEditGroupBox**: Improved user experience when editing dictionaries and arrays
8+
* When a `DictItem` or `FloatArrayItem` is modified within a `DataSetEditGroupBox` (with an Apply button), changes are now automatically applied when the editor dialog is validated
9+
* Previously, users had to click "Save & Close" in the dictionary/array editor, then click the "Apply" button in the dataset widget layout
10+
* Now, clicking "Save & Close" automatically triggers the apply action, making changes immediately effective
11+
* Implementation: The auto-apply trigger function is passed as an optional 5th parameter to button callbacks
12+
* This behavior only applies to dataset layouts with an Apply button (DataSetEditGroupBox), not to standalone editors
13+
* Provides more intuitive workflow and reduces the number of clicks required to apply changes
14+
* Affects both `DictItem` (dictionary editor) and `FloatArrayItem` (array editor)
15+
516
🛠️ Bug fixes:
617

718
* Fix the `AboutInfo.about` method: renamed parameter `addinfos` to `addinfo` for consistency

guidata/dataset/dataitems.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1415,8 +1415,22 @@ def __init__(
14151415

14161416
@staticmethod
14171417
# pylint: disable=unused-argument
1418-
def __dictedit(instance: DataSet, item: DataItem, value: dict, parent):
1419-
"""Open a dictionary editor"""
1418+
def __dictedit(
1419+
instance: DataSet,
1420+
item: DataItem,
1421+
value: dict,
1422+
parent,
1423+
trigger_apply=None,
1424+
):
1425+
"""Open a dictionary editor
1426+
1427+
Args:
1428+
instance: DataSet instance
1429+
item: DataItem instance
1430+
value: Current dictionary value
1431+
parent: Parent widget
1432+
trigger_apply: Optional callback to trigger auto-apply
1433+
"""
14201434
# pylint: disable=import-outside-toplevel
14211435
from guidata.qthelpers import exec_dialog
14221436
from guidata.widgets.collectionseditor import CollectionsEditor
@@ -1426,8 +1440,13 @@ def __dictedit(instance: DataSet, item: DataItem, value: dict, parent):
14261440
if value_was_none:
14271441
value = {}
14281442
editor.setup(value, readonly=instance.is_readonly())
1429-
if exec_dialog(editor):
1430-
return editor.get_value()
1443+
result = exec_dialog(editor)
1444+
if result:
1445+
new_value = editor.get_value()
1446+
# Auto-apply changes if trigger function was provided
1447+
if trigger_apply is not None:
1448+
trigger_apply()
1449+
return new_value
14311450
if value_was_none:
14321451
return None
14331452
return value

guidata/dataset/qtitemwidgets.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,35 @@ def notify_value_change(self) -> None:
230230
if not self.build_mode:
231231
self.parent_layout.widget_value_changed()
232232

233+
def _trigger_auto_apply(self) -> None:
234+
"""Automatically trigger the apply action if in DataSetEditGroupBox context.
235+
236+
This method checks if the parent layout is part of a DataSetEditGroupBox
237+
(which has an Apply button), and if so, automatically triggers the apply
238+
action. This provides a better user experience for editors like the dictionary
239+
and array editors, where users expect changes to be applied when they click
240+
"Save & Close" rather than requiring an additional "Apply" button click.
241+
242+
The apply is deferred to the next event loop iteration to ensure that the
243+
callback has finished updating the widget's value before apply is triggered.
244+
"""
245+
# pylint: disable=import-outside-toplevel
246+
from qtpy.QtCore import QTimer
247+
248+
from guidata.dataset.qtwidgets import DataSetEditGroupBox
249+
250+
# Walk up the widget hierarchy to find DataSetEditGroupBox
251+
# The parent_layout.parent may not directly be the DataSetEditGroupBox,
252+
# especially when the layout is embedded in tabs or other containers
253+
current = self.parent_layout.parent
254+
while current is not None:
255+
if isinstance(current, DataSetEditGroupBox):
256+
# Defer the apply to the next event loop iteration to ensure the
257+
# callback has finished updating the value
258+
QTimer.singleShot(0, lambda gb=current: gb.set(check=False))
259+
return
260+
current = current.parent() if hasattr(current, "parent") else None
261+
233262
def retrieve_top_level_layout(self) -> DataSetEditLayout:
234263
"""Retrieve the top-level layout associated with this widget.
235264
@@ -1370,6 +1399,8 @@ def edit_array(self) -> None:
13701399
):
13711400
self.update(self.arr)
13721401
self.notify_value_change()
1402+
# Auto-apply changes if in a DataSetEditGroupBox context
1403+
self._trigger_auto_apply()
13731404

13741405
def get(self) -> None:
13751406
"""Update widget contents from data item value"""
@@ -1524,8 +1555,13 @@ def clicked(self, *args) -> None:
15241555
"""
15251556
self.parent_layout.update_dataitems()
15261557
callback = self.item.get_prop_value("display", "callback")
1558+
# Pass auto-apply trigger function as optional 5th parameter
15271559
self.cb_value = callback(
1528-
self.item.instance, self.item.item, self.cb_value, self.button.parent()
1560+
self.item.instance,
1561+
self.item.item,
1562+
self.cb_value,
1563+
self.button.parent(),
1564+
self._trigger_auto_apply,
15291565
)
15301566
self.set()
15311567
self.parent_layout.update_widgets()
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Licensed under the terms of the BSD 3-Clause
4+
# (see guidata/LICENSE for details)
5+
6+
"""
7+
Auto-apply functionality test
8+
9+
This test verifies that DictItem and FloatArrayItem editors automatically
10+
trigger the apply action when used within a DataSetEditGroupBox context.
11+
"""
12+
13+
# guitest: show
14+
15+
import numpy as np
16+
17+
import guidata.dataset as gds
18+
from guidata.dataset.qtwidgets import DataSetEditGroupBox
19+
from guidata.env import execenv
20+
from guidata.qthelpers import qt_app_context
21+
22+
23+
class AutoApplyDataSet(gds.DataSet):
24+
"""Test dataset with DictItem and FloatArrayItem"""
25+
26+
dictionary = gds.DictItem("Dictionary", default={"a": 1, "b": 2, "c": 3})
27+
array = gds.FloatArrayItem("Array", default=np.array([[1, 2], [3, 4]]))
28+
string = gds.StringItem("String", default="test")
29+
30+
31+
class AutoApplySignalChecker:
32+
"""Helper class to track signal emissions"""
33+
34+
def __init__(self, groupbox: DataSetEditGroupBox):
35+
self.groupbox = groupbox
36+
self.signal_received = False
37+
groupbox.SIG_APPLY_BUTTON_CLICKED.connect(self.on_signal)
38+
39+
def on_signal(self):
40+
"""Signal handler"""
41+
self.signal_received = True
42+
execenv.print("Signal received: SIG_APPLY_BUTTON_CLICKED")
43+
44+
def reset(self):
45+
"""Reset the checker state"""
46+
self.signal_received = False
47+
48+
49+
def test_auto_apply_dictitem():
50+
"""Test that DictItem widget has auto-apply functionality"""
51+
with qt_app_context():
52+
# Create the groupbox and signal checker
53+
groupbox = DataSetEditGroupBox("Test", AutoApplyDataSet)
54+
checker = AutoApplySignalChecker(groupbox)
55+
56+
# Get the DictItem widget - widget.item is a DataItemVariable,
57+
# widget.item.item is the actual DataItem with the type/name info
58+
dict_widget = None
59+
for widget in groupbox.edit.widgets:
60+
if (
61+
hasattr(widget, "item")
62+
and hasattr(widget.item, "item")
63+
and isinstance(widget.item.item, gds.DictItem)
64+
):
65+
dict_widget = widget
66+
break
67+
68+
assert dict_widget is not None, "DictItem widget not found"
69+
70+
# Verify the widget has the _trigger_auto_apply method
71+
assert hasattr(dict_widget, "_trigger_auto_apply"), (
72+
"DictItem widget missing _trigger_auto_apply method"
73+
)
74+
75+
# Call _trigger_auto_apply to simulate what the dictionary editor does
76+
dict_widget._trigger_auto_apply()
77+
78+
# Process events to allow deferred execution
79+
from qtpy.QtWidgets import QApplication
80+
81+
QApplication.processEvents()
82+
83+
# Verify signal was received
84+
assert checker.signal_received, "Signal was not emitted after auto-apply"
85+
execenv.print("✓ DictItem auto-apply triggered signal")
86+
87+
# Verify button is disabled after processing events
88+
assert not groupbox.apply_button.isEnabled(), (
89+
"Apply button should be disabled after auto-apply"
90+
)
91+
execenv.print("✓ Apply button is disabled after auto-apply")
92+
93+
94+
def test_auto_apply_floatarrayitem():
95+
"""Test that FloatArrayItem widget has auto-apply functionality"""
96+
with qt_app_context():
97+
# Create the groupbox and signal checker
98+
groupbox = DataSetEditGroupBox("Test", AutoApplyDataSet)
99+
checker = AutoApplySignalChecker(groupbox)
100+
101+
# Get the FloatArrayItem widget
102+
array_widget = None
103+
for widget in groupbox.edit.widgets:
104+
if (
105+
hasattr(widget, "item")
106+
and hasattr(widget.item, "item")
107+
and isinstance(widget.item.item, gds.FloatArrayItem)
108+
):
109+
array_widget = widget
110+
break
111+
112+
assert array_widget is not None, "FloatArrayItem widget not found"
113+
114+
# Verify the widget has the _trigger_auto_apply method
115+
assert hasattr(array_widget, "_trigger_auto_apply"), (
116+
"FloatArrayItem widget missing _trigger_auto_apply method"
117+
)
118+
119+
# Call _trigger_auto_apply to simulate what the array editor does
120+
array_widget._trigger_auto_apply()
121+
122+
# Process events to allow deferred execution
123+
from qtpy.QtWidgets import QApplication
124+
125+
QApplication.processEvents()
126+
127+
# Verify signal was received
128+
assert checker.signal_received, "Signal was not emitted after auto-apply"
129+
execenv.print("✓ FloatArrayItem auto-apply triggered signal")
130+
131+
# Verify button is disabled after processing events
132+
assert not groupbox.apply_button.isEnabled(), (
133+
"Apply button should be disabled after auto-apply"
134+
)
135+
execenv.print("✓ Apply button is disabled after auto-apply")
136+
137+
138+
def test_auto_apply_widget_hierarchy():
139+
"""Test auto-apply works when DataSetEditGroupBox is in widget hierarchy"""
140+
with qt_app_context():
141+
from qtpy.QtWidgets import QFrame, QStackedWidget, QTabWidget, QVBoxLayout
142+
143+
# Create a complex widget hierarchy similar to DataLab's Properties panel
144+
tab_widget = QTabWidget()
145+
stacked = QStackedWidget()
146+
frame1 = QFrame()
147+
frame2 = QFrame()
148+
149+
# Create the groupbox inside the hierarchy
150+
groupbox = DataSetEditGroupBox("Test", AutoApplyDataSet)
151+
checker = AutoApplySignalChecker(groupbox)
152+
153+
# Build the hierarchy
154+
layout = QVBoxLayout()
155+
layout.addWidget(groupbox)
156+
frame2.setLayout(layout)
157+
frame1_layout = QVBoxLayout()
158+
frame1_layout.addWidget(frame2)
159+
frame1.setLayout(frame1_layout)
160+
stacked.addWidget(frame1)
161+
tab_widget.addTab(stacked, "Test Tab")
162+
163+
# Get the widget and trigger auto-apply
164+
dict_widget = None
165+
for widget in groupbox.edit.widgets:
166+
if (
167+
hasattr(widget, "item")
168+
and hasattr(widget.item, "item")
169+
and isinstance(widget.item.item, gds.DictItem)
170+
):
171+
dict_widget = widget
172+
break
173+
174+
assert dict_widget is not None, "DictItem widget not found"
175+
176+
# Simulate dictionary update and auto-apply
177+
dict_widget._trigger_auto_apply()
178+
179+
# Process events
180+
from qtpy.QtWidgets import QApplication
181+
182+
QApplication.processEvents()
183+
184+
# Verify it still works even with complex hierarchy
185+
assert checker.signal_received, "Signal was not emitted in complex hierarchy"
186+
assert not groupbox.apply_button.isEnabled(), (
187+
"Apply button should be disabled after auto-apply"
188+
)
189+
execenv.print("✓ Auto-apply works correctly in complex widget hierarchy")
190+
191+
192+
if __name__ == "__main__":
193+
# Run all tests
194+
test_auto_apply_dictitem()
195+
execenv.print("\n" + "=" * 80 + "\n")
196+
test_auto_apply_floatarrayitem()
197+
execenv.print("\n" + "=" * 80 + "\n")
198+
test_auto_apply_widget_hierarchy()
199+
execenv.print("\nAll tests passed!")

0 commit comments

Comments
 (0)