@onehat/ui 0.4.113 → 0.4.115

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.113",
3
+ "version": "0.4.115",
4
4
  "description": "Base UI for OneHat apps",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -1,4 +1,4 @@
1
- import { cloneElement, forwardRef, isValidElement, useRef } from 'react';
1
+ import { cloneElement, forwardRef, isValidElement, useContext, useRef } from 'react';
2
2
  import {
3
3
  Button,
4
4
  ButtonText,
@@ -10,9 +10,12 @@ import addIconProps from '../../Functions/addIconProps.js';
10
10
  import clsx from 'clsx';
11
11
  import withComponent from '../Hoc/withComponent.js';
12
12
  import withTooltip from '../Hoc/withTooltip.js';
13
+ import FormContext from '../Form/FormContext.js';
13
14
  import _ from 'lodash';
14
15
 
15
16
  const ButtonComponent = forwardRef((props, ref) => {
17
+
18
+ const formContext = useContext(FormContext);
16
19
  let {
17
20
  self,
18
21
  text, // the text to display on the button
@@ -32,6 +35,16 @@ const ButtonComponent = forwardRef((props, ref) => {
32
35
  propsToPass.onPress = propsToPass.handler; // alias
33
36
  }
34
37
 
38
+ const {
39
+ disableOnInvalid,
40
+ ...propsToPassWithoutDisableOnInvalid
41
+ } = propsToPass;
42
+ propsToPass = propsToPassWithoutDisableOnInvalid;
43
+
44
+ if (_.isNil(propsToPass.isDisabled) && disableOnInvalid && formContext && !formContext.isValid) {
45
+ propsToPass.isDisabled = true;
46
+ }
47
+
35
48
  if (icon) {
36
49
  if (isValidElement(icon)) {
37
50
  if (_icon) {
@@ -64,6 +77,10 @@ const ButtonComponent = forwardRef((props, ref) => {
64
77
  'flex',
65
78
  'flex-row',
66
79
  'items-center',
80
+ 'data-[disabled=true]:opacity-40',
81
+ 'data-[disabled=true]:cursor-not-allowed',
82
+ 'web:disabled:opacity-40',
83
+ 'web:disabled:cursor-not-allowed',
67
84
  );
68
85
  if (isExpandToFillVertical) {
69
86
  // 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;
@@ -1,4 +1,4 @@
1
- import { useEffect, useCallback, useState, useRef, isValidElement, } from 'react';
1
+ import { useEffect, useCallback, useState, useRef, isValidElement, cloneElement, Children, } from 'react';
2
2
  import {
3
3
  Box,
4
4
  HStack,
@@ -59,6 +59,7 @@ import Xmark from '../Icons/Xmark.js';
59
59
  import Check from '../Icons/Check.js';
60
60
  import Footer from '../Layout/Footer.js';
61
61
  import Label from '../Form/Label.js';
62
+ import FormContext from '../Form/FormContext.js';
62
63
  import _ from 'lodash';
63
64
 
64
65
  // TODO: memoize field Components
@@ -109,6 +110,7 @@ function Form(props) {
109
110
  formSetup, // this fn will be executed after the form setup is complete
110
111
  additionalEditButtons,
111
112
  useAdditionalEditButtons = true,
113
+ additionalFooterItems, // overrides additionalFooterButtons if both are provided
112
114
  additionalFooterButtons,
113
115
  disableFooter = false,
114
116
  hideResetButton = false,
@@ -207,6 +209,25 @@ function Form(props) {
207
209
  // Fallback to empty schema that allows any fields and defaults to valid
208
210
  return yup.object().noUnknown(false).default({});
209
211
  })() || yup.object().shape({}), // on rare occasions, validatorToUse was null. This fixes it
212
+ requiredFieldsFromValidatorDescription = (() => {
213
+ if (editorType !== EDITOR_TYPE__PLAIN || !validatorToUse?.describe) {
214
+ return new Set();
215
+ }
216
+ const description = validatorToUse.describe();
217
+ const fields = description?.fields || {};
218
+ const requiredFieldNames = new Set();
219
+ Object.entries(fields).forEach(([fieldName, fieldConfig]) => {
220
+ if (!fieldConfig) {
221
+ return;
222
+ }
223
+ const hasRequiredTest = Array.isArray(fieldConfig.tests)
224
+ && fieldConfig.tests.some((test) => test?.name === 'required');
225
+ if (fieldConfig.optional === false || hasRequiredTest) {
226
+ requiredFieldNames.add(fieldName);
227
+ }
228
+ });
229
+ return requiredFieldNames;
230
+ })(),
210
231
  {
211
232
  control,
212
233
  formState,
@@ -899,6 +920,9 @@ function Form(props) {
899
920
  if (!isMultiple) { // Don't require fields if editing multiple records
900
921
  if (getIsRequired) {
901
922
  isRequired = getIsRequired(formGetValues, formState);
923
+ } else if (requiredFieldsFromValidatorDescription.has(name)) {
924
+ // submitted validator (describe fallback for plain editor)
925
+ isRequired = true;
902
926
  } else if (validatorToUse?.fields && validatorToUse.fields[name]?.exclusiveTests?.required) {
903
927
  // submitted validator
904
928
  isRequired = true;
@@ -1096,6 +1120,42 @@ function Form(props) {
1096
1120
  alert(errors.message);
1097
1121
  }
1098
1122
  },
1123
+ decorateAdditionalFooterItems = (elements) => {
1124
+ const decorateElement = (element) => {
1125
+ if (!isValidElement(element)) {
1126
+ return element;
1127
+ }
1128
+
1129
+ const
1130
+ elementProps = element.props || {},
1131
+ propsToInject = {};
1132
+
1133
+ if (elementProps.disableOnInvalid && !formState.isValid) {
1134
+ propsToInject.isDisabled = true;
1135
+ }
1136
+
1137
+ if (typeof elementProps.onPress === 'function' && (elementProps.disableOnInvalid || elementProps.skipSubmit || elementProps.submitWithForm)) {
1138
+ const originalOnPress = elementProps.onPress;
1139
+ if (elementProps.skipSubmit) {
1140
+ propsToInject.onPress = () => originalOnPress();
1141
+ } else {
1142
+ propsToInject.onPress = (e) => handleSubmit(originalOnPress, onSubmitError)(e);
1143
+ }
1144
+ }
1145
+
1146
+ if (elementProps.children) {
1147
+ propsToInject.children = Children.map(elementProps.children, decorateElement);
1148
+ }
1149
+
1150
+ if (_.isEmpty(propsToInject)) {
1151
+ return element;
1152
+ }
1153
+
1154
+ return cloneElement(element, propsToInject);
1155
+ };
1156
+
1157
+ return Children.map(elements, decorateElement);
1158
+ },
1099
1159
  doReset = (values) => {
1100
1160
  reset(values);
1101
1161
  if (onReset) {
@@ -1277,7 +1337,7 @@ function Form(props) {
1277
1337
  formButtons = null,
1278
1338
  scrollButtons = null,
1279
1339
  footer = null,
1280
- footerButtons = null,
1340
+ footerItems = null,
1281
1341
  formComponents,
1282
1342
  editor,
1283
1343
  additionalButtons,
@@ -1419,10 +1479,10 @@ function Form(props) {
1419
1479
  if (!!onSubmit) {
1420
1480
  showSubmitBtn = true;
1421
1481
  }
1422
- footerButtons =
1482
+ footerItems =
1423
1483
  <>
1424
-
1425
- {additionalFooterButtons && _.map(additionalFooterButtons, (props, ix) => {
1484
+ {decorateAdditionalFooterItems(additionalFooterItems)}
1485
+ {!additionalFooterItems && additionalFooterButtons && _.map(additionalFooterButtons, (props, ix) => {
1426
1486
  let isDisabled = false;
1427
1487
  if (props.disableOnInvalid) {
1428
1488
  isDisabled = !formState.isValid;
@@ -1534,7 +1594,7 @@ function Form(props) {
1534
1594
  'rounded-b-lg',
1535
1595
  'bg-primary-700',
1536
1596
  )}
1537
- >{footerButtons}</HStack>
1597
+ >{footerItems}</HStack>
1538
1598
  </Box>;
1539
1599
  } else {
1540
1600
  if (!disableFooter) {
@@ -1559,7 +1619,7 @@ function Form(props) {
1559
1619
  footerClassName += ' ' + footerProps.className;
1560
1620
  }
1561
1621
  footer = <Footer {...footerProps} className={footerClassName}>
1562
- {footerButtons}
1622
+ {footerItems}
1563
1623
  </Footer>;
1564
1624
  }
1565
1625
  }
@@ -1574,7 +1634,8 @@ function Form(props) {
1574
1634
  className += ' ' + props.className;
1575
1635
  }
1576
1636
  const scrollToTopAnchor = <Box ref={(el) => (ancillaryItemsRef.current[0] = el)} className="h-0" />;
1577
- return <VStackNative
1637
+ return <FormContext.Provider value={{ isValid: formState.isValid }}>
1638
+ <VStackNative
1578
1639
  ref={formRef}
1579
1640
  {...testProps(self)}
1580
1641
  style={style}
@@ -1616,7 +1677,8 @@ function Form(props) {
1616
1677
  {isFabVisible && fab}
1617
1678
 
1618
1679
  </>}
1619
- </VStackNative>;
1680
+ </VStackNative>
1681
+ </FormContext.Provider>;
1620
1682
  }
1621
1683
 
1622
1684
  // helper fns
@@ -0,0 +1,7 @@
1
+ import { createContext } from 'react';
2
+
3
+ const FormContext = createContext({
4
+ isValid: true,
5
+ });
6
+
7
+ export default FormContext;
@@ -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
  }
@@ -663,7 +663,7 @@ export default function withEditor(WrappedComponent, isTree = false) {
663
663
  } finally {
664
664
  setIsWaitModalShown(false);
665
665
  }
666
- if (isSuccess) {
666
+ if (isSuccess && duplicateEntity) {
667
667
  setIsIgnoreNextSelectionChange(true);
668
668
  setSelection([duplicateEntity]);
669
669
  doEdit();
@@ -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',