@onehat/ui 0.4.112 → 0.4.114

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onehat/ui",
3
- "version": "0.4.112",
3
+ "version": "0.4.114",
4
4
  "description": "Base UI for OneHat apps",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -28,6 +28,10 @@ const ButtonComponent = forwardRef((props, ref) => {
28
28
  ...propsToPass
29
29
  } = props;
30
30
 
31
+ if (propsToPass.handler) {
32
+ propsToPass.onPress = propsToPass.handler; // alias
33
+ }
34
+
31
35
  if (icon) {
32
36
  if (isValidElement(icon)) {
33
37
  if (_icon) {
@@ -60,6 +64,8 @@ const ButtonComponent = forwardRef((props, ref) => {
60
64
  'flex',
61
65
  'flex-row',
62
66
  'items-center',
67
+ 'disabled:opacity-40',
68
+ 'disabled:cursor-not-allowed',
63
69
  );
64
70
  if (isExpandToFillVertical) {
65
71
  // IMPORTANT! Otherwise the button will cut off the vertical content due to size classes automatically added by Gluestack (e.g. h-10)
@@ -0,0 +1,39 @@
1
+ import Editor from './Editor.js';
2
+ import _ from 'lodash';
3
+
4
+ export default function ReportPresetsEditor(props) {
5
+
6
+ const
7
+ items = [
8
+ {
9
+ "type": "Column",
10
+ "flex": 1,
11
+ "defaults": {},
12
+ "items": [
13
+ {
14
+ "type": "FieldSet",
15
+ "title": "General",
16
+ "reference": "general",
17
+ "defaults": {},
18
+ "items": [
19
+ {
20
+ "name": "report_presets__name"
21
+ },
22
+ ]
23
+ }
24
+ ]
25
+ }
26
+ ],
27
+ ancillaryItems = [],
28
+ columnDefaults = { // defaults for each column defined in 'items', for use in Form amd Viewer
29
+ };
30
+ return <Editor
31
+ reference="ReportPresetsEditor"
32
+ title="Report Presets"
33
+ items={items}
34
+ ancillaryItems={ancillaryItems}
35
+ columnDefaults={columnDefaults}
36
+ {...props}
37
+ />;
38
+ }
39
+
@@ -0,0 +1,15 @@
1
+ import { ComboEditor } from './Combo.js';
2
+ import ReportPresetsEditorWindow from '../../../Window/ReportPresetsEditorWindow.js';
3
+
4
+ function ReportPresetsComboEditor(props) {
5
+ return <ComboEditor
6
+ reference="ReportPresetsCombo"
7
+ model="ReportPresets"
8
+ uniqueRepository={true}
9
+ Editor={ReportPresetsEditorWindow}
10
+ usePermissions={true}
11
+ {...props}
12
+ />;
13
+ }
14
+
15
+ export default ReportPresetsComboEditor;
@@ -109,6 +109,7 @@ function Form(props) {
109
109
  formSetup, // this fn will be executed after the form setup is complete
110
110
  additionalEditButtons,
111
111
  useAdditionalEditButtons = true,
112
+ additionalFooterItems, // overrides additionalFooterButtons if both are provided
112
113
  additionalFooterButtons,
113
114
  disableFooter = false,
114
115
  hideResetButton = false,
@@ -207,6 +208,25 @@ function Form(props) {
207
208
  // Fallback to empty schema that allows any fields and defaults to valid
208
209
  return yup.object().noUnknown(false).default({});
209
210
  })() || yup.object().shape({}), // on rare occasions, validatorToUse was null. This fixes it
211
+ requiredFieldsFromValidatorDescription = (() => {
212
+ if (editorType !== EDITOR_TYPE__PLAIN || !validatorToUse?.describe) {
213
+ return new Set();
214
+ }
215
+ const description = validatorToUse.describe();
216
+ const fields = description?.fields || {};
217
+ const requiredFieldNames = new Set();
218
+ Object.entries(fields).forEach(([fieldName, fieldConfig]) => {
219
+ if (!fieldConfig) {
220
+ return;
221
+ }
222
+ const hasRequiredTest = Array.isArray(fieldConfig.tests)
223
+ && fieldConfig.tests.some((test) => test?.name === 'required');
224
+ if (fieldConfig.optional === false || hasRequiredTest) {
225
+ requiredFieldNames.add(fieldName);
226
+ }
227
+ });
228
+ return requiredFieldNames;
229
+ })(),
210
230
  {
211
231
  control,
212
232
  formState,
@@ -899,6 +919,9 @@ function Form(props) {
899
919
  if (!isMultiple) { // Don't require fields if editing multiple records
900
920
  if (getIsRequired) {
901
921
  isRequired = getIsRequired(formGetValues, formState);
922
+ } else if (requiredFieldsFromValidatorDescription.has(name)) {
923
+ // submitted validator (describe fallback for plain editor)
924
+ isRequired = true;
902
925
  } else if (validatorToUse?.fields && validatorToUse.fields[name]?.exclusiveTests?.required) {
903
926
  // submitted validator
904
927
  isRequired = true;
@@ -1277,7 +1300,7 @@ function Form(props) {
1277
1300
  formButtons = null,
1278
1301
  scrollButtons = null,
1279
1302
  footer = null,
1280
- footerButtons = null,
1303
+ footerItems = null,
1281
1304
  formComponents,
1282
1305
  editor,
1283
1306
  additionalButtons,
@@ -1419,10 +1442,10 @@ function Form(props) {
1419
1442
  if (!!onSubmit) {
1420
1443
  showSubmitBtn = true;
1421
1444
  }
1422
- footerButtons =
1445
+ footerItems =
1423
1446
  <>
1424
-
1425
- {additionalFooterButtons && _.map(additionalFooterButtons, (props, ix) => {
1447
+ {additionalFooterItems}
1448
+ {!additionalFooterItems && additionalFooterButtons && _.map(additionalFooterButtons, (props, ix) => {
1426
1449
  let isDisabled = false;
1427
1450
  if (props.disableOnInvalid) {
1428
1451
  isDisabled = !formState.isValid;
@@ -1534,7 +1557,7 @@ function Form(props) {
1534
1557
  'rounded-b-lg',
1535
1558
  'bg-primary-700',
1536
1559
  )}
1537
- >{footerButtons}</HStack>
1560
+ >{footerItems}</HStack>
1538
1561
  </Box>;
1539
1562
  } else {
1540
1563
  if (!disableFooter) {
@@ -1559,7 +1582,7 @@ function Form(props) {
1559
1582
  footerClassName += ' ' + footerProps.className;
1560
1583
  }
1561
1584
  footer = <Footer {...footerProps} className={footerClassName}>
1562
- {footerButtons}
1585
+ {footerItems}
1563
1586
  </Footer>;
1564
1587
  }
1565
1588
  }
@@ -446,6 +446,55 @@ function GridComponent(props) {
446
446
  }
447
447
  return items;
448
448
  },
449
+ isScrollbarPress = (e) => {
450
+ if (CURRENT_MODE !== UI_MODE_WEB) {
451
+ return false;
452
+ }
453
+
454
+ const
455
+ nativeEvent = e?.nativeEvent || e,
456
+ clientX = nativeEvent?.clientX,
457
+ clientY = nativeEvent?.clientY;
458
+
459
+ if (_.isNil(clientX) || _.isNil(clientY)) {
460
+ return false;
461
+ }
462
+
463
+ function isPointOnScrollbar(element) {
464
+ if (!element || typeof element.getBoundingClientRect !== 'function') {
465
+ return false;
466
+ }
467
+
468
+ const rect = element.getBoundingClientRect();
469
+ if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) {
470
+ return false;
471
+ }
472
+
473
+ const verticalScrollbarWidth = element.offsetWidth - element.clientWidth;
474
+ if (verticalScrollbarWidth > 0 && clientX >= (rect.right - verticalScrollbarWidth)) {
475
+ return true;
476
+ }
477
+
478
+ const horizontalScrollbarHeight = element.offsetHeight - element.clientHeight;
479
+ if (horizontalScrollbarHeight > 0 && clientY >= (rect.bottom - horizontalScrollbarHeight)) {
480
+ return true;
481
+ }
482
+
483
+ return false;
484
+ };
485
+
486
+ const currentTarget = e?.currentTarget;
487
+ let target = nativeEvent?.target || e?.target;
488
+
489
+ while(target && target !== currentTarget) {
490
+ if (isPointOnScrollbar(target)) {
491
+ return true;
492
+ }
493
+ target = target.parentElement;
494
+ }
495
+
496
+ return isPointOnScrollbar(currentTarget);
497
+ },
449
498
  renderRow = (row) => {
450
499
  if (row.item.isDestroyed) {
451
500
  return null;
@@ -534,6 +583,15 @@ function GridComponent(props) {
534
583
  }
535
584
  }}
536
585
  onLongPress={(e) => {
586
+ if (isScrollbarPress(e)) {
587
+ if (e.preventDefault && e.cancelable) {
588
+ e.preventDefault();
589
+ }
590
+ if (e.stopPropagation) {
591
+ e.stopPropagation();
592
+ }
593
+ return;
594
+ }
537
595
  if (e.preventDefault && e.cancelable) {
538
596
  e.preventDefault();
539
597
  }
@@ -560,6 +618,15 @@ function GridComponent(props) {
560
618
  // web only; happens before onLongPress triggers
561
619
  // different behavior here than onLongPress:
562
620
  // if user clicks on a header row or phantom record, or if onContextMenu is not set, pass to the browser's context menu
621
+ if (isScrollbarPress(e)) {
622
+ if (e.preventDefault && e.cancelable) {
623
+ e.preventDefault();
624
+ }
625
+ if (e.stopPropagation) {
626
+ e.stopPropagation();
627
+ }
628
+ return;
629
+ }
563
630
  if (isHeaderRow || isReorderMode) {
564
631
  return
565
632
  }
@@ -38,6 +38,7 @@ export default function withSecondaryEditor(WrappedComponent, isTree = false) {
38
38
  secondaryDisableDelete = false,
39
39
  secondaryDisableDuplicate = false,
40
40
  secondaryDisableView = false,
41
+ secondaryWaitWhileSaving = false, // when true, show global wait modal while doEditorSave is in-flight
41
42
  secondaryUseRemoteDuplicate = false, // call specific copyToNew function on server, rather than simple duplicate on client
42
43
  secondaryGetRecordIdentifier = (secondarySelection) => {
43
44
  if (secondarySelection.length > 1) {
@@ -558,16 +559,24 @@ export default function withSecondaryEditor(WrappedComponent, isTree = false) {
558
559
  }
559
560
 
560
561
  setIsSaving(true);
562
+ if (secondaryWaitWhileSaving) {
563
+ setIsWaitModalShown(true);
564
+ }
561
565
  let success = true;
562
566
  const tempListener = (msg, data) => {
563
567
  success = { msg, data };
564
568
  };
565
569
 
566
- SecondaryRepository.on('error', tempListener); // add a temporary listener for the error event
567
- await SecondaryRepository.save(null, useStaged);
568
- SecondaryRepository.off('error', tempListener); // remove the temporary listener
569
-
570
- setIsSaving(false);
570
+ try {
571
+ SecondaryRepository.on('error', tempListener); // add a temporary listener for the error event
572
+ await SecondaryRepository.save(null, useStaged);
573
+ } finally {
574
+ SecondaryRepository.off('error', tempListener); // remove the temporary listener
575
+ setIsSaving(false);
576
+ if (secondaryWaitWhileSaving) {
577
+ setIsWaitModalShown(false);
578
+ }
579
+ }
571
580
 
572
581
  if (_.isBoolean(success) && success) {
573
582
  if (secondaryOnChange) {
@@ -39,6 +39,7 @@ export default function withEditor(WrappedComponent, isTree = false) {
39
39
  enableMultiDelete = false, // deleting multiple records at once is opt-in only
40
40
  disableDuplicate = false,
41
41
  disableView = false,
42
+ waitWhileSaving = false, // when true, show global wait modal while doEditorSave is in-flight
42
43
  useRemoteDuplicate = false, // call specific copyToNew function on server, rather than simple duplicate on client
43
44
  getDuplicateValues, // fn(entity) to get default values for duplication
44
45
  getRecordIdentifier = (selection) => {
@@ -662,7 +663,7 @@ export default function withEditor(WrappedComponent, isTree = false) {
662
663
  } finally {
663
664
  setIsWaitModalShown(false);
664
665
  }
665
- if (isSuccess) {
666
+ if (isSuccess && duplicateEntity) {
666
667
  setIsIgnoreNextSelectionChange(true);
667
668
  setSelection([duplicateEntity]);
668
669
  doEdit();
@@ -716,16 +717,24 @@ export default function withEditor(WrappedComponent, isTree = false) {
716
717
  }
717
718
 
718
719
  setIsSaving(true);
720
+ if (waitWhileSaving) {
721
+ setIsWaitModalShown(true);
722
+ }
719
723
  let success = true;
720
724
  const tempListener = (msg, data) => {
721
725
  success = false;
722
726
  };
723
727
 
724
- Repository.on('error', tempListener); // add a temporary listener for the error event
725
- await Repository.save(null, useStaged);
726
- Repository.off('error', tempListener); // remove the temporary listener
727
-
728
- setIsSaving(false);
728
+ try {
729
+ Repository.on('error', tempListener); // add a temporary listener for the error event
730
+ await Repository.save(null, useStaged);
731
+ } finally {
732
+ Repository.off('error', tempListener); // remove the temporary listener
733
+ setIsSaving(false);
734
+ if (waitWhileSaving) {
735
+ setIsWaitModalShown(false);
736
+ }
737
+ }
729
738
 
730
739
  if (_.isBoolean(success) && success) {
731
740
  if (onChange) {
@@ -22,8 +22,11 @@ import Form from '../Form/Form.js';
22
22
  import IconButton from '../Buttons/IconButton.js';
23
23
  import withComponent from '../Hoc/withComponent.js';
24
24
  import withAlert from '../Hoc/withAlert.js';
25
+ import getComponentFromType from '../../Functions/getComponentFromType.js';
25
26
  import testProps from '../../Functions/testProps.js';
26
27
  import ChartLine from '../Icons/ChartLine.js';
28
+ import Calendar from '../Icons/Calendar.js';
29
+ import Plus from '../Icons/Plus.js';
27
30
  import Pdf from '../Icons/Pdf.js';
28
31
  import Excel from '../Icons/Excel.js';
29
32
  import getReport from '../../Functions/getReport.js';
@@ -42,14 +45,22 @@ function Report(props) {
42
45
  showReportHeaders = true,
43
46
  isQuickReport = false,
44
47
  isDisabled = false,
48
+ usePresets = false,
49
+ useQueue = false,
50
+ useScheduledReports = false,
45
51
  disabledMessage = 'Report is Disabled',
46
52
  additionalData = {},
47
53
  quickReportData = {},
48
54
  alert,
49
55
  } = props,
50
- buttons = [],
56
+ formProps = props._form || {},
57
+ footerProps = formProps.footerProps || {},
58
+ footerClassName = clsx(
59
+ footerProps.className,
60
+ 'flex-wrap',
61
+ ),
51
62
  onPressQuickReport = () => {
52
- downloadReport({
63
+ return downloadReport({
53
64
  reportId,
54
65
  reportType: REPORT_TYPES__EXCEL,
55
66
  showReportHeaders,
@@ -59,9 +70,13 @@ function Report(props) {
59
70
  },
60
71
  });
61
72
  },
62
- downloadReport = (args) => {
63
- getReport(args);
64
- alert('Download started');
73
+ downloadReport = async (args) => {
74
+ try {
75
+ alert('Download started');
76
+ await getReport(args);
77
+ } catch (error) {
78
+ alert(error?.message || 'Unable to download report. Please try again.');
79
+ }
65
80
  };
66
81
 
67
82
  const propsIcon = props._icon || {};
@@ -161,10 +176,64 @@ function Report(props) {
161
176
  </VStackNative>;
162
177
  }
163
178
 
179
+ let footerItems = [];
180
+ if (usePresets) {
181
+ footerItems.push({
182
+ ...testProps('reportPresetsComboEditor'),
183
+ key: 'reportPresetsComboEditor',
184
+ type: 'ReportPresetsComboEditor',
185
+ tooltip: 'Report Presets',
186
+ placeholder: 'Report Presets',
187
+ disableEdit: true,
188
+ className: 'w-[100px]',
189
+ baseParams: {
190
+ reportId,
191
+ },
192
+ });
193
+ }
194
+ if (useScheduledReports) {
195
+ footerItems.push({
196
+ ...testProps('scheduleReportBtn'),
197
+ key: 'scheduleReportBtn',
198
+ type: 'Button',
199
+ tooltip: 'Schedule Report',
200
+ icon: Calendar,
201
+ onPress: (data) => scheduleReport({
202
+ reportId,
203
+ data: {
204
+ ...data,
205
+ ...additionalData,
206
+ },
207
+ reportType: REPORT_TYPES__EXCEL,
208
+ showReportHeaders,
209
+ }),
210
+ disableOnInvalid: true,
211
+ });
212
+ }
213
+ if (useQueue) {
214
+ footerItems.push({
215
+ ...testProps('queueBtn'),
216
+ key: 'queueBtn',
217
+ type: 'Button',
218
+ tooltip: 'Add to Queue',
219
+ icon: Plus,
220
+ onPress: (data) => addToQueue({
221
+ reportId,
222
+ data: {
223
+ ...data,
224
+ ...additionalData,
225
+ },
226
+ reportType: REPORT_TYPES__EXCEL,
227
+ showReportHeaders,
228
+ }),
229
+ disableOnInvalid: true,
230
+ });
231
+ }
164
232
  if (!disableExcel) {
165
- buttons.push({
233
+ footerItems.push({
166
234
  ...testProps('excelBtn'),
167
235
  key: 'excelBtn',
236
+ type: 'Button',
168
237
  text: 'Download Excel',
169
238
  icon: Excel,
170
239
  onPress: (data) => downloadReport({
@@ -180,9 +249,10 @@ function Report(props) {
180
249
  });
181
250
  }
182
251
  if (!disablePdf) {
183
- buttons.push({
252
+ footerItems.push({
184
253
  ...testProps('pdfBtn'),
185
254
  key: 'pdfBtn',
255
+ type: 'Button',
186
256
  text: 'Download PDF',
187
257
  icon: Pdf,
188
258
  onPress: (data) => downloadReport({
@@ -197,6 +267,12 @@ function Report(props) {
197
267
  disableOnInvalid: true,
198
268
  });
199
269
  }
270
+ if (footerItems.length) {
271
+ footerItems = footerItems.map(item => {
272
+ const Component = getComponentFromType(item.type);
273
+ return <Component {...item} />;
274
+ });
275
+ }
200
276
  return <VStackNative
201
277
  {...testProps('Report-' + reportId)}
202
278
  className={clsx(
@@ -225,9 +301,13 @@ function Report(props) {
225
301
  </VStack>
226
302
  </HStack>
227
303
  <Form
228
- type={EDITOR_TYPE__PLAIN}
229
- additionalFooterButtons={buttons}
230
- {...props._form}
304
+ editorType={EDITOR_TYPE__PLAIN}
305
+ additionalFooterItems={footerItems}
306
+ {...formProps}
307
+ footerProps={{
308
+ ...footerProps,
309
+ className: footerClassName,
310
+ }}
231
311
  />
232
312
  </Box>
233
313
  {isDisabled &&
@@ -0,0 +1,34 @@
1
+ /**
2
+ * COPYRIGHT NOTICE
3
+ * This file is categorized as "Custom Source Code"
4
+ * and is subject to the terms and conditions defined in the
5
+ * "LICENSE.txt" file, which is part of this source code package.
6
+ */
7
+ import UiGlobals from '../../UiGlobals.js';
8
+ import Panel from '../Panel/Panel.js';
9
+ import useAdjustedWindowSize from '../../Hooks/useAdjustedWindowSize.js';
10
+ import ReportPresetsEditor from '../Editor/ReportPresetsEditor.js';
11
+
12
+ export default function ReportPresetsEditorWindow(props) {
13
+ const {
14
+ style, // prevent it being passed to Editor
15
+ ...propsToPass
16
+ } = props,
17
+ styles = UiGlobals.styles,
18
+ [width, height] = useAdjustedWindowSize(styles.DEFAULT_WINDOW_WIDTH, styles.DEFAULT_WINDOW_HEIGHT);
19
+
20
+ return <Panel
21
+ {...props}
22
+ reference="ReportPresetsEditorWindow"
23
+ isCollapsible={false}
24
+ model="ReportPresets"
25
+ titleSuffix={props.editorMode === 'EDITOR_MODE__VIEW' || props.isEditorViewOnly ? ' Viewer' : ' Editor'}
26
+ className="ReportPresetsEditorWindow bg-white p-0"
27
+ isWindow={true}
28
+ w={width}
29
+ h={height}
30
+ flex={null}
31
+ >
32
+ <ReportPresetsEditor {...propsToPass} />
33
+ </Panel>;
34
+ }
@@ -283,6 +283,7 @@ import PlusMinusButton from './Buttons/PlusMinusButton.js';
283
283
  import PmCalcDebugViewer from './Viewer/PmCalcDebugViewer.js';
284
284
  import PmStatusesViewer from './Viewer/PmStatusesViewer.js';
285
285
  import RadioGroup from './Form/Field/RadioGroup/RadioGroup.js';
286
+ import ReportPresetsComboEditor from './Form/Field/Combo/ReportPresetsComboEditor.js';
286
287
  // import Slider from './Form/Field/Slider.js'; // Currently, Slider is not compatible with the new React architecture. Temporarily remove it from index.js to prevent issues.
287
288
  import SquareButton from './Buttons/SquareButton.js';
288
289
  import TabPanel from './Panel/TabPanel.js';
@@ -583,6 +584,7 @@ const components = {
583
584
  PmCalcDebugViewer,
584
585
  PmStatusesViewer,
585
586
  RadioGroup,
587
+ ReportPresetsComboEditor,
586
588
  // Slider,
587
589
  SquareButton,
588
590
  TabPanel,
@@ -1,4 +1,5 @@
1
1
  import qs from 'qs';
2
+ import getErrorMessageFromResponse from './getErrorMessageFromResponse.js';
2
3
 
3
4
  const downloadInBackground = async (url, data, authHeaders = {}) => {
4
5
  try {
@@ -13,7 +14,8 @@ const downloadInBackground = async (url, data, authHeaders = {}) => {
13
14
  });
14
15
 
15
16
  if (!response.ok) {
16
- throw new Error(`HTTP error! status: ${response.status}`);
17
+ const errorMessage = await getErrorMessageFromResponse(response);
18
+ throw new Error(errorMessage || `HTTP error! status: ${response.status}`);
17
19
  }
18
20
 
19
21
  // Get the blob from the response
@@ -1,46 +1,50 @@
1
- const downloadWithFetch = (url, options = {}, win = null) => {
2
- let obj = {};
3
- fetch(url, options)
4
- .then((res) => {
5
- const contentDisposition = res.headers.get('Content-Disposition');
6
- let filename = 'download';
7
- if (contentDisposition && contentDisposition.indexOf('attachment') !== -1) {
8
- const matches = /filename="([^"]*)"/.exec(contentDisposition);
9
- if (matches != null && matches[1]) {
10
- filename = matches[1];
11
- }
12
- }
13
- return res.blob().then((blob) => ({ blob, filename }));
14
- })
15
- .then(({ blob, filename }) => {
16
- // if (!win) {
17
- // const
18
- // winName = 'Download',
19
- // opts = 'location=0,menubar=0,scrollbars=0';
20
- // win = window.open('about:blank', winName, opts);
21
- // }
22
-
23
- // const file = win.URL.createObjectURL(blob);
24
- // obj.window = win;
25
- const file = URL.createObjectURL(blob);
26
-
27
- // const link = win.document.createElement('a');
28
- const link = document.createElement('a');
29
- link.href = file;
30
- link.download = filename; // Set the filename from the Content-Disposition header
31
- link.target = "_blank";
32
- link.click();
33
-
34
-
35
- // win.URL.revokeObjectURL(file); // if you revoke it, the PDF viewer will not be able to download the PDF.
36
-
37
- // const newWin = win.open(file);
38
- // win.location.assign(file);
39
- // setTimeout(() => {
40
- // win.close();
41
- // }, 2000);
42
-
43
- });
44
- return obj;
1
+ import getErrorMessageFromResponse from './getErrorMessageFromResponse.js';
2
+
3
+ const downloadWithFetch = async (url, options = {}, win = null) => {
4
+ const res = await fetch(url, options);
5
+
6
+ if (!res.ok) {
7
+ const errorMessage = await getErrorMessageFromResponse(res);
8
+ throw new Error(errorMessage || `HTTP error! status: ${res.status}`);
9
+ }
10
+
11
+ const contentDisposition = res.headers.get('Content-Disposition');
12
+ let filename = 'download';
13
+ if (contentDisposition && contentDisposition.indexOf('attachment') !== -1) {
14
+ const matches = /filename="([^"]*)"/.exec(contentDisposition);
15
+ if (matches != null && matches[1]) {
16
+ filename = matches[1];
17
+ }
18
+ }
19
+
20
+ const blob = await res.blob();
21
+
22
+ // if (!win) {
23
+ // const
24
+ // winName = 'Download',
25
+ // opts = 'location=0,menubar=0,scrollbars=0';
26
+ // win = window.open('about:blank', winName, opts);
27
+ // }
28
+
29
+ // const file = win.URL.createObjectURL(blob);
30
+ // obj.window = win;
31
+ const file = URL.createObjectURL(blob);
32
+
33
+ // const link = win.document.createElement('a');
34
+ const link = document.createElement('a');
35
+ link.href = file;
36
+ link.download = filename; // Set the filename from the Content-Disposition header
37
+ link.target = "_blank";
38
+ link.click();
39
+
40
+ // win.URL.revokeObjectURL(file); // if you revoke it, the PDF viewer will not be able to download the PDF.
41
+
42
+ // const newWin = win.open(file);
43
+ // win.location.assign(file);
44
+ // setTimeout(() => {
45
+ // win.close();
46
+ // }, 2000);
47
+
48
+ return { window: win };
45
49
  };
46
50
  export default downloadWithFetch;
@@ -0,0 +1,21 @@
1
+ const getErrorMessageFromResponse = async (response) => {
2
+ const contentType = response.headers.get('Content-Type') || '';
3
+
4
+ if (contentType.includes('application/json')) {
5
+ const errorData = await response.json().catch(() => null);
6
+ if (typeof errorData === 'string' && errorData.trim()) {
7
+ return errorData;
8
+ }
9
+ if (errorData?.message) {
10
+ return errorData.message;
11
+ }
12
+ if (errorData?.error) {
13
+ return errorData.error;
14
+ }
15
+ }
16
+
17
+ const text = await response.text().catch(() => '');
18
+ return text?.trim() || null;
19
+ };
20
+
21
+ export default getErrorMessageFromResponse;
@@ -9,6 +9,72 @@ import {
9
9
  getRepositoryAuthHeaders,
10
10
  } from './authFunctions.js';
11
11
  import UiGlobals from '../UiGlobals.js';
12
+ import _ from 'lodash';
13
+
14
+
15
+
16
+ // keeps safe serializable values (primitives, arrays, plain objects, Date to ISO),
17
+ // and removes cyclic/non-plain references, preventing "RangeError: Cyclic object value" errors.
18
+ const sanitizeReportData = (value, seen = new WeakSet()) => {
19
+ if (value == null) {
20
+ return value;
21
+ }
22
+
23
+ const valueType = typeof value;
24
+ if (
25
+ valueType === 'string'
26
+ || valueType === 'number'
27
+ || valueType === 'boolean'
28
+ ) {
29
+ return value;
30
+ }
31
+ if (valueType === 'bigint') {
32
+ return value.toString();
33
+ }
34
+ if (valueType === 'function' || valueType === 'symbol') {
35
+ return undefined;
36
+ }
37
+
38
+ if (value instanceof Date) {
39
+ return value.toISOString();
40
+ }
41
+
42
+ if (valueType !== 'object') {
43
+ return undefined;
44
+ }
45
+
46
+ if (seen.has(value)) {
47
+ return undefined;
48
+ }
49
+ seen.add(value);
50
+
51
+ if (Array.isArray(value)) {
52
+ const output = [];
53
+ for (const item of value) {
54
+ const sanitized = sanitizeReportData(item, seen);
55
+ if (sanitized !== undefined) {
56
+ output.push(sanitized);
57
+ }
58
+ }
59
+ seen.delete(value);
60
+ return output;
61
+ }
62
+
63
+ if (!_.isPlainObject(value)) {
64
+ seen.delete(value);
65
+ return undefined;
66
+ }
67
+
68
+ const output = {};
69
+ for (const [key, item] of Object.entries(value)) {
70
+ const sanitized = sanitizeReportData(item, seen);
71
+ if (sanitized !== undefined) {
72
+ output[key] = sanitized;
73
+ }
74
+ }
75
+ seen.delete(value);
76
+ return output;
77
+ };
12
78
 
13
79
  export default function getReport(args) {
14
80
  const {
@@ -24,18 +90,19 @@ export default function getReport(args) {
24
90
 
25
91
  const
26
92
  url = UiGlobals.baseURL + 'Reports/getReport',
93
+ sanitizedData = sanitizeReportData(data) || {},
27
94
  params = {
28
95
  report_id: reportId,
29
96
  outputFileType: reportType,
30
97
  showReportHeaders,
31
- ...data,
98
+ ...sanitizedData,
32
99
  },
33
100
  user = UiGlobals?.redux?.getState ? UiGlobals.redux.getState()?.auth?.user : null,
34
101
  token = getUserToken(user),
35
102
  authHeaders = getRepositoryAuthHeaders(token);
36
103
 
37
104
  if (reportType === REPORT_TYPES__EXCEL) {
38
- downloadInBackground(url, params, authHeaders);
105
+ return downloadInBackground(url, params, authHeaders);
39
106
  } else {
40
107
  const options = {
41
108
  method: 'POST',
@@ -45,6 +112,6 @@ export default function getReport(args) {
45
112
  },
46
113
  body: JSON.stringify(params),
47
114
  };
48
- downloadWithFetch(url, options);
115
+ return downloadWithFetch(url, options);
49
116
  }
50
117
  };
@@ -0,0 +1,3 @@
1
+ export default function hasSameId(left, right) {
2
+ return left?.id === right?.id;
3
+ }
@@ -28,6 +28,13 @@ export default function testProps(id, suffix) {
28
28
  accessible: true,
29
29
  };
30
30
  }
31
+ if (Platform.OS === 'web') {
32
+ return {
33
+ dataSet: {
34
+ testid: id,
35
+ },
36
+ };
37
+ }
31
38
  return {
32
39
  testID: id,
33
40
  };
@@ -344,7 +344,7 @@ export const verifyStartupAuthThunk = createAsyncThunk(
344
344
  const userData = getUserData(user);
345
345
  const token = getUserToken(userData);
346
346
  if (!token) {
347
- await dispatch(forceUnauthenticatedThunk(EXPIRED_MESSAGE));
347
+ await dispatch(forceUnauthenticatedThunk(null));
348
348
  return false;
349
349
  }
350
350
 
@@ -364,7 +364,7 @@ export const verifyStartupAuthThunk = createAsyncThunk(
364
364
  dispatch(setAuthStatus(AUTH_STATUS_AUTHENTICATED));
365
365
  return true;
366
366
  } catch (error) {
367
- await dispatch(forceUnauthenticatedThunk(EXPIRED_MESSAGE));
367
+ await dispatch(forceUnauthenticatedThunk(null));
368
368
  return false;
369
369
  }
370
370
  }
@@ -855,6 +855,7 @@ function AttachmentsElement(props) {
855
855
  'AttachmentsElement-icon-VStack1',
856
856
  'h-full',
857
857
  'flex-1',
858
+ 'min-h-0',
858
859
  'border',
859
860
  'p-1',
860
861
  isLoading ? [
@@ -866,6 +867,9 @@ function AttachmentsElement(props) {
866
867
  <HStack
867
868
  className={clsx(
868
869
  'AttachmentsElement-HStack',
870
+ 'flex-1',
871
+ 'min-h-0',
872
+ 'overflow-y-auto',
869
873
  'gap-2',
870
874
  'flex-wrap',
871
875
  'items-start',