@onehat/ui 0.4.61 → 0.4.62

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.61",
3
+ "version": "0.4.62",
4
4
  "description": "Base UI for OneHat apps",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,108 @@
1
+ import { useCallback } from 'react';
2
+ import {
3
+ Fab, FabIcon, FabLabel,
4
+ VStack,
5
+ } from '@project-components/Gluestack';
6
+ import Animated, {
7
+ useSharedValue,
8
+ useAnimatedStyle,
9
+ withTiming,
10
+ } from 'react-native-reanimated';
11
+ import IconButton from '../Buttons/IconButton.js';
12
+ import FabWithTooltip from './FabWithTooltip.js';
13
+ import EllipsisVertical from '../Icons/EllipsisVertical.js';
14
+ import Xmark from '../Icons/Xmark.js';
15
+
16
+ // This component creates a floating action button (FAB)
17
+ // that can expand and collapse to show multiple buttons beneath it.
18
+
19
+ export default function DynamicFab(props) {
20
+ const {
21
+ icon,
22
+ buttons, // to show when expanded
23
+ label,
24
+ tooltip,
25
+ tooltipPlacement = 'left',
26
+ tooltipClassName,
27
+ tooltipTriggerClassName,
28
+ collapseOnPress = true,
29
+ } = props,
30
+ isExpanded = useSharedValue(0),
31
+ toggleFab = useCallback(() => {
32
+ isExpanded.value = isExpanded.value ? 0 : 1;
33
+ }, []),
34
+ buttonSpacing = 45,
35
+ verticalOffset = 50; // to shift the entire expanded group up
36
+
37
+ let className = `
38
+ DynamicFab
39
+ fixed
40
+ pb-[20px]
41
+ bottom-4
42
+ right-4
43
+ `;
44
+ if (props.className) {
45
+ className += ` ${props.className}`;
46
+ }
47
+
48
+ return <VStack className={className}>
49
+ {buttons
50
+ .slice() // clone, so we don't mutate the original array
51
+ .reverse() // so buttons appear in the correct order
52
+ .map((btnConfig, ix) => {
53
+ const {
54
+ tooltipPlacement = 'left',
55
+ onPress,
56
+ key,
57
+ ...btnConfigToPass
58
+ } = btnConfig,
59
+ animatedStyle = useAnimatedStyle(() => {
60
+ return {
61
+ opacity: withTiming(isExpanded.value, { duration: 200 }),
62
+ pointerEvents: isExpanded.value ? 'auto' : 'none', // Disable interaction when collapsed
63
+ };
64
+ });
65
+
66
+ return <Animated.View
67
+ key={ix}
68
+ style={[
69
+ animatedStyle,
70
+ {
71
+ position: 'absolute',
72
+ bottom: buttonSpacing * (ix + 1) + verticalOffset, // Static vertical positioning
73
+ right: 0,
74
+ },
75
+ ]}
76
+ >
77
+ <IconButton
78
+ className={`
79
+ bg-primary-600
80
+ text-white
81
+ hover:bg-primary-700
82
+ active:bg-primary-800
83
+ `}
84
+ tooltipPlacement={tooltipPlacement}
85
+ onPress={() => {
86
+ onPress();
87
+ if (collapseOnPress) {
88
+ isExpanded.value = 0;
89
+ }
90
+ }}
91
+ {...btnConfigToPass}
92
+ />
93
+ </Animated.View>;
94
+ })}
95
+ <FabWithTooltip
96
+ size="lg"
97
+ onPress={toggleFab}
98
+ className="z-100 bg-primary-600"
99
+ tooltip={tooltip}
100
+ tooltipPlacement={tooltipPlacement}
101
+ tooltipClassName={tooltipClassName}
102
+ tooltipTriggerClassName={tooltipTriggerClassName}
103
+ >
104
+ <FabIcon as={isExpanded.value ? Xmark : icon || EllipsisVertical} />
105
+ {label ? <FabLabel>{label}</FabLabel> : null}
106
+ </FabWithTooltip>
107
+ </VStack>;
108
+ };
@@ -0,0 +1,6 @@
1
+ import {
2
+ Fab,
3
+ } from '@project-components/Gluestack';
4
+ import withTooltip from '../Hoc/withTooltip.js';
5
+
6
+ export default withTooltip(Fab);
@@ -659,7 +659,7 @@ export const ComboComponent = forwardRef((props, ref) => {
659
659
  placeholder={placeholder}
660
660
  tooltip={tooltip}
661
661
  tooltipPlacement={tooltipPlacement}
662
- tooltipClassName={`
662
+ tooltipTriggerClassName={`
663
663
  grow
664
664
  h-auto
665
665
  self-stretch
@@ -901,7 +901,7 @@ export const ComboComponent = forwardRef((props, ref) => {
901
901
  placeholder={placeholder}
902
902
  tooltip={tooltip}
903
903
  tooltipPlacement={tooltipPlacement}
904
- tooltipClassName={`
904
+ tooltipTriggerClassName={`
905
905
  grow
906
906
  h-full
907
907
  flex-1
@@ -1014,7 +1014,7 @@ export const ComboComponent = forwardRef((props, ref) => {
1014
1014
  placeholder={placeholder}
1015
1015
  tooltip={tooltip}
1016
1016
  tooltipPlacement={tooltipPlacement}
1017
- tooltipClassName={`
1017
+ tooltipTriggerClassName={`
1018
1018
  h-full
1019
1019
  flex-1
1020
1020
  `}
@@ -366,7 +366,7 @@ export const DateElement = forwardRef((props, ref) => {
366
366
  isDisabled={isDisabled}
367
367
  tooltip={tooltip}
368
368
  tooltipPlacement={tooltipPlacement}
369
- tooltipClassName={`
369
+ tooltipTriggerClassName={`
370
370
  flex-1
371
371
  h-full
372
372
  `}
@@ -164,7 +164,10 @@ function NumberElement(props) {
164
164
  isDisabled={isDisabled}
165
165
  tooltip={tooltip}
166
166
  tooltipPlacement={tooltipPlacement}
167
- tooltipClassName="flex-1"
167
+ tooltipTriggerClassName={`
168
+ flex-1
169
+ h-full
170
+ `}
168
171
  className={`
169
172
  h-full
170
173
  text-center
@@ -1,4 +1,4 @@
1
- import { useEffect, useState, useRef, isValidElement, } from 'react';
1
+ import { useEffect, useCallback, useState, useRef, isValidElement, } from 'react';
2
2
  import {
3
3
  Box,
4
4
  HStack,
@@ -8,6 +8,11 @@ import {
8
8
  VStack,
9
9
  VStackNative,
10
10
  } from '@project-components/Gluestack';
11
+ import Animated, {
12
+ useSharedValue,
13
+ useAnimatedStyle,
14
+ withTiming,
15
+ } from 'react-native-reanimated';
11
16
  import {
12
17
  VIEW,
13
18
  } from '../../Constants/Commands.js';
@@ -40,7 +45,7 @@ import testProps from '../../Functions/testProps.js';
40
45
  import Toolbar from '../Toolbar/Toolbar.js';
41
46
  import Button from '../Buttons/Button.js';
42
47
  import IconButton from '../Buttons/IconButton.js';
43
- import DynamicFab from '../Buttons/DynamicFab.js';
48
+ import DynamicFab from '../Fab/DynamicFab.js';
44
49
  import AngleLeft from '../Icons/AngleLeft.js';
45
50
  import Eye from '../Icons/Eye.js';
46
51
  import Rotate from '../Icons/Rotate.js';
@@ -71,12 +76,15 @@ import _ from 'lodash';
71
76
  // EDITOR_TYPE__PLAIN
72
77
  // Form is embedded on screen in some other way. Mainly use startingValues, items, validator
73
78
 
79
+ const FAB_FADE_TIME = 300; // ms
80
+
74
81
  function Form(props) {
75
82
  const {
76
83
  editorType = EDITOR_TYPE__WINDOWED, // EDITOR_TYPE__INLINE | EDITOR_TYPE__WINDOWED | EDITOR_TYPE__SIDE | EDITOR_TYPE__SMART | EDITOR_TYPE__PLAIN
77
84
  startingValues = {},
78
85
  items = [], // Columns, FieldSets, Fields, etc to define the form
79
86
  ancillaryItems = [], // additional items which are not controllable form elements, but should appear in the form
87
+ showAncillaryButtons = false,
80
88
  columnDefaults = {}, // defaults for each Column defined in items (above)
81
89
  columnsConfig, // Which columns are shown in Grid, so the inline editor can match. Used only for EDITOR_TYPE__INLINE
82
90
  validator, // custom validator, mainly for EDITOR_TYPE__PLAIN
@@ -140,7 +148,13 @@ function Form(props) {
140
148
  } = props,
141
149
  formRef = useRef(),
142
150
  ancillaryItemsRef = useRef({}),
143
- ancillaryFabs = useRef([]),
151
+ ancillaryButtons = useRef([]),
152
+ setAncillaryButtons = (array) => {
153
+ ancillaryButtons.current = array;
154
+ },
155
+ getAncillaryButtons = () => {
156
+ return ancillaryButtons.current;
157
+ },
144
158
  styles = UiGlobals.styles,
145
159
  record = props.record?.length === 1 ? props.record[0] : props.record;
146
160
 
@@ -158,6 +172,14 @@ function Form(props) {
158
172
  forceUpdate = useForceUpdate(),
159
173
  [previousRecord, setPreviousRecord] = useState(record),
160
174
  [containerWidth, setContainerWidth] = useState(),
175
+ [isFabVisible, setIsFabVisible] = useState(false),
176
+ fabOpacity = useSharedValue(0),
177
+ fabAnimatedStyle = useAnimatedStyle(() => {
178
+ return {
179
+ opacity: withTiming(fabOpacity.value, { duration: FAB_FADE_TIME }), // Smooth fade animation
180
+ pointerEvents: fabOpacity.value > 0 ? 'auto' : 'none', // Disable interaction when invisible
181
+ };
182
+ }),
161
183
  initialValues = _.merge(startingValues, (record && !record.isDestroyed ? record.submitValues : {})),
162
184
  defaultValues = isMultiple ? getNullFieldValues(initialValues, Repository) : initialValues, // when multiple entities, set all default values to null
163
185
  validatorToUse = validator || (isMultiple ? disableRequiredYupFields(Repository?.schema?.model?.validator) : Repository?.schema?.model?.validator) || yup.object(),
@@ -902,13 +924,15 @@ function Form(props) {
902
924
  },
903
925
  buildAncillary = () => {
904
926
  const components = [];
905
- ancillaryFabs.current = [];
927
+ setAncillaryButtons([]);
906
928
  if (ancillaryItems.length) {
907
929
 
908
930
  // add the "scroll to top" button
909
- ancillaryFabs.current.push({
931
+ getAncillaryButtons().push({
910
932
  icon: ArrowUp,
933
+ reference: 'scrollToTop',
911
934
  onPress: () => scrollToAncillaryItem(0),
935
+ tooltip: 'Scroll to top',
912
936
  });
913
937
 
914
938
  _.each(ancillaryItems, (item, ix) => {
@@ -925,11 +949,12 @@ function Form(props) {
925
949
  return;
926
950
  }
927
951
  if (icon) {
928
- // TODO: this assumes that if one Ancillary item has an icon, they all do.
929
- // If they don't, the ix will be wrong.
930
- ancillaryFabs.current.push({
952
+ // NOTE: this assumes that if one Ancillary item has an icon, they all do.
953
+ // If they don't, the ix will be wrong!
954
+ getAncillaryButtons().push({
931
955
  icon,
932
956
  onPress: () => scrollToAncillaryItem(ix +1), // offset for the "scroll to top" button
957
+ tooltip: title,
933
958
  });
934
959
  }
935
960
  if (type.match(/Grid/) && !itemPropsToPass.h) {
@@ -959,7 +984,7 @@ function Form(props) {
959
984
  `}
960
985
  >{title}</Text>;
961
986
  if (icon) {
962
- title = <HStack className="items-center"><Icon as={icon} size="lg" className="mr-2" />{title}</HStack>
987
+ title = <HStack className="items-center"><Icon as={icon} className="w-[32px] h-[32px] mr-2" />{title}</HStack>
963
988
  }
964
989
  }
965
990
  if (description) {
@@ -988,12 +1013,6 @@ function Form(props) {
988
1013
  }
989
1014
  return components;
990
1015
  },
991
- scrollToAncillaryItem = (ix) => {
992
- ancillaryItemsRef.current[ix]?.scrollIntoView({
993
- behavior: 'smooth',
994
- block: 'start',
995
- });
996
- },
997
1016
  onSubmitError = (errors, e) => {
998
1017
  if (editorType === EDITOR_TYPE__INLINE) {
999
1018
  alert(errors.message);
@@ -1026,7 +1045,31 @@ function Form(props) {
1026
1045
  }
1027
1046
 
1028
1047
  setContainerWidth(e.nativeEvent.layout.width);
1029
- };
1048
+ },
1049
+ scrollToAncillaryItem = (ix) => {
1050
+ ancillaryItemsRef.current[ix]?.scrollIntoView({
1051
+ behavior: 'smooth',
1052
+ block: 'start',
1053
+ });
1054
+ },
1055
+ onScroll = useCallback(
1056
+ _.debounce((e) => {
1057
+ if (!showAncillaryButtons) {
1058
+ return;
1059
+ }
1060
+ const
1061
+ scrollY = e.nativeEvent.contentOffset.y,
1062
+ isFabVisible = scrollY > 50;
1063
+ fabOpacity.value = isFabVisible ? 1 : 0;
1064
+ if (isFabVisible) {
1065
+ setIsFabVisible(true);
1066
+ } else {
1067
+ // delay removal from DOM until fade-out is complete
1068
+ setTimeout(() => setIsFabVisible(isFabVisible), FAB_FADE_TIME);
1069
+ }
1070
+ }, 100), // delay
1071
+ []
1072
+ );
1030
1073
 
1031
1074
  useEffect(() => {
1032
1075
  if (skipAll) {
@@ -1111,8 +1154,9 @@ function Form(props) {
1111
1154
  style.maxHeight = maxHeight;
1112
1155
  }
1113
1156
 
1114
- const formButtons = [];
1115
1157
  let modeHeader = null,
1158
+ formButtons = null,
1159
+ scrollButtons = null,
1116
1160
  footer = null,
1117
1161
  footerButtons = null,
1118
1162
  formComponents,
@@ -1188,16 +1232,19 @@ function Form(props) {
1188
1232
  </Toolbar>;
1189
1233
  }
1190
1234
  if (getEditorMode() === EDITOR_MODE__EDIT && !_.isEmpty(additionalButtons)) {
1191
- formButtons.push(<Toolbar key="additionalButtonsToolbar" className="justify-end flex-wrap gap-2">
1235
+ formButtons = <Toolbar className="justify-end flex-wrap gap-2">
1192
1236
  {additionalButtons}
1193
- </Toolbar>)
1237
+ </Toolbar>;
1194
1238
  }
1195
- if (!_.isEmpty(ancillaryFabs.current)) {
1196
- fab = <DynamicFab
1197
- fabs={ancillaryFabs.current}
1239
+ if (showAncillaryButtons && !_.isEmpty(getAncillaryButtons())) {
1240
+ fab = <Animated.View style={fabAnimatedStyle}>
1241
+ <DynamicFab
1242
+ buttons={getAncillaryButtons()}
1198
1243
  collapseOnPress={false}
1199
1244
  className="bottom-[55px]"
1200
- />;
1245
+ tooltip="Scroll to Ancillary Item"
1246
+ />
1247
+ </Animated.View>;
1201
1248
  }
1202
1249
  }
1203
1250
 
@@ -1412,6 +1459,8 @@ function Form(props) {
1412
1459
  pb-1
1413
1460
  web:min-h-[${minHeight}px]
1414
1461
  `}
1462
+ onScroll={onScroll}
1463
+ scrollEventThrottle={16 /* ms */}
1415
1464
  contentContainerStyle={{
1416
1465
  // height: '100%',
1417
1466
  }}
@@ -1420,11 +1469,16 @@ function Form(props) {
1420
1469
  {modeHeader}
1421
1470
  {formHeader}
1422
1471
  {formButtons}
1472
+ {showAncillaryButtons && !_.isEmpty(getAncillaryButtons()) &&
1473
+ <Toolbar className="justify-start flex-wrap gap-2">
1474
+ <Text>Scroll:</Text>
1475
+ {buildAdditionalButtons(_.omitBy(getAncillaryButtons(), (btnConfig) => btnConfig.reference === 'scrollToTop'))}
1476
+ </Toolbar>}
1423
1477
  {editor}
1424
1478
  </ScrollView>}
1425
1479
 
1426
1480
  {footer}
1427
- {fab}
1481
+ {isFabVisible && fab}
1428
1482
 
1429
1483
  </>}
1430
1484
  </VStackNative>;
@@ -99,6 +99,22 @@ export default function withData(WrappedComponent) {
99
99
 
100
100
  }, []);
101
101
 
102
+ useEffect(() => {
103
+ if (!baseParams || !LocalRepository) {
104
+ return;
105
+ }
106
+
107
+ // If baseParams changes, re-load the Repository
108
+ if (LocalRepository.isLoaded && !_.isEqual(LocalRepository.getBaseParams(), baseParams)) {
109
+ LocalRepository.setBaseParams(baseParams);
110
+
111
+ if (LocalRepository.isRemote && !LocalRepository.isLoading) {
112
+ LocalRepository.load();
113
+ }
114
+ }
115
+
116
+ }, [baseParams, LocalRepository]);
117
+
102
118
  if (!isReady) {
103
119
  return null;
104
120
  }
@@ -50,6 +50,7 @@ export default function withEditor(WrappedComponent, isTree = false) {
50
50
  newEntityDisplayValue,
51
51
  newEntityDisplayProperty, // in case the field to set for newEntityDisplayValue is different from model
52
52
  defaultValues,
53
+ initialEditorMode = EDITOR_MODE__VIEW,
53
54
  stayInEditModeOnSelectionChange = false,
54
55
 
55
56
  // withComponent
@@ -81,7 +82,7 @@ export default function withEditor(WrappedComponent, isTree = false) {
81
82
  listeners = useRef({}),
82
83
  editorStateRef = useRef(),
83
84
  newEntityDisplayValueRef = useRef(),
84
- editorModeRef = useRef(EDITOR_MODE__VIEW),
85
+ editorModeRef = useRef(initialEditorMode),
85
86
  isIgnoreNextSelectionChangeRef = useRef(false),
86
87
  [currentRecord, setCurrentRecord] = useState(null),
87
88
  [isAdding, setIsAdding] = useState(false),
@@ -57,6 +57,7 @@ export default function withPresetButtons(WrappedComponent, isGrid = false) {
57
57
  canRecordBeEdited,
58
58
  canRecordBeDeleted,
59
59
  canRecordBeDuplicated,
60
+ canProceedWithCrud, // fn returns bool on if the CRUD operation can proceed
60
61
  ...propsToPass
61
62
  } = props,
62
63
  {
@@ -209,7 +210,12 @@ export default function withPresetButtons(WrappedComponent, isGrid = false) {
209
210
  case ADD:
210
211
  key = 'addBtn';
211
212
  text = 'Add';
212
- handler = (parent, e) => onAdd();
213
+ handler = (parent, e) => {
214
+ if (canProceedWithCrud && !canProceedWithCrud()) {
215
+ return;
216
+ }
217
+ onAdd();
218
+ };
213
219
  icon = Plus;
214
220
  if (isNoSelectorSelected() ||
215
221
  (isTree && isEmptySelection())
@@ -220,7 +226,12 @@ export default function withPresetButtons(WrappedComponent, isGrid = false) {
220
226
  case EDIT:
221
227
  key = 'editBtn';
222
228
  text = 'Edit';
223
- handler = (parent, e) => onEdit();
229
+ handler = (parent, e) => {
230
+ if (canProceedWithCrud && !canProceedWithCrud()) {
231
+ return;
232
+ }
233
+ onEdit();
234
+ };
224
235
  icon = Edit;
225
236
  if (isNoSelectorSelected() ||
226
237
  isEmptySelection() ||
@@ -235,7 +246,12 @@ export default function withPresetButtons(WrappedComponent, isGrid = false) {
235
246
  key = 'deleteBtn';
236
247
  text = 'Delete';
237
248
  handler = onDelete;
238
- handler = (parent, e) => onDelete();
249
+ handler = (parent, e) => {
250
+ if (canProceedWithCrud && !canProceedWithCrud()) {
251
+ return;
252
+ }
253
+ onDelete();
254
+ };
239
255
  icon = Trash;
240
256
  if (isNoSelectorSelected() ||
241
257
  isEmptySelection() ||
@@ -280,7 +296,12 @@ export default function withPresetButtons(WrappedComponent, isGrid = false) {
280
296
  case DUPLICATE:
281
297
  key = 'duplicateBtn';
282
298
  text = 'Duplicate';
283
- handler = (parent, e) => onDuplicate();
299
+ handler = (parent, e) => {
300
+ if (canProceedWithCrud && !canProceedWithCrud()) {
301
+ return;
302
+ }
303
+ onDuplicate();
304
+ };
284
305
  icon = Duplicate;
285
306
  isDisabled = !selection.length || selection.length !== 1;
286
307
  if (isNoSelectorSelected() ||
@@ -11,6 +11,7 @@ export default function withTooltip(WrappedComponent) {
11
11
  tooltip,
12
12
  tooltipPlacement = 'bottom',
13
13
  tooltipClassName,
14
+ tooltipTriggerClassName,
14
15
  _tooltip = {},
15
16
  ...propsToPass
16
17
  } = props;
@@ -22,6 +23,7 @@ export default function withTooltip(WrappedComponent) {
22
23
  label={tooltip}
23
24
  placement={tooltipPlacement}
24
25
  className={tooltipClassName}
26
+ triggerClassName={tooltipTriggerClassName}
25
27
  {..._tooltip}
26
28
  >
27
29
  {component}
@@ -18,6 +18,7 @@ import {
18
18
  } from '../../Constants/ReportTypes.js';
19
19
  import Form from '../Form/Form.js';
20
20
  import withComponent from '../Hoc/withComponent.js';
21
+ import withAlert from '../Hoc/withAlert.js';
21
22
  import testProps from '../../Functions/testProps.js';
22
23
  import ChartLine from '../Icons/ChartLine.js';
23
24
  import Pdf from '../Icons/Pdf.js';
@@ -36,8 +37,13 @@ function Report(props) {
36
37
  disablePdf = false,
37
38
  disableExcel = false,
38
39
  showReportHeaders = true,
40
+ alert,
39
41
  } = props,
40
- buttons = [];
42
+ buttons = [],
43
+ downloadReport = (args) => {
44
+ getReport(args);
45
+ alert('Download started');
46
+ };
41
47
 
42
48
  const propsIcon = props._icon || {};
43
49
  propsIcon.className = 'w-full h-full text-primary-500';
@@ -59,7 +65,7 @@ function Report(props) {
59
65
  key: 'excelBtn',
60
66
  text: 'Download Excel',
61
67
  icon: Excel,
62
- onPress: (data) => getReport({
68
+ onPress: (data) => downloadReport({
63
69
  reportId,
64
70
  data,
65
71
  reportType: REPORT_TYPES__EXCEL,
@@ -74,7 +80,7 @@ function Report(props) {
74
80
  key: 'pdfBtn',
75
81
  text: 'Download PDF',
76
82
  icon: Pdf,
77
- onPress: (data) => getReport({
83
+ onPress: (data) => downloadReport({
78
84
  reportId,
79
85
  data,
80
86
  reportType: REPORT_TYPES__PDF,
@@ -113,4 +119,4 @@ function Report(props) {
113
119
  </VStackNative>;
114
120
  }
115
121
 
116
- export default withComponent(Report);
122
+ export default withComponent(withAlert(Report));
@@ -398,13 +398,16 @@ function TabBar(props) {
398
398
  if (!tabs[currentTabIx]) {
399
399
  return null;
400
400
  }
401
- if (!tabs[currentTabIx].content && !tabs[currentTabIx].items) {
401
+
402
+ const currentTab = tabs[currentTabIx];
403
+ if (!currentTab.content && !currentTab.items) {
402
404
  return null;
403
405
  }
404
- if (tabs[currentTabIx].content) {
405
- return tabs[currentTabIx].content;
406
+
407
+ if (currentTab.content) {
408
+ return currentTab.content;
406
409
  }
407
- return _.map(tabs[currentTabIx].items, (item, ix) => {
410
+ return _.map(currentTab.items, (item, ix) => {
408
411
  return cloneElement(item, { key: ix });
409
412
  });
410
413
  };
@@ -462,6 +465,8 @@ function TabBar(props) {
462
465
  items-center
463
466
  justify-start
464
467
  py-2
468
+ overflow-x-hidden
469
+ overflow-y-auto
465
470
  ${styles.TAB_BAR_CLASSNAME}
466
471
  `}
467
472
  >
@@ -487,6 +492,8 @@ function TabBar(props) {
487
492
  ${'h-[' + tabHeight + 'px]'}
488
493
  items-center
489
494
  justify-start
495
+ overflow-x-auto
496
+ overflow-y-hidden
490
497
  p-1
491
498
  pb-0
492
499
  ${styles.TAB_BAR_CLASSNAME}
@@ -17,8 +17,8 @@ const TooltipElement = forwardRef((props, ref) => {
17
17
  }
18
18
 
19
19
  let triggerClassName = 'Tooltip-trigger';
20
- if (props.className) {
21
- triggerClassName += ' ' + props.className;
20
+ if (props.triggerClassName) {
21
+ triggerClassName += ' ' + props.triggerClassName;
22
22
  }
23
23
 
24
24
  return <Tooltip
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState, isValidElement, } from 'react';
1
+ import { useEffect, useCallback, useRef, useState, isValidElement, } from 'react';
2
2
  import {
3
3
  Box,
4
4
  HStack,
@@ -8,11 +8,17 @@ import {
8
8
  VStack,
9
9
  VStackNative,
10
10
  } from '@project-components/Gluestack';
11
+ import Animated, {
12
+ useSharedValue,
13
+ useAnimatedStyle,
14
+ withTiming,
15
+ } from 'react-native-reanimated';
11
16
  import {
12
17
  EDIT,
13
18
  } from '../../Constants/Commands.js';
14
19
  import {
15
20
  EDITOR_TYPE__SIDE,
21
+ EDITOR_TYPE__SMART,
16
22
  } from '../../Constants/Editor.js';
17
23
  import {
18
24
  extractCssPropertyFromClassName,
@@ -26,7 +32,7 @@ import inArray from '../../Functions/inArray.js';
26
32
  import getComponentFromType from '../../Functions/getComponentFromType.js';
27
33
  import buildAdditionalButtons from '../../Functions/buildAdditionalButtons.js';
28
34
  import testProps from '../../Functions/testProps.js';
29
- import DynamicFab from '../Buttons/DynamicFab.js';
35
+ import DynamicFab from '../Fab/DynamicFab.js';
30
36
  import Toolbar from '../Toolbar/Toolbar.js';
31
37
  import ArrowUp from '../Icons/ArrowUp.js';
32
38
  import Button from '../Buttons/Button.js';
@@ -35,11 +41,14 @@ import Pencil from '../Icons/Pencil.js';
35
41
  import Footer from '../Layout/Footer.js';
36
42
  import _ from 'lodash';
37
43
 
44
+ const FAB_FADE_TIME = 300; // ms
45
+
38
46
  function Viewer(props) {
39
47
  const {
40
48
  viewerCanDelete = false,
41
49
  items = [], // Columns, FieldSets, Fields, etc to define the form
42
50
  ancillaryItems = [], // additional items which are not controllable form elements, but should appear in the form
51
+ showAncillaryButtons = false,
43
52
  columnDefaults = {}, // defaults for each Column defined in items (above)
44
53
  record,
45
54
  additionalViewButtons,
@@ -71,10 +80,25 @@ function Viewer(props) {
71
80
  } = props,
72
81
  scrollViewRef = useRef(),
73
82
  ancillaryItemsRef = useRef({}),
74
- ancillaryFabs = useRef([]),
83
+ ancillaryButtons = useRef([]),
84
+ setAncillaryButtons = (array) => {
85
+ ancillaryButtons.current = array;
86
+ },
87
+ getAncillaryButtons = () => {
88
+ return ancillaryButtons.current;
89
+ },
75
90
  isMultiple = _.isArray(record),
76
91
  [containerWidth, setContainerWidth] = useState(),
92
+ [isFabVisible, setIsFabVisible] = useState(false),
93
+ fabOpacity = useSharedValue(0),
94
+ fabAnimatedStyle = useAnimatedStyle(() => {
95
+ return {
96
+ opacity: withTiming(fabOpacity.value, { duration: FAB_FADE_TIME }), // Smooth fade animation
97
+ pointerEvents: fabOpacity.value > 0 ? 'auto' : 'none', // Disable interaction when invisible
98
+ };
99
+ }),
77
100
  isSideEditor = editorType === EDITOR_TYPE__SIDE,
101
+ isSmartEditor = editorType === EDITOR_TYPE__SMART,
78
102
  styles = UiGlobals.styles,
79
103
  flex = props.flex || 1,
80
104
  buildFromItems = () => {
@@ -301,13 +325,16 @@ function Viewer(props) {
301
325
  },
302
326
  buildAncillary = () => {
303
327
  const components = [];
304
- ancillaryFabs.current = [];
328
+ setAncillaryButtons([]);
305
329
  if (ancillaryItems.length) {
306
330
 
307
331
  // add the "scroll to top" button
308
- ancillaryFabs.current.push({
332
+ getAncillaryButtons().push({
309
333
  icon: ArrowUp,
334
+ key: 'scrollToTop',
335
+ reference: 'scrollToTop',
310
336
  onPress: () => scrollToAncillaryItem(0),
337
+ tooltip: 'Scroll to top',
311
338
  });
312
339
 
313
340
  _.each(ancillaryItems, (item, ix) => {
@@ -324,9 +351,11 @@ function Viewer(props) {
324
351
  if (icon) {
325
352
  // NOTE: this assumes that if one Ancillary item has an icon, they all do.
326
353
  // If they don't, the ix will be wrong.
327
- ancillaryFabs.current.push({
354
+ getAncillaryButtons().push({
328
355
  icon,
329
- onPress: () => scrollToAncillaryItem(ix +1), // offset for the "scroll to top" button
356
+ key: 'ancillary-' + ix,
357
+ onPress: () => { scrollToAncillaryItem(ix +1)}, // offset for the "scroll to top" button
358
+ tooltip: title,
330
359
  });
331
360
  }
332
361
  if (type.match(/Grid/) && !itemPropsToPass.h) {
@@ -358,7 +387,7 @@ function Viewer(props) {
358
387
  }
359
388
  title = <Text className={`${styles.VIEWER_ANCILLARY_FONTSIZE} font-bold`}>{title}</Text>;
360
389
  if (icon) {
361
- title = <HStack className="items-center"><Icon as={icon} size="lg" className="mr-2" />{title}</HStack>
390
+ title = <HStack className="items-center"><Icon as={icon} className="w-[32px] h-[32px] mr-2" />{title}</HStack>
362
391
  }
363
392
  }
364
393
  components.push(<VStack
@@ -373,15 +402,33 @@ function Viewer(props) {
373
402
  }
374
403
  return components;
375
404
  },
405
+ onLayout = (e) => {
406
+ setContainerWidth(e.nativeEvent.layout.width);
407
+ },
376
408
  scrollToAncillaryItem = (ix) => {
377
409
  ancillaryItemsRef.current[ix]?.scrollIntoView({
378
410
  behavior: 'smooth',
379
411
  block: 'start',
380
412
  });
381
413
  },
382
- onLayout = (e) => {
383
- setContainerWidth(e.nativeEvent.layout.width);
384
- };
414
+ onScroll = useCallback(
415
+ _.debounce((e) => {
416
+ if (!showAncillaryButtons) {
417
+ return;
418
+ }
419
+ const
420
+ scrollY = e.nativeEvent.contentOffset.y,
421
+ isFabVisible = scrollY > 50;
422
+ fabOpacity.value = isFabVisible ? 1 : 0;
423
+ if (isFabVisible) {
424
+ setIsFabVisible(true);
425
+ } else {
426
+ // delay removal from DOM until fade-out is complete
427
+ setTimeout(() => setIsFabVisible(isFabVisible), FAB_FADE_TIME);
428
+ }
429
+ }, 100), // delay
430
+ []
431
+ );
385
432
 
386
433
  useEffect(() => {
387
434
  if (viewerSetup && record?.getSubmitValues) {
@@ -395,7 +442,7 @@ function Viewer(props) {
395
442
 
396
443
  const
397
444
  showDeleteBtn = onDelete && viewerCanDelete,
398
- showCloseBtn = !isSideEditor,
445
+ showCloseBtn = !isSideEditor && !isSmartEditor && onClose,
399
446
  showFooter = (showDeleteBtn || showCloseBtn);
400
447
  let additionalButtons = null,
401
448
  viewerComponents = null,
@@ -407,12 +454,14 @@ function Viewer(props) {
407
454
  viewerComponents = buildFromItems();
408
455
  ancillaryComponents = buildAncillary();
409
456
 
410
- if (!_.isEmpty(ancillaryFabs.current)) {
411
- fab = <DynamicFab
412
- fabs={ancillaryFabs.current}
457
+ if (showAncillaryButtons && !_.isEmpty(getAncillaryButtons())) {
458
+ fab = <Animated.View style={fabAnimatedStyle}>
459
+ <DynamicFab
460
+ buttons={getAncillaryButtons()}
413
461
  collapseOnPress={false}
414
- verticalOffset={showFooter ? 15 : 0}
415
- />;
462
+ tooltip="Scroll to Ancillary Item"
463
+ />
464
+ </Animated.View>;
416
465
  }
417
466
  }
418
467
 
@@ -450,7 +499,7 @@ function Viewer(props) {
450
499
  text="Delete"
451
500
  />
452
501
  </HStack>}
453
- {onClose && showCloseBtn &&
502
+ {showCloseBtn &&
454
503
  <Button
455
504
  {...testProps('closeBtn')}
456
505
  key="closeBtn"
@@ -472,6 +521,8 @@ function Viewer(props) {
472
521
  <ScrollView
473
522
  _web={{ height: 1 }}
474
523
  ref={scrollViewRef}
524
+ onScroll={onScroll}
525
+ scrollEventThrottle={16 /* ms */}
475
526
  className={`
476
527
  Viewer-ScrollView
477
528
  w-full
@@ -502,17 +553,23 @@ function Viewer(props) {
502
553
  </Toolbar>}
503
554
 
504
555
  {!_.isEmpty(additionalButtons) &&
505
- <Toolbar className="justify-end flex-wrap">
556
+ <Toolbar className="justify-end flex-wrap gap-2">
506
557
  {additionalButtons}
507
558
  </Toolbar>}
508
559
 
560
+ {showAncillaryButtons && !_.isEmpty(getAncillaryButtons()) &&
561
+ <Toolbar className="justify-start flex-wrap gap-2">
562
+ <Text>Scroll:</Text>
563
+ {buildAdditionalButtons(_.omitBy(getAncillaryButtons(), (btnConfig) => btnConfig.reference === 'scrollToTop'))}
564
+ </Toolbar>}
565
+
509
566
  {containerWidth >= styles.FORM_ONE_COLUMN_THRESHOLD ? <HStack className="Viewer-formComponents-HStack p-4 gap-4 justify-center">{viewerComponents}</HStack> : null}
510
567
  {containerWidth < styles.FORM_ONE_COLUMN_THRESHOLD ? <VStack className="Viewer-formComponents-VStack p-4">{viewerComponents}</VStack> : null}
511
568
  <VStack className="Viewer-AncillaryComponents m-2 pt-4 px-2">{ancillaryComponents}</VStack>
512
569
  </ScrollView>
513
570
 
514
571
  {footer}
515
- {fab}
572
+ {isFabVisible && fab}
516
573
 
517
574
  </>}
518
575
  </VStackNative>;
@@ -7,30 +7,23 @@ export default function buildAdditionalButtons(configs, self, handlerArgs = {})
7
7
  _.each(configs, (config) => {
8
8
  const {
9
9
  key,
10
- text,
11
- handler,
12
- icon,
13
- isDisabled,
14
- tooltip,
15
10
  color = '#fff',
11
+ ...configToPass
16
12
  } = config,
17
13
  buttonProps = {
18
- key,
19
- parent: self,
20
- reference: key,
21
- text,
22
- icon,
23
- isDisabled,
24
- tooltip,
25
- color,
14
+ ...configToPass,
26
15
  };
27
- if (handler) {
28
- buttonProps.onPress = () => handler(handlerArgs);
16
+ buttonProps.parent = config.self;
17
+ buttonProps.color = color;
18
+
19
+ if (!config.onPress && config.handler) {
20
+ buttonProps.onPress = () => config.handler(handlerArgs);
29
21
  }
30
22
 
31
23
  additionalButtons.push(<Button
32
24
  {...testProps(key)}
33
25
  {...buttonProps}
26
+ key={key}
34
27
  />);
35
28
  });
36
29
  return additionalButtons;
@@ -0,0 +1,5 @@
1
+ export default function(value, allowNull = true) {
2
+ if (allowNull && !value) return true; // Allow null or empty values
3
+ const date = new Date(value);
4
+ return !isNaN(date.getTime()); // Check if the date is valid
5
+ }
@@ -0,0 +1,6 @@
1
+ import UiGlobals from '../UiGlobals';
2
+ import Inflector from 'inflector-js';
3
+
4
+ export default function urlize(str) {
5
+ return '/' + UiGlobals.urlPrefix + Inflector.dasherize(Inflector.underscore(str));
6
+ }
@@ -1,98 +0,0 @@
1
- import { useCallback } from 'react';
2
- import {
3
- Fab, FabIcon, FabLabel,
4
- ScrollView,
5
- VStack,
6
- } from '@project-components/Gluestack';
7
- import Animated, {
8
- useSharedValue,
9
- useAnimatedStyle,
10
- withTiming,
11
- interpolate,
12
- } from 'react-native-reanimated';
13
- import EllipsisVertical from '../Icons/EllipsisVertical.js';
14
- import Xmark from '../Icons/Xmark.js';
15
-
16
- // This component creates a floating action button (FAB)
17
- // that can expand and collapse to show multiple FABs beneath it.
18
-
19
- export default function DynamicFab(props) {
20
- const {
21
- fabs,
22
- icon = null,
23
- label = null,
24
- collapseOnPress = true,
25
- } = props,
26
- isExpanded = useSharedValue(0),
27
- toggleFab = useCallback(() => {
28
- isExpanded.value = isExpanded.value ? 0 : 1;
29
- }, []),
30
- fabSpacing = 50,
31
- verticalOffset = 15; // to shift the entire expanded group up
32
-
33
- let className = `
34
- DynamicFab
35
- fixed
36
- pb-[20px]
37
- bottom-4
38
- right-4
39
- `;
40
- if (props.className) {
41
- className += ` ${props.className}`;
42
- }
43
-
44
- return <VStack className={className}>
45
- {fabs
46
- .slice() // clone, so we don't mutate the original array
47
- .reverse() // so fabs appear in the correct order
48
- .map((fab, index) => {
49
- const {
50
- icon,
51
- label,
52
- onPress,
53
- } = fab,
54
- animatedStyle = useAnimatedStyle(() => {
55
- const translateY = interpolate(
56
- isExpanded.value,
57
- [0, 1],
58
- [0, -(fabSpacing * (index + 1)) - verticalOffset]
59
- );
60
- return {
61
- transform: [{ translateY }],
62
- opacity: withTiming(isExpanded.value, { duration: 200 }),
63
- };
64
- });
65
-
66
- return <Animated.View
67
- key={index}
68
- style={animatedStyle}
69
- className="absolute bottom-0 right-0"
70
- >
71
- <Fab
72
- size="md"
73
- className="bg-primary-600"
74
- onPress={() => {
75
- onPress();
76
- if (collapseOnPress) {
77
- isExpanded.value = 0;
78
- }
79
- }}
80
- style={{
81
- shadowColor: 'transparent', // otherwise, on collapse a bunch of shadows build up for a moment!
82
- }}
83
- >
84
- <FabIcon as={icon} />
85
- {label && <FabLabel>{label}</FabLabel>}
86
- </Fab>
87
- </Animated.View>;
88
- })}
89
- <Fab
90
- size="lg"
91
- onPress={toggleFab}
92
- className="z-100 bg-primary-600"
93
- >
94
- <FabIcon as={isExpanded.value ? Xmark : icon || EllipsisVertical} />
95
- {label && <FabLabel>{label}</FabLabel>}
96
- </Fab>
97
- </VStack>;
98
- };
@@ -1,2 +0,0 @@
1
- export const EDITOR_MODE_ADD = 'EDITOR_MODE_ADD';
2
- export const EDITOR_MODE_EDIT = 'EDITOR_MODE_EDIT';