@onehat/ui 0.4.103 → 0.4.105

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.
@@ -0,0 +1,65 @@
1
+ # Copilot Instructions
2
+
3
+ ## General Principles
4
+ - Be direct, concise, and critical. Do not apologize, agree blindly, be sycophantic, or use flattery.
5
+ - If a request is inefficient or flawed, point it out and suggest a better, more secure, or more idiomatic approach.
6
+ - Use "Uncle Bob's Clean Code" principles as a baseline for code quality.
7
+ - Prioritize clarity over cleverness.
8
+ - Avoid unnecessary abstractions.
9
+ - Prefer explicit code over magic.
10
+ - Follow the project's existing coding style and conventions.
11
+ - Do not introduce new dependencies unless absolutely necessary.
12
+ - Always consider security implications and best practices.
13
+ - Aim for full test coverage of new code, and suggest tests for existing code when appropriate.
14
+
15
+ <!-- ## Coding Standards
16
+
17
+ ### Naming
18
+ - Use descriptive variable and function names.
19
+ - Do not abbreviate unless universally understood.
20
+ - Use camelCase for JavaScript variables and functions.
21
+ - Use PascalCase for React components.
22
+ - Use snake_case for database columns.
23
+
24
+ ### Comments
25
+ - Do not write obvious comments.
26
+ - Only comment non-obvious business logic.
27
+ - Prefer self-documenting code.
28
+
29
+ ## Error Handling
30
+ - Always handle errors explicitly.
31
+ - Do not swallow exceptions.
32
+ - Return meaningful error messages.
33
+
34
+ ## Security
35
+ - Never expose secrets or API keys.
36
+ - Validate and sanitize all user input.
37
+ - Use prepared statements for database queries.
38
+
39
+ ## Project-Specific Notes
40
+
41
+ ### Backend (PHP / CakePHP)
42
+ - Follow PSR-12 formatting.
43
+ - Use dependency injection where appropriate.
44
+ - Do not use deprecated framework methods.
45
+ - Prefer modern APIs over legacy helpers.
46
+
47
+ ### Frontend (React / Expo)
48
+ - Use functional components only.
49
+ - Prefer hooks over class components.
50
+ - Avoid inline styles unless necessary.
51
+ - Keep components under 200 lines.
52
+
53
+ ## Testing
54
+ - Suggest unit tests for new logic.
55
+ - Mock external dependencies.
56
+ - Keep tests deterministic.
57
+
58
+ ## Performance
59
+ - Avoid unnecessary loops.
60
+ - Do not perform database queries inside loops.
61
+ - Prefer memoization when appropriate.
62
+
63
+ ## Output Expectations
64
+ - Produce production-ready code.
65
+ - Avoid TODO comments unless necessary. -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onehat/ui",
3
- "version": "0.4.103",
3
+ "version": "0.4.105",
4
4
  "description": "Base UI for OneHat apps",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -90,6 +90,7 @@ export const ComboComponent = forwardRef((props, ref) => {
90
90
  isInTag = false,
91
91
  minimizeForRow = false,
92
92
  reloadOnTrigger = false,
93
+ loadAfterRender = false,
93
94
  searchHasInitialPercent = false,
94
95
  menuHeight,
95
96
  placeholder,
@@ -585,11 +586,16 @@ export const ComboComponent = forwardRef((props, ref) => {
585
586
  };
586
587
 
587
588
  useEffect(() => {
588
- // on render, focus the input
589
589
  if (!isRendered) {
590
590
  return () => {};
591
591
  }
592
+
593
+ if (loadAfterRender) {
594
+ Repository?.reload();
595
+ }
596
+
592
597
  if (autoFocus && !inputRef.current.isFocused()) {
598
+ // on render, focus the input
593
599
  inputRef.current.focus();
594
600
  }
595
601
 
@@ -599,7 +605,7 @@ export const ComboComponent = forwardRef((props, ref) => {
599
605
  }
600
606
  };
601
607
 
602
- }, [isRendered]);
608
+ }, [isRendered, loadAfterRender, Repository]);
603
609
 
604
610
  useEffect(() => {
605
611
  (async () => {
@@ -139,6 +139,7 @@ function Form(props) {
139
139
  onDelete,
140
140
  editorStateRef,
141
141
  disableView,
142
+ isEditorModeControlledByParent = false,
142
143
 
143
144
  // parent container
144
145
  selectorId,
@@ -253,8 +254,9 @@ function Form(props) {
253
254
  'border-r-grey-200',
254
255
  'px-1',
255
256
  styles.INLINE_EDITOR_MIN_WIDTH,
256
- );
257
- _.each(columnsConfig, (config, ix) => {
257
+ ),
258
+ validColumnsConfig = _.filter(columnsConfig, (config) => !!config); // filter out any null/undefined configs
259
+ _.each(validColumnsConfig, (config, ix) => {
258
260
  let {
259
261
  fieldName,
260
262
  isEditable = false,
@@ -273,7 +275,7 @@ function Form(props) {
273
275
  type,
274
276
  editorTypeProps = {},
275
277
  viewerTypeProps = {};
276
-
278
+
277
279
  if (isHidden) {
278
280
  return;
279
281
  }
@@ -965,9 +967,11 @@ function Form(props) {
965
967
  />;
966
968
  },
967
969
  buildAncillary = () => {
968
- const components = [];
970
+ const
971
+ validAncillaryItems = _.filter(ancillaryItems, (item) => !!item), // filter out any null/undefined items
972
+ components = [];
969
973
  setAncillaryButtons([]);
970
- if (ancillaryItems.length) {
974
+ if (validAncillaryItems.length) {
971
975
 
972
976
  // add the "scroll to top" button
973
977
  getAncillaryButtons().push({
@@ -978,7 +982,7 @@ function Form(props) {
978
982
  tooltip: 'Scroll to top',
979
983
  });
980
984
 
981
- _.each(ancillaryItems, (item, ix) => {
985
+ _.each(validAncillaryItems, (item, ix) => {
982
986
  let {
983
987
  type,
984
988
  title = null,
@@ -1259,7 +1263,7 @@ function Form(props) {
1259
1263
 
1260
1264
  if (inArray(editorType, [EDITOR_TYPE__SIDE, EDITOR_TYPE__SMART, EDITOR_TYPE__WINDOWED]) &&
1261
1265
  isSingle && getEditorMode() === EDITOR_MODE__EDIT &&
1262
- (onBack || onViewMode)) {
1266
+ (onBack || onViewMode || isEditorModeControlledByParent)) {
1263
1267
  modeHeader = <Toolbar>
1264
1268
  <HStack className="flex-1 items-center">
1265
1269
  {onBack &&
@@ -1277,7 +1281,7 @@ function Form(props) {
1277
1281
  )}
1278
1282
  text="Back"
1279
1283
  />}
1280
- <Text className="text-[20px] ml-1 text-grey-500">Edit Mode</Text>
1284
+ <Text className="text-[20px] ml-1 text-grey-500">{isEditorModeControlledByParent ? 'Edit Mode (Inherited)' : 'Edit Mode'}</Text>
1281
1285
  </HStack>
1282
1286
  {onViewMode && !disableView && (!canUser || canUser(VIEW)) &&
1283
1287
  <Button
@@ -589,6 +589,8 @@ function GridComponent(props) {
589
589
  'flex-row',
590
590
  'grow',
591
591
  'max-h-[80px]',
592
+ 'focus:outline-none', // hide the focus outline
593
+ 'focus-visible:outline-none',
592
594
  )}
593
595
  >
594
596
  {({
@@ -1914,6 +1916,8 @@ function GridComponent(props) {
1914
1916
  'w-full',
1915
1917
  'border',
1916
1918
  'border-grey-300',
1919
+ 'focus:outline-none', // hide the focus outline
1920
+ 'focus-visible:outline-none',
1917
1921
  );
1918
1922
  if (props.className) {
1919
1923
  className += ' ' + props.className;
@@ -490,7 +490,11 @@ const GridRow = forwardRef((props, ref) => {
490
490
  }}
491
491
  >{rowContents}</HStackNative>;
492
492
  if (rowProps.tooltip) {
493
- row = <Tooltip label={rowProps.tooltip} placement="bottom left">{row}</Tooltip>;
493
+ row = <Tooltip
494
+ label={rowProps.tooltip}
495
+ placement="bottom left"
496
+ triggerClassName={rowClassName}
497
+ >{row}</Tooltip>;
494
498
  }
495
499
  return row;
496
500
  }, [
@@ -47,6 +47,7 @@ export default function withSecondaryEditor(WrappedComponent, isTree = false) {
47
47
  },
48
48
  secondaryEditorType,
49
49
  secondaryOnAdd,
50
+ secondaryOnBeforeAdd,
50
51
  secondaryOnChange, // any kind of crud change
51
52
  secondaryOnBeforeDelete,
52
53
  secondaryOnDelete,
@@ -201,10 +202,26 @@ export default function withSecondaryEditor(WrappedComponent, isTree = false) {
201
202
  }
202
203
 
203
204
  if (getListeners().onBeforeAdd) {
204
- const listenerResult = await getListeners().onBeforeAdd();
205
+ // This listener is set by child components using setWithEditListeners()
206
+ const listenerResult = await getListeners().onBeforeAdd(addValues);
207
+ if (listenerResult === false) {
208
+ return;
209
+ }
210
+ if (listenerResult) {
211
+ // allow the listener to override the addValues by returning an object
212
+ addValues = listenerResult;
213
+ }
214
+ }
215
+ if (secondaryOnBeforeAdd) {
216
+ // This listener is set by parent components using a prop
217
+ const listenerResult = await secondaryOnBeforeAdd(addValues);
205
218
  if (listenerResult === false) {
206
219
  return;
207
220
  }
221
+ if (listenerResult) {
222
+ // allow the listener to override the addValues by returning an object
223
+ addValues = listenerResult;
224
+ }
208
225
  }
209
226
 
210
227
  if (isTree) {
@@ -1,4 +1,4 @@
1
- import { forwardRef, useEffect, useState, useRef, } from 'react';
1
+ import { forwardRef, useContext, useEffect, useState, useRef, } from 'react';
2
2
  import {
3
3
  ADD,
4
4
  EDIT,
@@ -15,6 +15,7 @@ import {
15
15
  } from '../../Constants/Editor.js';
16
16
  import useForceUpdate from '../../Hooks/useForceUpdate.js'
17
17
  import Button from '../Buttons/Button.js';
18
+ import EditorModeContext from '../../Contexts/EditorModeContext.js';
18
19
  import UiGlobals from '../../UiGlobals.js';
19
20
  import _ from 'lodash';
20
21
 
@@ -47,6 +48,7 @@ export default function withEditor(WrappedComponent, isTree = false) {
47
48
  },
48
49
  editorType,
49
50
  onAdd,
51
+ onBeforeAdd,
50
52
  onChange, // any kind of crud change
51
53
  onBeforeDelete,
52
54
  onDelete,
@@ -57,6 +59,7 @@ export default function withEditor(WrappedComponent, isTree = false) {
57
59
  defaultValues,
58
60
  initialEditorMode = EDITOR_MODE__VIEW,
59
61
  stayInEditModeOnSelectionChange = false,
62
+ inheritParentEditorMode = true,
60
63
 
61
64
  // withComponent
62
65
  self,
@@ -83,6 +86,7 @@ export default function withEditor(WrappedComponent, isTree = false) {
83
86
  confirm,
84
87
  hideAlert,
85
88
  } = props,
89
+ parentEditorModeContext = useContext(EditorModeContext),
86
90
  forceUpdate = useForceUpdate(),
87
91
  listeners = useRef({}),
88
92
  editorStateRef = useRef(),
@@ -169,13 +173,85 @@ export default function withEditor(WrappedComponent, isTree = false) {
169
173
  forceUpdate();
170
174
  }
171
175
  },
176
+ getParentEditorMode = () => {
177
+ return parentEditorModeContext?.effectiveEditorMode || null;
178
+ },
179
+ getInheritedEditorMode = () => {
180
+ if (!inheritParentEditorMode) {
181
+ return null;
182
+ }
183
+ const parentMode = getParentEditorMode();
184
+ if (parentMode === EDITOR_MODE__ADD) {
185
+ return EDITOR_MODE__EDIT;
186
+ }
187
+ if (parentMode === EDITOR_MODE__EDIT || parentMode === EDITOR_MODE__VIEW) {
188
+ return parentMode;
189
+ }
190
+ return null;
191
+ },
192
+ getIsParentSaveLocked = () => {
193
+ return !!parentEditorModeContext?.isAnyAncestorUnsaved;
194
+ },
195
+ getIsCurrentSelectionUnsaved = () => {
196
+ const selection = getSelection();
197
+ if (!selection || selection.length !== 1) {
198
+ return false;
199
+ }
200
+ const record = selection[0];
201
+ if (!record || record.isDestroyed) {
202
+ return false;
203
+ }
204
+ return !!(record.isPhantom || record.isRemotePhantom);
205
+ },
206
+ getIsEditorDisabledByParent = () => {
207
+ return getIsParentSaveLocked();
208
+ },
209
+ getIsEditorModeControlledByParent = () => {
210
+ return !!getInheritedEditorMode();
211
+ },
172
212
  getNewEntityDisplayValue = () => {
173
213
  return newEntityDisplayValueRef.current;
174
214
  },
175
215
  setNewEntityDisplayValue = (val) => {
176
216
  newEntityDisplayValueRef.current = val;
177
217
  },
218
+ showViewFallback = async () => {
219
+ // helper for doEdit
220
+ // If the editor is forced into EDIT mode by parent inheritance,
221
+ // but the child editor cannot actually be in edit mode due to permissions or configuration,
222
+ // switch the mode to VIEW.
223
+
224
+ if (!userCanView) {
225
+ return;
226
+ }
227
+ if (canUser && !canUser(VIEW)) {
228
+ showPermissionsError(VIEW);
229
+ return;
230
+ }
231
+ if (canProceedWithCrud && !canProceedWithCrud()) {
232
+ return;
233
+ }
234
+ if (editorType === EDITOR_TYPE__INLINE) {
235
+ return;
236
+ }
237
+ const selection = getSelection();
238
+ if (selection.length !== 1) {
239
+ return;
240
+ }
241
+ setIsEditorViewOnly(true);
242
+ setEditorMode(EDITOR_MODE__VIEW);
243
+ setIsEditorShown(true);
244
+ if (getListeners().onAfterView) {
245
+ await getListeners().onAfterView();
246
+ }
247
+ },
178
248
  doAdd = async (e, values) => {
249
+ if (getIsEditorDisabledByParent()) {
250
+ return;
251
+ }
252
+ if (getInheritedEditorMode() === EDITOR_MODE__VIEW) {
253
+ return;
254
+ }
179
255
  if (canUser && !canUser(ADD)) {
180
256
  showPermissionsError(ADD);
181
257
  return;
@@ -218,10 +294,26 @@ export default function withEditor(WrappedComponent, isTree = false) {
218
294
  }
219
295
 
220
296
  if (getListeners().onBeforeAdd) {
221
- const listenerResult = await getListeners().onBeforeAdd();
297
+ // This listener is set by child components using setWithEditListeners()
298
+ const listenerResult = await getListeners().onBeforeAdd(addValues);
299
+ if (listenerResult === false) {
300
+ return;
301
+ }
302
+ if (listenerResult) {
303
+ // allow the listener to override the addValues by returning an object
304
+ addValues = listenerResult;
305
+ }
306
+ }
307
+ if (onBeforeAdd) {
308
+ // This listener is set by parent components using a prop
309
+ const listenerResult = await onBeforeAdd(addValues);
222
310
  if (listenerResult === false) {
223
311
  return;
224
312
  }
313
+ if (listenerResult) {
314
+ // allow the listener to override the addValues by returning an object
315
+ addValues = listenerResult;
316
+ }
225
317
  }
226
318
 
227
319
  if (isTree) {
@@ -278,7 +370,23 @@ export default function withEditor(WrappedComponent, isTree = false) {
278
370
  setIsEditorShown(true);
279
371
  },
280
372
  doEdit = async () => {
373
+ if (getIsEditorDisabledByParent()) {
374
+ return;
375
+ }
376
+ const inheritedEditorMode = getInheritedEditorMode();
377
+ if (inheritedEditorMode === EDITOR_MODE__VIEW) {
378
+ await doView(false);
379
+ return;
380
+ }
381
+ if (inheritedEditorMode === EDITOR_MODE__EDIT && (!userCanEdit || disableEdit || canEditorViewOnly)) {
382
+ await showViewFallback();
383
+ return;
384
+ }
281
385
  if (canUser && !canUser(EDIT)) {
386
+ if (inheritedEditorMode === EDITOR_MODE__EDIT) {
387
+ await showViewFallback();
388
+ return;
389
+ }
282
390
  showPermissionsError(EDIT);
283
391
  return;
284
392
  }
@@ -300,6 +408,12 @@ export default function withEditor(WrappedComponent, isTree = false) {
300
408
  setIsEditorShown(true);
301
409
  },
302
410
  doDelete = async (args) => {
411
+ if (getIsEditorDisabledByParent()) {
412
+ return;
413
+ }
414
+ if (getInheritedEditorMode() === EDITOR_MODE__VIEW) {
415
+ return;
416
+ }
303
417
  if (canUser && !canUser(DELETE)) {
304
418
  showPermissionsError(DELETE);
305
419
  return;
@@ -413,6 +527,17 @@ export default function withEditor(WrappedComponent, isTree = false) {
413
527
  }
414
528
  },
415
529
  doView = async (allowEditing = false) => {
530
+ if (getIsEditorDisabledByParent()) {
531
+ return;
532
+ }
533
+ const inheritedEditorMode = getInheritedEditorMode();
534
+ if (inheritedEditorMode === EDITOR_MODE__EDIT) {
535
+ await doEdit();
536
+ return;
537
+ }
538
+ if (inheritedEditorMode === EDITOR_MODE__VIEW) {
539
+ allowEditing = false;
540
+ }
416
541
  if (!userCanView) {
417
542
  return;
418
543
  }
@@ -443,6 +568,12 @@ export default function withEditor(WrappedComponent, isTree = false) {
443
568
  }
444
569
  },
445
570
  doDuplicate = async () => {
571
+ if (getIsEditorDisabledByParent()) {
572
+ return;
573
+ }
574
+ if (getInheritedEditorMode() === EDITOR_MODE__VIEW) {
575
+ return;
576
+ }
446
577
  if (!userCanEdit || disableDuplicate) {
447
578
  return;
448
579
  }
@@ -516,6 +647,9 @@ export default function withEditor(WrappedComponent, isTree = false) {
516
647
  }
517
648
  },
518
649
  doEditorSave = async (data, e) => {
650
+ if (getIsEditorDisabledByParent()) {
651
+ return false;
652
+ }
519
653
  let mode = getEditorMode() === EDITOR_MODE__ADD ? ADD : EDIT;
520
654
  if (canUser && !canUser(mode)) {
521
655
  showPermissionsError(mode);
@@ -634,6 +768,12 @@ export default function withEditor(WrappedComponent, isTree = false) {
634
768
  setIsEditorShown(false);
635
769
  },
636
770
  doEditorDelete = async () => {
771
+ if (getIsEditorDisabledByParent()) {
772
+ return;
773
+ }
774
+ if (getInheritedEditorMode() === EDITOR_MODE__VIEW) {
775
+ return;
776
+ }
637
777
  if (canUser && !canUser(DELETE)) {
638
778
  showPermissionsError(DELETE);
639
779
  return;
@@ -645,6 +785,46 @@ export default function withEditor(WrappedComponent, isTree = false) {
645
785
  });
646
786
  },
647
787
  calculateEditorMode = () => {
788
+ // Calculate the editor's effective mode based on parent inheritance, permissions, and local selection state.
789
+ // Priority order:
790
+ // 1. If parent is save-locked (unsaved ancestor), force VIEW mode
791
+ // 2. If parent forces VIEW mode via inheritance, return VIEW (child cannot edit if parent is view-only)
792
+ // 3. If parent forces EDIT mode via inheritance, check child permissions:
793
+ // a. If parent disabled, child disabled, or child cannot edit, return VIEW
794
+ // b. If single phantom record, return ADD (new record being created)
795
+ // c. Otherwise return EDIT or VIEW based on selection count
796
+ // 4. Fall back to local selection heuristics (multiple→EDIT, single→VIEW, stays in previous mode if configured)
797
+ const
798
+ selection = getSelection(),
799
+ inheritedEditorMode = getInheritedEditorMode();
800
+
801
+ if (getIsEditorDisabledByParent()) {
802
+ return EDITOR_MODE__VIEW;
803
+ }
804
+
805
+ if (inheritedEditorMode === EDITOR_MODE__VIEW) {
806
+ return EDITOR_MODE__VIEW;
807
+ }
808
+
809
+ if (inheritedEditorMode === EDITOR_MODE__EDIT) {
810
+ if (!getCanEditorBeInEditMode()) {
811
+ return EDITOR_MODE__VIEW;
812
+ }
813
+ if (canEditorViewOnly || !userCanEdit || disableEdit) {
814
+ return EDITOR_MODE__VIEW;
815
+ }
816
+ if (canUser && !canUser(EDIT)) {
817
+ return EDITOR_MODE__VIEW;
818
+ }
819
+ if (canRecordBeEdited && canRecordBeEdited(selection) === false) {
820
+ return EDITOR_MODE__VIEW;
821
+ }
822
+ if (selection.length === 1 && !selection[0].isDestroyed && (selection[0].isPhantom || selection[0].isRemotePhantom) && !disableAdd) {
823
+ return EDITOR_MODE__ADD;
824
+ }
825
+ return selection.length ? EDITOR_MODE__EDIT : EDITOR_MODE__VIEW;
826
+ }
827
+
648
828
  if (!getCanEditorBeInEditMode()) { // this is a result of canRecordBeEdited returning false
649
829
  return EDITOR_MODE__VIEW;
650
830
  }
@@ -660,7 +840,6 @@ export default function withEditor(WrappedComponent, isTree = false) {
660
840
  }
661
841
 
662
842
  // calculateEditorMode gets called only on selection changes
663
- const selection = getSelection();
664
843
  let mode;
665
844
  if (editorType === EDITOR_TYPE__SIDE && !_.isNil(UiGlobals.isSideEditorAlwaysEditMode) && UiGlobals.isSideEditorAlwaysEditMode) {
666
845
  // special case: side editor is always edit mode
@@ -688,6 +867,9 @@ export default function withEditor(WrappedComponent, isTree = false) {
688
867
  return mode;
689
868
  },
690
869
  setEditMode = () => {
870
+ if (getIsEditorDisabledByParent() || getIsEditorModeControlledByParent()) {
871
+ return;
872
+ }
691
873
  if (canUser && !canUser(EDIT)) {
692
874
  showPermissionsError(EDIT);
693
875
  return;
@@ -696,6 +878,9 @@ export default function withEditor(WrappedComponent, isTree = false) {
696
878
  setEditorMode(EDITOR_MODE__EDIT);
697
879
  },
698
880
  setViewMode = () => {
881
+ if (getIsEditorDisabledByParent() || getIsEditorModeControlledByParent()) {
882
+ return;
883
+ }
699
884
  if (canUser && !canUser(VIEW)) {
700
885
  showPermissionsError(VIEW);
701
886
  return;
@@ -712,10 +897,20 @@ export default function withEditor(WrappedComponent, isTree = false) {
712
897
  }
713
898
  };
714
899
 
900
+ const
901
+ inheritedEditorMode = getInheritedEditorMode(),
902
+ isEditorDisabledByParent = getIsEditorDisabledByParent(),
903
+ isEditorModeControlledByParent = getIsEditorModeControlledByParent(),
904
+ isCurrentSelectionUnsaved = getIsCurrentSelectionUnsaved(),
905
+ isAnyAncestorUnsaved = getIsParentSaveLocked() || isCurrentSelectionUnsaved,
906
+ isCrudBlockedByInheritedView = inheritedEditorMode === EDITOR_MODE__VIEW;
907
+
715
908
  useEffect(() => {
716
909
 
717
910
  if (editorType === EDITOR_TYPE__SIDE) {
718
- if (selection?.length) { // || isAdding
911
+ if (isEditorDisabledByParent) {
912
+ setIsEditorShown(false);
913
+ } else if (selection?.length) { // || isAdding
719
914
  // there is a selection, so show the editor
720
915
  setIsEditorShown(true);
721
916
  } else {
@@ -729,6 +924,19 @@ export default function withEditor(WrappedComponent, isTree = false) {
729
924
  } else {
730
925
  setCanEditorBeInEditMode(true);
731
926
  }
927
+
928
+ if (isEditorDisabledByParent || inheritedEditorMode === EDITOR_MODE__VIEW) {
929
+ setIsEditorViewOnly(true);
930
+ } else if (inheritedEditorMode === EDITOR_MODE__EDIT) {
931
+ const canEditInInheritedMode =
932
+ !canEditorViewOnly &&
933
+ userCanEdit &&
934
+ !disableEdit &&
935
+ (!canUser || canUser(EDIT)) &&
936
+ (!canRecordBeEdited || canRecordBeEdited(selection));
937
+ setIsEditorViewOnly(!canEditInInheritedMode);
938
+ }
939
+
732
940
  setEditorMode(calculateEditorMode());
733
941
  setLastSelection(selection);
734
942
 
@@ -738,7 +946,7 @@ export default function withEditor(WrappedComponent, isTree = false) {
738
946
  Promise.resolve().then(() => {
739
947
  setIsIgnoreNextSelectionChange(false);
740
948
  });
741
- }, [selection]);
949
+ }, [selection, inheritedEditorMode, isEditorDisabledByParent]);
742
950
 
743
951
  if (self) {
744
952
  self.add = doAdd;
@@ -759,7 +967,13 @@ export default function withEditor(WrappedComponent, isTree = false) {
759
967
  setEditorMode(calculateEditorMode());
760
968
  }
761
969
 
762
- return <WrappedComponent
970
+ const editorModeContextValue = {
971
+ effectiveEditorMode: getEditorMode(),
972
+ isAnyAncestorUnsaved,
973
+ };
974
+
975
+ return <EditorModeContext.Provider value={editorModeContextValue}>
976
+ <WrappedComponent
763
977
  {...props}
764
978
  ref={ref}
765
979
  disableWithEditor={false}
@@ -769,35 +983,38 @@ export default function withEditor(WrappedComponent, isTree = false) {
769
983
  isEditorShown={getIsEditorShown()}
770
984
  getIsEditorShown={getIsEditorShown}
771
985
  isEditorViewOnly={isEditorViewOnly}
986
+ isEditorModeControlledByParent={isEditorModeControlledByParent}
987
+ isEditorDisabledByParent={isEditorDisabledByParent}
772
988
  isAdding={isAdding}
773
989
  isSaving={isSaving}
774
990
  editorMode={getEditorMode()}
775
991
  getEditorMode={getEditorMode}
776
- onEditMode={setEditMode}
777
- onViewMode={setViewMode}
992
+ onEditMode={(isEditorModeControlledByParent || isEditorDisabledByParent) ? null : setEditMode}
993
+ onViewMode={(isEditorModeControlledByParent || isEditorDisabledByParent) ? null : setViewMode}
778
994
  editorStateRef={editorStateRef}
779
995
  setIsEditorShown={setIsEditorShown}
780
996
  setIsIgnoreNextSelectionChange={setIsIgnoreNextSelectionChange}
781
- onAdd={(!userCanEdit || disableAdd) ? null : doAdd}
782
- onEdit={(!userCanEdit || disableEdit || (canRecordBeEdited && !canRecordBeEdited(selection))) ? null : doEdit}
783
- onDelete={(!userCanEdit || disableDelete || (canRecordBeDeleted && !canRecordBeDeleted(selection))) ? null : doDelete}
784
- onView={doView}
785
- onDuplicate={doDuplicate}
997
+ onAdd={(isEditorDisabledByParent || isCrudBlockedByInheritedView || !userCanEdit || disableAdd) ? null : doAdd}
998
+ onEdit={(isEditorDisabledByParent || isCrudBlockedByInheritedView || !userCanEdit || disableEdit || (canRecordBeEdited && !canRecordBeEdited(selection))) ? null : doEdit}
999
+ onDelete={(isEditorDisabledByParent || isCrudBlockedByInheritedView || !userCanEdit || disableDelete || (canRecordBeDeleted && !canRecordBeDeleted(selection))) ? null : doDelete}
1000
+ onView={isEditorDisabledByParent ? null : doView}
1001
+ onDuplicate={(isEditorDisabledByParent || isCrudBlockedByInheritedView) ? null : doDuplicate}
786
1002
  onEditorSave={doEditorSave}
787
1003
  onEditorCancel={doEditorCancel}
788
- onEditorDelete={(!userCanEdit || disableDelete) ? null : doEditorDelete}
1004
+ onEditorDelete={(isEditorDisabledByParent || isCrudBlockedByInheritedView || !userCanEdit || disableDelete) ? null : doEditorDelete}
789
1005
  onEditorClose={doEditorClose}
790
1006
  setWithEditListeners={setListeners}
791
1007
  isEditor={true}
792
1008
  userCanEdit={userCanEdit}
793
1009
  userCanView={userCanView}
794
- disableAdd={disableAdd}
795
- disableEdit={disableEdit}
796
- disableDelete={disableDelete}
797
- disableDuplicate={disableDuplicate}
798
- disableView ={disableView}
1010
+ disableAdd={disableAdd || isEditorDisabledByParent || isCrudBlockedByInheritedView}
1011
+ disableEdit={disableEdit || isEditorDisabledByParent || isCrudBlockedByInheritedView}
1012
+ disableDelete={disableDelete || isEditorDisabledByParent || isCrudBlockedByInheritedView}
1013
+ disableDuplicate={disableDuplicate || isEditorDisabledByParent || isCrudBlockedByInheritedView}
1014
+ disableView ={disableView || isEditorDisabledByParent}
799
1015
  setSelection={setSelectionDecorated}
800
1016
  isTree={isTree}
801
- />;
1017
+ />
1018
+ </EditorModeContext.Provider>;
802
1019
  });
803
1020
  }
@@ -418,6 +418,9 @@ export default function withPdfButtons(WrappedComponent) {
418
418
  },
419
419
  ];
420
420
  _.each(buttons, (button) => {
421
+ if (!button) {
422
+ return; // guard against null/undefined
423
+ }
421
424
  if (!_.find(additionalEditButtons, btn => button.key === btn.key)) {
422
425
  additionalEditButtons.push(button);
423
426
  }
@@ -68,6 +68,7 @@ export default function withPresetButtons(WrappedComponent) {
68
68
  canDeleteRootNode = false,
69
69
  isSideEditor = false,
70
70
  canEditorViewOnly = false,
71
+ canRecordBeAdded, // fn(selection) returns bool on if the current record(s) can be added
71
72
  canRecordBeEdited, // fn(selection) returns bool on if the current record(s) can be edited
72
73
  canRecordBeDeleted, // fn(selection) returns bool on if the current record(s) can be deleted
73
74
  canRecordBeDuplicated, // fn(selection) returns bool on if the current record(s) can be duplicated
@@ -239,6 +240,7 @@ export default function withPresetButtons(WrappedComponent) {
239
240
  };
240
241
  icon = Plus;
241
242
  if (isNoSelectorSelected() ||
243
+ (canRecordBeAdded && !canRecordBeAdded(selection)) ||
242
244
  (isTree && isEmptySelection())
243
245
  ) {
244
246
  isDisabled = true;
@@ -50,6 +50,7 @@ function Panel(props) {
50
50
  disableTitleChange = false,
51
51
 
52
52
  // Content
53
+ renderWhileCollapsed = false,
53
54
  topToolbar = null,
54
55
  children = null,
55
56
  bottomToolbar = null,
@@ -139,14 +140,7 @@ function Panel(props) {
139
140
  className += ' ' + filteredClassName;
140
141
  }
141
142
 
142
- return <VStackNative
143
- {...testProps(self?.reference)}
144
- className={className}
145
- style={style}
146
- >
147
- {isDisabled && <Mask />}
148
- {headerComponent}
149
- {!isCollapsed && <>
143
+ let content = <>
150
144
  {topToolbar}
151
145
  <VStack
152
146
  className={clsx(
@@ -169,7 +163,41 @@ function Panel(props) {
169
163
  </VStack>
170
164
  {bottomToolbar}
171
165
  {footer}
172
- </>}
166
+ </>;
167
+ if (renderWhileCollapsed) {
168
+ content = <Box
169
+ className={clsx(
170
+ 'Panel-Box',
171
+ ...(isCollapsed ?
172
+ [
173
+ 'w-[0px]',
174
+ 'h-[0px]',
175
+ 'overflow-hidden',
176
+ ] :
177
+ [
178
+ 'w-full',
179
+ 'h-full',
180
+ ])
181
+ )}
182
+ >
183
+ {content}
184
+ </Box>;
185
+ } else {
186
+ if (isCollapsed) {
187
+ // hide the content when collapsed
188
+ content = null;
189
+ }
190
+ }
191
+
192
+
193
+ return <VStackNative
194
+ {...testProps(self?.reference)}
195
+ className={className}
196
+ style={style}
197
+ >
198
+ {isDisabled && <Mask />}
199
+ {headerComponent}
200
+ {content}
173
201
  </VStackNative>;
174
202
 
175
203
  }
@@ -92,18 +92,18 @@ export default function PmEventsEditor(props) {
92
92
  case PM_EVENT_TYPES__INITIAL:
93
93
  case PM_EVENT_TYPES__WORK_ORDER:
94
94
  case PM_EVENT_TYPES__ALERT:
95
+ case PM_EVENT_TYPES__COMPLETE:
95
96
  setIsIntervalHidden(true);
96
97
  setIsDateHidden(true);
97
98
  setIsMeterReadingHidden(false);
98
99
  setIsDetailsHidden(false);
99
100
  setIsPmTechnicianHidden(true);
100
101
  break;
101
- case PM_EVENT_TYPES__COMPLETE:
102
102
  case PM_EVENT_TYPES__RESET:
103
103
  setIsIntervalHidden(true);
104
104
  setIsDateHidden(true);
105
- setIsMeterReadingHidden(false);
106
- setIsDetailsHidden(false);
105
+ setIsMeterReadingHidden(true);
106
+ setIsDetailsHidden(true);
107
107
  setIsPmTechnicianHidden(true);
108
108
  break;
109
109
  case PM_EVENT_TYPES__DELAY_BY_DAYS:
@@ -17,7 +17,7 @@ import ClockRegular from '../../Icons/ClockRegular.js';
17
17
  import OilCan from '../../Icons/OilCan.js';
18
18
  import TabBar from '../../Tab/TabBar.js';
19
19
  import TreeSpecific from '../Layout/TreeSpecific/TreeSpecific.js';
20
- import UpcomingPmsGrid from '../Grid/UpcomingPmsGrid.js';
20
+ import UpcomingPmsGrid from '@src/Components/Grid/UpcomingPmsGrid.js';
21
21
  import PmEventsFilteredGridEditor from '@src/Components/Grid/PmEventsFilteredGridEditor.js';
22
22
  import PmEventsFilteredSideGridEditor from '@src/Components/Grid/PmEventsFilteredSideGridEditor.js';
23
23
  import _ from 'lodash';
@@ -31,6 +31,7 @@ function ManagerScreen(props) {
31
31
  [isModeSet, setIsModeSet] = useState(false),
32
32
  [allowSideBySide, setAllowSideBySide] = useState(false),
33
33
  [mode, setModeRaw] = useState(SCREEN_MODES__SIDE),
34
+ isDisabled = propsToPass._panel?.isDisabled ?? false, // this is kind of a hack, since there's no Panel, but it works
34
35
  actualMode = (!allowSideBySide || mode === SCREEN_MODES__FULL) ? SCREEN_MODES__FULL : SCREEN_MODES__SIDE,
35
36
  setMode = (newMode) => {
36
37
  if (!allowSideBySide && newMode === SCREEN_MODES__SIDE) {
@@ -97,7 +98,7 @@ function ManagerScreen(props) {
97
98
  onFullWidth={() => setMode(SCREEN_MODES__FULL)}
98
99
  onSideBySide={() => setMode(SCREEN_MODES__SIDE)}
99
100
  />
100
- {isRendered && isModeSet && whichComponent}
101
+ {isRendered && isModeSet && !isDisabled && whichComponent}
101
102
  </VStackNative>;
102
103
  }
103
104
 
@@ -11,15 +11,17 @@ const PaginationToolbar = forwardRef((props, ref) => {
11
11
  const {
12
12
  toolbarItems = [],
13
13
  disablePageSize = false,
14
+ disableMinimizing = false,
14
15
  minimize,
15
16
  } = props,
16
17
  [minimizeLocal, setMinimizeLocal] = useState(minimize),
17
18
  propsToPass = _.omit(props, 'toolbarItems'),
18
19
  showPagination = true,//props.Repository?.totalPages > 1,
19
20
  onLayout = (e) => {
20
- if (minimize) {
21
- return; // skip if already minimized
21
+ if (minimize || disableMinimizing) {
22
+ return;
22
23
  }
24
+
23
25
  // Note to future self: this is using hard-coded values.
24
26
  // Eventually might want to make it responsive to actual sizes
25
27
 
@@ -32,7 +34,7 @@ const PaginationToolbar = forwardRef((props, ref) => {
32
34
  threshold = pagingToolbarMinwidth + toolbarItemsMinwidth,
33
35
  shouldMinimize = width < threshold;
34
36
 
35
- if (shouldMinimize !== minimize) {
37
+ if (shouldMinimize !== minimizeLocal) {
36
38
  setMinimizeLocal(shouldMinimize);
37
39
  }
38
40
  };
@@ -72,6 +72,7 @@ function Viewer(props) {
72
72
  onEditMode,
73
73
  onClose,
74
74
  onDelete,
75
+ isEditorModeControlledByParent = false,
75
76
 
76
77
  // parent container
77
78
  selectorId,
@@ -350,9 +351,11 @@ function Viewer(props) {
350
351
  return <HStack key={ix} className="Viewer-HStack4 px-2 pb-1">{element}</HStack>;
351
352
  },
352
353
  buildAncillary = () => {
353
- const components = [];
354
+ const
355
+ validAncillaryItems = _.filter(ancillaryItems, (item) => !!item), // filter out any null/undefined items
356
+ components = [];
354
357
  setAncillaryButtons([]);
355
- if (ancillaryItems.length) {
358
+ if (validAncillaryItems.length) {
356
359
 
357
360
  // add the "scroll to top" button
358
361
  getAncillaryButtons().push({
@@ -363,7 +366,7 @@ function Viewer(props) {
363
366
  tooltip: 'Scroll to top',
364
367
  });
365
368
 
366
- _.each(ancillaryItems, (item, ix) => {
369
+ _.each(validAncillaryItems, (item, ix) => {
367
370
  let {
368
371
  type,
369
372
  title = null,
@@ -561,7 +564,7 @@ function Viewer(props) {
561
564
 
562
565
  <Toolbar className="justify-end">
563
566
  <HStack className="flex-1 items-center">
564
- <Text className="text-[20px] ml-1 text-grey-500">View Mode</Text>
567
+ <Text className="text-[20px] ml-1 text-grey-500">{isEditorModeControlledByParent ? 'View Mode (Inherited)' : 'View Mode'}</Text>
565
568
  </HStack>
566
569
  {onEditMode && (!canUser || canUser(EDIT)) &&
567
570
  <Button
@@ -0,0 +1 @@
1
+ export const PM_SCHEDULES__ONE_OFF = 200;
@@ -0,0 +1,5 @@
1
+ import { createContext } from 'react';
2
+
3
+ const EditorModeContext = createContext(null);
4
+
5
+ export default EditorModeContext;
@@ -5,6 +5,11 @@ import _ from 'lodash';
5
5
  export default function buildAdditionalButtons(configs, self, handlerArgs = {}) {
6
6
  const additionalButtons = [];
7
7
  _.each(configs, (config) => {
8
+
9
+ if (!config) {
10
+ return;
11
+ }
12
+
8
13
  const {
9
14
  key,
10
15
  color = '#fff',
@@ -388,6 +388,19 @@ function AttachmentsElement(props) {
388
388
  downloadInBackground(url, {}, Attachments.headers);
389
389
  }
390
390
  },
391
+ onDownloadAll = () => {
392
+ if (!model || _.isNil(modelid.current) || _.isArray(modelid.current)) {
393
+ alert('Cannot download all attachments without a single selected model and model id.');
394
+ return;
395
+ }
396
+
397
+ const url = Attachments.api.baseURL + 'Attachments/downloadAll/' + encodeURIComponent(model) + '/' + encodeURIComponent(modelid.current);
398
+ if (isPwa) {
399
+ alert('Files cannot be downloaded and viewed within an iOS PWA. Please use the Safari browser instead.');
400
+ } else {
401
+ downloadInBackground(url, {}, Attachments.headers);
402
+ }
403
+ },
391
404
 
392
405
  // dropzone
393
406
  onDropzoneChange = async (files) => {
@@ -1087,6 +1100,19 @@ function AttachmentsElement(props) {
1087
1100
  )}
1088
1101
  tooltip="List View"
1089
1102
  />
1103
+ <Box className="spacer flex-1" />
1104
+ <IconButton
1105
+ onPress={onDownloadAll}
1106
+ icon={Download}
1107
+ className={clsx(
1108
+ 'w-[25px]',
1109
+ 'h-[25px]',
1110
+ 'px-[2px]',
1111
+ 'py-[2px]',
1112
+ )}
1113
+ isDisabled={!model || _.isNil(modelid.current) || _.isArray(modelid.current) || files.length === 0}
1114
+ tooltip="Download All"
1115
+ />
1090
1116
  </HStack>
1091
1117
 
1092
1118
  {content}
@@ -1,2 +0,0 @@
1
- export const EDITOR_MODE_ADD = 'EDITOR_MODE_ADD';
2
- export const EDITOR_MODE_EDIT = 'EDITOR_MODE_EDIT';