@onehat/ui 0.4.81 → 0.4.83

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.
Files changed (31) hide show
  1. package/package.json +7 -6
  2. package/src/Components/Container/Container.js +4 -4
  3. package/src/Components/Form/Field/Combo/Combo.js +88 -16
  4. package/src/Components/Form/Field/Combo/MeterTypesCombo.js +1 -0
  5. package/src/Components/Form/Field/Date.js +1 -1
  6. package/src/Components/Form/Field/Json.js +2 -1
  7. package/src/Components/Form/Field/Select/PageSizeSelect.js +6 -1
  8. package/src/Components/Form/Field/Select/Select.js +14 -38
  9. package/src/Components/Form/Field/Tag/Tag.js +234 -14
  10. package/src/Components/Form/Field/Tag/ValueBox.js +20 -1
  11. package/src/Components/Form/Form.js +26 -13
  12. package/src/Components/Grid/Grid.js +316 -106
  13. package/src/Components/Grid/GridHeaderRow.js +42 -22
  14. package/src/Components/Grid/GridRow.js +16 -6
  15. package/src/Components/Grid/RowHandle.js +16 -4
  16. package/src/Components/Hoc/Secondary/withSecondaryEditor.js +137 -43
  17. package/src/Components/Hoc/Secondary/withSecondarySideEditor.js +1 -1
  18. package/src/Components/Hoc/withData.js +7 -0
  19. package/src/Components/Hoc/withEditor.js +19 -4
  20. package/src/Components/Hoc/withPresetButtons.js +1 -1
  21. package/src/Components/Hoc/withSideEditor.js +1 -1
  22. package/src/Components/Icons/Join.js +10 -0
  23. package/src/Components/Layout/AsyncOperation.js +61 -14
  24. package/src/Components/Layout/CenterBox.js +1 -1
  25. package/src/Components/Screens/Manager.js +1 -1
  26. package/src/Components/Toolbar/Pagination.js +108 -106
  27. package/src/Components/Toolbar/PaginationToolbar.js +3 -1
  28. package/src/Components/Toolbar/Toolbar.js +10 -6
  29. package/src/Components/Tree/TreeNode.js +39 -9
  30. package/src/Components/Viewer/Viewer.js +7 -2
  31. package/src/Constants/Progress.js +2 -1
@@ -1,18 +1,28 @@
1
- import { useRef, } from 'react';
1
+ import { useRef, useState, useEffect, } from 'react';
2
2
  import {
3
3
  HStack,
4
4
  VStackNative,
5
5
  } from '@project-components/Gluestack';
6
6
  import clsx from 'clsx';
7
+ import * as yup from 'yup'; // https://github.com/jquense/yup#string
8
+ import oneHatData from '@onehat/data';
7
9
  import {
8
10
  EDITOR_TYPE__WINDOWED,
9
11
  } from '../../../../Constants/Editor.js';
12
+ import {
13
+ EDITOR_TYPE__PLAIN,
14
+ } from '../../../../Constants/Editor.js';
15
+ import Button from '../../../Buttons/Button.js';
16
+ import testProps from '../../../../Functions/testProps.js';
17
+ import Form from '../../Form.js';
18
+ import Viewer from '../../../Viewer/Viewer.js';
10
19
  import withAlert from '../../../Hoc/withAlert.js';
11
20
  import withComponent from '../../../Hoc/withComponent.js';
12
21
  import withData from '../../../Hoc/withData.js';
13
22
  import withModal from '../../../Hoc/withModal.js';
14
23
  import withValue from '../../../Hoc/withValue.js';
15
24
  import ValueBox from './ValueBox.js';
25
+ import Inflector from 'inflector-js';
16
26
  import Combo, { ComboEditor } from '../Combo/Combo.js';
17
27
  import UiGlobals from '../../../../UiGlobals.js';
18
28
  import _ from 'lodash';
@@ -27,8 +37,14 @@ function TagComponent(props) {
27
37
  minimizeForRow = false,
28
38
  Editor,
29
39
  _combo = {},
40
+ SourceRepository,
41
+ mustSaveBeforeEditingJoinData = false,
42
+ joinDataConfig,
43
+ getBaseParams, // See note in useEffect
44
+ outerValueId, // See note in useEffect
30
45
  tooltip,
31
46
  testID,
47
+ isDirty = false,
32
48
 
33
49
  // parent Form
34
50
  onChangeValue,
@@ -39,6 +55,10 @@ function TagComponent(props) {
39
55
  // withComponent
40
56
  self,
41
57
 
58
+ // withData
59
+ Repository: TargetRepository,
60
+ setBaseParams,
61
+
42
62
  // withFilters
43
63
  isInFilter,
44
64
 
@@ -53,11 +73,22 @@ function TagComponent(props) {
53
73
  ...propsToPass // break connection between Tag and Combo props
54
74
  } = props,
55
75
  styles = UiGlobals.styles,
76
+ propertyDef = SourceRepository?.getSchema().getPropertyDefinition(self.reference),
77
+ hasJoinData = propertyDef?.joinData?.length,
78
+ [JoinRepository] = useState(() => {
79
+ if (hasJoinData) {
80
+ return oneHatData.getRepository(propertyDef.joinModel, true);
81
+ }
82
+ return null;
83
+ }),
84
+ [isInited, setIsInited] = useState(_.isUndefined(getBaseParams)), // default to true unless getBaseParams is defined
85
+ modelFieldStartsWith = hasJoinData ? Inflector.underscore(JoinRepository.getSchema().name) + '__' : '',
56
86
  valueRef = useRef(value),
57
87
  onView = async (item, e) => {
88
+ // show the joined record's viewer
58
89
  const
59
90
  id = item.id,
60
- repository = propsToPass.Repository;
91
+ repository = TargetRepository;
61
92
  if (repository.isLoading) {
62
93
  await repository.waitUntilDoneLoading();
63
94
  }
@@ -115,9 +146,8 @@ function TagComponent(props) {
115
146
  }
116
147
 
117
148
  // The value we get from combo is a simple int
118
- // Convert this to id and displayValue from either Repository or data array.
149
+ // Convert this to { id, text} from either Repository or data array.
119
150
  const
120
- Repository = props.Repository,
121
151
  data = props.data,
122
152
  idIx = props.idIx,
123
153
  displayIx = props.displayIx,
@@ -127,9 +157,9 @@ function TagComponent(props) {
127
157
 
128
158
  if (!id) {
129
159
  displayValue = '';
130
- } else if (Repository) {
131
- if (!Repository.isDestroyed) {
132
- item = Repository.getById(id);
160
+ } else if (TargetRepository) {
161
+ if (!TargetRepository.isDestroyed) {
162
+ item = TargetRepository.getById(id);
133
163
  if (!item) {
134
164
  throw Error('item not found');
135
165
  }
@@ -143,13 +173,46 @@ function TagComponent(props) {
143
173
  displayValue = item[displayIx];
144
174
  }
145
175
 
176
+ let joinData = {};
177
+ if (hasJoinData) {
178
+ // build up the default starting values for joinData,
179
+ // first with schema defaultValues...
180
+ const
181
+ allSchemaDefaults = JoinRepository.getSchema().getDefaultValues(),
182
+ modelSchemaDefaults = _.pickBy(allSchemaDefaults, (value, key) => {
183
+ return key.startsWith(modelFieldStartsWith);
184
+ }),
185
+ fullFieldNames = propertyDef.joinData.map((fieldName) => { // add the 'model_name__' prefix so we can get schema default values
186
+ return modelFieldStartsWith + fieldName;
187
+ }),
188
+ schemaDefaultValues = _.pick(modelSchemaDefaults, fullFieldNames);
189
+ joinData = _.mapKeys(schemaDefaultValues, (value, key) => { // strip out the 'model_name__' prefix from field names
190
+ return key.startsWith(modelFieldStartsWith) ? key.slice(modelFieldStartsWith.length) : key;
191
+ });
192
+
193
+ // then override with default values in joinDataConfig, if they exist
194
+ if (joinDataConfig) {
195
+ _.each(Object.keys(joinDataConfig), (fieldName) => {
196
+ const fieldConfig = joinDataConfig[fieldName];
197
+ if (!_.isUndefined(fieldConfig.defaultValue)) { // null in jsonDataConfig will override a default value in schema!
198
+ joinData[fieldName] = fieldConfig.defaultValue;
199
+ }
200
+ });
201
+ }
202
+ }
203
+
146
204
 
147
205
  // add new value
148
- const newValue = [...value]; // clone, so we trigger a re-render
149
- newValue.push({
150
- id,
151
- text: displayValue,
152
- })
206
+ const
207
+ newValue = [...value], // clone Tag's full current value (array), so we trigger a re-render after adding the new value
208
+ newItem = {
209
+ id,
210
+ text: displayValue,
211
+ };
212
+ if (hasJoinData) {
213
+ newItem.joinData = joinData;
214
+ }
215
+ newValue.push(newItem);
153
216
  setValue(newValue);
154
217
  clearComboValue();
155
218
  },
@@ -160,6 +223,122 @@ function TagComponent(props) {
160
223
  });
161
224
  setValue(newValue);
162
225
  },
226
+ onViewEditJoinData = async (item, e) => {
227
+ // show the joinData viewer/editor
228
+
229
+ /* item format:
230
+ item = {
231
+ id: 3,
232
+ text: "1000HR PM",
233
+ joinData: {
234
+ hide_every_n: 0,
235
+ also_resets: '[]',
236
+ },
237
+ }
238
+ */
239
+
240
+ // Prepare Form to edit the joinData
241
+ const
242
+ // create the Form.record, format: { meters_pm_schedules__also_resets: null, meters_pm_schedules__hide_every_n: 5 }
243
+ record = _.mapKeys(item.joinData, (value, key) => { // add the 'model_name__' prefix so we can match JoinRepository property names
244
+ return modelFieldStartsWith + key;
245
+ }),
246
+ // create the Form.items
247
+ items = propertyDef.joinData.map((fieldName) => {
248
+ let obj = {
249
+ name: modelFieldStartsWith + fieldName,
250
+ };
251
+
252
+ // add in any specific config for joinData[fieldName]], if it exists
253
+ // (The outer *Editor can configure each Tag field's joinData Form item.
254
+ // This moves that configuration down and adds outerValueId)
255
+ if (joinDataConfig?.[fieldName]) {
256
+ const joinDataConfigFieldname = _.clone(joinDataConfig[fieldName]); // don't mutate original
257
+ joinDataConfigFieldname.outerValueId = item.id; // so that joinData can be aware of the value of the inspected ValueBox; see note in useEffect, below
258
+ obj = {
259
+ ...obj,
260
+ ...joinDataConfigFieldname,
261
+ };
262
+ }
263
+
264
+ return obj;
265
+ });
266
+
267
+ let height = 300;
268
+ let body;
269
+ const extraModalProps = {};
270
+ if (isViewOnly) {
271
+ // show Viewer
272
+ body = <Viewer
273
+ record={record}
274
+ Repository={JoinRepository}
275
+ items={items}
276
+ columnDefaults={{
277
+ labelWidth: 200,
278
+ }}
279
+ />;
280
+
281
+ extraModalProps.customButtons = [
282
+ <Button
283
+ {...testProps('closeBtn')}
284
+ key="closeBtn"
285
+ onPress={hideModal}
286
+ text="Close"
287
+ className="text-white"
288
+ />,
289
+ ];
290
+ } else {
291
+ body = <Form
292
+ editorType={EDITOR_TYPE__PLAIN}
293
+ isEditorViewOnly={false}
294
+ record={record}
295
+ Repository={JoinRepository}
296
+ items={items}
297
+ additionalFooterButtons={[
298
+ {
299
+ text: 'Cancel',
300
+ onPress: hideModal,
301
+ skipSubmit: true,
302
+ variant: 'outline',
303
+ }
304
+ ]}
305
+ onSave={(values)=> {
306
+
307
+ // strip the 'model_name__' prefix from the field names
308
+ values = _.mapKeys(values, (value, key) => {
309
+ return key.startsWith(modelFieldStartsWith) ? key.slice(modelFieldStartsWith.length) : key;
310
+ });
311
+
312
+ // Put these values back on joinData
313
+ item.joinData = values;
314
+ const newValue = [...valueRef.current]; // clone
315
+ const ix = _.findIndex(newValue, (val) => {
316
+ return val.id === item.id;
317
+ });
318
+ newValue[ix] = item;
319
+ setValue(newValue);
320
+
321
+ hideModal();
322
+ }}
323
+ />;
324
+ }
325
+ switch (items.length) {
326
+ case 1: height = 250; break;
327
+ case 2: height = 400; break;
328
+ default: height = 600; break;
329
+ }
330
+
331
+ showModal({
332
+ title: 'Extra data for "' + item.text + '"',
333
+ w: 400,
334
+ h: height,
335
+ canClose: true,
336
+ includeReset: false,
337
+ includeCancel: false,
338
+ body,
339
+ ...extraModalProps,
340
+ });
341
+ },
163
342
  onGridAdd = (selection) => {
164
343
  // underlying GridEditor added a record.
165
344
  // add it to this Tag's value
@@ -226,12 +405,53 @@ function TagComponent(props) {
226
405
  key={ix}
227
406
  text={val.text}
228
407
  onView={() => onView(val)}
229
- onDelete={!isViewOnly ? () => onDelete(val) : null}
230
408
  showEye={showEye}
409
+ onViewEditJoinData={() => onViewEditJoinData(val)}
410
+ showJoin={hasJoinData && (!mustSaveBeforeEditingJoinData || !isDirty)}
411
+ onDelete={!isViewOnly ? () => onDelete(val) : null}
231
412
  minimizeForRow={minimizeForRow}
232
413
  />;
233
414
  });
234
415
 
416
+ if (!_.isUndefined(getBaseParams) && outerValueId) {
417
+ useEffect(() => {
418
+
419
+ // NOTE: This useEffect is so we can dynamically set the TargetRepository's baseParams,
420
+ // based on outerValueId, before it loads.
421
+ // We did this for cases where the Tag field has joinData that's managing a nested Tag field.
422
+ // ... This deals with recursion, so gets "alice in wonderland" quickly!
423
+ // If that inner Tag field has getBaseParams defined on a joinDataConfig field of the outer Tag,
424
+ // then that means it needs to set its baseParams dynamically, based on the value of the outer ValueBox.
425
+
426
+ // For example: in the MetersEditor:
427
+ // {
428
+ // name: 'meters__pm_schedules',
429
+ // mustSaveBeforeEditingJoinData: true,
430
+ // joinDataConfig: {
431
+ // also_resets: {
432
+ // getBaseParams: (values, outerValueId) => {
433
+ // const baseParams = {
434
+ // 'conditions[MetersPmSchedules.meter_id]': meter_id, // limit also_resets to those MetersPmSchedules related to this meter
435
+ // };
436
+ // if (outerValueId) {
437
+ // baseParams['conditions[MetersPmSchedules.id <>]'] = outerValueId; // exclude the ValueBox that was clicked on
438
+ // }
439
+ // return baseParams;
440
+ // },
441
+ // },
442
+ // },
443
+ // }
444
+
445
+ TargetRepository.setBaseParams(getBaseParams(value, outerValueId));
446
+ setIsInited(true);
447
+
448
+ }, [value]);
449
+ }
450
+
451
+ if (!isInited) {
452
+ return null;
453
+ }
454
+
235
455
  valueRef.current = value; // the onGrid* methods were dealing with stale data, so use a ref, and update it here
236
456
 
237
457
  let WhichCombo = Combo;
@@ -309,7 +529,7 @@ function TagComponent(props) {
309
529
 
310
530
  {!isViewOnly &&
311
531
  <WhichCombo
312
- Repository={props.Repository}
532
+ Repository={TargetRepository}
313
533
  Editor={props.Editor}
314
534
  onSubmit={onChangeComboValue}
315
535
  parent={self}
@@ -6,6 +6,7 @@ import clsx from 'clsx';
6
6
  import testProps from '../../../../Functions/testProps.js';
7
7
  import IconButton from '../../../Buttons/IconButton.js';
8
8
  import Eye from '../../../Icons/Eye.js';
9
+ import Edit from '../../../Icons/Edit.js';
9
10
  import Xmark from '../../../Icons/Xmark.js';
10
11
  import UiGlobals from '../../../../UiGlobals.js';
11
12
  import _ from 'lodash';
@@ -14,8 +15,10 @@ export default function ValueBox(props) {
14
15
  const {
15
16
  text,
16
17
  onView,
18
+ showEye = false,
19
+ onViewEditJoinData,
20
+ showJoin = false,
17
21
  onDelete,
18
- showEye,
19
22
  minimizeForRow = false,
20
23
  } = props,
21
24
  styles = UiGlobals.styles;
@@ -49,6 +52,22 @@ export default function ValueBox(props) {
49
52
  styles.FORM_TAG_BTN_CLASSNAME,
50
53
  )}
51
54
  />}
55
+ {showJoin &&
56
+ <IconButton
57
+ {...testProps('joinBtn')}
58
+ icon={Edit}
59
+ _icon={{
60
+ size: styles.FORM_TAG_VALUEBOX_ICON_SIZE,
61
+ className: 'text-grey-600',
62
+ }}
63
+ onPress={onViewEditJoinData}
64
+ className={clsx(
65
+ 'ValueBox-joinBtn',
66
+ 'h-full',
67
+ minimizeForRow ? 'py-0' : '',
68
+ styles.FORM_TAG_BTN_CLASSNAME,
69
+ )}
70
+ />}
52
71
  <Text
53
72
  className={clsx(
54
73
  'ValueBox-Text',
@@ -183,7 +183,7 @@ function Form(props) {
183
183
  pointerEvents: fabOpacity.value > 0 ? 'auto' : 'none', // Disable interaction when invisible
184
184
  };
185
185
  }),
186
- initialValues = _.merge(startingValues, (record && !record.isDestroyed ? record.submitValues : {})),
186
+ initialValues = _.merge(startingValues, ((record && !record.isDestroyed) ? (record.submitValues || record) : {})),
187
187
  defaultValues = isMultiple ? getNullFieldValues(initialValues, Repository) : initialValues, // when multiple entities, set all default values to null
188
188
  validatorToUse = (() => {
189
189
  // If a custom validator is provided, use it
@@ -440,6 +440,7 @@ function Form(props) {
440
440
  }
441
441
  if (type.match(/Tag/)) {
442
442
  elementClassName += ' overflow-auto';
443
+ configPropsToPass.SourceRepository = Repository;
443
444
  }
444
445
  if (!type.match(/Toggle/)) {
445
446
  elementClassName += ' h-full';
@@ -540,8 +541,8 @@ function Form(props) {
540
541
  if (isHidden) {
541
542
  return null;
542
543
  }
543
- if (type === 'DisplayField') {
544
- isEditable = false;
544
+ if (type === 'DisplayField' || type?.match(/Grid/)) {
545
+ isEditable = false; // this merely disables the FormController for this element
545
546
  }
546
547
  if (!itemPropsToPass.className) {
547
548
  itemPropsToPass.className = '';
@@ -667,16 +668,19 @@ function Form(props) {
667
668
  if (isEditorViewOnly || !isEditable) {
668
669
  let value = null;
669
670
  if (isSingle) {
670
- value = record?.properties[name]?.displayValue || null;
671
- if (_.isNil(value) && record && record[name]) {
671
+ value = record?.properties?.[name]?.displayValue || null;
672
+ if (_.isNil(value) && !_.isNil(record?.[name])) {
672
673
  value = record[name];
673
674
  }
674
- if (_.isNil(value) && startingValues && startingValues[name]) {
675
+ if (_.isNil(value) && !_.isNil(startingValues?.[name])) {
675
676
  value = startingValues[name];
676
677
  }
677
678
  }
679
+ if (type.match(/Tag/)) {
680
+ itemPropsToPass.SourceRepository = Repository;
681
+ }
678
682
 
679
- let elementClassName = 'field-' + name;
683
+ let elementClassName = name ? 'field-' + name : '';
680
684
  const defaultsClassName = defaults.className;
681
685
  if (defaultsClassName) {
682
686
  elementClassName += ' ' + defaultsClassName;
@@ -689,7 +693,6 @@ function Form(props) {
689
693
  if (viewerTypeClassName) {
690
694
  elementClassName += ' ' + viewerTypeClassName;
691
695
  }
692
-
693
696
  let element = <Element
694
697
  {...testProps('field-' + name)}
695
698
  value={value}
@@ -787,6 +790,9 @@ function Form(props) {
787
790
  if (getDynamicProps) {
788
791
  dynamicProps = getDynamicProps({ fieldState, formSetValue, formGetValues, formState });
789
792
  }
793
+ if (type.match(/Tag/)) {
794
+ itemPropsToPass.SourceRepository = Repository;
795
+ }
790
796
 
791
797
  let elementClassName = 'Form-Element field-' + name + ' w-full';
792
798
  const defaultsClassName = defaults.className;
@@ -810,6 +816,7 @@ function Form(props) {
810
816
  {...testProps('field-' + name)}
811
817
  name={name}
812
818
  value={value}
819
+ isDirty={isDirty}
813
820
  onChangeValue={(newValue) => {
814
821
  if (newValue === undefined) {
815
822
  newValue = null; // React Hook Form doesn't respond well when setting value to undefined
@@ -886,7 +893,8 @@ function Form(props) {
886
893
  >*</Text>;
887
894
  }
888
895
  }
889
- if (!disableLabels && label && editorType !== EDITOR_TYPE__INLINE) {
896
+ const labelToUse = dynamicProps.label || label;
897
+ if (!disableLabels && labelToUse && editorType !== EDITOR_TYPE__INLINE) {
890
898
  const style = {};
891
899
  if (defaults?.labelWidth) {
892
900
  style.width = defaults.labelWidth;
@@ -901,7 +909,7 @@ function Form(props) {
901
909
  element = <HStack className="Form-HStack8 w-full">
902
910
  <Label style={style}>
903
911
  {requiredIndicator}
904
- {label}
912
+ {labelToUse}
905
913
  </Label>
906
914
  {element}
907
915
  </HStack>;
@@ -909,7 +917,7 @@ function Form(props) {
909
917
  element = <VStack className="Form-VStack9 w-full mt-3">
910
918
  <Label style={style}>
911
919
  {requiredIndicator}
912
- {label}
920
+ {labelToUse}
913
921
  </Label>
914
922
  {element}
915
923
  </VStack>;
@@ -1485,8 +1493,13 @@ function Form(props) {
1485
1493
 
1486
1494
  } // END if (containerWidth)
1487
1495
 
1488
- let className = props.className || '';
1489
- className += ' Form-VStackNative';
1496
+ let className = clsx(
1497
+ 'Form-VStackNative',
1498
+ '[transform:translateZ(0)]', // so embedded FAB will be relative to this container, not to viewport
1499
+ );
1500
+ if (props.className) {
1501
+ className += ' ' + props.className;
1502
+ }
1490
1503
  const scrollToTopAnchor = <Box ref={(el) => (ancillaryItemsRef.current[0] = el)} className="h-0" />;
1491
1504
  return <VStackNative
1492
1505
  ref={formRef}