@onehat/ui 0.4.60 → 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.60",
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,6 +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';
48
+ import DynamicFab from '../Fab/DynamicFab.js';
43
49
  import AngleLeft from '../Icons/AngleLeft.js';
44
50
  import Eye from '../Icons/Eye.js';
45
51
  import Rotate from '../Icons/Rotate.js';
@@ -47,9 +53,9 @@ import Pencil from '../Icons/Pencil.js';
47
53
  import Plus from '../Icons/Plus.js';
48
54
  import FloppyDiskRegular from '../Icons/FloppyDiskRegular.js';
49
55
  import Trash from '../Icons/Trash.js';
56
+ import ArrowUp from '../Icons/ArrowUp.js';
50
57
  import Xmark from '../Icons/Xmark.js';
51
58
  import Check from '../Icons/Check.js';
52
-
53
59
  import Footer from '../Layout/Footer.js';
54
60
  import Label from '../Form/Label.js';
55
61
  import _ from 'lodash';
@@ -70,12 +76,15 @@ import _ from 'lodash';
70
76
  // EDITOR_TYPE__PLAIN
71
77
  // Form is embedded on screen in some other way. Mainly use startingValues, items, validator
72
78
 
79
+ const FAB_FADE_TIME = 300; // ms
80
+
73
81
  function Form(props) {
74
82
  const {
75
83
  editorType = EDITOR_TYPE__WINDOWED, // EDITOR_TYPE__INLINE | EDITOR_TYPE__WINDOWED | EDITOR_TYPE__SIDE | EDITOR_TYPE__SMART | EDITOR_TYPE__PLAIN
76
84
  startingValues = {},
77
85
  items = [], // Columns, FieldSets, Fields, etc to define the form
78
86
  ancillaryItems = [], // additional items which are not controllable form elements, but should appear in the form
87
+ showAncillaryButtons = false,
79
88
  columnDefaults = {}, // defaults for each Column defined in items (above)
80
89
  columnsConfig, // Which columns are shown in Grid, so the inline editor can match. Used only for EDITOR_TYPE__INLINE
81
90
  validator, // custom validator, mainly for EDITOR_TYPE__PLAIN
@@ -138,6 +147,14 @@ function Form(props) {
138
147
 
139
148
  } = props,
140
149
  formRef = useRef(),
150
+ ancillaryItemsRef = useRef({}),
151
+ ancillaryButtons = useRef([]),
152
+ setAncillaryButtons = (array) => {
153
+ ancillaryButtons.current = array;
154
+ },
155
+ getAncillaryButtons = () => {
156
+ return ancillaryButtons.current;
157
+ },
141
158
  styles = UiGlobals.styles,
142
159
  record = props.record?.length === 1 ? props.record[0] : props.record;
143
160
 
@@ -155,6 +172,14 @@ function Form(props) {
155
172
  forceUpdate = useForceUpdate(),
156
173
  [previousRecord, setPreviousRecord] = useState(record),
157
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
+ }),
158
183
  initialValues = _.merge(startingValues, (record && !record.isDestroyed ? record.submitValues : {})),
159
184
  defaultValues = isMultiple ? getNullFieldValues(initialValues, Repository) : initialValues, // when multiple entities, set all default values to null
160
185
  validatorToUse = validator || (isMultiple ? disableRequiredYupFields(Repository?.schema?.model?.validator) : Repository?.schema?.model?.validator) || yup.object(),
@@ -899,12 +924,23 @@ function Form(props) {
899
924
  },
900
925
  buildAncillary = () => {
901
926
  const components = [];
927
+ setAncillaryButtons([]);
902
928
  if (ancillaryItems.length) {
929
+
930
+ // add the "scroll to top" button
931
+ getAncillaryButtons().push({
932
+ icon: ArrowUp,
933
+ reference: 'scrollToTop',
934
+ onPress: () => scrollToAncillaryItem(0),
935
+ tooltip: 'Scroll to top',
936
+ });
937
+
903
938
  _.each(ancillaryItems, (item, ix) => {
904
939
  let {
905
940
  type,
906
941
  title = null,
907
942
  description = null,
943
+ icon,
908
944
  selectorId,
909
945
  selectorSelectedField,
910
946
  ...itemPropsToPass
@@ -912,6 +948,15 @@ function Form(props) {
912
948
  if (isMultiple && type !== 'Attachments') {
913
949
  return;
914
950
  }
951
+ if (icon) {
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({
955
+ icon,
956
+ onPress: () => scrollToAncillaryItem(ix +1), // offset for the "scroll to top" button
957
+ tooltip: title,
958
+ });
959
+ }
915
960
  if (type.match(/Grid/) && !itemPropsToPass.h) {
916
961
  itemPropsToPass.h = 400;
917
962
  }
@@ -938,6 +983,9 @@ function Form(props) {
938
983
  ${styles.FORM_ANCILLARY_TITLE_CLASSNAME}
939
984
  `}
940
985
  >{title}</Text>;
986
+ if (icon) {
987
+ title = <HStack className="items-center"><Icon as={icon} className="w-[32px] h-[32px] mr-2" />{title}</HStack>
988
+ }
941
989
  }
942
990
  if (description) {
943
991
  description = <Text
@@ -949,6 +997,7 @@ function Form(props) {
949
997
  >{description}</Text>;
950
998
  }
951
999
  components.push(<VStack
1000
+ ref={(el) => (ancillaryItemsRef.current[ix +1 /* offset for "scroll to top" */] = el)}
952
1001
  key={'ancillary-' + ix}
953
1002
  className={`
954
1003
  Form-VStack12
@@ -996,7 +1045,31 @@ function Form(props) {
996
1045
  }
997
1046
 
998
1047
  setContainerWidth(e.nativeEvent.layout.width);
999
- };
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
+ );
1000
1073
 
1001
1074
  useEffect(() => {
1002
1075
  if (skipAll) {
@@ -1081,13 +1154,15 @@ function Form(props) {
1081
1154
  style.maxHeight = maxHeight;
1082
1155
  }
1083
1156
 
1084
- const formButtons = [];
1085
1157
  let modeHeader = null,
1158
+ formButtons = null,
1159
+ scrollButtons = null,
1086
1160
  footer = null,
1087
1161
  footerButtons = null,
1088
1162
  formComponents,
1089
1163
  editor,
1090
1164
  additionalButtons,
1165
+ fab = null,
1091
1166
  isSaveDisabled = false,
1092
1167
  isSubmitDisabled = false,
1093
1168
  showDeleteBtn = false,
@@ -1101,13 +1176,13 @@ function Form(props) {
1101
1176
  // create editor
1102
1177
  if (editorType === EDITOR_TYPE__INLINE) {
1103
1178
  editor = buildFromColumnsConfig();
1104
- // } else if (editorType === EDITOR_TYPE__PLAIN) {
1105
- // formComponents = buildFromItems();
1106
- // const formAncillaryComponents = buildAncillary();
1107
- // editor = <>
1108
- // <VStack className="p-4">{formComponents}</VStack>
1109
- // <VStack className="pt-4">{formAncillaryComponents}</VStack>
1110
- // </>;
1179
+ // } else if (editorType === EDITOR_TYPE__PLAIN) {
1180
+ // formComponents = buildFromItems();
1181
+ // const formAncillaryComponents = buildAncillary();
1182
+ // editor = <>
1183
+ // <VStack className="p-4">{formComponents}</VStack>
1184
+ // <VStack className="pt-4">{formAncillaryComponents}</VStack>
1185
+ // </>;
1111
1186
  } else {
1112
1187
  formComponents = buildFromItems();
1113
1188
  const formAncillaryComponents = buildAncillary();
@@ -1157,14 +1232,22 @@ function Form(props) {
1157
1232
  </Toolbar>;
1158
1233
  }
1159
1234
  if (getEditorMode() === EDITOR_MODE__EDIT && !_.isEmpty(additionalButtons)) {
1160
- formButtons.push(<Toolbar key="additionalButtonsToolbar" className="justify-end flex-wrap gap-2">
1235
+ formButtons = <Toolbar className="justify-end flex-wrap gap-2">
1161
1236
  {additionalButtons}
1162
- </Toolbar>)
1237
+ </Toolbar>;
1238
+ }
1239
+ if (showAncillaryButtons && !_.isEmpty(getAncillaryButtons())) {
1240
+ fab = <Animated.View style={fabAnimatedStyle}>
1241
+ <DynamicFab
1242
+ buttons={getAncillaryButtons()}
1243
+ collapseOnPress={false}
1244
+ className="bottom-[55px]"
1245
+ tooltip="Scroll to Ancillary Item"
1246
+ />
1247
+ </Animated.View>;
1163
1248
  }
1164
1249
  }
1165
1250
 
1166
-
1167
-
1168
1251
  // create footer
1169
1252
  if (!formState.isValid) {
1170
1253
  isSaveDisabled = true;
@@ -1356,6 +1439,7 @@ function Form(props) {
1356
1439
 
1357
1440
  let className = props.className || '';
1358
1441
  className += ' Form-VStackNative';
1442
+ const scrollToTopAnchor = <Box ref={(el) => (ancillaryItemsRef.current[0] = el)} className="h-0" />;
1359
1443
  return <VStackNative
1360
1444
  ref={formRef}
1361
1445
  {...testProps(self)}
@@ -1375,17 +1459,26 @@ function Form(props) {
1375
1459
  pb-1
1376
1460
  web:min-h-[${minHeight}px]
1377
1461
  `}
1462
+ onScroll={onScroll}
1463
+ scrollEventThrottle={16 /* ms */}
1378
1464
  contentContainerStyle={{
1379
1465
  // height: '100%',
1380
1466
  }}
1381
1467
  >
1468
+ {scrollToTopAnchor}
1382
1469
  {modeHeader}
1383
1470
  {formHeader}
1384
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>}
1385
1477
  {editor}
1386
1478
  </ScrollView>}
1387
1479
 
1388
1480
  {footer}
1481
+ {isFabVisible && fab}
1389
1482
 
1390
1483
  </>}
1391
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}
@@ -0,0 +1,11 @@
1
+ import { createIcon } from "../Gluestack/icon";
2
+ // Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc.
3
+ import { Path, Svg } from 'react-native-svg';
4
+
5
+ const SvgComponent = createIcon({
6
+ Root: Svg,
7
+ viewBox: '0 0 384 512',
8
+ path: <Path d="M214.6 41.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 141.2V448c0 17.7 14.3 32 32 32s32-14.3 32-32V141.3l105.4 105.3c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z" />,
9
+ });
10
+
11
+ export default SvgComponent
@@ -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,16 +1,24 @@
1
- import { useEffect, useRef, useState, isValidElement, } from 'react';
1
+ import { useEffect, useCallback, useRef, useState, isValidElement, } from 'react';
2
2
  import {
3
+ Box,
3
4
  HStack,
5
+ Icon,
4
6
  ScrollView,
5
7
  Text,
6
8
  VStack,
7
9
  VStackNative,
8
10
  } from '@project-components/Gluestack';
11
+ import Animated, {
12
+ useSharedValue,
13
+ useAnimatedStyle,
14
+ withTiming,
15
+ } from 'react-native-reanimated';
9
16
  import {
10
17
  EDIT,
11
18
  } from '../../Constants/Commands.js';
12
19
  import {
13
20
  EDITOR_TYPE__SIDE,
21
+ EDITOR_TYPE__SMART,
14
22
  } from '../../Constants/Editor.js';
15
23
  import {
16
24
  extractCssPropertyFromClassName,
@@ -24,18 +32,23 @@ import inArray from '../../Functions/inArray.js';
24
32
  import getComponentFromType from '../../Functions/getComponentFromType.js';
25
33
  import buildAdditionalButtons from '../../Functions/buildAdditionalButtons.js';
26
34
  import testProps from '../../Functions/testProps.js';
35
+ import DynamicFab from '../Fab/DynamicFab.js';
27
36
  import Toolbar from '../Toolbar/Toolbar.js';
37
+ import ArrowUp from '../Icons/ArrowUp.js';
28
38
  import Button from '../Buttons/Button.js';
29
39
  import Label from '../Form/Label.js';
30
40
  import Pencil from '../Icons/Pencil.js';
31
41
  import Footer from '../Layout/Footer.js';
32
42
  import _ from 'lodash';
33
43
 
44
+ const FAB_FADE_TIME = 300; // ms
45
+
34
46
  function Viewer(props) {
35
47
  const {
36
48
  viewerCanDelete = false,
37
49
  items = [], // Columns, FieldSets, Fields, etc to define the form
38
50
  ancillaryItems = [], // additional items which are not controllable form elements, but should appear in the form
51
+ showAncillaryButtons = false,
39
52
  columnDefaults = {}, // defaults for each Column defined in items (above)
40
53
  record,
41
54
  additionalViewButtons,
@@ -66,9 +79,26 @@ function Viewer(props) {
66
79
 
67
80
  } = props,
68
81
  scrollViewRef = useRef(),
82
+ ancillaryItemsRef = useRef({}),
83
+ ancillaryButtons = useRef([]),
84
+ setAncillaryButtons = (array) => {
85
+ ancillaryButtons.current = array;
86
+ },
87
+ getAncillaryButtons = () => {
88
+ return ancillaryButtons.current;
89
+ },
69
90
  isMultiple = _.isArray(record),
70
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
+ }),
71
100
  isSideEditor = editorType === EDITOR_TYPE__SIDE,
101
+ isSmartEditor = editorType === EDITOR_TYPE__SMART,
72
102
  styles = UiGlobals.styles,
73
103
  flex = props.flex || 1,
74
104
  buildFromItems = () => {
@@ -295,17 +325,39 @@ function Viewer(props) {
295
325
  },
296
326
  buildAncillary = () => {
297
327
  const components = [];
328
+ setAncillaryButtons([]);
298
329
  if (ancillaryItems.length) {
330
+
331
+ // add the "scroll to top" button
332
+ getAncillaryButtons().push({
333
+ icon: ArrowUp,
334
+ key: 'scrollToTop',
335
+ reference: 'scrollToTop',
336
+ onPress: () => scrollToAncillaryItem(0),
337
+ tooltip: 'Scroll to top',
338
+ });
339
+
299
340
  _.each(ancillaryItems, (item, ix) => {
300
341
  let {
301
342
  type,
302
343
  title = null,
344
+ icon,
303
345
  selectorId = null,
304
346
  ...itemPropsToPass
305
347
  } = item;
306
348
  if (isMultiple && type !== 'Attachments') {
307
349
  return;
308
350
  }
351
+ if (icon) {
352
+ // NOTE: this assumes that if one Ancillary item has an icon, they all do.
353
+ // If they don't, the ix will be wrong.
354
+ getAncillaryButtons().push({
355
+ icon,
356
+ key: 'ancillary-' + ix,
357
+ onPress: () => { scrollToAncillaryItem(ix +1)}, // offset for the "scroll to top" button
358
+ tooltip: title,
359
+ });
360
+ }
309
361
  if (type.match(/Grid/) && !itemPropsToPass.h) {
310
362
  itemPropsToPass.h = 400;
311
363
  }
@@ -334,15 +386,49 @@ function Viewer(props) {
334
386
  title += ' for ' + record.displayValue;
335
387
  }
336
388
  title = <Text className={`${styles.VIEWER_ANCILLARY_FONTSIZE} font-bold`}>{title}</Text>;
389
+ if (icon) {
390
+ title = <HStack className="items-center"><Icon as={icon} className="w-[32px] h-[32px] mr-2" />{title}</HStack>
391
+ }
337
392
  }
338
- components.push(<VStack key={'ancillary-' + ix} className="my-3">{title}{element}</VStack>);
393
+ components.push(<VStack
394
+ ref={(el) => (ancillaryItemsRef.current[ix +1 /* offset for "scroll to top" */] = el)}
395
+ key={'ancillary-' + ix}
396
+ className="my-3"
397
+ >
398
+ {title}
399
+ {element}
400
+ </VStack>);
339
401
  });
340
402
  }
341
403
  return components;
342
404
  },
343
405
  onLayout = (e) => {
344
406
  setContainerWidth(e.nativeEvent.layout.width);
345
- };
407
+ },
408
+ scrollToAncillaryItem = (ix) => {
409
+ ancillaryItemsRef.current[ix]?.scrollIntoView({
410
+ behavior: 'smooth',
411
+ block: 'start',
412
+ });
413
+ },
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
+ );
346
432
 
347
433
  useEffect(() => {
348
434
  if (viewerSetup && record?.getSubmitValues) {
@@ -356,15 +442,27 @@ function Viewer(props) {
356
442
 
357
443
  const
358
444
  showDeleteBtn = onDelete && viewerCanDelete,
359
- showCloseBtn = !isSideEditor;
445
+ showCloseBtn = !isSideEditor && !isSmartEditor && onClose,
446
+ showFooter = (showDeleteBtn || showCloseBtn);
360
447
  let additionalButtons = null,
361
448
  viewerComponents = null,
362
- ancillaryComponents = null;
449
+ ancillaryComponents = null,
450
+ fab = null;
363
451
 
364
452
  if (containerWidth) { // we need to render this component twice in order to get the container width. Skip this on first render
365
453
  additionalButtons = buildAdditionalButtons(additionalViewButtons);
366
454
  viewerComponents = buildFromItems();
367
455
  ancillaryComponents = buildAncillary();
456
+
457
+ if (showAncillaryButtons && !_.isEmpty(getAncillaryButtons())) {
458
+ fab = <Animated.View style={fabAnimatedStyle}>
459
+ <DynamicFab
460
+ buttons={getAncillaryButtons()}
461
+ collapseOnPress={false}
462
+ tooltip="Scroll to Ancillary Item"
463
+ />
464
+ </Animated.View>;
465
+ }
368
466
  }
369
467
 
370
468
  let canEdit = true;
@@ -385,6 +483,33 @@ function Viewer(props) {
385
483
  className += ' ' + props.className;
386
484
  }
387
485
 
486
+ const footer = showFooter ?
487
+ <Footer className="justify-end">
488
+ {showDeleteBtn &&
489
+ <HStack className="flex-1 justify-start">
490
+ <Button
491
+ {...testProps('deleteBtn')}
492
+ key="deleteBtn"
493
+ onPress={onDelete}
494
+ className={`
495
+ text-white
496
+ bg-warning-500
497
+ hover:bg-warning-600
498
+ `}
499
+ text="Delete"
500
+ />
501
+ </HStack>}
502
+ {showCloseBtn &&
503
+ <Button
504
+ {...testProps('closeBtn')}
505
+ key="closeBtn"
506
+ onPress={onClose}
507
+ className="text-white"
508
+ text="Close"
509
+ />}
510
+ </Footer> : null;
511
+
512
+ const scrollToTopAnchor = <Box ref={(el) => (ancillaryItemsRef.current[0] = el)} className="h-0" />;
388
513
  return <VStackNative
389
514
  {...testProps(self)}
390
515
  style={style}
@@ -396,6 +521,8 @@ function Viewer(props) {
396
521
  <ScrollView
397
522
  _web={{ height: 1 }}
398
523
  ref={scrollViewRef}
524
+ onScroll={onScroll}
525
+ scrollEventThrottle={16 /* ms */}
399
526
  className={`
400
527
  Viewer-ScrollView
401
528
  w-full
@@ -403,6 +530,7 @@ function Viewer(props) {
403
530
  flex-1
404
531
  `}
405
532
  >
533
+ {scrollToTopAnchor}
406
534
  {canEdit && onEditMode &&
407
535
  <Toolbar className="justify-end">
408
536
  <HStack className="flex-1 items-center">
@@ -425,40 +553,23 @@ function Viewer(props) {
425
553
  </Toolbar>}
426
554
 
427
555
  {!_.isEmpty(additionalButtons) &&
428
- <Toolbar className="justify-end flex-wrap">
556
+ <Toolbar className="justify-end flex-wrap gap-2">
429
557
  {additionalButtons}
430
558
  </Toolbar>}
431
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
+
432
566
  {containerWidth >= styles.FORM_ONE_COLUMN_THRESHOLD ? <HStack className="Viewer-formComponents-HStack p-4 gap-4 justify-center">{viewerComponents}</HStack> : null}
433
567
  {containerWidth < styles.FORM_ONE_COLUMN_THRESHOLD ? <VStack className="Viewer-formComponents-VStack p-4">{viewerComponents}</VStack> : null}
434
568
  <VStack className="Viewer-AncillaryComponents m-2 pt-4 px-2">{ancillaryComponents}</VStack>
435
569
  </ScrollView>
436
570
 
437
- {(showDeleteBtn || showCloseBtn) &&
438
- <Footer className="justify-end">
439
- {showDeleteBtn &&
440
- <HStack className="flex-1 justify-start">
441
- <Button
442
- {...testProps('deleteBtn')}
443
- key="deleteBtn"
444
- onPress={onDelete}
445
- className={`
446
- text-white
447
- bg-warning-500
448
- hover:bg-warning-600
449
- `}
450
- text="Delete"
451
- />
452
- </HStack>}
453
- {onClose && showCloseBtn &&
454
- <Button
455
- {...testProps('closeBtn')}
456
- key="closeBtn"
457
- onPress={onClose}
458
- className="text-white"
459
- text="Close"
460
- />}
461
- </Footer>}
571
+ {footer}
572
+ {isFabVisible && fab}
462
573
 
463
574
  </>}
464
575
  </VStackNative>;
@@ -9,6 +9,7 @@ import AngleRight from './Icons/AngleRight.js';
9
9
  import AnglesLeft from './Icons/AnglesLeft.js';
10
10
  import AnglesRight from './Icons/AnglesRight.js';
11
11
  import Asterisk from './Icons/Asterisk.js';
12
+ import ArrowUp from './Icons/ArrowUp.js';
12
13
  import Ban from './Icons/Ban.js';
13
14
  import Bars from './Icons/Bars.js';
14
15
  import BarsStaggered from './Icons/BarsStaggered.js';
@@ -253,6 +254,7 @@ const components = {
253
254
  AnglesLeft,
254
255
  AnglesRight,
255
256
  Asterisk,
257
+ ArrowUp,
256
258
  Ban,
257
259
  Bars,
258
260
  BarsStaggered,
@@ -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,2 +0,0 @@
1
- export const EDITOR_MODE_ADD = 'EDITOR_MODE_ADD';
2
- export const EDITOR_MODE_EDIT = 'EDITOR_MODE_EDIT';