sapiopycommons 2025.6.10a560__py3-none-any.whl → 2025.6.16a562__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sapiopycommons might be problematic. Click here for more details.

@@ -4,6 +4,7 @@ import io
4
4
  import re
5
5
  import warnings
6
6
  from copy import copy
7
+ from enum import Enum
7
8
  from typing import Iterable, TypeAlias, Any, Callable, Container, Collection
8
9
  from weakref import WeakValueDictionary
9
10
 
@@ -44,6 +45,23 @@ from sapiopycommons.recordmodel.record_handler import RecordHandler
44
45
  DataTypeLayoutIdentifier: TypeAlias = DataTypeLayout | str | None
45
46
 
46
47
 
48
+ # FR-47690: Added enum to customize blank handling result behavior, instead of using the require_selection/input
49
+ # boolean parameter.
50
+ class BlankResultHandling(Enum):
51
+ """
52
+ An enum that controls how blank results are handled in dialogs.
53
+ """
54
+ DEFAULT = 0
55
+ """Used only by dialog functions. If a dialog function parameter is set to this value, then the blank result
56
+ handling of the CallbackUtil is used."""
57
+ REPEAT = 1
58
+ """If the user provides a blank result, repeat the dialog."""
59
+ CANCEL = 2
60
+ """If the user provides a blank result, throw a cancel exception."""
61
+ RETURN = 3
62
+ """If the user provides a blank result, return it to the caller."""
63
+
64
+
47
65
  # CR-47521: Updated various parameter type hints from list or Iterable to more specific type hints.
48
66
  # If we need to iterate over the parameter, then it is Iterable.
49
67
  # If we need to see if the parameter contains a value, then it is Container.
@@ -60,6 +78,7 @@ class CallbackUtil:
60
78
  timeout_seconds: int
61
79
  width_pixels: int | None
62
80
  width_percent: float | None
81
+ _default_blank_result_handling: BlankResultHandling
63
82
 
64
83
  __instances: WeakValueDictionary[SapioUser, CallbackUtil] = WeakValueDictionary()
65
84
  __initialized: bool
@@ -97,6 +116,7 @@ class CallbackUtil:
97
116
  self.timeout_seconds = self.user.timeout_seconds
98
117
  self.width_pixels = None
99
118
  self.width_percent = None
119
+ self._default_blank_result_handling = BlankResultHandling.CANCEL
100
120
  self.__layouts = {}
101
121
 
102
122
  def set_dialog_width(self, width_pixels: int | None = None, width_percent: float | None = None):
@@ -123,6 +143,19 @@ class CallbackUtil:
123
143
  """
124
144
  self.timeout_seconds = timeout
125
145
 
146
+ def set_default_blank_result_handling(self, handling: BlankResultHandling):
147
+ """
148
+ Set the default handling of blank results provided by the user in certain dialogs. This will only be used
149
+ if the dialog functions have their own blank_result_handling parameter set to DEFAULT.
150
+
151
+ :param handling: The handling to use for blank results in dialogs.
152
+ """
153
+ if not isinstance(handling, BlankResultHandling):
154
+ raise SapioException("Invalid blank result handling provided.")
155
+ if handling == BlankResultHandling.DEFAULT:
156
+ raise SapioException("Blank result handling cannot be set to DEFAULT.")
157
+ self._default_blank_result_handling = handling
158
+
126
159
  def toaster_popup(self, message: str, title: str = "", popup_type: PopupType = PopupType.Info) -> None:
127
160
  """
128
161
  Display a toaster popup in the bottom right corner of the user's screen.
@@ -184,7 +217,7 @@ class CallbackUtil:
184
217
  # Send the request to the user.
185
218
  request = OptionDialogRequest(title, msg, options, default_option, user_can_cancel,
186
219
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
187
- response: int = self.__handle_dialog_request(request, self.callback.show_option_dialog)
220
+ response: int = self.__send_dialog(request, self.callback.show_option_dialog)
188
221
  return options[response]
189
222
 
190
223
  def ok_dialog(self, title: str, msg: str) -> None:
@@ -221,16 +254,46 @@ class CallbackUtil:
221
254
  """
222
255
  return self.option_dialog(title, msg, ["Yes", "No"], 0 if default_yes else 1, False) == "Yes"
223
256
 
257
+ # FR-47690: Added function.
258
+ def accept_decline_dialog(self, title: str, msg: str, default_accept: bool = True) -> bool:
259
+ """
260
+ Create an option dialog where the only options are "Accept" and "Decline". Doesn't allow the user to cancel the
261
+ dialog using the X in the top right corner.
262
+
263
+ :param title: The title of the dialog.
264
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
265
+ :param default_accept: If true, "Accept" is the default choice. Otherwise, the default choice is "Decline".
266
+ :return: True if the user selected Accept. False if the user selected Decline.
267
+ """
268
+ return self.option_dialog(title, msg, ["Accept", "Decline"], 0 if default_accept else 1, False) == "Accept"
269
+
270
+ # FR-47690: Added function.
271
+ def confirm_deny_dialog(self, title: str, msg: str, default_confirm: bool = True) -> bool:
272
+ """
273
+ Create an option dialog where the only options are "Confirm" and "Deny". Doesn't allow the user to cancel the
274
+ dialog using the X in the top right corner.
275
+
276
+ :param title: The title of the dialog.
277
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
278
+ :param default_confirm: If true, "Confirm" is the default choice. Otherwise, the default choice is "Deny".
279
+ :return: True if the user selected Confirm. False if the user selected Deny.
280
+ """
281
+ return self.option_dialog(title, msg, ["Confirm", "Deny"], 0 if default_confirm else 1, False) == "Confirm"
282
+
224
283
  # CR-47310: Add a parameter to the list, input, selection, and e-sign dialog functions to control reprompting the
225
284
  # user if no input/selection/valid credentials are provided.
285
+ # FR-47690: Added shortcut_single_option parameter. Updated with blank result handling behavior.
226
286
  def list_dialog(self,
227
287
  title: str,
228
288
  options: Iterable[str],
229
289
  multi_select: bool = False,
230
290
  preselected_values: Iterable[str] | None = None,
231
291
  *,
232
- require_selection: bool = False,
233
- repeat_message: str | None = "Please provide a selection to continue.") -> list[str]:
292
+ shortcut_single_option: bool = True,
293
+ require_selection = None,
294
+ blank_result_handling: BlankResultHandling = BlankResultHandling.DEFAULT,
295
+ repeat_message: str | None = "Please provide a selection to continue.",
296
+ cancel_message: str | None = "No selection was provided. Cancelling dialog.") -> list[str]:
234
297
  """
235
298
  Create a list dialog with the given options for the user to choose from.
236
299
 
@@ -239,28 +302,232 @@ class CallbackUtil:
239
302
  :param multi_select: Whether the user is able to select multiple options from the list.
240
303
  :param preselected_values: A list of values that will already be selected when the list dialog is created. The
241
304
  user can unselect these values if they want to.
242
- :param require_selection: If true, the request will be re-sent if the user submits the dialog without making
243
- a selection.
244
- :param repeat_message: If require_selection is true and a repeat_message is provided, then that message appears
245
- as toaster text if the dialog is repeated.
305
+ :param shortcut_single_option: If true, then if the list contains only one option, the dialog will not be shown
306
+ and the single option will be returned immediately.
307
+ :param require_selection: DEPRECATED. Use blank_result_handling with a value of BlankResultHandling.REPEAT
308
+ instead.
309
+ :param blank_result_handling: Determine how to handle the result of a callback when the user provides a blank
310
+ result.
311
+ :param repeat_message: If blank_result_handling is REPEAT and a repeat_message is provided, then that message
312
+ appears as toaster text when the user provides a blank result.
313
+ :param cancel_message: If blank_result_handling is CANCEL and a cancel_message is provided, then that message
314
+ appears as toaster text when the user provides a blank result.
246
315
  :return: The list of options that the user selected.
247
316
  """
248
317
  if not options:
249
318
  raise SapioException("No options provided.")
319
+ options = list(options)
320
+ if len(options) == 1 and shortcut_single_option:
321
+ return [options[0]]
250
322
 
251
323
  # Send the request to the user.
252
- request = ListDialogRequest(title, multi_select, list(options),
324
+ request = ListDialogRequest(title, multi_select, options,
253
325
  list(preselected_values) if preselected_values else None,
254
326
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
255
327
 
256
- # If require_selection is true, repeat the request if the user didn't make a selection.
328
+ # Reverse compatibility: If require_selection is true and blank_result_handling is not set, then
329
+ # set blank_result_handling to REPEAT.
330
+ if require_selection is True and blank_result_handling == BlankResultHandling.DEFAULT:
331
+ blank_result_handling = BlankResultHandling.REPEAT
332
+ def not_blank_func(r: list[str]) -> bool:
333
+ return bool(r)
334
+ return self.__send_dialog_blank_results(request, self.callback.show_list_dialog, not_blank_func,
335
+ blank_result_handling, repeat_message, cancel_message)
336
+
337
+ # FR-47690: Updated with blank result handling behavior.
338
+ def input_dialog(self,
339
+ title: str,
340
+ msg: str,
341
+ field: AbstractVeloxFieldDefinition,
342
+ *,
343
+ require_input = None,
344
+ blank_result_handling: BlankResultHandling = BlankResultHandling.DEFAULT,
345
+ repeat_message: str | None = "Please provide a value to continue.",
346
+ cancel_message: str | None = "No input was provided. Cancelling dialog.") -> FieldValue:
347
+ """
348
+ Create an input dialog where the user must input data for a singular field.
349
+
350
+ :param title: The title of the dialog.
351
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
352
+ :param field: The definition for a field that the user must provide input to.
353
+ :param require_input: DEPRECATED. Use blank_result_handling with a value of BlankResultHandling.REPEAT
354
+ instead.
355
+ :param blank_result_handling: Determine how to handle the result of a callback when the user provides a blank
356
+ result.
357
+ :param repeat_message: If blank_result_handling is REPEAT and a repeat_message is provided, then that message
358
+ appears as toaster text when the user provides a blank result.
359
+ :param cancel_message: If blank_result_handling is CANCEL and a cancel_message is provided, then that message
360
+ appears as toaster text when the user provides a blank result.
361
+ :return: The response value from the user for the given field.
362
+ """
363
+ # Send the request to the user.
364
+ request = InputDialogCriteria(title, msg, field,
365
+ width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
366
+
367
+ # Reverse compatibility: If require_selection is true and blank_result_handling is not set, then
368
+ # set blank_result_handling to REPEAT.
369
+ handling = blank_result_handling
370
+ if require_input is True and handling == BlankResultHandling.DEFAULT:
371
+ handling = BlankResultHandling.REPEAT
372
+ if handling == BlankResultHandling.DEFAULT or handling is None:
373
+ handling = self._default_blank_result_handling
374
+
257
375
  while True:
258
- response: list[str] = self.__handle_dialog_request(request, self.callback.show_list_dialog)
259
- if not require_selection or response:
260
- break
261
- if repeat_message:
262
- self.toaster_popup(repeat_message, popup_type=PopupType.Warning)
263
- return response
376
+ try:
377
+ self.user.timeout_seconds = self.timeout_seconds
378
+ # It's not possible to distinguish between the user cancelling this dialog and submitting the dialog
379
+ # with no input if the ClientCallback show_input_dialog function is used, as both cases just return
380
+ # None. Therefore, in order to be able to make that distinction, we need to call the endpoint without
381
+ # ClientCallback and get the raw response object.
382
+ raw_response = self.user.post('/clientcallback/showInputDialog', payload=request.to_json())
383
+ # A response status code of 204 is what represents a cancelled dialog.
384
+ if raw_response.status_code == 204:
385
+ raise SapioUserCancelledException()
386
+ self.user.raise_for_status(raw_response)
387
+ json_dct: dict | None = self.user.get_json_data_or_none(raw_response)
388
+ response: FieldValue = json_dct['result'] if json_dct else None
389
+ except ReadTimeout:
390
+ raise SapioDialogTimeoutException()
391
+ finally:
392
+ self.user.timeout_seconds = self._original_timeout
393
+
394
+ # String fields that the user didn't provide will return as an empty string instead of a None response.
395
+ is_str: bool = isinstance(response, str)
396
+ if (is_str and response) or (not is_str and response is not None):
397
+ return response
398
+
399
+ match handling:
400
+ case BlankResultHandling.CANCEL:
401
+ # If the user provided no selection, throw an exception.
402
+ if cancel_message:
403
+ self.toaster_popup(cancel_message, popup_type=PopupType.Warning)
404
+ raise SapioUserCancelledException()
405
+ case BlankResultHandling.REPEAT:
406
+ # If the user provided no selection, repeat the dialog.
407
+ # If a repeatMessage is provided, display it as a toaster popup.
408
+ if repeat_message:
409
+ self.toaster_popup(repeat_message, popup_type=PopupType.Warning)
410
+ case BlankResultHandling.RETURN:
411
+ # If the user provided no selection, return the blank result.
412
+ return response
413
+
414
+ def string_input_dialog(self,
415
+ title: str,
416
+ msg: str,
417
+ field_name: str,
418
+ default_value: str | None = None,
419
+ max_length: int | None = None,
420
+ editable: bool = True,
421
+ *,
422
+ require_input: bool = False,
423
+ repeat_message: str | None = "Please provide a value to continue.",
424
+ **kwargs) -> str:
425
+ """
426
+ Create an input dialog where the user must input data for a singular text field.
427
+
428
+ :param title: The title of the dialog.
429
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
430
+ :param field_name: The name and display name of the string field.
431
+ :param default_value: The default value to place into the string field, if any.
432
+ :param max_length: The max length of the string value. If not provided, uses the length of the default value.
433
+ If neither this nor a default value are provided, defaults to 100 characters.
434
+ :param editable: Whether the field is editable by the user.
435
+ :param require_input: If true, the request will be re-sent if the user submits the dialog without making
436
+ a selection.
437
+ :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
438
+ as toaster text if the dialog is repeated.
439
+ :param kwargs: Any additional keyword arguments to pass to the field definition.
440
+ :return: The string that the user input into the dialog.
441
+ """
442
+ # FR-47690: Deprecated in favor of suggesting the use of the FieldBuilder to customize an input_dialog's field.
443
+ warnings.warn("Deprecated. Use the base input_dialog function and the FieldBuilder class to construct the "
444
+ "input field.", DeprecationWarning)
445
+ if max_length is None:
446
+ max_length = len(default_value) if default_value else 100
447
+ field = VeloxStringFieldDefinition("Input", field_name, field_name, default_value=default_value,
448
+ max_length=max_length, editable=editable, **kwargs)
449
+ return self.input_dialog(title, msg, field,
450
+ require_input=require_input, repeat_message=repeat_message)
451
+
452
+ def integer_input_dialog(self,
453
+ title: str,
454
+ msg: str,
455
+ field_name: str,
456
+ default_value: int = None,
457
+ min_value: int = -10000,
458
+ max_value: int = 10000,
459
+ editable: bool = True,
460
+ *,
461
+ require_input: bool = False,
462
+ repeat_message: str | None = "Please provide a value to continue.",
463
+ **kwargs) -> int:
464
+ """
465
+ Create an input dialog where the user must input data for a singular integer field.
466
+
467
+ :param title: The title of the dialog.
468
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
469
+ :param field_name: The name and display name of the integer field.
470
+ :param default_value: The default value to place into the integer field. If not provided, defaults to the 0 or
471
+ the minimum value, whichever is higher.
472
+ :param min_value: The minimum allowed value of the input.
473
+ :param max_value: The maximum allowed value of the input.
474
+ :param editable: Whether the field is editable by the user.
475
+ :param require_input: If true, the request will be re-sent if the user submits the dialog without making
476
+ a selection.
477
+ :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
478
+ as toaster text if the dialog is repeated.
479
+ :param kwargs: Any additional keyword arguments to pass to the field definition.
480
+ :return: The integer that the user input into the dialog.
481
+ """
482
+ # FR-47690: Deprecated in favor of suggesting the use of the FieldBuilder to customize an input_dialog's field.
483
+ warnings.warn("Deprecated. Use the base input_dialog function and the FieldBuilder class to construct the "
484
+ "input field.", DeprecationWarning)
485
+ if default_value is None:
486
+ default_value = max(0, min_value)
487
+ field = VeloxIntegerFieldDefinition("Input", field_name, field_name, default_value=default_value,
488
+ min_value=min_value, max_value=max_value, editable=editable, **kwargs)
489
+ return self.input_dialog(title, msg, field,
490
+ require_input=require_input, repeat_message=repeat_message)
491
+
492
+ def double_input_dialog(self,
493
+ title: str,
494
+ msg: str,
495
+ field_name: str,
496
+ default_value: float = None,
497
+ min_value: float = -10000000,
498
+ max_value: float = 100000000,
499
+ editable: bool = True,
500
+ *,
501
+ require_input: bool = False,
502
+ repeat_message: str | None = "Please provide a value to continue.",
503
+ **kwargs) -> float:
504
+ """
505
+ Create an input dialog where the user must input data for a singular double field.
506
+
507
+ :param title: The title of the dialog.
508
+ :param msg: The message to display in the dialog. This can be formatted using HTML elements.
509
+ :param field_name: The name and display name of the double field.
510
+ :param default_value: The default value to place into the double field. If not provided, defaults to the 0 or
511
+ the minimum value, whichever is higher.
512
+ :param min_value: The minimum allowed value of the input.
513
+ :param max_value: The maximum allowed value of the input.
514
+ :param editable: Whether the field is editable by the user.
515
+ :param require_input: If true, the request will be re-sent if the user submits the dialog without making
516
+ a selection.
517
+ :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
518
+ as toaster text if the dialog is repeated.
519
+ :param kwargs: Any additional keyword arguments to pass to the field definition.
520
+ :return: The float that the user input into the dialog.
521
+ """
522
+ # FR-47690: Deprecated in favor of suggesting the use of the FieldBuilder to customize an input_dialog's field.
523
+ warnings.warn("Deprecated. Use the base input_dialog function and the FieldBuilder class to construct the "
524
+ "input field.", DeprecationWarning)
525
+ if default_value is None:
526
+ default_value = max(0., min_value)
527
+ field = VeloxDoubleFieldDefinition("Input", field_name, field_name, default_value=default_value,
528
+ min_value=min_value, max_value=max_value, editable=editable, **kwargs)
529
+ return self.input_dialog(title, msg, field,
530
+ require_input=require_input, repeat_message=repeat_message)
264
531
 
265
532
  def form_dialog(self,
266
533
  title: str,
@@ -280,7 +547,8 @@ class CallbackUtil:
280
547
  :param msg: The message to display at the top of the form. This can be formatted using HTML elements.
281
548
  :param fields: The definitions of the fields to display in the form. Fields will be displayed in the order they
282
549
  are provided in this list.
283
- :param values: Sets the default values of the fields.
550
+ :param values: Sets the default values of the fields. If a field name from the fields parameter is not
551
+ provided in this dictionary, it will be initialized with its default value.
284
552
  :param column_positions: If a tuple is provided for a field name, alters that field's column position and column
285
553
  span. (Field order is still determined by the fields list.)
286
554
  :param data_type: The data type name for the temporary data type that will be created for this form.
@@ -294,10 +562,17 @@ class CallbackUtil:
294
562
  # Build a temporary data type for the request.
295
563
  temp_dt = self.__temp_dt_from_field_defs(data_type, display_name, plural_display_name, fields, column_positions)
296
564
 
565
+ # FR-47690: Set default values for fields that aren't present.
566
+ if values is None:
567
+ values = {}
568
+ for field in fields:
569
+ if field.data_field_name not in values:
570
+ values[field.data_field_name] = field.default_value
571
+
297
572
  # Send the request to the user.
298
573
  request = FormEntryDialogRequest(title, msg, temp_dt, values,
299
574
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
300
- response: FieldMap = self.__handle_dialog_request(request, self.callback.show_form_entry_dialog)
575
+ response: FieldMap = self.__send_dialog(request, self.callback.show_form_entry_dialog)
301
576
  return response
302
577
 
303
578
  def record_form_dialog(self,
@@ -369,7 +644,7 @@ class CallbackUtil:
369
644
  # Send the request to the user.
370
645
  request = FormEntryDialogRequest(title, msg, temp_dt, values,
371
646
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
372
- response: FieldMap = self.__handle_dialog_request(request, self.callback.show_form_entry_dialog)
647
+ response: FieldMap = self.__send_dialog(request, self.callback.show_form_entry_dialog)
373
648
  return response
374
649
 
375
650
  # FR-47314: Create record form and table dialogs for updating or creating records.
@@ -463,170 +738,11 @@ class CallbackUtil:
463
738
  default_modifier=default_modifier, field_modifiers=field_modifiers)
464
739
  return record
465
740
 
466
- def input_dialog(self,
467
- title: str,
468
- msg: str,
469
- field: AbstractVeloxFieldDefinition,
470
- *,
471
- require_input: bool = False,
472
- repeat_message: str | None = "Please provide a value to continue.") -> FieldValue:
473
- """
474
- Create an input dialog where the user must input data for a singular field.
475
-
476
- :param title: The title of the dialog.
477
- :param msg: The message to display in the dialog. This can be formatted using HTML elements.
478
- :param field: The definition for a field that the user must provide input to.
479
- :param require_input: If true, the request will be re-sent if the user submits the dialog without providing an
480
- input field value.
481
- :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
482
- as toaster text if the dialog is repeated.
483
- :return: The response value from the user for the given field.
484
- """
485
- # Send the request to the user.
486
- request = InputDialogCriteria(title, msg, field,
487
- width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
488
-
489
- # If require_input is true, repeat the request if the user didn't provide a field value.
490
- while True:
491
- try:
492
- self.user.timeout_seconds = self.timeout_seconds
493
- # It's not possible to distinguish between the user cancelling this dialog and submitting the dialog
494
- # with no input if the ClientCallback show_input_dialog function is used, as both cases just return
495
- # None. Therefore, in order to be able to make that distinction, we need to call the endpoint without
496
- # ClientCallback and get the raw response object.
497
- raw_response = self.user.post('/clientcallback/showInputDialog', payload=request.to_json())
498
- # A response status code of 204 is what represents a cancelled dialog.
499
- if raw_response.status_code == 204:
500
- raise SapioUserCancelledException()
501
- self.user.raise_for_status(raw_response)
502
- json_dct: dict | None = self.user.get_json_data_or_none(raw_response)
503
- response: FieldValue = json_dct['result'] if json_dct else None
504
- except ReadTimeout:
505
- raise SapioDialogTimeoutException()
506
- finally:
507
- self.user.timeout_seconds = self._original_timeout
508
- # String fields that the user didn't provide will return as an empty string instead of a None response.
509
- is_str: bool = isinstance(response, str)
510
- if not require_input or (is_str and response) or (not is_str and response is not None):
511
- break
512
- if repeat_message:
513
- self.toaster_popup(repeat_message, popup_type=PopupType.Warning)
514
- return response
515
-
516
- def string_input_dialog(self,
517
- title: str,
518
- msg: str,
519
- field_name: str,
520
- default_value: str | None = None,
521
- max_length: int | None = None,
522
- editable: bool = True,
523
- *,
524
- require_input: bool = False,
525
- repeat_message: str | None = "Please provide a value to continue.",
526
- **kwargs) -> str:
527
- """
528
- Create an input dialog where the user must input data for a singular text field.
529
-
530
- :param title: The title of the dialog.
531
- :param msg: The message to display in the dialog. This can be formatted using HTML elements.
532
- :param field_name: The name and display name of the string field.
533
- :param default_value: The default value to place into the string field, if any.
534
- :param max_length: The max length of the string value. If not provided, uses the length of the default value.
535
- If neither this nor a default value are provided, defaults to 100 characters.
536
- :param editable: Whether the field is editable by the user.
537
- :param require_input: If true, the request will be re-sent if the user submits the dialog without making
538
- a selection.
539
- :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
540
- as toaster text if the dialog is repeated.
541
- :param kwargs: Any additional keyword arguments to pass to the field definition.
542
- :return: The string that the user input into the dialog.
543
- """
544
- if max_length is None:
545
- max_length = len(default_value) if default_value else 100
546
- field = VeloxStringFieldDefinition("Input", field_name, field_name, default_value=default_value,
547
- max_length=max_length, editable=editable, **kwargs)
548
- return self.input_dialog(title, msg, field,
549
- require_input=require_input, repeat_message=repeat_message)
550
-
551
- def integer_input_dialog(self,
552
- title: str,
553
- msg: str,
554
- field_name: str,
555
- default_value: int = None,
556
- min_value: int = -10000,
557
- max_value: int = 10000,
558
- editable: bool = True,
559
- *,
560
- require_input: bool = False,
561
- repeat_message: str | None = "Please provide a value to continue.",
562
- **kwargs) -> int:
563
- """
564
- Create an input dialog where the user must input data for a singular integer field.
565
-
566
- :param title: The title of the dialog.
567
- :param msg: The message to display in the dialog. This can be formatted using HTML elements.
568
- :param field_name: The name and display name of the integer field.
569
- :param default_value: The default value to place into the integer field. If not provided, defaults to the 0 or
570
- the minimum value, whichever is higher.
571
- :param min_value: The minimum allowed value of the input.
572
- :param max_value: The maximum allowed value of the input.
573
- :param editable: Whether the field is editable by the user.
574
- :param require_input: If true, the request will be re-sent if the user submits the dialog without making
575
- a selection.
576
- :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
577
- as toaster text if the dialog is repeated.
578
- :param kwargs: Any additional keyword arguments to pass to the field definition.
579
- :return: The integer that the user input into the dialog.
580
- """
581
- if default_value is None:
582
- default_value = max(0, min_value)
583
- field = VeloxIntegerFieldDefinition("Input", field_name, field_name, default_value=default_value,
584
- min_value=min_value, max_value=max_value, editable=editable, **kwargs)
585
- return self.input_dialog(title, msg, field,
586
- require_input=require_input, repeat_message=repeat_message)
587
-
588
- def double_input_dialog(self,
589
- title: str,
590
- msg: str,
591
- field_name: str,
592
- default_value: float = None,
593
- min_value: float = -10000000,
594
- max_value: float = 100000000,
595
- editable: bool = True,
596
- *,
597
- require_input: bool = False,
598
- repeat_message: str | None = "Please provide a value to continue.",
599
- **kwargs) -> float:
600
- """
601
- Create an input dialog where the user must input data for a singular double field.
602
-
603
- :param title: The title of the dialog.
604
- :param msg: The message to display in the dialog. This can be formatted using HTML elements.
605
- :param field_name: The name and display name of the double field.
606
- :param default_value: The default value to place into the double field. If not provided, defaults to the 0 or
607
- the minimum value, whichever is higher.
608
- :param min_value: The minimum allowed value of the input.
609
- :param max_value: The maximum allowed value of the input.
610
- :param editable: Whether the field is editable by the user.
611
- :param require_input: If true, the request will be re-sent if the user submits the dialog without making
612
- a selection.
613
- :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
614
- as toaster text if the dialog is repeated.
615
- :param kwargs: Any additional keyword arguments to pass to the field definition.
616
- :return: The float that the user input into the dialog.
617
- """
618
- if default_value is None:
619
- default_value = max(0., min_value)
620
- field = VeloxDoubleFieldDefinition("Input", field_name, field_name, default_value=default_value,
621
- min_value=min_value, max_value=max_value, editable=editable, **kwargs)
622
- return self.input_dialog(title, msg, field,
623
- require_input=require_input, repeat_message=repeat_message)
624
-
625
741
  def table_dialog(self,
626
742
  title: str,
627
743
  msg: str,
628
744
  fields: Iterable[AbstractVeloxFieldDefinition],
629
- values: Iterable[FieldMap],
745
+ values: Iterable[FieldMap] | int,
630
746
  *,
631
747
  data_type: DataTypeIdentifier = "Default",
632
748
  display_name: str | None = None,
@@ -641,7 +757,8 @@ class CallbackUtil:
641
757
  :param msg: The message to display at the top of the form. This can be formatted using HTML elements.
642
758
  :param fields: The definitions of the fields to display as table columns. Fields will be displayed in the order
643
759
  they are provided in this list.
644
- :param values: The values to set for each row of the table.
760
+ :param values: The values to set for each row of the table. If an integer is provided, it is treated as the
761
+ number of rows to create in the table, with each row using the default values of the field definitions.
645
762
  :param group_by: If provided, the created table dialog will be grouped by the field with this name by default.
646
763
  The user may remove this grouping if they want to.
647
764
  :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
@@ -654,9 +771,18 @@ class CallbackUtil:
654
771
  :return: A list of dictionaries mapping the data field names of the given field definitions to the response
655
772
  value from the user for that field for each row.
656
773
  """
774
+ # FR-47690: Accept an integer as the values parameter to create a table with that many rows.
775
+ if isinstance(values, int):
776
+ values: list[dict[str, Any]] = [{} for _ in range(values)]
657
777
  if not values:
658
778
  raise SapioException("No values provided.")
659
779
 
780
+ # FR-47690: Set default values for fields that aren't present.
781
+ for row in values:
782
+ for field in fields:
783
+ if field.data_field_name not in values:
784
+ row[field.data_field_name] = field.default_value
785
+
660
786
  # Convert the group_by parameter to a field name.
661
787
  if group_by is not None:
662
788
  group_by: str = AliasUtil.to_data_field_name(group_by)
@@ -670,7 +796,7 @@ class CallbackUtil:
670
796
  request = TableEntryDialogRequest(title, msg, temp_dt, list(values),
671
797
  record_image_data_list=image_data, group_by_field=group_by,
672
798
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
673
- response: list[FieldMap] = self.__handle_dialog_request(request, self.callback.show_table_entry_dialog)
799
+ response: list[FieldMap] = self.__send_dialog(request, self.callback.show_table_entry_dialog)
674
800
  return response
675
801
 
676
802
  def record_table_dialog(self,
@@ -760,7 +886,7 @@ class CallbackUtil:
760
886
  request = TableEntryDialogRequest(title, msg, temp_dt, field_map_list,
761
887
  record_image_data_list=image_data, group_by_field=group_by,
762
888
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
763
- response: list[FieldMap] = self.__handle_dialog_request(request, self.callback.show_table_entry_dialog)
889
+ response: list[FieldMap] = self.__send_dialog(request, self.callback.show_table_entry_dialog)
764
890
  return response
765
891
 
766
892
  # FR-47314: Create record form and table dialogs for updating or creating records.
@@ -817,6 +943,7 @@ class CallbackUtil:
817
943
  index: int = result.pop(index_field)
818
944
  records_by_id[index].set_field_values(result)
819
945
 
946
+ # FR-47690: Updated with blank result handling behavior.
820
947
  def create_record_table_dialog(self,
821
948
  title: str,
822
949
  msg: str,
@@ -828,8 +955,10 @@ class CallbackUtil:
828
955
  field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
829
956
  group_by: FieldIdentifier | None = None,
830
957
  image_data: Iterable[bytes] | None = None,
831
- require_input: bool = False,
832
- repeat_message: str | None = "Please provide a value to continue.") \
958
+ require_input = None,
959
+ blank_result_handling: BlankResultHandling = BlankResultHandling.DEFAULT,
960
+ repeat_message: str | None = "Please provide a value to continue.",
961
+ cancel_message: str | None = "No value was provided. Cancelling dialog.") \
833
962
  -> list[WrappedType] | list[PyRecordModel]:
834
963
  """
835
964
  Create a table dialog where the user may input data into the fields of the table. The table is constructed from
@@ -863,13 +992,18 @@ class CallbackUtil:
863
992
  The user may remove this grouping if they want to.
864
993
  :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
865
994
  the image data list corresponds to the element at the same index in the records list.
866
- :param require_input: If true and the user is prompted to input the number of records to create, the request
867
- will be re-sent if the user submits the dialog without making a selection.
868
- :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
869
- as toaster text if the record count dialog is repeated.
995
+ :param require_input: DEPRECATED. Use blank_result_handling with a value of BlankResultHandling.REPEAT
996
+ instead.
997
+ :param blank_result_handling: Determine how to handle the result of a callback when the user provides a blank
998
+ result.
999
+ :param repeat_message: If blank_result_handling is REPEAT and a repeat_message is provided, then that message
1000
+ appears as toaster text when the user provides a blank result.
1001
+ :param cancel_message: If blank_result_handling is CANCEL and a cancel_message is provided, then that message
1002
+ appears as toaster text when the user provides a blank result.
870
1003
  :return: A list of the newly created records.
871
1004
  """
872
- count: int = self.__prompt_for_count(count, wrapper_type, require_input, repeat_message)
1005
+ count: int = self.__prompt_for_count(count, wrapper_type, require_input, blank_result_handling, repeat_message,
1006
+ cancel_message)
873
1007
  if count <= 0:
874
1008
  return []
875
1009
  records: list[WrappedType] | list[PyRecordModel] = self.rec_handler.add_models(wrapper_type, count)
@@ -1005,6 +1139,7 @@ class CallbackUtil:
1005
1139
  default_modifier=default_modifier, field_modifiers=field_modifiers,
1006
1140
  group_by=group_by, image_data=image_data)
1007
1141
 
1142
+ # FR-47690: Updated with blank result handling behavior.
1008
1143
  def create_record_adaptive_dialog(self,
1009
1144
  title: str,
1010
1145
  msg: str,
@@ -1017,8 +1152,10 @@ class CallbackUtil:
1017
1152
  column_positions: dict[str, tuple[int, int]] | None = None,
1018
1153
  group_by: FieldIdentifier | None = None,
1019
1154
  image_data: Iterable[bytes] | None = None,
1020
- require_input: bool = False,
1021
- repeat_message: str | None = "Please provide a value to continue.") \
1155
+ require_input = None,
1156
+ blank_result_handling: BlankResultHandling = BlankResultHandling.DEFAULT,
1157
+ repeat_message: str | None = "Please provide a value to continue.",
1158
+ cancel_message: str | None = "No value was provided. Cancelling dialog.") \
1022
1159
  -> list[WrappedType]:
1023
1160
  """
1024
1161
  Create a dialog where the user may input data into the specified fields. The dialog is constructed from
@@ -1059,14 +1196,19 @@ class CallbackUtil:
1059
1196
  :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
1060
1197
  the image data list corresponds to the element at the same index in the records list. Only used if the
1061
1198
  adaptive dialog becomes a table.
1062
- :param require_input: If true and the user is prompted to input the number of records to create, the request
1063
- will be re-sent if the user submits the dialog without making a selection.
1064
- :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
1065
- as toaster text if the record count dialog is repeated.
1199
+ :param require_input: DEPRECATED. Use blank_result_handling with a value of BlankResultHandling.REPEAT
1200
+ instead.
1201
+ :param blank_result_handling: Determine how to handle the result of a callback when the user provides a blank
1202
+ result.
1203
+ :param repeat_message: If blank_result_handling is REPEAT and a repeat_message is provided, then that message
1204
+ appears as toaster text when the user provides a blank result.
1205
+ :param cancel_message: If blank_result_handling is CANCEL and a cancel_message is provided, then that message
1206
+ appears as toaster text when the user provides a blank result.
1066
1207
  :return: A list of the newly created records. Even if a form was displayed, the created record will still be
1067
1208
  returned in a list.
1068
1209
  """
1069
- count: int = self.__prompt_for_count(count, wrapper_type, require_input, repeat_message)
1210
+ count: int = self.__prompt_for_count(count, wrapper_type, require_input, blank_result_handling, repeat_message,
1211
+ cancel_message)
1070
1212
  if count <= 0:
1071
1213
  return []
1072
1214
  if count == 1:
@@ -1076,6 +1218,7 @@ class CallbackUtil:
1076
1218
  default_modifier=default_modifier, field_modifiers=field_modifiers,
1077
1219
  group_by=group_by, image_data=image_data)
1078
1220
 
1221
+ # FR-47690: Add group_by and image_data parameters.
1079
1222
  def multi_type_table_dialog(self,
1080
1223
  title: str,
1081
1224
  msg: str,
@@ -1086,7 +1229,9 @@ class CallbackUtil:
1086
1229
  field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
1087
1230
  data_type: DataTypeIdentifier = "Default",
1088
1231
  display_name: str | None = None,
1089
- plural_display_name: str | None = None) -> list[FieldMap]:
1232
+ plural_display_name: str | None = None,
1233
+ group_by: FieldIdentifier | None = None,
1234
+ image_data: list[bytes] | None = None) -> list[FieldMap]:
1090
1235
  """
1091
1236
  Create a table dialog where the user may input data into the fields of the table. The table is constructed from
1092
1237
  a given list of records of multiple data types or field maps. Provided field names must match with field names
@@ -1140,6 +1285,10 @@ class CallbackUtil:
1140
1285
  name.
1141
1286
  :param plural_display_name: The plural display name for the temporary data type. If not provided, defaults to
1142
1287
  the display name + "s".
1288
+ :param group_by: If provided, the created table dialog will be grouped by the field with this name by default.
1289
+ The user may remove this grouping if they want to.
1290
+ :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
1291
+ the image data list corresponds to the element at the same index in the values list.
1143
1292
  :return: A list of dictionaries mapping the data field names of the given field definitions to the response
1144
1293
  value from the user for that field for each row.
1145
1294
  """
@@ -1204,31 +1353,49 @@ class CallbackUtil:
1204
1353
  field_names.add(name)
1205
1354
 
1206
1355
  # Get the values for each row.
1356
+ # FR-47690: Updated this for loop to better match the Java implementation.
1207
1357
  values: list[dict[str, FieldValue]] = []
1208
1358
  for row in row_contents:
1209
1359
  # The final values for this row:
1210
1360
  row_values: dict[str, FieldValue] = {}
1211
1361
 
1212
- # Map the records for this row by their data type. If a field map is provided, its data type is Default.
1213
- row_records: dict[str, SapioRecord | FieldMap] = {}
1362
+ # Map the records for this row by their data type. If a field map is provided, save it separately to
1363
+ # the temp_values dict.
1364
+ row_records: dict[str, SapioRecord] = {}
1365
+ temp_values: FieldMap = {}
1214
1366
  for rec in row:
1215
1367
  # Toss out null elements.
1216
1368
  if rec is None:
1217
1369
  continue
1218
1370
  # Map records to their data type name. Map field maps to Default.
1219
1371
  dt: str = "Default" if isinstance(rec, dict) else AliasUtil.to_data_type_name(rec)
1220
- # Warn if the same data type name appears more than once.
1221
- if dt in row_records:
1222
- raise SapioException(f"The data type \"{dt}\" appears more than once in the given row contents.")
1223
- row_records[dt] = rec
1372
+ if dt == "Default":
1373
+ temp_values.update(rec)
1374
+ else:
1375
+ # Warn if the same data type name appears more than once.
1376
+ if dt in row_records:
1377
+ raise SapioException(f"The data type \"{dt}\" appears more than once in the given row contents.")
1378
+ row_records[dt] = rec
1224
1379
 
1225
1380
  # Get the field values from the above records.
1226
1381
  for field in final_fields:
1382
+ value: Any | None = None
1383
+
1227
1384
  # Find the object that corresponds to this field given its data type name.
1228
- record: SapioRecord | FieldMap | None = row_records.get(field.data_type_name)
1229
- # This could be either a record, a field map, or null. Convert any records to field maps.
1230
- if not isinstance(record, dict) and record is not None:
1231
- record: FieldMap | None = AliasUtil.to_field_map(record)
1385
+ dt: str = field.data_type_name
1386
+ fd: str = field.data_field_name
1387
+ if dt == "Default":
1388
+ # If the field map is provided, get the value from it.
1389
+ # FR-47690: If a value is not provided, then use the default value of the field definition.
1390
+ if fd in temp_values:
1391
+ value = temp_values.get(fd)
1392
+ else:
1393
+ value = field.default_value
1394
+ elif dt in row_records:
1395
+ record: SapioRecord = row_records[dt]
1396
+ # If the record is not null, get the value from the record.
1397
+ if record is not None:
1398
+ value = record.get_field_value(fd)
1232
1399
 
1233
1400
  # Find out if this field had its data type prepended to it. If this is the case, then we need to find
1234
1401
  # the true data field name before retrieving the value from the field map.
@@ -1237,16 +1404,22 @@ class CallbackUtil:
1237
1404
  name = name.split(".")[1]
1238
1405
 
1239
1406
  # Set the value for this particular field.
1240
- row_values[field.data_field_name] = record.get(name) if record else None
1407
+ row_values[name] = value
1241
1408
  values.append(row_values)
1242
1409
 
1243
1410
  # Build a temporary data type for the request.
1244
1411
  temp_dt = self.__temp_dt_from_field_defs(data_type, display_name, plural_display_name, final_fields, None)
1412
+ temp_dt.record_image_assignable = bool(image_data)
1413
+
1414
+ # Convert the group_by parameter to a field name.
1415
+ if group_by is not None:
1416
+ group_by: str = AliasUtil.to_data_field_name(group_by)
1245
1417
 
1246
1418
  # Send the request to the user.
1247
1419
  request = TableEntryDialogRequest(title, msg, temp_dt, values,
1420
+ record_image_data_list=image_data, group_by_field=group_by,
1248
1421
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
1249
- response: list[FieldMap] = self.__handle_dialog_request(request, self.callback.show_table_entry_dialog)
1422
+ response: list[FieldMap] = self.__send_dialog(request, self.callback.show_table_entry_dialog)
1250
1423
  return response
1251
1424
 
1252
1425
  def record_view_dialog(self,
@@ -1283,13 +1456,14 @@ class CallbackUtil:
1283
1456
  # Send the request to the user.
1284
1457
  request = DataRecordDialogRequest(title, record, layout, minimized, access_level, plugin_path_list,
1285
1458
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
1286
- response: bool = self.__handle_dialog_request(request, self.callback.data_record_form_view_dialog)
1459
+ response: bool = self.__send_dialog(request, self.callback.data_record_form_view_dialog)
1287
1460
  # The __handle_dialog_request function only throws a cancelled exception if the response is None, but in
1288
1461
  # this case we also want to throw if the response is False.
1289
1462
  if not response:
1290
1463
  raise SapioUserCancelledException()
1291
1464
 
1292
1465
  # CR-47326: Allow the selection dialog functions to preselect rows/records in the table.
1466
+ # FR-47690: Added shortcut_single_option parameter. Updated with blank result handling behavior.
1293
1467
  def selection_dialog(self,
1294
1468
  msg: str,
1295
1469
  fields: Iterable[AbstractVeloxFieldDefinition],
@@ -1301,8 +1475,11 @@ class CallbackUtil:
1301
1475
  display_name: str | None = None,
1302
1476
  plural_display_name: str | None = None,
1303
1477
  image_data: Iterable[bytes] | None = None,
1304
- require_selection: bool = False,
1305
- repeat_message: str | None = "Please provide a selection to continue.") -> list[FieldMap]:
1478
+ shortcut_single_option: bool = True,
1479
+ require_selection = None,
1480
+ blank_result_handling: BlankResultHandling = BlankResultHandling.DEFAULT,
1481
+ repeat_message: str | None = "Please provide a selection to continue.",
1482
+ cancel_message: str | None = "No selection was made. Cancelling dialog.") -> list[FieldMap]:
1306
1483
  """
1307
1484
  Create a selection dialog for a list of field maps for the user to choose from. Requires that the caller
1308
1485
  provide the definitions of every field in the table.
@@ -1325,14 +1502,23 @@ class CallbackUtil:
1325
1502
  the display name + "s".
1326
1503
  :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
1327
1504
  the image data list corresponds to the element at the same index in the values list.
1328
- :param require_selection: If true, the request will be re-sent if the user submits the dialog without making
1329
- a selection.
1330
- :param repeat_message: If require_selection is true and a repeat_message is provided, then that message appears
1331
- as toaster text if the dialog is repeated.
1505
+ :param shortcut_single_option: If true, then if the list contains only one option, the dialog will not be shown
1506
+ and the single option will be returned immediately.
1507
+ :param require_selection: DEPRECATED. Use blank_result_handling with a value of BlankResultHandling.REPEAT
1508
+ instead.
1509
+ :param blank_result_handling: Determine how to handle the result of a callback when the user provides a blank
1510
+ result.
1511
+ :param repeat_message: If blank_result_handling is REPEAT and a repeat_message is provided, then that message
1512
+ appears as toaster text when the user provides a blank result.
1513
+ :param cancel_message: If blank_result_handling is CANCEL and a cancel_message is provided, then that message
1514
+ appears as toaster text when the user provides a blank result.
1332
1515
  :return: A list of field maps corresponding to the chosen input field maps.
1333
1516
  """
1334
1517
  if not values:
1335
1518
  raise SapioException("No values provided.")
1519
+ values = list(values)
1520
+ if len(values) == 1 and shortcut_single_option:
1521
+ return [values[0]]
1336
1522
 
1337
1523
  if preselected_rows:
1338
1524
  # Confirm that the provided field maps are validly configured to allow the use of preselected rows.
@@ -1369,16 +1555,17 @@ class CallbackUtil:
1369
1555
 
1370
1556
  # Send the request to the user.
1371
1557
  request = TempTableSelectionRequest(temp_dt, msg, list(values), image_data, preselected_rows, multi_select)
1372
- # If require_selection is true, repeat the request if the user didn't make a selection.
1373
- while True:
1374
- response: list[FieldMap] = self.__handle_dialog_request(request,
1375
- self.callback.show_temp_table_selection_dialog)
1376
- if not require_selection or response:
1377
- break
1378
- if repeat_message:
1379
- self.toaster_popup(repeat_message, popup_type=PopupType.Warning)
1380
- return response
1381
1558
 
1559
+ # Reverse compatibility: If require_selection is true and blank_result_handling is not set, then
1560
+ # set blank_result_handling to REPEAT.
1561
+ if require_selection is True and blank_result_handling == BlankResultHandling.DEFAULT:
1562
+ blank_result_handling = BlankResultHandling.REPEAT
1563
+ def not_blank_func(r: list[FieldMap]) -> bool:
1564
+ return bool(r)
1565
+ return self.__send_dialog_blank_results(request, self.callback.show_temp_table_selection_dialog, not_blank_func,
1566
+ blank_result_handling, repeat_message, cancel_message)
1567
+
1568
+ # FR-47690: Added shortcut_single_option parameter. Updated with blank result handling behavior.
1382
1569
  def record_selection_dialog(self,
1383
1570
  msg: str,
1384
1571
  fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
@@ -1387,8 +1574,11 @@ class CallbackUtil:
1387
1574
  preselected_records: Iterable[RecordIdentifier] | None = None,
1388
1575
  *,
1389
1576
  image_data: Iterable[bytes] | None = None,
1390
- require_selection: bool = False,
1391
- repeat_message: str | None = "Please provide a selection to continue.") \
1577
+ shortcut_single_option: bool = True,
1578
+ require_selection = None,
1579
+ blank_result_handling: BlankResultHandling = BlankResultHandling.DEFAULT,
1580
+ repeat_message: str | None = "Please provide a selection to continue.",
1581
+ cancel_message: str | None = "No selection was made. Cancelling dialog.") \
1392
1582
  -> list[SapioRecord]:
1393
1583
  """
1394
1584
  Create a record selection dialog for a list of records for the user to choose from. Provided field names must
@@ -1410,15 +1600,24 @@ class CallbackUtil:
1410
1600
  record IDs are provided, the dialog will automatically allow multi-selection of records.
1411
1601
  :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
1412
1602
  the image data list corresponds to the element at the same index in the records list.
1413
- :param require_selection: If true, the request will be re-sent if the user submits the dialog without making
1414
- a selection.
1415
- :param repeat_message: If require_selection is true and a repeat_message is provided, then that message appears
1416
- as toaster text if the dialog is repeated.
1603
+ :param shortcut_single_option: If true, then if the list contains only one option, the dialog will not be shown
1604
+ and the single option will be returned immediately.
1605
+ :param require_selection: DEPRECATED. Use blank_result_handling with a value of BlankResultHandling.REPEAT
1606
+ instead.
1607
+ :param blank_result_handling: Determine how to handle the result of a callback when the user provides a blank
1608
+ result.
1609
+ :param repeat_message: If blank_result_handling is REPEAT and a repeat_message is provided, then that message
1610
+ appears as toaster text when the user provides a blank result.
1611
+ :param cancel_message: If blank_result_handling is CANCEL and a cancel_message is provided, then that message
1612
+ appears as toaster text when the user provides a blank result.
1417
1613
  :return: A list of the selected records.
1418
1614
  """
1419
1615
  # Get the data type name and field values from the provided records.
1420
1616
  if not records:
1421
1617
  raise SapioException("No records provided.")
1618
+ records = list(records)
1619
+ if len(records) == 1 and shortcut_single_option:
1620
+ return [records[0]]
1422
1621
  data_type: str = AliasUtil.to_singular_data_type_name(records)
1423
1622
  field_map_list: list[FieldMap] = AliasUtil.to_field_map_list(records, include_record_id=True)
1424
1623
 
@@ -1445,14 +1644,17 @@ class CallbackUtil:
1445
1644
 
1446
1645
  # Send the request to the user.
1447
1646
  request = TempTableSelectionRequest(temp_dt, msg, field_map_list, image_data, preselected_records, multi_select)
1448
- # If require_selection is true, repeat the request if the user didn't make a selection.
1449
- while True:
1450
- response: list[FieldMap] = self.__handle_dialog_request(request,
1451
- self.callback.show_temp_table_selection_dialog)
1452
- if not require_selection or response:
1453
- break
1454
- if repeat_message:
1455
- self.toaster_popup(repeat_message, popup_type=PopupType.Warning)
1647
+
1648
+ # Reverse compatibility: If require_selection is true and blank_result_handling is not set, then
1649
+ # set blank_result_handling to REPEAT.
1650
+ if require_selection is True and blank_result_handling == BlankResultHandling.DEFAULT:
1651
+ blank_result_handling = BlankResultHandling.REPEAT
1652
+ def not_blank_func(r: list[FieldMap]) -> bool:
1653
+ return bool(r)
1654
+ response: list[FieldMap] = self.__send_dialog_blank_results(request,
1655
+ self.callback.show_temp_table_selection_dialog,
1656
+ not_blank_func, blank_result_handling,
1657
+ repeat_message, cancel_message)
1456
1658
 
1457
1659
  # Map the field maps in the response back to the record they come from, returning the chosen record instead of
1458
1660
  # the chosen field map.
@@ -1463,6 +1665,7 @@ class CallbackUtil:
1463
1665
  return ret_list
1464
1666
 
1465
1667
  # CR-47377: Add allow_creation and default_creation_number to cover new parameters of this request type from 24.12.
1668
+ # FR-47690: Updated with blank result handling behavior.
1466
1669
  def input_selection_dialog(self,
1467
1670
  wrapper_type: type[WrappedType] | str,
1468
1671
  msg: str,
@@ -1477,8 +1680,10 @@ class CallbackUtil:
1477
1680
  allow_creation: bool = False,
1478
1681
  default_creation_number: int = 1,
1479
1682
  *,
1480
- require_selection: bool = False,
1481
- repeat_message: str | None = "Please provide a selection to continue.") \
1683
+ require_selection = None,
1684
+ blank_result_handling: BlankResultHandling = BlankResultHandling.DEFAULT,
1685
+ repeat_message: str | None = "Please provide a selection to continue.",
1686
+ cancel_message: str | None = "No selection was made. Cancelling dialog.") \
1482
1687
  -> list[WrappedType] | list[PyRecordModel]:
1483
1688
  """
1484
1689
  Display a table of records that exist in the system matching the given data type and filter criteria and have
@@ -1520,10 +1725,14 @@ class CallbackUtil:
1520
1725
  than 1, then multi-selection must be true. The data type definition of the records being created must have
1521
1726
  "Prompt for Number to Add" set to true in order to allow the user to select how many records to create, as
1522
1727
  otherwise user will only ever be able to create one record at a time.
1523
- :param require_selection: If true, the request will be re-sent if the user submits the dialog without making
1524
- a selection.
1525
- :param repeat_message: If require_selection is true and a repeat_message is provided, then that message appears
1526
- as toaster text if the dialog is repeated.
1728
+ :param require_selection: DEPRECATED. Use blank_result_handling with a value of BlankResultHandling.REPEAT
1729
+ instead.
1730
+ :param blank_result_handling: Determine how to handle the result of a callback when the user provides a blank
1731
+ result.
1732
+ :param repeat_message: If blank_result_handling is REPEAT and a repeat_message is provided, then that message
1733
+ appears as toaster text when the user provides a blank result.
1734
+ :param cancel_message: If blank_result_handling is CANCEL and a cancel_message is provided, then that message
1735
+ appears as toaster text when the user provides a blank result.
1527
1736
  :return: A list of the records selected by the user in the dialog, wrapped as record models using the provided
1528
1737
  wrapper.
1529
1738
  """
@@ -1549,23 +1758,25 @@ class CallbackUtil:
1549
1758
  request = InputSelectionRequest(data_type, msg, search_types, only_key_fields, record_blacklist,
1550
1759
  record_whitelist, preselected_records, custom_search, scan_criteria,
1551
1760
  multi_select, allow_creation, default_creation_number)
1552
- # If require_selection is true, repeat the request if the user didn't make a selection.
1553
- while True:
1554
- response: list[DataRecord] = self.__handle_dialog_request(request,
1555
- self.callback.show_input_selection_dialog)
1556
- if not require_selection or response:
1557
- break
1558
- if repeat_message:
1559
- self.toaster_popup(repeat_message, popup_type=PopupType.Warning)
1560
- return self.rec_handler.wrap_models(response, wrapper_type)
1561
1761
 
1762
+ # Reverse compatibility: If require_selection is true and blank_result_handling is not set, then
1763
+ # set blank_result_handling to REPEAT.
1764
+ if require_selection is True and blank_result_handling == BlankResultHandling.DEFAULT:
1765
+ blank_result_handling = BlankResultHandling.REPEAT
1766
+ def not_blank_func(r: list[DataRecord]) -> bool:
1767
+ return bool(r)
1768
+ return self.__send_dialog_blank_results(request, self.callback.show_input_selection_dialog, not_blank_func,
1769
+ blank_result_handling, repeat_message, cancel_message)
1770
+
1771
+ # FR-47690: Deprecated the require_authentication parameter.
1772
+ # noinspection PyUnusedLocal
1562
1773
  def esign_dialog(self,
1563
1774
  title: str,
1564
1775
  msg: str,
1565
1776
  show_comment: bool = True,
1566
1777
  additional_fields: Iterable[AbstractVeloxFieldDefinition] | None = None,
1567
1778
  *,
1568
- require_authentication: bool = False) -> ESigningResponsePojo:
1779
+ require_authentication = None) -> ESigningResponsePojo:
1569
1780
  """
1570
1781
  Create an e-sign dialog for the user to interact with.
1571
1782
 
@@ -1575,8 +1786,7 @@ class CallbackUtil:
1575
1786
  user is required to provide an action.
1576
1787
  :param additional_fields: Field definitions for additional fields to display in the dialog, for if there is
1577
1788
  other information you wish to gather from the user alongside the e-sign.
1578
- :param require_authentication: If true, the request will be re-sent if the user submits the dialog with invalid
1579
- credentials.
1789
+ :param require_authentication: DEPRECATED. Authentication is always required when using this function.
1580
1790
  :return: An e-sign response object containing information about the e-sign attempt.
1581
1791
  """
1582
1792
  # Construct a temporary data type if any additional fields are provided.
@@ -1590,10 +1800,9 @@ class CallbackUtil:
1590
1800
  # Send the request to the user.
1591
1801
  request = ESigningRequestPojo(title, msg, show_comment, temp_dt,
1592
1802
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
1593
- # If require_authentication is true, repeat the request if the user didn't provide valid credentials.
1594
1803
  while True:
1595
- response: ESigningResponsePojo = self.__handle_dialog_request(request, self.callback.show_esign_dialog)
1596
- if not require_authentication or response.authenticated:
1804
+ response: ESigningResponsePojo = self.__send_dialog(request, self.callback.show_esign_dialog)
1805
+ if response.authenticated:
1597
1806
  break
1598
1807
  # This matches the OOB behavior.
1599
1808
  self.toaster_popup("Incorrect username/password", popup_type=PopupType.Error)
@@ -1629,7 +1838,7 @@ class CallbackUtil:
1629
1838
 
1630
1839
  # Send the request to the user.
1631
1840
  request = FilePromptRequest(title, show_image_editor, ",".join(exts), show_camera_button)
1632
- file_path: str = self.__handle_dialog_request(request, self.callback.show_file_dialog, data_sink=do_consume)
1841
+ file_path: str = self.__send_dialog(request, self.callback.show_file_dialog, data_sink=do_consume)
1633
1842
 
1634
1843
  # Verify that each of the file given matches the expected extension(s).
1635
1844
  self.__verify_file(file_path, sink.data, exts)
@@ -1654,7 +1863,7 @@ class CallbackUtil:
1654
1863
 
1655
1864
  # Send the request to the user.
1656
1865
  request = MultiFilePromptRequest(title, show_image_editor, ",".join(exts), show_camera_button)
1657
- file_paths: list[str] = self.__handle_dialog_request(request, self.callback.show_multi_file_dialog)
1866
+ file_paths: list[str] = self.__send_dialog(request, self.callback.show_multi_file_dialog)
1658
1867
 
1659
1868
  # Verify that each of the files given match the expected extension(s).
1660
1869
  ret_dict: dict[str, bytes] = {}
@@ -1681,7 +1890,8 @@ class CallbackUtil:
1681
1890
  if allowed_extensions:
1682
1891
  matches: bool = False
1683
1892
  for ext in allowed_extensions:
1684
- if file_path.endswith("." + ext.lstrip(".")):
1893
+ # FR-47690: Changed to a case-insensitive match.
1894
+ if file_path.casefold().endswith("." + ext.lstrip(".").casefold()):
1685
1895
  matches = True
1686
1896
  break
1687
1897
  if matches is False:
@@ -1705,8 +1915,7 @@ class CallbackUtil:
1705
1915
  :param zip_name: The name of the zip file.
1706
1916
  :param files: A dictionary of the files to add to the zip file.
1707
1917
  """
1708
- with io.BytesIO(FileUtil.zip_files(files)) as data:
1709
- self.callback.send_file(zip_name, False, data)
1918
+ self.write_file(zip_name, FileUtil.zip_files(files))
1710
1919
 
1711
1920
  @staticmethod
1712
1921
  def __get_indexed_field_maps(records: Iterable[SapioRecord], index_field: str) -> list[FieldMap]:
@@ -1736,6 +1945,8 @@ class CallbackUtil:
1736
1945
  """
1737
1946
  Construct a Temporary Data Type definition from a provided list of field definitions for use in a callback.
1738
1947
  """
1948
+ if not fields:
1949
+ raise SapioException("No fields provided to create a temporary data type.")
1739
1950
  # Get the data type name as a string from the parameters, and set the display name and plural display name if
1740
1951
  # they haven't been set.
1741
1952
  data_type: str = AliasUtil.to_data_type_name(data_type)
@@ -1855,8 +2066,10 @@ class CallbackUtil:
1855
2066
  temp_dt.set_field_definition(modifier.modify_field(field_def))
1856
2067
  return temp_dt
1857
2068
 
2069
+ # FR-47690: Updated with blank result handling behavior.
1858
2070
  def __prompt_for_count(self, count: tuple[int, int] | int, wrapper_type: type[WrappedType] | str,
1859
- require_input: bool, repeat_message: str) -> int:
2071
+ require_input: bool, blank_result_handling: BlankResultHandling, repeat_message: str,
2072
+ cancel_message: str) -> int:
1860
2073
  """
1861
2074
  Given a count value, if it is a tuple representing an allowable range of values for a number of records to
1862
2075
  create, prompt the user to input the exact count to use. If the count is already a single integer, simply
@@ -1869,8 +2082,14 @@ class CallbackUtil:
1869
2082
  plural: str = self.dt_cache.get_plural_display_name(AliasUtil.to_data_type_name(wrapper_type))
1870
2083
  min_val, max_val = count
1871
2084
  msg: str = f"How many {plural} should be created? ({min_val} to {max_val})"
1872
- count: int = self.integer_input_dialog(f"Create {plural}", msg, "Count", min_val, min_val, max_val,
1873
- require_input=require_input, repeat_message=repeat_message)
2085
+ count_field: VeloxIntegerFieldDefinition = FieldBuilder().int_field("Count", min_value=min_val,
2086
+ max_value=max_val,
2087
+ default_value=min_val)
2088
+ count: int = self.input_dialog(f"Create {plural}", msg, count_field,
2089
+ require_input=require_input, blank_result_handling=blank_result_handling,
2090
+ repeat_message=repeat_message, cancel_message=cancel_message)
2091
+ if count is None:
2092
+ count = 0
1874
2093
  return count
1875
2094
 
1876
2095
  def __to_layout(self, data_type: str, layout: DataTypeLayoutIdentifier) -> DataTypeLayout | None:
@@ -1935,19 +2154,18 @@ class CallbackUtil:
1935
2154
  raise SapioException(f"No field of name \"{field_name}\" in field definitions of type \"{data_type}\"")
1936
2155
  return field_def
1937
2156
 
1938
- def __handle_dialog_request(self, request: Any, func: Callable, **kwargs) -> Any:
2157
+ def __handle_timeout(self, func: Callable, request: Any, **kwargs) -> Any:
1939
2158
  """
1940
2159
  Send a client callback request to the user that creates a dialog.
1941
2160
 
1942
2161
  This function handles updating the user object's request timeout to match the request timeout of this
1943
2162
  CallbackUtil for the duration of the dialog.
1944
2163
  If the dialog times out then a SapioDialogTimeoutException is thrown.
1945
- If the user cancels the dialog then a SapioUserCancelledException is thrown.
1946
2164
 
1947
2165
  :param request: The client callback request to send to the user.
1948
2166
  :param func: The ClientCallback function to call with the given request as input.
1949
2167
  :param kwargs: Additional keywords for the provided function call.
1950
- :return: The response from the client callback, if one was received.
2168
+ :return: The response from the client callback.
1951
2169
  """
1952
2170
  try:
1953
2171
  self.user.timeout_seconds = self.timeout_seconds
@@ -1956,10 +2174,73 @@ class CallbackUtil:
1956
2174
  raise SapioDialogTimeoutException()
1957
2175
  finally:
1958
2176
  self.user.timeout_seconds = self._original_timeout
2177
+ return response
2178
+
2179
+ def __send_dialog(self, request: Any, func: Callable, **kwargs) -> Any:
2180
+ """
2181
+ Send a client callback request to the user that creates a dialog.
2182
+
2183
+ This function handles updating the user object's request timeout to match the request timeout of this
2184
+ CallbackUtil for the duration of the dialog.
2185
+ If the dialog times out then a SapioDialogTimeoutException is thrown.
2186
+ If the user cancels the dialog then a SapioUserCancelledException is thrown.
2187
+
2188
+ :param request: The client callback request to send to the user.
2189
+ :param func: The ClientCallback function to call with the given request as input.
2190
+ :param kwargs: Additional keywords for the provided function call.
2191
+ :return: The response from the client callback, if one was received.
2192
+ """
2193
+ response: Any | None = self.__handle_timeout(func, request, **kwargs)
1959
2194
  if response is None:
1960
2195
  raise SapioUserCancelledException()
1961
2196
  return response
1962
2197
 
2198
+ def __send_dialog_blank_results(self, request: Any, func: Callable, not_blank_func: Callable,
2199
+ handling: BlankResultHandling,
2200
+ repeat_message: str | None, cancel_message: str | None, **kwargs):
2201
+ """
2202
+ Send a client callback request to the user that creates a dialog.
2203
+
2204
+ This function handles updating the user object's request timeout to match the request timeout of this
2205
+ CallbackUtil for the duration of the dialog.
2206
+ If the dialog times out then a SapioDialogTimeoutException is thrown.
2207
+ If the user cancels the dialog then a SapioUserCancelledException is thrown.
2208
+ If the user provides a blank result, then the handling is used to determine what to do with that result.
2209
+
2210
+ :param request: The client callback request to send to the user.
2211
+ :param func: The ClientCallback function to call with the given request as input.
2212
+ :param not_blank_func: The function to determine whether the provided result is blank or not.
2213
+ :param handling: The handling to use for blank results.
2214
+ :param repeat_message: If handling is REPEAT and a repeat_message is provided, then that message appears as
2215
+ toaster text when the user provides a blank result.
2216
+ :param cancel_message: If handling is CANCEL and a cancel_message is provided, then that message appears as
2217
+ toaster text when the user provides a blank result.
2218
+ :param kwargs: Additional keywords for the provided function call.
2219
+ :return: The response from the client callback, if one was received.
2220
+ """
2221
+ if handling == BlankResultHandling.DEFAULT or handling is None:
2222
+ handling = self._default_blank_result_handling
2223
+ while True:
2224
+ response: Any | None = self.__handle_timeout(func, request, **kwargs)
2225
+ if response is None:
2226
+ raise SapioUserCancelledException()
2227
+ if not_blank_func(response):
2228
+ return response
2229
+ match handling:
2230
+ case BlankResultHandling.CANCEL:
2231
+ # If the user provided no selection, throw an exception.
2232
+ if cancel_message:
2233
+ self.toaster_popup(cancel_message, popup_type=PopupType.Warning)
2234
+ raise SapioUserCancelledException()
2235
+ case BlankResultHandling.REPEAT:
2236
+ # If the user provided no selection, repeat the dialog.
2237
+ # If a repeatMessage is provided, display it as a toaster popup.
2238
+ if repeat_message:
2239
+ self.toaster_popup(repeat_message, popup_type=PopupType.Warning)
2240
+ case BlankResultHandling.RETURN:
2241
+ # If the user provided no selection, return the blank result.
2242
+ return response
2243
+
1963
2244
 
1964
2245
  class FieldModifier:
1965
2246
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sapiopycommons
3
- Version: 2025.6.10a560
3
+ Version: 2025.6.16a562
4
4
  Summary: Official Sapio Python API Utilities Package
5
5
  Project-URL: Homepage, https://github.com/sapiosciences
6
6
  Author-email: Jonathan Steck <jsteck@sapiosciences.com>, Yechen Qiao <yqiao@sapiosciences.com>
@@ -1,6 +1,6 @@
1
1
  sapiopycommons/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  sapiopycommons/callbacks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- sapiopycommons/callbacks/callback_util.py,sha256=lnrwG9hCVmA2GuzxlRrGKw7Eq5hzz7GBZvizgcKkSX0,134996
3
+ sapiopycommons/callbacks/callback_util.py,sha256=rps6RA6lmzCOwiBqPQAe2Mkf0CIF4RjHPQTYgduMAgE,153011
4
4
  sapiopycommons/callbacks/field_builder.py,sha256=rnIP-RJafk3mZlAx1eJ8a0eSW9Ps_L6_WadCmusnENw,38772
5
5
  sapiopycommons/chem/IndigoMolecules.py,sha256=7ucCaRMLu1zfH2uPIvXwRTSdpNcS03O1P9p_O-5B4xQ,5110
6
6
  sapiopycommons/chem/Molecules.py,sha256=mVqPn32MPMjF0iZas-5MFkS-upIdoW5OB72KKZmJRJA,12523
@@ -62,7 +62,7 @@ sapiopycommons/webhook/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3
62
62
  sapiopycommons/webhook/webhook_context.py,sha256=D793uLsb1691SalaPnBUk3rOSxn_hYLhdvkaIxjNXss,1909
63
63
  sapiopycommons/webhook/webhook_handlers.py,sha256=tUVNCw05CDGu1gFDm2g558hX_O203WVm_n__ojjoRRM,39841
64
64
  sapiopycommons/webhook/webservice_handlers.py,sha256=tyaYGG1-v_JJrJHZ6cy5mGCxX9z1foLw7pM4MDJlFxs,14297
65
- sapiopycommons-2025.6.10a560.dist-info/METADATA,sha256=M4tA963cTPGJR8zHZNPz-HaeVhD_adMnBAVB1sli1gw,3143
66
- sapiopycommons-2025.6.10a560.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
67
- sapiopycommons-2025.6.10a560.dist-info/licenses/LICENSE,sha256=HyVuytGSiAUQ6ErWBHTqt1iSGHhLmlC8fO7jTCuR8dU,16725
68
- sapiopycommons-2025.6.10a560.dist-info/RECORD,,
65
+ sapiopycommons-2025.6.16a562.dist-info/METADATA,sha256=uPyB0-P-hjRawjFs78zjP8QjsY1kjpGhU6zsDcx7tBY,3143
66
+ sapiopycommons-2025.6.16a562.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
67
+ sapiopycommons-2025.6.16a562.dist-info/licenses/LICENSE,sha256=HyVuytGSiAUQ6ErWBHTqt1iSGHhLmlC8fO7jTCuR8dU,16725
68
+ sapiopycommons-2025.6.16a562.dist-info/RECORD,,