sapiopycommons 2025.7.9a582__py3-none-any.whl → 2025.7.9a583__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.

Files changed (59) hide show
  1. sapiopycommons/callbacks/callback_util.py +665 -332
  2. sapiopycommons/callbacks/field_builder.py +2 -0
  3. sapiopycommons/chem/IndigoMolecules.py +29 -1
  4. sapiopycommons/chem/Molecules.py +3 -3
  5. sapiopycommons/customreport/auto_pagers.py +26 -1
  6. sapiopycommons/customreport/term_builder.py +1 -1
  7. sapiopycommons/datatype/pseudo_data_types.py +349 -326
  8. sapiopycommons/eln/experiment_cache.py +188 -0
  9. sapiopycommons/eln/experiment_handler.py +408 -767
  10. sapiopycommons/eln/experiment_report_util.py +11 -6
  11. sapiopycommons/eln/experiment_step_factory.py +476 -0
  12. sapiopycommons/eln/plate_designer.py +7 -2
  13. sapiopycommons/eln/step_creation.py +236 -0
  14. sapiopycommons/files/file_util.py +7 -5
  15. sapiopycommons/general/accession_service.py +2 -2
  16. sapiopycommons/general/aliases.py +3 -1
  17. sapiopycommons/general/audit_log.py +7 -0
  18. sapiopycommons/general/custom_report_util.py +12 -0
  19. sapiopycommons/general/data_structure_util.py +115 -0
  20. sapiopycommons/processtracking/custom_workflow_handler.py +11 -1
  21. sapiopycommons/processtracking/endpoints.py +27 -0
  22. sapiopycommons/recordmodel/record_handler.py +783 -389
  23. sapiopycommons/rules/eln_rule_handler.py +8 -1
  24. sapiopycommons/rules/on_save_rule_handler.py +8 -1
  25. sapiopycommons/webhook/webhook_handlers.py +9 -4
  26. sapiopycommons/webhook/webservice_handlers.py +2 -2
  27. {sapiopycommons-2025.7.9a582.dist-info → sapiopycommons-2025.7.9a583.dist-info}/METADATA +2 -2
  28. sapiopycommons-2025.7.9a583.dist-info/RECORD +68 -0
  29. sapiopycommons/ai/__init__.py +0 -0
  30. sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2.py +0 -43
  31. sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2.pyi +0 -31
  32. sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2_grpc.py +0 -24
  33. sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2.py +0 -123
  34. sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2.pyi +0 -598
  35. sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2_grpc.py +0 -24
  36. sapiopycommons/ai/api/plan/proto/step_output_pb2.py +0 -45
  37. sapiopycommons/ai/api/plan/proto/step_output_pb2.pyi +0 -42
  38. sapiopycommons/ai/api/plan/proto/step_output_pb2_grpc.py +0 -24
  39. sapiopycommons/ai/api/plan/proto/step_pb2.py +0 -43
  40. sapiopycommons/ai/api/plan/proto/step_pb2.pyi +0 -43
  41. sapiopycommons/ai/api/plan/proto/step_pb2_grpc.py +0 -24
  42. sapiopycommons/ai/api/plan/script/proto/script_pb2.py +0 -55
  43. sapiopycommons/ai/api/plan/script/proto/script_pb2.pyi +0 -115
  44. sapiopycommons/ai/api/plan/script/proto/script_pb2_grpc.py +0 -153
  45. sapiopycommons/ai/api/plan/tool/proto/entry_pb2.py +0 -57
  46. sapiopycommons/ai/api/plan/tool/proto/entry_pb2.pyi +0 -96
  47. sapiopycommons/ai/api/plan/tool/proto/entry_pb2_grpc.py +0 -24
  48. sapiopycommons/ai/api/plan/tool/proto/tool_pb2.py +0 -67
  49. sapiopycommons/ai/api/plan/tool/proto/tool_pb2.pyi +0 -220
  50. sapiopycommons/ai/api/plan/tool/proto/tool_pb2_grpc.py +0 -154
  51. sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2.py +0 -39
  52. sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2.pyi +0 -32
  53. sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2_grpc.py +0 -24
  54. sapiopycommons/ai/protobuf_utils.py +0 -508
  55. sapiopycommons/ai/test_client.py +0 -251
  56. sapiopycommons/ai/tool_service_base.py +0 -798
  57. sapiopycommons-2025.7.9a582.dist-info/RECORD +0 -92
  58. {sapiopycommons-2025.7.9a582.dist-info → sapiopycommons-2025.7.9a583.dist-info}/WHEEL +0 -0
  59. {sapiopycommons-2025.7.9a582.dist-info → sapiopycommons-2025.7.9a583.dist-info}/licenses/LICENSE +0 -0
@@ -4,7 +4,8 @@ import io
4
4
  import re
5
5
  import warnings
6
6
  from copy import copy
7
- from typing import Iterable, TypeAlias, Any, Callable
7
+ from enum import Enum
8
+ from typing import Iterable, TypeAlias, Any, Callable, Container, Collection
8
9
  from weakref import WeakValueDictionary
9
10
 
10
11
  from requests import ReadTimeout
@@ -38,11 +39,35 @@ from sapiopycommons.general.aliases import FieldMap, SapioRecord, AliasUtil, Rec
38
39
  from sapiopycommons.general.custom_report_util import CustomReportUtil
39
40
  from sapiopycommons.general.exceptions import SapioUserCancelledException, SapioException, SapioUserErrorException, \
40
41
  SapioDialogTimeoutException
42
+ from sapiopycommons.general.time_util import TimeUtil
41
43
  from sapiopycommons.recordmodel.record_handler import RecordHandler
42
44
 
43
45
  DataTypeLayoutIdentifier: TypeAlias = DataTypeLayout | str | None
44
46
 
45
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
+
65
+ # CR-47521: Updated various parameter type hints from list or Iterable to more specific type hints.
66
+ # If we need to iterate over the parameter, then it is Iterable.
67
+ # If we need to see if the parameter contains a value, then it is Container.
68
+ # If the length/size of the parameter is needed, then it is Collection.
69
+ # If we need to access the parameter by an index, then it is Sequence. (This excludes sets and dictionaries, so it's
70
+ # probably better to accept a Collection then cast the parameter to a list if you need to get an element from it.)
46
71
  class CallbackUtil:
47
72
  user: SapioUser
48
73
  callback: ClientCallback
@@ -53,6 +78,7 @@ class CallbackUtil:
53
78
  timeout_seconds: int
54
79
  width_pixels: int | None
55
80
  width_percent: float | None
81
+ _default_blank_result_handling: BlankResultHandling
56
82
 
57
83
  __instances: WeakValueDictionary[SapioUser, CallbackUtil] = WeakValueDictionary()
58
84
  __initialized: bool
@@ -90,6 +116,7 @@ class CallbackUtil:
90
116
  self.timeout_seconds = self.user.timeout_seconds
91
117
  self.width_pixels = None
92
118
  self.width_percent = None
119
+ self._default_blank_result_handling = BlankResultHandling.CANCEL
93
120
  self.__layouts = {}
94
121
 
95
122
  def set_dialog_width(self, width_pixels: int | None = None, width_percent: float | None = None):
@@ -116,6 +143,19 @@ class CallbackUtil:
116
143
  """
117
144
  self.timeout_seconds = timeout
118
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
+
119
159
  def toaster_popup(self, message: str, title: str = "", popup_type: PopupType = PopupType.Info) -> None:
120
160
  """
121
161
  Display a toaster popup in the bottom right corner of the user's screen.
@@ -177,7 +217,7 @@ class CallbackUtil:
177
217
  # Send the request to the user.
178
218
  request = OptionDialogRequest(title, msg, options, default_option, user_can_cancel,
179
219
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
180
- response: int = self.__handle_dialog_request(request, self.callback.show_option_dialog)
220
+ response: int = self.__send_dialog(request, self.callback.show_option_dialog)
181
221
  return options[response]
182
222
 
183
223
  def ok_dialog(self, title: str, msg: str) -> None:
@@ -193,7 +233,7 @@ class CallbackUtil:
193
233
  def ok_cancel_dialog(self, title: str, msg: str, default_ok: bool = True) -> bool:
194
234
  """
195
235
  Create an option dialog where the only options are "OK" and "Cancel". Doesn't allow the user to cancel the
196
- dialog using the X at the top right corner.
236
+ dialog using the X in the top right corner.
197
237
 
198
238
  :param title: The title of the dialog.
199
239
  :param msg: The message to display in the dialog. This can be formatted using HTML elements.
@@ -205,7 +245,7 @@ class CallbackUtil:
205
245
  def yes_no_dialog(self, title: str, msg: str, default_yes: bool = True) -> bool:
206
246
  """
207
247
  Create an option dialog where the only options are "Yes" and "No". Doesn't allow the user to cancel the
208
- dialog using the X at the top right corner.
248
+ dialog using the X in the top right corner.
209
249
 
210
250
  :param title: The title of the dialog.
211
251
  :param msg: The message to display in the dialog. This can be formatted using HTML elements.
@@ -214,16 +254,46 @@ class CallbackUtil:
214
254
  """
215
255
  return self.option_dialog(title, msg, ["Yes", "No"], 0 if default_yes else 1, False) == "Yes"
216
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
+
217
283
  # CR-47310: Add a parameter to the list, input, selection, and e-sign dialog functions to control reprompting the
218
284
  # user if no input/selection/valid credentials are provided.
285
+ # FR-47690: Added shortcut_single_option parameter. Updated with blank result handling behavior.
219
286
  def list_dialog(self,
220
287
  title: str,
221
288
  options: Iterable[str],
222
289
  multi_select: bool = False,
223
290
  preselected_values: Iterable[str] | None = None,
224
291
  *,
225
- require_selection: bool = False,
226
- 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]:
227
297
  """
228
298
  Create a list dialog with the given options for the user to choose from.
229
299
 
@@ -232,33 +302,237 @@ class CallbackUtil:
232
302
  :param multi_select: Whether the user is able to select multiple options from the list.
233
303
  :param preselected_values: A list of values that will already be selected when the list dialog is created. The
234
304
  user can unselect these values if they want to.
235
- :param require_selection: If true, the request will be re-sent if the user submits the dialog without making
236
- a selection.
237
- :param repeat_message: If require_selection is true and a repeat_message is provided, then that message appears
238
- 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.
239
315
  :return: The list of options that the user selected.
240
316
  """
241
317
  if not options:
242
318
  raise SapioException("No options provided.")
319
+ options = list(options)
320
+ if len(options) == 1 and shortcut_single_option:
321
+ return [options[0]]
243
322
 
244
323
  # Send the request to the user.
245
- request = ListDialogRequest(title, multi_select, list(options),
324
+ request = ListDialogRequest(title, multi_select, options,
246
325
  list(preselected_values) if preselected_values else None,
247
326
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
248
327
 
249
- # 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
+
250
375
  while True:
251
- response: list[str] = self.__handle_dialog_request(request, self.callback.show_list_dialog)
252
- if not require_selection or response:
253
- break
254
- if repeat_message:
255
- self.toaster_popup(repeat_message, popup_type=PopupType.Warning)
256
- 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)
257
531
 
258
532
  def form_dialog(self,
259
533
  title: str,
260
534
  msg: str,
261
- fields: list[AbstractVeloxFieldDefinition],
535
+ fields: Iterable[AbstractVeloxFieldDefinition],
262
536
  values: FieldMap = None,
263
537
  column_positions: dict[str, tuple[int, int]] = None,
264
538
  *,
@@ -273,7 +547,8 @@ class CallbackUtil:
273
547
  :param msg: The message to display at the top of the form. This can be formatted using HTML elements.
274
548
  :param fields: The definitions of the fields to display in the form. Fields will be displayed in the order they
275
549
  are provided in this list.
276
- :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.
277
552
  :param column_positions: If a tuple is provided for a field name, alters that field's column position and column
278
553
  span. (Field order is still determined by the fields list.)
279
554
  :param data_type: The data type name for the temporary data type that will be created for this form.
@@ -287,16 +562,23 @@ class CallbackUtil:
287
562
  # Build a temporary data type for the request.
288
563
  temp_dt = self.__temp_dt_from_field_defs(data_type, display_name, plural_display_name, fields, column_positions)
289
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
+
290
572
  # Send the request to the user.
291
573
  request = FormEntryDialogRequest(title, msg, temp_dt, values,
292
574
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
293
- 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)
294
576
  return response
295
577
 
296
578
  def record_form_dialog(self,
297
579
  title: str,
298
580
  msg: str,
299
- fields: list[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
581
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
300
582
  record: SapioRecord,
301
583
  column_positions: dict[str, tuple[int, int]] | None = None,
302
584
  editable=None,
@@ -362,14 +644,14 @@ class CallbackUtil:
362
644
  # Send the request to the user.
363
645
  request = FormEntryDialogRequest(title, msg, temp_dt, values,
364
646
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
365
- 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)
366
648
  return response
367
649
 
368
650
  # FR-47314: Create record form and table dialogs for updating or creating records.
369
651
  def set_record_form_dialog(self,
370
652
  title: str,
371
653
  msg: str,
372
- fields: list[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
654
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
373
655
  record: SapioRecord,
374
656
  column_positions: dict[str, tuple[int, int]] | None = None,
375
657
  *,
@@ -412,7 +694,7 @@ class CallbackUtil:
412
694
  def create_record_form_dialog(self,
413
695
  title: str,
414
696
  msg: str,
415
- fields: list[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
697
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
416
698
  wrapper_type: type[WrappedType] | str,
417
699
  column_positions: dict[str, tuple[int, int]] | None = None,
418
700
  *,
@@ -456,176 +738,17 @@ class CallbackUtil:
456
738
  default_modifier=default_modifier, field_modifiers=field_modifiers)
457
739
  return record
458
740
 
459
- def input_dialog(self,
460
- title: str,
461
- msg: str,
462
- field: AbstractVeloxFieldDefinition,
463
- *,
464
- require_input: bool = False,
465
- repeat_message: str | None = "Please provide a value to continue.") -> FieldValue:
466
- """
467
- Create an input dialog where the user must input data for a singular field.
468
-
469
- :param title: The title of the dialog.
470
- :param msg: The message to display in the dialog. This can be formatted using HTML elements.
471
- :param field: The definition for a field that the user must provide input to.
472
- :param require_input: If true, the request will be re-sent if the user submits the dialog without providing an
473
- input field value.
474
- :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
475
- as toaster text if the dialog is repeated.
476
- :return: The response value from the user for the given field.
477
- """
478
- # Send the request to the user.
479
- request = InputDialogCriteria(title, msg, field,
480
- width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
481
-
482
- # If require_input is true, repeat the request if the user didn't provide a field value.
483
- while True:
484
- try:
485
- self.user.timeout_seconds = self.timeout_seconds
486
- # It's not possible to distinguish between the user cancelling this dialog and submitting the dialog
487
- # with no input if the ClientCallback show_input_dialog function is used, as both cases just return
488
- # None. Therefore, in order to be able to make that distinction, we need to call the endpoint without
489
- # ClientCallback and get the raw response object.
490
- raw_response = self.user.post('/clientcallback/showInputDialog', payload=request.to_json())
491
- # A response status code of 204 is what represents a cancelled dialog.
492
- if raw_response.status_code == 204:
493
- raise SapioUserCancelledException()
494
- self.user.raise_for_status(raw_response)
495
- json_dct: dict | None = self.user.get_json_data_or_none(raw_response)
496
- response: FieldValue = json_dct['result'] if json_dct else None
497
- except ReadTimeout:
498
- raise SapioDialogTimeoutException()
499
- finally:
500
- self.user.timeout_seconds = self._original_timeout
501
- # String fields that the user didn't provide will return as an empty string instead of a None response.
502
- is_str: bool = isinstance(response, str)
503
- if not require_input or (is_str and response) or (not is_str and response is not None):
504
- break
505
- if repeat_message:
506
- self.toaster_popup(repeat_message, popup_type=PopupType.Warning)
507
- return response
508
-
509
- def string_input_dialog(self,
510
- title: str,
511
- msg: str,
512
- field_name: str,
513
- default_value: str | None = None,
514
- max_length: int | None = None,
515
- editable: bool = True,
516
- *,
517
- require_input: bool = False,
518
- repeat_message: str | None = "Please provide a value to continue.",
519
- **kwargs) -> str:
520
- """
521
- Create an input dialog where the user must input data for a singular text field.
522
-
523
- :param title: The title of the dialog.
524
- :param msg: The message to display in the dialog. This can be formatted using HTML elements.
525
- :param field_name: The name and display name of the string field.
526
- :param default_value: The default value to place into the string field, if any.
527
- :param max_length: The max length of the string value. If not provided, uses the length of the default value.
528
- If neither this nor a default value are provided, defaults to 100 characters.
529
- :param editable: Whether the field is editable by the user.
530
- :param require_input: If true, the request will be re-sent if the user submits the dialog without making
531
- a selection.
532
- :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
533
- as toaster text if the dialog is repeated.
534
- :param kwargs: Any additional keyword arguments to pass to the field definition.
535
- :return: The string that the user input into the dialog.
536
- """
537
- if max_length is None:
538
- max_length = len(default_value) if default_value else 100
539
- field = VeloxStringFieldDefinition("Input", field_name, field_name, default_value=default_value,
540
- max_length=max_length, editable=editable, **kwargs)
541
- return self.input_dialog(title, msg, field,
542
- require_input=require_input, repeat_message=repeat_message)
543
-
544
- def integer_input_dialog(self,
545
- title: str,
546
- msg: str,
547
- field_name: str,
548
- default_value: int = None,
549
- min_value: int = -10000,
550
- max_value: int = 10000,
551
- editable: bool = True,
552
- *,
553
- require_input: bool = False,
554
- repeat_message: str | None = "Please provide a value to continue.",
555
- **kwargs) -> int:
556
- """
557
- Create an input dialog where the user must input data for a singular integer field.
558
-
559
- :param title: The title of the dialog.
560
- :param msg: The message to display in the dialog. This can be formatted using HTML elements.
561
- :param field_name: The name and display name of the integer field.
562
- :param default_value: The default value to place into the integer field. If not provided, defaults to the 0 or
563
- the minimum value, whichever is higher.
564
- :param min_value: The minimum allowed value of the input.
565
- :param max_value: The maximum allowed value of the input.
566
- :param editable: Whether the field is editable by the user.
567
- :param require_input: If true, the request will be re-sent if the user submits the dialog without making
568
- a selection.
569
- :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
570
- as toaster text if the dialog is repeated.
571
- :param kwargs: Any additional keyword arguments to pass to the field definition.
572
- :return: The integer that the user input into the dialog.
573
- """
574
- if default_value is None:
575
- default_value = max(0, min_value)
576
- field = VeloxIntegerFieldDefinition("Input", field_name, field_name, default_value=default_value,
577
- min_value=min_value, max_value=max_value, editable=editable, **kwargs)
578
- return self.input_dialog(title, msg, field,
579
- require_input=require_input, repeat_message=repeat_message)
580
-
581
- def double_input_dialog(self,
582
- title: str,
583
- msg: str,
584
- field_name: str,
585
- default_value: float = None,
586
- min_value: float = -10000000,
587
- max_value: float = 100000000,
588
- editable: bool = True,
589
- *,
590
- require_input: bool = False,
591
- repeat_message: str | None = "Please provide a value to continue.",
592
- **kwargs) -> float:
593
- """
594
- Create an input dialog where the user must input data for a singular double field.
595
-
596
- :param title: The title of the dialog.
597
- :param msg: The message to display in the dialog. This can be formatted using HTML elements.
598
- :param field_name: The name and display name of the double field.
599
- :param default_value: The default value to place into the double field. If not provided, defaults to the 0 or
600
- the minimum value, whichever is higher.
601
- :param min_value: The minimum allowed value of the input.
602
- :param max_value: The maximum allowed value of the input.
603
- :param editable: Whether the field is editable by the user.
604
- :param require_input: If true, the request will be re-sent if the user submits the dialog without making
605
- a selection.
606
- :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
607
- as toaster text if the dialog is repeated.
608
- :param kwargs: Any additional keyword arguments to pass to the field definition.
609
- :return: The float that the user input into the dialog.
610
- """
611
- if default_value is None:
612
- default_value = max(0., min_value)
613
- field = VeloxDoubleFieldDefinition("Input", field_name, field_name, default_value=default_value,
614
- min_value=min_value, max_value=max_value, editable=editable, **kwargs)
615
- return self.input_dialog(title, msg, field,
616
- require_input=require_input, repeat_message=repeat_message)
617
-
618
741
  def table_dialog(self,
619
742
  title: str,
620
743
  msg: str,
621
- fields: list[AbstractVeloxFieldDefinition],
622
- values: list[FieldMap],
744
+ fields: Iterable[AbstractVeloxFieldDefinition],
745
+ values: Iterable[FieldMap] | int,
623
746
  *,
624
747
  data_type: DataTypeIdentifier = "Default",
625
748
  display_name: str | None = None,
626
749
  plural_display_name: str | None = None,
627
750
  group_by: FieldIdentifier | None = None,
628
- image_data: list[bytes] | None = None) -> list[FieldMap]:
751
+ image_data: Iterable[bytes] | None = None) -> list[FieldMap]:
629
752
  """
630
753
  Create a table dialog where the user may input data into the fields of the table. Requires that the caller
631
754
  provide the definitions of every field in the table.
@@ -634,7 +757,8 @@ class CallbackUtil:
634
757
  :param msg: The message to display at the top of the form. This can be formatted using HTML elements.
635
758
  :param fields: The definitions of the fields to display as table columns. Fields will be displayed in the order
636
759
  they are provided in this list.
637
- :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.
638
762
  :param group_by: If provided, the created table dialog will be grouped by the field with this name by default.
639
763
  The user may remove this grouping if they want to.
640
764
  :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
@@ -647,9 +771,18 @@ class CallbackUtil:
647
771
  :return: A list of dictionaries mapping the data field names of the given field definitions to the response
648
772
  value from the user for that field for each row.
649
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)]
650
777
  if not values:
651
778
  raise SapioException("No values provided.")
652
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 row:
784
+ row[field.data_field_name] = field.default_value
785
+
653
786
  # Convert the group_by parameter to a field name.
654
787
  if group_by is not None:
655
788
  group_by: str = AliasUtil.to_data_field_name(group_by)
@@ -660,23 +793,24 @@ class CallbackUtil:
660
793
  temp_dt.record_image_assignable = bool(image_data)
661
794
 
662
795
  # Send the request to the user.
663
- request = TableEntryDialogRequest(title, msg, temp_dt, values,
796
+ request = TableEntryDialogRequest(title, msg, temp_dt, list(values),
664
797
  record_image_data_list=image_data, group_by_field=group_by,
665
798
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
666
- 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)
667
800
  return response
668
801
 
669
802
  def record_table_dialog(self,
670
803
  title: str,
671
804
  msg: str,
672
- fields: list[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
673
- records: list[SapioRecord],
805
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
806
+ records: Iterable[SapioRecord],
674
807
  editable=None,
675
808
  *,
676
809
  default_modifier: FieldModifier | None = None,
677
810
  field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
678
811
  group_by: FieldIdentifier | None = None,
679
- image_data: list[bytes] | None = None) -> list[FieldMap]:
812
+ image_data: Iterable[bytes] | None = None,
813
+ index_field: str | None = None) -> list[FieldMap]:
680
814
  """
681
815
  Create a table dialog where the user may input data into the fields of the table. The table is constructed from
682
816
  a given list of records of a singular type.
@@ -706,6 +840,12 @@ class CallbackUtil:
706
840
  The user may remove this grouping if they want to.
707
841
  :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
708
842
  the image data list corresponds to the element at the same index in the records list.
843
+ :param index_field: If provided, the returned field maps will contain a field with this name that is equal to
844
+ the record ID of the record at the same index in the records list. This can be used to map the results
845
+ back to the original records. This is used instead of using a RecordId field, as the RecordId field has
846
+ special behavior in the system that can cause issues if the given records are uncommitted record models
847
+ with negative record IDs, meaning we don't want to have a RecordId field in the field maps provided to the
848
+ system.
709
849
  :return: A list of dictionaries mapping the data field names of the given field definitions to the response
710
850
  value from the user for that field for each row.
711
851
  """
@@ -717,7 +857,10 @@ class CallbackUtil:
717
857
  if not records:
718
858
  raise SapioException("No records provided.")
719
859
  data_type: str = AliasUtil.to_singular_data_type_name(records)
720
- field_map_list: list[FieldMap] = AliasUtil.to_field_map_list(records)
860
+ if index_field is not None:
861
+ field_map_list: list[FieldMap] = self.__get_indexed_field_maps(records, index_field)
862
+ else:
863
+ field_map_list: list[FieldMap] = AliasUtil.to_field_map_list(records)
721
864
 
722
865
  # Convert the group_by parameter to a field name.
723
866
  if group_by is not None:
@@ -743,20 +886,20 @@ class CallbackUtil:
743
886
  request = TableEntryDialogRequest(title, msg, temp_dt, field_map_list,
744
887
  record_image_data_list=image_data, group_by_field=group_by,
745
888
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
746
- 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)
747
890
  return response
748
891
 
749
892
  # FR-47314: Create record form and table dialogs for updating or creating records.
750
893
  def set_record_table_dialog(self,
751
894
  title: str,
752
895
  msg: str,
753
- fields: list[FieldValue] | DataTypeLayoutIdentifier,
754
- records: list[SapioRecord],
896
+ fields: Iterable[FieldValue] | DataTypeLayoutIdentifier,
897
+ records: Iterable[SapioRecord],
755
898
  *,
756
899
  default_modifier: FieldModifier | None = None,
757
900
  field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
758
901
  group_by: FieldIdentifier | None = None,
759
- image_data: list[bytes] | None = None):
902
+ image_data: Iterable[bytes] | None = None):
760
903
  """
761
904
  Create a table dialog where the user may input data into the fields of the table. The table is constructed from
762
905
  a given list of records of a singular type. After the user submits this dialog, the values that the user
@@ -787,27 +930,35 @@ class CallbackUtil:
787
930
  :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
788
931
  the image data list corresponds to the element at the same index in the records list.
789
932
  """
933
+ # Index the records with a field name that is the current time in milliseconds. This is done to avoid
934
+ # collisions with any existing field names.
935
+ index_field: str = f"_{TimeUtil.now_in_millis()}"
790
936
  results: list[FieldMap] = self.record_table_dialog(title, msg, fields, records,
791
937
  default_modifier=default_modifier,
792
938
  field_modifiers=field_modifiers,
793
- group_by=group_by, image_data=image_data)
939
+ group_by=group_by, image_data=image_data,
940
+ index_field=index_field)
794
941
  records_by_id: dict[int, SapioRecord] = self.rec_handler.map_by_id(records)
795
942
  for result in results:
796
- records_by_id[result["RecordId"]].set_field_values(result)
943
+ index: int = result.pop(index_field)
944
+ records_by_id[index].set_field_values(result)
797
945
 
946
+ # FR-47690: Updated with blank result handling behavior.
798
947
  def create_record_table_dialog(self,
799
948
  title: str,
800
949
  msg: str,
801
- fields: list[FieldValue] | DataTypeLayoutIdentifier,
950
+ fields: Iterable[FieldValue] | DataTypeLayoutIdentifier,
802
951
  wrapper_type: type[WrappedType] | str,
803
952
  count: int | tuple[int, int],
804
953
  *,
805
954
  default_modifier: FieldModifier | None = None,
806
955
  field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
807
956
  group_by: FieldIdentifier | None = None,
808
- image_data: list[bytes] | None = None,
809
- require_input: bool = False,
810
- repeat_message: str | None = "Please provide a value to continue.") \
957
+ image_data: Iterable[bytes] | None = None,
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.") \
811
962
  -> list[WrappedType] | list[PyRecordModel]:
812
963
  """
813
964
  Create a table dialog where the user may input data into the fields of the table. The table is constructed from
@@ -841,13 +992,18 @@ class CallbackUtil:
841
992
  The user may remove this grouping if they want to.
842
993
  :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
843
994
  the image data list corresponds to the element at the same index in the records list.
844
- :param require_input: If true and the user is prompted to input the number of records to create, the request
845
- will be re-sent if the user submits the dialog without making a selection.
846
- :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
847
- 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.
848
1003
  :return: A list of the newly created records.
849
1004
  """
850
- 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)
851
1007
  if count <= 0:
852
1008
  return []
853
1009
  records: list[WrappedType] | list[PyRecordModel] = self.rec_handler.add_models(wrapper_type, count)
@@ -860,14 +1016,15 @@ class CallbackUtil:
860
1016
  def record_adaptive_dialog(self,
861
1017
  title: str,
862
1018
  msg: str,
863
- fields: list[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
864
- records: list[SapioRecord],
1019
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
1020
+ records: Collection[SapioRecord],
865
1021
  *,
866
1022
  default_modifier: FieldModifier | None = None,
867
1023
  field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
868
1024
  column_positions: dict[str, tuple[int, int]] | None = None,
869
1025
  group_by: FieldIdentifier | None = None,
870
- image_data: list[bytes] | None = None) -> list[FieldMap]:
1026
+ image_data: Iterable[bytes] | None = None,
1027
+ index_field: str | None = None) -> list[FieldMap]:
871
1028
  """
872
1029
  Create a dialog where the user may input data into the specified fields. The dialog is constructed from
873
1030
  a given list of records of a singular type.
@@ -903,6 +1060,12 @@ class CallbackUtil:
903
1060
  :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
904
1061
  the image data list corresponds to the element at the same index in the records list. Only used if the
905
1062
  adaptive dialog becomes a table.
1063
+ :param index_field: If provided, the returned field maps will contain a field with this name that is equal to
1064
+ the record ID of the record at the same index in the records list. This can be used to map the results
1065
+ back to the original records. This is used instead of using a RecordId field, as the RecordId field has
1066
+ special behavior in the system that can cause issues if the given records are uncommitted record models
1067
+ with negative record IDs, meaning we don't want to have a RecordId field in the field maps provided to the
1068
+ system. Only used if the adaptive dialog becomes a table.
906
1069
  :return: A list of dictionaries mapping the data field names of the given field definitions to the response
907
1070
  value from the user for that field for each row. Even if a form was displayed, the field values will still
908
1071
  be returned in a list.
@@ -911,23 +1074,23 @@ class CallbackUtil:
911
1074
  if not count:
912
1075
  raise SapioException("No records provided.")
913
1076
  if count == 1:
914
- return [self.record_form_dialog(title, msg, fields, records[0], column_positions,
1077
+ return [self.record_form_dialog(title, msg, fields, list(records)[0], column_positions,
915
1078
  default_modifier=default_modifier, field_modifiers=field_modifiers)]
916
1079
  return self.record_table_dialog(title, msg, fields, records,
917
1080
  default_modifier=default_modifier, field_modifiers=field_modifiers,
918
- group_by=group_by, image_data=image_data)
1081
+ group_by=group_by, image_data=image_data, index_field=index_field)
919
1082
 
920
1083
  def set_record_adaptive_dialog(self,
921
1084
  title: str,
922
1085
  msg: str,
923
- fields: list[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
924
- records: list[SapioRecord],
1086
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
1087
+ records: Collection[SapioRecord],
925
1088
  *,
926
1089
  default_modifier: FieldModifier | None = None,
927
1090
  field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
928
1091
  column_positions: dict[str, tuple[int, int]] | None = None,
929
1092
  group_by: FieldIdentifier | None = None,
930
- image_data: list[bytes] | None = None) -> None:
1093
+ image_data: Iterable[bytes] | None = None) -> None:
931
1094
  """
932
1095
  Create a dialog where the user may input data into the fields of the dialog. The dialog is constructed from
933
1096
  a given list of records of a singular type. After the user submits this dialog, the values that the user
@@ -969,17 +1132,18 @@ class CallbackUtil:
969
1132
  if not count:
970
1133
  raise SapioException("No records provided.")
971
1134
  if count == 1:
972
- self.set_record_form_dialog(title, msg, fields, records[0], column_positions,
1135
+ self.set_record_form_dialog(title, msg, fields, list(records)[0], column_positions,
973
1136
  default_modifier=default_modifier, field_modifiers=field_modifiers)
974
1137
  else:
975
1138
  self.set_record_table_dialog(title, msg, fields, records,
976
1139
  default_modifier=default_modifier, field_modifiers=field_modifiers,
977
1140
  group_by=group_by, image_data=image_data)
978
1141
 
1142
+ # FR-47690: Updated with blank result handling behavior.
979
1143
  def create_record_adaptive_dialog(self,
980
1144
  title: str,
981
1145
  msg: str,
982
- fields: list[FieldValue] | DataTypeLayoutIdentifier,
1146
+ fields: Iterable[FieldValue] | DataTypeLayoutIdentifier,
983
1147
  wrapper_type: type[WrappedType] | str,
984
1148
  count: int | tuple[int, int],
985
1149
  *,
@@ -987,9 +1151,11 @@ class CallbackUtil:
987
1151
  field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
988
1152
  column_positions: dict[str, tuple[int, int]] | None = None,
989
1153
  group_by: FieldIdentifier | None = None,
990
- image_data: list[bytes] | None = None,
991
- require_input: bool = False,
992
- repeat_message: str | None = "Please provide a value to continue.") \
1154
+ image_data: Iterable[bytes] | None = None,
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.") \
993
1159
  -> list[WrappedType]:
994
1160
  """
995
1161
  Create a dialog where the user may input data into the specified fields. The dialog is constructed from
@@ -1030,14 +1196,19 @@ class CallbackUtil:
1030
1196
  :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
1031
1197
  the image data list corresponds to the element at the same index in the records list. Only used if the
1032
1198
  adaptive dialog becomes a table.
1033
- :param require_input: If true and the user is prompted to input the number of records to create, the request
1034
- will be re-sent if the user submits the dialog without making a selection.
1035
- :param repeat_message: If require_input is true and a repeat_message is provided, then that message appears
1036
- 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.
1037
1207
  :return: A list of the newly created records. Even if a form was displayed, the created record will still be
1038
1208
  returned in a list.
1039
1209
  """
1040
- 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)
1041
1212
  if count <= 0:
1042
1213
  return []
1043
1214
  if count == 1:
@@ -1047,17 +1218,20 @@ class CallbackUtil:
1047
1218
  default_modifier=default_modifier, field_modifiers=field_modifiers,
1048
1219
  group_by=group_by, image_data=image_data)
1049
1220
 
1221
+ # FR-47690: Add group_by and image_data parameters.
1050
1222
  def multi_type_table_dialog(self,
1051
1223
  title: str,
1052
1224
  msg: str,
1053
- fields: list[tuple[DataTypeIdentifier, FieldIdentifier] | AbstractVeloxFieldDefinition],
1054
- row_contents: list[list[SapioRecord | FieldMap]],
1225
+ fields: Iterable[tuple[DataTypeIdentifier, FieldIdentifier] | AbstractVeloxFieldDefinition],
1226
+ row_contents: Iterable[Iterable[SapioRecord | FieldMap]],
1055
1227
  *,
1056
1228
  default_modifier: FieldModifier | None = None,
1057
1229
  field_modifiers: dict[FieldIdentifier, FieldModifier] | None = None,
1058
1230
  data_type: DataTypeIdentifier = "Default",
1059
1231
  display_name: str | None = None,
1060
- 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]:
1061
1235
  """
1062
1236
  Create a table dialog where the user may input data into the fields of the table. The table is constructed from
1063
1237
  a given list of records of multiple data types or field maps. Provided field names must match with field names
@@ -1111,6 +1285,10 @@ class CallbackUtil:
1111
1285
  name.
1112
1286
  :param plural_display_name: The plural display name for the temporary data type. If not provided, defaults to
1113
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.
1114
1292
  :return: A list of dictionaries mapping the data field names of the given field definitions to the response
1115
1293
  value from the user for that field for each row.
1116
1294
  """
@@ -1175,31 +1353,49 @@ class CallbackUtil:
1175
1353
  field_names.add(name)
1176
1354
 
1177
1355
  # Get the values for each row.
1356
+ # FR-47690: Updated this for loop to better match the Java implementation.
1178
1357
  values: list[dict[str, FieldValue]] = []
1179
1358
  for row in row_contents:
1180
1359
  # The final values for this row:
1181
1360
  row_values: dict[str, FieldValue] = {}
1182
1361
 
1183
- # Map the records for this row by their data type. If a field map is provided, its data type is Default.
1184
- 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 = {}
1185
1366
  for rec in row:
1186
1367
  # Toss out null elements.
1187
1368
  if rec is None:
1188
1369
  continue
1189
1370
  # Map records to their data type name. Map field maps to Default.
1190
- dt: str = "Default" if isinstance(rec, dict) else AliasUtil.to_data_type_names(rec)
1191
- # Warn if the same data type name appears more than once.
1192
- if dt in row_records:
1193
- raise SapioException(f"The data type \"{dt}\" appears more than once in the given row contents.")
1194
- row_records[dt] = rec
1371
+ dt: str = "Default" if isinstance(rec, dict) else AliasUtil.to_data_type_name(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
1195
1379
 
1196
1380
  # Get the field values from the above records.
1197
1381
  for field in final_fields:
1382
+ value: Any | None = None
1383
+
1198
1384
  # Find the object that corresponds to this field given its data type name.
1199
- record: SapioRecord | FieldMap | None = row_records.get(field.data_type_name)
1200
- # This could be either a record, a field map, or null. Convert any records to field maps.
1201
- if not isinstance(record, dict) and record is not None:
1202
- 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)
1203
1399
 
1204
1400
  # Find out if this field had its data type prepended to it. If this is the case, then we need to find
1205
1401
  # the true data field name before retrieving the value from the field map.
@@ -1208,16 +1404,22 @@ class CallbackUtil:
1208
1404
  name = name.split(".")[1]
1209
1405
 
1210
1406
  # Set the value for this particular field.
1211
- row_values[field.data_field_name] = record.get(name) if record else None
1407
+ row_values[name] = value
1212
1408
  values.append(row_values)
1213
1409
 
1214
1410
  # Build a temporary data type for the request.
1215
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)
1216
1417
 
1217
1418
  # Send the request to the user.
1218
1419
  request = TableEntryDialogRequest(title, msg, temp_dt, values,
1420
+ record_image_data_list=image_data, group_by_field=group_by,
1219
1421
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
1220
- 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)
1221
1423
  return response
1222
1424
 
1223
1425
  def record_view_dialog(self,
@@ -1226,7 +1428,7 @@ class CallbackUtil:
1226
1428
  layout: DataTypeLayoutIdentifier = None,
1227
1429
  minimized: bool = False,
1228
1430
  access_level: FormAccessLevel | None = None,
1229
- plugin_path_list: list[str] | None = None) -> None:
1431
+ plugin_path_list: Iterable[str] | None = None) -> None:
1230
1432
  """
1231
1433
  Create an IDV dialog for the given record. This IDV may use an existing layout already defined in the system,
1232
1434
  and can be created to allow the user to edit the field in the IDV, or to be read-only for the user to review.
@@ -1254,26 +1456,30 @@ class CallbackUtil:
1254
1456
  # Send the request to the user.
1255
1457
  request = DataRecordDialogRequest(title, record, layout, minimized, access_level, plugin_path_list,
1256
1458
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
1257
- 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)
1258
1460
  # The __handle_dialog_request function only throws a cancelled exception if the response is None, but in
1259
1461
  # this case we also want to throw if the response is False.
1260
1462
  if not response:
1261
1463
  raise SapioUserCancelledException()
1262
1464
 
1263
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.
1264
1467
  def selection_dialog(self,
1265
1468
  msg: str,
1266
- fields: list[AbstractVeloxFieldDefinition],
1267
- values: list[FieldMap],
1469
+ fields: Iterable[AbstractVeloxFieldDefinition],
1470
+ values: Iterable[FieldMap],
1268
1471
  multi_select: bool = True,
1269
- preselected_rows: list[FieldMap | RecordIdentifier] | None = None,
1472
+ preselected_rows: Iterable[FieldMap | RecordIdentifier] | None = None,
1270
1473
  *,
1271
1474
  data_type: DataTypeIdentifier = "Default",
1272
1475
  display_name: str | None = None,
1273
1476
  plural_display_name: str | None = None,
1274
- image_data: list[bytes] | None = None,
1275
- require_selection: bool = False,
1276
- repeat_message: str | None = "Please provide a selection to continue.") -> list[FieldMap]:
1477
+ image_data: Iterable[bytes] | None = None,
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]:
1277
1483
  """
1278
1484
  Create a selection dialog for a list of field maps for the user to choose from. Requires that the caller
1279
1485
  provide the definitions of every field in the table.
@@ -1296,14 +1502,23 @@ class CallbackUtil:
1296
1502
  the display name + "s".
1297
1503
  :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
1298
1504
  the image data list corresponds to the element at the same index in the values list.
1299
- :param require_selection: If true, the request will be re-sent if the user submits the dialog without making
1300
- a selection.
1301
- :param repeat_message: If require_selection is true and a repeat_message is provided, then that message appears
1302
- 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.
1303
1515
  :return: A list of field maps corresponding to the chosen input field maps.
1304
1516
  """
1305
1517
  if not values:
1306
1518
  raise SapioException("No values provided.")
1519
+ values = list(values)
1520
+ if len(values) == 1 and shortcut_single_option:
1521
+ return [values[0]]
1307
1522
 
1308
1523
  if preselected_rows:
1309
1524
  # Confirm that the provided field maps are validly configured to allow the use of preselected rows.
@@ -1329,6 +1544,7 @@ class CallbackUtil:
1329
1544
 
1330
1545
  # Add a RecordId definition to the fields if one is not already present. This is necessary for the
1331
1546
  # pre-selected records parameter to function.
1547
+ fields = list(fields)
1332
1548
  if "RecordId" not in [x.data_field_name for x in fields]:
1333
1549
  builder = FieldBuilder(data_type)
1334
1550
  fields.append(builder.long_field("RecordId", abstract_info=AnyFieldInfo(visible=False)))
@@ -1338,27 +1554,31 @@ class CallbackUtil:
1338
1554
  temp_dt.record_image_assignable = bool(image_data)
1339
1555
 
1340
1556
  # Send the request to the user.
1341
- request = TempTableSelectionRequest(temp_dt, msg, values, image_data, preselected_rows, multi_select)
1342
- # If require_selection is true, repeat the request if the user didn't make a selection.
1343
- while True:
1344
- response: list[FieldMap] = self.__handle_dialog_request(request,
1345
- self.callback.show_temp_table_selection_dialog)
1346
- if not require_selection or response:
1347
- break
1348
- if repeat_message:
1349
- self.toaster_popup(repeat_message, popup_type=PopupType.Warning)
1350
- return response
1351
-
1557
+ request = TempTableSelectionRequest(temp_dt, msg, list(values), image_data, preselected_rows, multi_select)
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.
1352
1569
  def record_selection_dialog(self,
1353
1570
  msg: str,
1354
- fields: list[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
1355
- records: list[SapioRecord],
1571
+ fields: Iterable[FieldIdentifier | FieldFilterCriteria] | DataTypeLayoutIdentifier,
1572
+ records: Iterable[SapioRecord],
1356
1573
  multi_select: bool = True,
1357
- preselected_records: list[RecordIdentifier] | None = None,
1574
+ preselected_records: Iterable[RecordIdentifier] | None = None,
1358
1575
  *,
1359
- image_data: list[bytes] | None = None,
1360
- require_selection: bool = False,
1361
- repeat_message: str | None = "Please provide a selection to continue.") \
1576
+ image_data: Iterable[bytes] | None = None,
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.") \
1362
1582
  -> list[SapioRecord]:
1363
1583
  """
1364
1584
  Create a record selection dialog for a list of records for the user to choose from. Provided field names must
@@ -1380,15 +1600,24 @@ class CallbackUtil:
1380
1600
  record IDs are provided, the dialog will automatically allow multi-selection of records.
1381
1601
  :param image_data: The bytes to the images that should be displayed in the rows of the table. Each element in
1382
1602
  the image data list corresponds to the element at the same index in the records list.
1383
- :param require_selection: If true, the request will be re-sent if the user submits the dialog without making
1384
- a selection.
1385
- :param repeat_message: If require_selection is true and a repeat_message is provided, then that message appears
1386
- 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.
1387
1613
  :return: A list of the selected records.
1388
1614
  """
1389
1615
  # Get the data type name and field values from the provided records.
1390
1616
  if not records:
1391
1617
  raise SapioException("No records provided.")
1618
+ records = list(records)
1619
+ if len(records) == 1 and shortcut_single_option:
1620
+ return [records[0]]
1392
1621
  data_type: str = AliasUtil.to_singular_data_type_name(records)
1393
1622
  field_map_list: list[FieldMap] = AliasUtil.to_field_map_list(records, include_record_id=True)
1394
1623
 
@@ -1415,14 +1644,17 @@ class CallbackUtil:
1415
1644
 
1416
1645
  # Send the request to the user.
1417
1646
  request = TempTableSelectionRequest(temp_dt, msg, field_map_list, image_data, preselected_records, multi_select)
1418
- # If require_selection is true, repeat the request if the user didn't make a selection.
1419
- while True:
1420
- response: list[FieldMap] = self.__handle_dialog_request(request,
1421
- self.callback.show_temp_table_selection_dialog)
1422
- if not require_selection or response:
1423
- break
1424
- if repeat_message:
1425
- 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)
1426
1658
 
1427
1659
  # Map the field maps in the response back to the record they come from, returning the chosen record instead of
1428
1660
  # the chosen field map.
@@ -1433,22 +1665,25 @@ class CallbackUtil:
1433
1665
  return ret_list
1434
1666
 
1435
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.
1436
1669
  def input_selection_dialog(self,
1437
1670
  wrapper_type: type[WrappedType] | str,
1438
1671
  msg: str,
1439
1672
  multi_select: bool = True,
1440
1673
  only_key_fields: bool = False,
1441
- search_types: list[SearchType] | None = None,
1674
+ search_types: Iterable[SearchType] | None = None,
1442
1675
  scan_criteria: ScanToSelectCriteria | None = None,
1443
1676
  custom_search: CustomReport | CustomReportCriteria | str | None = None,
1444
- preselected_records: list[RecordIdentifier] | None = None,
1445
- record_blacklist: list[RecordIdentifier] | None = None,
1446
- record_whitelist: list[RecordIdentifier] | None = None,
1677
+ preselected_records: Iterable[RecordIdentifier] | None = None,
1678
+ record_blacklist: Iterable[RecordIdentifier] | None = None,
1679
+ record_whitelist: Iterable[RecordIdentifier] | None = None,
1447
1680
  allow_creation: bool = False,
1448
1681
  default_creation_number: int = 1,
1449
1682
  *,
1450
- require_selection: bool = False,
1451
- 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.") \
1452
1687
  -> list[WrappedType] | list[PyRecordModel]:
1453
1688
  """
1454
1689
  Display a table of records that exist in the system matching the given data type and filter criteria and have
@@ -1490,10 +1725,14 @@ class CallbackUtil:
1490
1725
  than 1, then multi-selection must be true. The data type definition of the records being created must have
1491
1726
  "Prompt for Number to Add" set to true in order to allow the user to select how many records to create, as
1492
1727
  otherwise user will only ever be able to create one record at a time.
1493
- :param require_selection: If true, the request will be re-sent if the user submits the dialog without making
1494
- a selection.
1495
- :param repeat_message: If require_selection is true and a repeat_message is provided, then that message appears
1496
- 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.
1497
1736
  :return: A list of the records selected by the user in the dialog, wrapped as record models using the provided
1498
1737
  wrapper.
1499
1738
  """
@@ -1519,23 +1758,25 @@ class CallbackUtil:
1519
1758
  request = InputSelectionRequest(data_type, msg, search_types, only_key_fields, record_blacklist,
1520
1759
  record_whitelist, preselected_records, custom_search, scan_criteria,
1521
1760
  multi_select, allow_creation, default_creation_number)
1522
- # If require_selection is true, repeat the request if the user didn't make a selection.
1523
- while True:
1524
- response: list[DataRecord] = self.__handle_dialog_request(request,
1525
- self.callback.show_input_selection_dialog)
1526
- if not require_selection or response:
1527
- break
1528
- if repeat_message:
1529
- self.toaster_popup(repeat_message, popup_type=PopupType.Warning)
1530
- return self.rec_handler.wrap_models(response, wrapper_type)
1531
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
1532
1773
  def esign_dialog(self,
1533
1774
  title: str,
1534
1775
  msg: str,
1535
1776
  show_comment: bool = True,
1536
- additional_fields: list[AbstractVeloxFieldDefinition] | None = None,
1777
+ additional_fields: Iterable[AbstractVeloxFieldDefinition] | None = None,
1537
1778
  *,
1538
- require_authentication: bool = False) -> ESigningResponsePojo:
1779
+ require_authentication = None) -> ESigningResponsePojo:
1539
1780
  """
1540
1781
  Create an e-sign dialog for the user to interact with.
1541
1782
 
@@ -1545,8 +1786,7 @@ class CallbackUtil:
1545
1786
  user is required to provide an action.
1546
1787
  :param additional_fields: Field definitions for additional fields to display in the dialog, for if there is
1547
1788
  other information you wish to gather from the user alongside the e-sign.
1548
- :param require_authentication: If true, the request will be re-sent if the user submits the dialog with invalid
1549
- credentials.
1789
+ :param require_authentication: DEPRECATED. Authentication is always required when using this function.
1550
1790
  :return: An e-sign response object containing information about the e-sign attempt.
1551
1791
  """
1552
1792
  # Construct a temporary data type if any additional fields are provided.
@@ -1560,10 +1800,9 @@ class CallbackUtil:
1560
1800
  # Send the request to the user.
1561
1801
  request = ESigningRequestPojo(title, msg, show_comment, temp_dt,
1562
1802
  width_in_pixels=self.width_pixels, width_percentage=self.width_percent)
1563
- # If require_authentication is true, repeat the request if the user didn't provide valid credentials.
1564
1803
  while True:
1565
- response: ESigningResponsePojo = self.__handle_dialog_request(request, self.callback.show_esign_dialog)
1566
- if not require_authentication or response.authenticated:
1804
+ response: ESigningResponsePojo = self.__send_dialog(request, self.callback.show_esign_dialog)
1805
+ if response.authenticated:
1567
1806
  break
1568
1807
  # This matches the OOB behavior.
1569
1808
  self.toaster_popup("Incorrect username/password", popup_type=PopupType.Error)
@@ -1572,7 +1811,7 @@ class CallbackUtil:
1572
1811
  popup_type=PopupType.Error)
1573
1812
  return response
1574
1813
 
1575
- def request_file(self, title: str, exts: list[str] | None = None,
1814
+ def request_file(self, title: str, exts: Iterable[str] | None = None,
1576
1815
  show_image_editor: bool = False, show_camera_button: bool = False) -> tuple[str, bytes]:
1577
1816
  """
1578
1817
  Request a single file from the user.
@@ -1599,13 +1838,13 @@ class CallbackUtil:
1599
1838
 
1600
1839
  # Send the request to the user.
1601
1840
  request = FilePromptRequest(title, show_image_editor, ",".join(exts), show_camera_button)
1602
- 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)
1603
1842
 
1604
1843
  # Verify that each of the file given matches the expected extension(s).
1605
1844
  self.__verify_file(file_path, sink.data, exts)
1606
1845
  return file_path, sink.data
1607
1846
 
1608
- def request_files(self, title: str, exts: list[str] | None = None,
1847
+ def request_files(self, title: str, exts: Iterable[str] | None = None,
1609
1848
  show_image_editor: bool = False, show_camera_button: bool = False) -> dict[str, bytes]:
1610
1849
  """
1611
1850
  Request multiple files from the user.
@@ -1624,7 +1863,7 @@ class CallbackUtil:
1624
1863
 
1625
1864
  # Send the request to the user.
1626
1865
  request = MultiFilePromptRequest(title, show_image_editor, ",".join(exts), show_camera_button)
1627
- 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)
1628
1867
 
1629
1868
  # Verify that each of the files given match the expected extension(s).
1630
1869
  ret_dict: dict[str, bytes] = {}
@@ -1637,7 +1876,7 @@ class CallbackUtil:
1637
1876
  return ret_dict
1638
1877
 
1639
1878
  @staticmethod
1640
- def __verify_file(file_path: str, file_bytes: bytes, allowed_extensions: list[str]) -> None:
1879
+ def __verify_file(file_path: str, file_bytes: bytes, allowed_extensions: Iterable[str]) -> None:
1641
1880
  """
1642
1881
  Verify that the provided file was read (i.e. the file path and file bytes aren't None or empty) and that it
1643
1882
  has the correct file extension. Raises a user error exception if something about the file is incorrect.
@@ -1648,10 +1887,11 @@ class CallbackUtil:
1648
1887
  """
1649
1888
  if file_path is None or len(file_path) == 0 or file_bytes is None or len(file_bytes) == 0:
1650
1889
  raise SapioUserErrorException("Empty file provided or file unable to be read.")
1651
- if len(allowed_extensions) != 0:
1890
+ if allowed_extensions:
1652
1891
  matches: bool = False
1653
1892
  for ext in allowed_extensions:
1654
- if file_path.endswith("." + ext.lstrip(".")):
1893
+ # FR-47690: Changed to a case-insensitive match.
1894
+ if file_path.casefold().endswith("." + ext.lstrip(".").casefold()):
1655
1895
  matches = True
1656
1896
  break
1657
1897
  if matches is False:
@@ -1665,8 +1905,8 @@ class CallbackUtil:
1665
1905
  :param file_name: The name of the file.
1666
1906
  :param file_data: The data of the file, provided as either a string or as a bytes array.
1667
1907
  """
1668
- data = io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data)
1669
- self.callback.send_file(file_name, False, data)
1908
+ with io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data) as data:
1909
+ self.callback.send_file(file_name, False, data)
1670
1910
 
1671
1911
  def write_zip_file(self, zip_name: str, files: dict[str, str | bytes]) -> None:
1672
1912
  """
@@ -1675,16 +1915,38 @@ class CallbackUtil:
1675
1915
  :param zip_name: The name of the zip file.
1676
1916
  :param files: A dictionary of the files to add to the zip file.
1677
1917
  """
1678
- data = io.BytesIO(FileUtil.zip_files(files))
1679
- self.callback.send_file(zip_name, False, data)
1918
+ self.write_file(zip_name, FileUtil.zip_files(files))
1919
+
1920
+ @staticmethod
1921
+ def __get_indexed_field_maps(records: Iterable[SapioRecord], index_field: str) -> list[FieldMap]:
1922
+ """
1923
+ For dialogs that accept multiple records, we may want to be able to match the returned results back to the
1924
+ records that they're for. In this case, we need to add an index to each record so that we can match them back
1925
+ to the original records. We can't use the RecordId field, as new record models have negative record IDs that
1926
+ cause the callback dialogs to bug out if the RecordId field is present and negative.
1927
+
1928
+ :param records: The records to return indexed field maps of.
1929
+ :param index_field: The name of the field to use as the index. Make sure that this field doesn't exist on the
1930
+ records, as then it will overwrite the existing value.
1931
+ :return: A list of field maps for the records, with an index field added to each. The value of the index on
1932
+ each field map is the record's record ID (even if it's a record model with a negative ID).
1933
+ """
1934
+ ret_val: list[FieldMap] = []
1935
+ for record in records:
1936
+ field_map: FieldMap = AliasUtil.to_field_map(record)
1937
+ field_map[index_field] = AliasUtil.to_record_id(record)
1938
+ ret_val.append(field_map)
1939
+ return ret_val
1680
1940
 
1681
1941
  @staticmethod
1682
1942
  def __temp_dt_from_field_defs(data_type: DataTypeIdentifier, display_name: str | None,
1683
- plural_display_name: str | None, fields: list[AbstractVeloxFieldDefinition],
1943
+ plural_display_name: str | None, fields: Iterable[AbstractVeloxFieldDefinition],
1684
1944
  column_positions: dict[str, tuple[int, int]] | None) -> TemporaryDataType:
1685
1945
  """
1686
1946
  Construct a Temporary Data Type definition from a provided list of field definitions for use in a callback.
1687
1947
  """
1948
+ if not fields:
1949
+ raise SapioException("No fields provided to create a temporary data type.")
1688
1950
  # Get the data type name as a string from the parameters, and set the display name and plural display name if
1689
1951
  # they haven't been set.
1690
1952
  data_type: str = AliasUtil.to_data_type_name(data_type)
@@ -1714,7 +1976,7 @@ class CallbackUtil:
1714
1976
  builder.add_field(field_def, column, span)
1715
1977
  return builder.get_temporary_data_type()
1716
1978
 
1717
- def __temp_dt_from_field_names(self, data_type: str, fields: list[FieldIdentifier | FieldFilterCriteria],
1979
+ def __temp_dt_from_field_names(self, data_type: str, fields: Iterable[FieldIdentifier | FieldFilterCriteria],
1718
1980
  column_positions: dict[str, tuple[int, int]] | None,
1719
1981
  default_modifier: FieldModifier, field_modifiers: dict[str, FieldModifier]) \
1720
1982
  -> TemporaryDataType:
@@ -1731,6 +1993,7 @@ class CallbackUtil:
1731
1993
 
1732
1994
  # Determine if any FieldFilterCriteria were provided. If so, remove them from the fields list so that it
1733
1995
  # contains only field identifiers.
1996
+ fields = list(fields)
1734
1997
  filter_criteria: list[FieldFilterCriteria] = [x for x in fields if isinstance(x, FieldFilterCriteria)]
1735
1998
  for criteria in filter_criteria:
1736
1999
  fields.remove(criteria)
@@ -1803,8 +2066,10 @@ class CallbackUtil:
1803
2066
  temp_dt.set_field_definition(modifier.modify_field(field_def))
1804
2067
  return temp_dt
1805
2068
 
2069
+ # FR-47690: Updated with blank result handling behavior.
1806
2070
  def __prompt_for_count(self, count: tuple[int, int] | int, wrapper_type: type[WrappedType] | str,
1807
- require_input: bool, repeat_message: str) -> int:
2071
+ require_input: bool, blank_result_handling: BlankResultHandling, repeat_message: str,
2072
+ cancel_message: str) -> int:
1808
2073
  """
1809
2074
  Given a count value, if it is a tuple representing an allowable range of values for a number of records to
1810
2075
  create, prompt the user to input the exact count to use. If the count is already a single integer, simply
@@ -1817,8 +2082,14 @@ class CallbackUtil:
1817
2082
  plural: str = self.dt_cache.get_plural_display_name(AliasUtil.to_data_type_name(wrapper_type))
1818
2083
  min_val, max_val = count
1819
2084
  msg: str = f"How many {plural} should be created? ({min_val} to {max_val})"
1820
- count: int = self.integer_input_dialog(f"Create {plural}", msg, "Count", min_val, min_val, max_val,
1821
- 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
1822
2093
  return count
1823
2094
 
1824
2095
  def __to_layout(self, data_type: str, layout: DataTypeLayoutIdentifier) -> DataTypeLayout | None:
@@ -1883,19 +2154,18 @@ class CallbackUtil:
1883
2154
  raise SapioException(f"No field of name \"{field_name}\" in field definitions of type \"{data_type}\"")
1884
2155
  return field_def
1885
2156
 
1886
- def __handle_dialog_request(self, request: Any, func: Callable, **kwargs) -> Any:
2157
+ def __handle_timeout(self, func: Callable, request: Any, **kwargs) -> Any:
1887
2158
  """
1888
2159
  Send a client callback request to the user that creates a dialog.
1889
2160
 
1890
2161
  This function handles updating the user object's request timeout to match the request timeout of this
1891
2162
  CallbackUtil for the duration of the dialog.
1892
2163
  If the dialog times out then a SapioDialogTimeoutException is thrown.
1893
- If the user cancels the dialog then a SapioUserCancelledException is thrown.
1894
2164
 
1895
2165
  :param request: The client callback request to send to the user.
1896
2166
  :param func: The ClientCallback function to call with the given request as input.
1897
2167
  :param kwargs: Additional keywords for the provided function call.
1898
- :return: The response from the client callback, if one was received.
2168
+ :return: The response from the client callback.
1899
2169
  """
1900
2170
  try:
1901
2171
  self.user.timeout_seconds = self.timeout_seconds
@@ -1904,10 +2174,73 @@ class CallbackUtil:
1904
2174
  raise SapioDialogTimeoutException()
1905
2175
  finally:
1906
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)
1907
2194
  if response is None:
1908
2195
  raise SapioUserCancelledException()
1909
2196
  return response
1910
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
+
1911
2244
 
1912
2245
  class FieldModifier:
1913
2246
  """
@@ -1983,15 +2316,15 @@ class FieldFilterCriteria:
1983
2316
  key_field: bool | None
1984
2317
  identifier: bool | None
1985
2318
  system_field: bool | None
1986
- field_types: list[FieldType] | None
1987
- not_field_types: list[FieldType] | None
2319
+ field_types: Container[FieldType] | None
2320
+ not_field_types: Container[FieldType] | None
1988
2321
  matches_tag: str | None
1989
2322
  contains_tag: str | None
1990
2323
  regex_tag: str | re.Pattern[str] | None
1991
2324
 
1992
2325
  def __init__(self, *, required: bool | None = None, editable: bool | None = None, key_field: bool | None = None,
1993
2326
  identifier: bool | None = None, system_field: bool | None = None,
1994
- field_types: list[FieldType] | None = None, not_field_types: list[FieldType] | None = None,
2327
+ field_types: Container[FieldType] | None = None, not_field_types: Container[FieldType] | None = None,
1995
2328
  matches_tag: str | None = None, contains_tag: str | None = None,
1996
2329
  regex_tag: str | re.Pattern[str] | None = None):
1997
2330
  """