@axinom/mosaic-ui 0.32.0 → 0.33.0-rc.1

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 (67) hide show
  1. package/dist/components/Accordion/AccordionItem/AccordionItem.d.ts +2 -0
  2. package/dist/components/Accordion/AccordionItem/AccordionItem.d.ts.map +1 -1
  3. package/dist/components/Actions/Actions.d.ts.map +1 -1
  4. package/dist/components/Explorer/Explorer.d.ts.map +1 -1
  5. package/dist/components/Explorer/Explorer.model.d.ts +5 -0
  6. package/dist/components/Explorer/Explorer.model.d.ts.map +1 -1
  7. package/dist/components/Explorer/SelectionExplorer/SelectionExplorer.d.ts.map +1 -1
  8. package/dist/components/Filters/Filter/Filter.d.ts.map +1 -1
  9. package/dist/components/Filters/Filters.model.d.ts +7 -2
  10. package/dist/components/Filters/Filters.model.d.ts.map +1 -1
  11. package/dist/components/Filters/SelectionTypes/MultiOptionFilter/MultiOptionFilter.d.ts +13 -0
  12. package/dist/components/Filters/SelectionTypes/MultiOptionFilter/MultiOptionFilter.d.ts.map +1 -0
  13. package/dist/components/FormStation/FormStation.d.ts.map +1 -1
  14. package/dist/components/FormStation/SaveOnNavigate/SaveOnNavigate.d.ts +2 -0
  15. package/dist/components/FormStation/SaveOnNavigate/SaveOnNavigate.d.ts.map +1 -1
  16. package/dist/components/FormStation/SaveOnNavigate/handleNavigationAttempt.d.ts +1 -1
  17. package/dist/components/FormStation/SaveOnNavigate/handleNavigationAttempt.d.ts.map +1 -1
  18. package/dist/components/FormStation/StationErrorStateType.d.ts +5 -0
  19. package/dist/components/FormStation/StationErrorStateType.d.ts.map +1 -0
  20. package/dist/components/FormStation/useValidationError.d.ts +15 -0
  21. package/dist/components/FormStation/useValidationError.d.ts.map +1 -0
  22. package/dist/components/InfoPanel/Section/Section.d.ts +3 -1
  23. package/dist/components/InfoPanel/Section/Section.d.ts.map +1 -1
  24. package/dist/hooks/useBusy/useBusy.d.ts +4 -0
  25. package/dist/hooks/useBusy/useBusy.d.ts.map +1 -0
  26. package/dist/index.es.js +3 -3
  27. package/dist/index.es.js.map +1 -1
  28. package/dist/index.js +3 -3
  29. package/dist/index.js.map +1 -1
  30. package/dist/initialize.d.ts +11 -1
  31. package/dist/initialize.d.ts.map +1 -1
  32. package/dist/types/ui-config.d.ts +7 -0
  33. package/dist/types/ui-config.d.ts.map +1 -1
  34. package/package.json +3 -3
  35. package/src/components/Accordion/AccordionItem/AccordionItem.spec.tsx +11 -0
  36. package/src/components/Accordion/AccordionItem/AccordionItem.tsx +5 -1
  37. package/src/components/Actions/Actions.spec.tsx +19 -0
  38. package/src/components/Actions/Actions.tsx +8 -1
  39. package/src/components/Explorer/Explorer.model.ts +5 -0
  40. package/src/components/Explorer/Explorer.spec.tsx +37 -5
  41. package/src/components/Explorer/Explorer.tsx +5 -3
  42. package/src/components/Explorer/SelectionExplorer/SelectionExplorer.spec.tsx +30 -0
  43. package/src/components/Explorer/SelectionExplorer/SelectionExplorer.tsx +1 -0
  44. package/src/components/Filters/Filter/Filter.tsx +24 -0
  45. package/src/components/Filters/Filters.model.ts +7 -1
  46. package/src/components/Filters/Filters.stories.tsx +20 -0
  47. package/src/components/Filters/SelectionTypes/MultiOptionFilter/MultiOptionFilter.scss +40 -0
  48. package/src/components/Filters/SelectionTypes/MultiOptionFilter/MultiOptionFilter.tsx +71 -0
  49. package/src/components/FormStation/FormStation.scss +29 -0
  50. package/src/components/FormStation/FormStation.spec.tsx +66 -8
  51. package/src/components/FormStation/FormStation.tsx +34 -16
  52. package/src/components/FormStation/SaveOnNavigate/SaveOnNavigate.tsx +5 -0
  53. package/src/components/FormStation/SaveOnNavigate/handleNavigationAttempt.spec.ts +21 -0
  54. package/src/components/FormStation/SaveOnNavigate/handleNavigationAttempt.ts +2 -0
  55. package/src/components/FormStation/StationErrorStateType.tsx +5 -0
  56. package/src/components/FormStation/useValidationError.tsx +59 -0
  57. package/src/components/InfoPanel/Paragraph/Paragraph.scss +1 -1
  58. package/src/components/InfoPanel/Section/Section.scss +34 -2
  59. package/src/components/InfoPanel/Section/Section.spec.tsx +117 -0
  60. package/src/components/InfoPanel/Section/Section.tsx +32 -9
  61. package/src/components/LandingPageTiles/TileLarge/TileLarge.scss +3 -3
  62. package/src/components/LandingPageTiles/TileSmall/TileSmall.scss +3 -3
  63. package/src/hooks/useBusy/useBusy.spec.tsx +34 -0
  64. package/src/hooks/useBusy/useBusy.tsx +14 -0
  65. package/src/initialize.ts +30 -2
  66. package/src/styles/variables.scss +3 -0
  67. package/src/types/ui-config.ts +15 -0
@@ -5,6 +5,7 @@ import { act } from 'react-dom/test-utils';
5
5
  import { MemoryRouter, Route } from 'react-router-dom';
6
6
  import * as Yup from 'yup';
7
7
  import { noop } from '../../helpers/utils';
8
+ import { hideSaveIndicator, showSaveIndicator } from '../../initialize';
8
9
  import { ActionData, Actions } from '../Actions';
9
10
  import { Action } from '../Actions/Action';
10
11
  import { MessageBar } from '../MessageBar/MessageBar';
@@ -12,6 +13,8 @@ import { PageHeader, PageHeaderAction } from '../PageHeader';
12
13
  import { FormStation, ObjectSchemaDefinition } from './FormStation';
13
14
  import { SaveOnNavigate } from './SaveOnNavigate/SaveOnNavigate';
14
15
 
16
+ jest.mock('../../initialize');
17
+
15
18
  /* eslint-disable @typescript-eslint/no-non-null-assertion */
16
19
 
17
20
  const mockActions = (onActionSelected: jest.Mock): ActionData[] => [
@@ -41,6 +44,10 @@ const MakeDirty: React.FC = () => {
41
44
  };
42
45
 
43
46
  describe('Details', () => {
47
+ beforeEach(() => {
48
+ jest.clearAllMocks();
49
+ });
50
+
44
51
  it('renders the component without crashing', () => {
45
52
  const wrapper = shallow(<FormStation {...defaultProps} />);
46
53
 
@@ -102,7 +109,7 @@ describe('Details', () => {
102
109
  .find(Action)
103
110
  .prop('action').onActionSelected;
104
111
  await act(async () => {
105
- actionSelected();
112
+ actionSelected && actionSelected();
106
113
  });
107
114
 
108
115
  expect(spy).toHaveBeenCalledTimes(1);
@@ -148,7 +155,7 @@ describe('Details', () => {
148
155
  .find(Action)
149
156
  .prop('action').onActionSelected;
150
157
  await act(async () => {
151
- actionSelected();
158
+ actionSelected && actionSelected();
152
159
  });
153
160
 
154
161
  expect(spy).not.toHaveBeenCalled();
@@ -176,7 +183,7 @@ describe('Details', () => {
176
183
  .find(Action)
177
184
  .prop('action').onActionSelected;
178
185
  await act(async () => {
179
- actionSelected();
186
+ actionSelected && actionSelected();
180
187
  });
181
188
 
182
189
  expect(spy).not.toHaveBeenCalled();
@@ -201,7 +208,7 @@ describe('Details', () => {
201
208
  .find(Action)
202
209
  .prop('action').onActionSelected;
203
210
  await act(async () => {
204
- actionSelected();
211
+ actionSelected && actionSelected();
205
212
  });
206
213
 
207
214
  expect(saveSpy).not.toHaveBeenCalled();
@@ -421,7 +428,7 @@ describe('Details', () => {
421
428
  .find(Action)
422
429
  .prop('action').onActionSelected;
423
430
  await act(async () => {
424
- actionSelected();
431
+ actionSelected && actionSelected();
425
432
  });
426
433
 
427
434
  wrapper.update();
@@ -473,7 +480,7 @@ describe('Details', () => {
473
480
  .find(Action)
474
481
  .prop('action').onActionSelected;
475
482
  await act(async () => {
476
- actionSelected();
483
+ actionSelected && actionSelected();
477
484
  });
478
485
 
479
486
  wrapper.update();
@@ -534,7 +541,7 @@ describe('Details', () => {
534
541
  .find(Action)
535
542
  .prop('action').onActionSelected;
536
543
  await act(async () => {
537
- actionSelected();
544
+ actionSelected && actionSelected();
538
545
  });
539
546
 
540
547
  // place form into 'submitting' state
@@ -611,7 +618,7 @@ describe('Details', () => {
611
618
  .find(Action)
612
619
  .prop('action').onActionSelected;
613
620
  await act(async () => {
614
- actionSelected();
621
+ actionSelected && actionSelected();
615
622
  });
616
623
 
617
624
  // place form into 'submitting' state
@@ -644,5 +651,56 @@ describe('Details', () => {
644
651
  isDisabled = action.prop('action').isDisabled;
645
652
  expect(isDisabled).toBe(false);
646
653
  });
654
+
655
+ it('sets the global busy state when submitting', async () => {
656
+ jest.useFakeTimers();
657
+ const spy = jest.fn();
658
+ const sampleActions = mockActions(spy);
659
+ const onSubmit = (): Promise<{ id: number }> =>
660
+ new Promise((resolve) =>
661
+ setTimeout(() => {
662
+ resolve({ id: 3 });
663
+ }, 1000),
664
+ );
665
+
666
+ const wrapper = mount(
667
+ <MemoryRouter>
668
+ <FormStation
669
+ {...defaultProps}
670
+ actions={sampleActions}
671
+ saveData={onSubmit}
672
+ initialData={{ loading: false, data: {} }}
673
+ >
674
+ <MakeDirty />
675
+ </FormStation>
676
+ </MemoryRouter>,
677
+ );
678
+
679
+ // submit form
680
+ const actionSelected = wrapper
681
+ .find(Action)
682
+ .prop('action').onActionSelected;
683
+
684
+ await act(async () => {
685
+ actionSelected && actionSelected();
686
+ });
687
+
688
+ // place form into 'submitting' state
689
+ await act(async () => {
690
+ jest.advanceTimersByTime(500);
691
+ });
692
+
693
+ wrapper.update();
694
+
695
+ expect(showSaveIndicator).toHaveBeenCalledTimes(1);
696
+
697
+ // complete form submission
698
+ await act(async () => {
699
+ jest.runAllTimers();
700
+ });
701
+ wrapper.update();
702
+
703
+ expect(hideSaveIndicator).toHaveBeenCalledTimes(1);
704
+ });
647
705
  });
648
706
  });
@@ -16,21 +16,24 @@ import React, {
16
16
  } from 'react';
17
17
  import { useHistory } from 'react-router-dom';
18
18
  import { OptionalObjectSchema } from 'yup/lib/object';
19
+ import { hideSaveIndicator, showSaveIndicator } from '../../initialize';
19
20
  import { Data } from '../../types/data';
20
21
  import { ErrorTypeToStationError } from '../../utils/ErrorTypeToStationError';
21
22
  import { Actions, ActionsProps } from '../Actions';
22
23
  import { isNavigationAction } from '../Actions/Action/Action';
23
24
  import { IconName } from '../Icons';
24
25
  import { MessageBar } from '../MessageBar';
25
- import { ErrorType, StationError, StationMessage } from '../models';
26
26
  import {
27
27
  PageHeader,
28
28
  PageHeaderActionType,
29
29
  PageHeaderProps,
30
30
  } from '../PageHeader';
31
+ import { ErrorType, StationMessage } from '../models';
31
32
  import { FormActionData, InitialFormData } from './FormStation.models';
32
33
  import classes from './FormStation.scss';
33
34
  import { SaveOnNavigate } from './SaveOnNavigate/SaveOnNavigate';
35
+ import { StationErrorStateType } from './StationErrorStateType';
36
+ import { useValidationError } from './useValidationError';
34
37
 
35
38
  export type ObjectSchemaDefinition<
36
39
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -102,7 +105,8 @@ interface FormActionProps<T, Y> extends Omit<ActionsProps, 'actions'> {
102
105
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
103
106
  validationSchema?: OptionalObjectSchema<ObjectSchemaDefinition<any>>;
104
107
  actions?: FormActionData<T, Y>[];
105
- setStationError: (error: StationError) => void;
108
+ setStationError: (error: StationErrorStateType) => void;
109
+ setValidationError: () => void;
106
110
  submitResponse?: React.MutableRefObject<Y | undefined>;
107
111
  alwaysSubmitBeforeAction?: boolean;
108
112
  className?: string;
@@ -130,8 +134,12 @@ export const FormStation = <TValues extends Data, TSubmitResponse = unknown>({
130
134
  }: PropsWithChildren<
131
135
  FormStationProps<TValues, TSubmitResponse>
132
136
  >): JSX.Element => {
133
- const [stationError, setStationError] = useState<StationError>();
134
- const [isLoadingError, setIsLoadingError] = useState(false);
137
+ const [stationError, setStationError] = useState<StationErrorStateType>();
138
+
139
+ const { setValidationError, validationWatcher } = useValidationError(
140
+ stationError,
141
+ setStationError,
142
+ );
135
143
 
136
144
  const submitResponse = useRef<TSubmitResponse>();
137
145
  const [isFormSubmitting, setIsFormSubmitting] = useState<boolean>(false);
@@ -144,8 +152,10 @@ export const FormStation = <TValues extends Data, TSubmitResponse = unknown>({
144
152
  if (isFormSubmitting) {
145
153
  return;
146
154
  }
155
+
147
156
  try {
148
157
  setIsFormSubmitting(true);
158
+ showSaveIndicator();
149
159
  setStationError(undefined);
150
160
  if (!initialData.loading && saveData) {
151
161
  const response = await saveData(values, initialData, formikHelpers);
@@ -167,6 +177,7 @@ export const FormStation = <TValues extends Data, TSubmitResponse = unknown>({
167
177
  } finally {
168
178
  formikHelpers.setSubmitting(false);
169
179
  setIsFormSubmitting(false);
180
+ hideSaveIndicator();
170
181
  }
171
182
  },
172
183
  [isFormSubmitting, initialData, saveData, setStationError],
@@ -178,19 +189,20 @@ export const FormStation = <TValues extends Data, TSubmitResponse = unknown>({
178
189
  (initialData.data === null && !initialData.loading) ||
179
190
  initialData.entityNotFound
180
191
  ) {
181
- const stationError = ErrorTypeToStationError(
182
- initialData.error,
183
- 'An error occurred when trying to load data.',
184
- 'Entity not found',
185
- );
192
+ const stationError = {
193
+ ...ErrorTypeToStationError(
194
+ initialData.error,
195
+ 'An error occurred when trying to load data.',
196
+ 'Entity not found',
197
+ ),
198
+ type: 'loading',
199
+ };
186
200
 
187
201
  setStationError(stationError);
188
- setIsLoadingError(true);
189
202
  } else {
190
- if (isLoadingError === true) {
203
+ if (stationError?.type === 'loading') {
191
204
  // Only clear the error if it is a loading error, which now seems to be cleared.
192
205
  setStationError(undefined);
193
- setIsLoadingError(false);
194
206
  }
195
207
  }
196
208
  }, [
@@ -198,7 +210,7 @@ export const FormStation = <TValues extends Data, TSubmitResponse = unknown>({
198
210
  initialData.error,
199
211
  initialData.entityNotFound,
200
212
  initialData.data,
201
- isLoadingError,
213
+ stationError?.type,
202
214
  ]);
203
215
 
204
216
  const getContent = (): JSX.Element | undefined => {
@@ -219,7 +231,10 @@ export const FormStation = <TValues extends Data, TSubmitResponse = unknown>({
219
231
  // Loading successful
220
232
  return (
221
233
  <>
222
- <SaveOnNavigate isSubmitting={isFormSubmitting} />
234
+ <SaveOnNavigate
235
+ isSubmitting={isFormSubmitting}
236
+ onNavigationCancelled={setValidationError}
237
+ />
223
238
  <div className={classes.main}>
224
239
  <div
225
240
  className={clsx(classes.formWrapper, {
@@ -268,6 +283,7 @@ export const FormStation = <TValues extends Data, TSubmitResponse = unknown>({
268
283
  enableReinitialize={true}
269
284
  >
270
285
  <>
286
+ {validationWatcher}
271
287
  <FormStationHeader
272
288
  titleProperty={titleProperty as string}
273
289
  defaultTitle={defaultTitle}
@@ -292,6 +308,7 @@ export const FormStation = <TValues extends Data, TSubmitResponse = unknown>({
292
308
  width={actionsWidth}
293
309
  validationSchema={validationSchema}
294
310
  setStationError={setStationError}
311
+ setValidationError={setValidationError}
295
312
  submitResponse={submitResponse}
296
313
  alwaysSubmitBeforeAction={alwaysSubmitBeforeAction}
297
314
  className={classes.actionsPanel}
@@ -313,6 +330,7 @@ const FormStationAction = <T, Y>(
313
330
  actions,
314
331
  validationSchema,
315
332
  setStationError,
333
+ setValidationError,
316
334
  submitResponse,
317
335
  alwaysSubmitBeforeAction,
318
336
  className = '',
@@ -348,8 +366,7 @@ const FormStationAction = <T, Y>(
348
366
  !isValid ||
349
367
  (await validationSchema?.isValid(values)) === false
350
368
  ) {
351
- // eslint-disable-next-line no-console
352
- console.log('form invalid, action not performed');
369
+ setValidationError();
353
370
  // Making sure that the fields will actually show the validation messages (they won't if the from was not touched yet - e.g. on a create station)
354
371
  validateForm();
355
372
  return;
@@ -401,6 +418,7 @@ const FormStationAction = <T, Y>(
401
418
  isValid,
402
419
  resetForm,
403
420
  setStationError,
421
+ setValidationError,
404
422
  submitForm,
405
423
  submitResponse,
406
424
  validateForm,
@@ -1,11 +1,14 @@
1
1
  import { useFormikContext } from 'formik';
2
2
  import React, { useState } from 'react';
3
+ import { noop } from '../../../helpers/utils';
3
4
  import { NavigationAPI, useReactRouterPause } from '../../../hooks';
4
5
  import { handleNavigationAttempt } from './handleNavigationAttempt';
5
6
 
6
7
  interface SaveOnNavigateProps {
7
8
  /** If set to true, will prevent form submission when navigating away. (default: false) */
8
9
  isSubmitting?: boolean;
10
+ /** Callback that will be called when a navigation attempt was cancelled */
11
+ onNavigationCancelled?: () => void;
9
12
  }
10
13
 
11
14
  /**
@@ -13,6 +16,7 @@ interface SaveOnNavigateProps {
13
16
  */
14
17
  export const SaveOnNavigate: React.FC<SaveOnNavigateProps> = ({
15
18
  isSubmitting = false,
19
+ onNavigationCancelled = noop,
16
20
  }) => {
17
21
  const { dirty, isValid, submitForm } = useFormikContext();
18
22
  const [canSubmit, setCanSubmit] = useState<boolean>(true);
@@ -28,6 +32,7 @@ export const SaveOnNavigate: React.FC<SaveOnNavigateProps> = ({
28
32
  isSubmitting,
29
33
  canSubmit,
30
34
  setCanSubmit,
35
+ onNavigationCancelled,
31
36
  ),
32
37
  },
33
38
  dirty,
@@ -20,6 +20,7 @@ describe('handleNavigationAttempt', () => {
20
20
  const isSubmitting = false;
21
21
  const canSubmit = true;
22
22
  const setCanSubmit = jest.fn();
23
+ const onNavigationCancelled = jest.fn();
23
24
 
24
25
  // Act
25
26
  await handleNavigationAttempt(
@@ -30,6 +31,7 @@ describe('handleNavigationAttempt', () => {
30
31
  isSubmitting,
31
32
  canSubmit,
32
33
  setCanSubmit,
34
+ onNavigationCancelled,
33
35
  );
34
36
 
35
37
  // Assert
@@ -38,6 +40,7 @@ describe('handleNavigationAttempt', () => {
38
40
  expect(navigation.cancel).not.toHaveBeenCalled();
39
41
  expect(submitForm).not.toHaveBeenCalled();
40
42
  expect(setCanSubmit).not.toHaveBeenCalled();
43
+ expect(onNavigationCancelled).not.toHaveBeenCalled();
41
44
  });
42
45
 
43
46
  it(`blocks navigation when 'isValid' is 'false' by calling 'navigation.cancel()'`, async () => {
@@ -48,6 +51,7 @@ describe('handleNavigationAttempt', () => {
48
51
  const isSubmitting = false;
49
52
  const canSubmit = true;
50
53
  const setCanSubmit = jest.fn();
54
+ const onNavigationCancelled = jest.fn();
51
55
 
52
56
  // Act
53
57
  await handleNavigationAttempt(
@@ -58,6 +62,7 @@ describe('handleNavigationAttempt', () => {
58
62
  isSubmitting,
59
63
  canSubmit,
60
64
  setCanSubmit,
65
+ onNavigationCancelled,
61
66
  );
62
67
 
63
68
  // Assert
@@ -66,6 +71,7 @@ describe('handleNavigationAttempt', () => {
66
71
  expect(navigation.cancel).toHaveBeenCalledTimes(1);
67
72
  expect(submitForm).not.toHaveBeenCalled();
68
73
  expect(setCanSubmit).not.toHaveBeenCalled();
74
+ expect(onNavigationCancelled).toHaveBeenCalled();
69
75
  });
70
76
 
71
77
  it(`pauses further navigation when 'dirty' is 'true' and 'isValid' is 'true' by calling 'navigation.pause()'`, async () => {
@@ -76,6 +82,7 @@ describe('handleNavigationAttempt', () => {
76
82
  const isSubmitting = true;
77
83
  const canSubmit = true;
78
84
  const setCanSubmit = jest.fn();
85
+ const onNavigationCancelled = jest.fn();
79
86
 
80
87
  // Act
81
88
  await handleNavigationAttempt(
@@ -86,6 +93,7 @@ describe('handleNavigationAttempt', () => {
86
93
  isSubmitting,
87
94
  canSubmit,
88
95
  setCanSubmit,
96
+ onNavigationCancelled,
89
97
  );
90
98
 
91
99
  // Assert
@@ -94,6 +102,7 @@ describe('handleNavigationAttempt', () => {
94
102
  expect(navigation.cancel).not.toHaveBeenCalled();
95
103
  expect(submitForm).not.toHaveBeenCalled();
96
104
  expect(setCanSubmit).not.toHaveBeenCalled();
105
+ expect(onNavigationCancelled).not.toHaveBeenCalled();
97
106
  });
98
107
 
99
108
  it(`while 'isSubmitting' is 'true' navigation should continue to be paused by not calling 'navigation.resume()'`, async () => {
@@ -104,6 +113,7 @@ describe('handleNavigationAttempt', () => {
104
113
  const isSubmitting = true;
105
114
  const canSubmit = true;
106
115
  const setCanSubmit = jest.fn();
116
+ const onNavigationCancelled = jest.fn();
107
117
 
108
118
  // Act
109
119
  await handleNavigationAttempt(
@@ -114,6 +124,7 @@ describe('handleNavigationAttempt', () => {
114
124
  isSubmitting,
115
125
  canSubmit,
116
126
  setCanSubmit,
127
+ onNavigationCancelled,
117
128
  );
118
129
 
119
130
  // Assert
@@ -122,6 +133,7 @@ describe('handleNavigationAttempt', () => {
122
133
  expect(navigation.cancel).not.toHaveBeenCalled();
123
134
  expect(submitForm).not.toHaveBeenCalled();
124
135
  expect(setCanSubmit).not.toHaveBeenCalled();
136
+ expect(onNavigationCancelled).not.toHaveBeenCalled();
125
137
  });
126
138
 
127
139
  it(`while 'canSubmit' is 'false' navigation should continue to be paused by not calling 'navigation.resume()'`, async () => {
@@ -132,6 +144,7 @@ describe('handleNavigationAttempt', () => {
132
144
  const isSubmitting = false;
133
145
  const canSubmit = false;
134
146
  const setCanSubmit = jest.fn();
147
+ const onNavigationCancelled = jest.fn();
135
148
 
136
149
  // Act
137
150
  await handleNavigationAttempt(
@@ -142,6 +155,7 @@ describe('handleNavigationAttempt', () => {
142
155
  isSubmitting,
143
156
  canSubmit,
144
157
  setCanSubmit,
158
+ onNavigationCancelled,
145
159
  );
146
160
 
147
161
  // Assert
@@ -150,6 +164,7 @@ describe('handleNavigationAttempt', () => {
150
164
  expect(navigation.cancel).not.toHaveBeenCalled();
151
165
  expect(submitForm).not.toHaveBeenCalled();
152
166
  expect(setCanSubmit).not.toHaveBeenCalled();
167
+ expect(onNavigationCancelled).not.toHaveBeenCalled();
153
168
  });
154
169
 
155
170
  it(`navigation can resume after the submitForm promise was successfully resolved by calling 'navigation.resume()'`, async () => {
@@ -160,6 +175,7 @@ describe('handleNavigationAttempt', () => {
160
175
  const isSubmitting = false;
161
176
  const canSubmit = true;
162
177
  const setCanSubmit = jest.fn();
178
+ const onNavigationCancelled = jest.fn();
163
179
 
164
180
  // Act
165
181
  await handleNavigationAttempt(
@@ -170,6 +186,7 @@ describe('handleNavigationAttempt', () => {
170
186
  isSubmitting,
171
187
  canSubmit,
172
188
  setCanSubmit,
189
+ onNavigationCancelled,
173
190
  );
174
191
 
175
192
  // Assert
@@ -179,6 +196,7 @@ describe('handleNavigationAttempt', () => {
179
196
  expect(submitForm).toHaveBeenCalledTimes(1);
180
197
  expect(setCanSubmit).toHaveBeenCalledTimes(1);
181
198
  expect(setCanSubmit).toHaveBeenCalledWith(false);
199
+ expect(onNavigationCancelled).not.toHaveBeenCalledWith(false);
182
200
  });
183
201
 
184
202
  it(`navigation is cancelled when the 'submitForm' promise was rejected by calling 'navigation.cancel()'`, async () => {
@@ -191,6 +209,7 @@ describe('handleNavigationAttempt', () => {
191
209
  const isSubmitting = false;
192
210
  const canSubmit = true;
193
211
  const setCanSubmit = jest.fn();
212
+ const onNavigationCancelled = jest.fn();
194
213
 
195
214
  // Act
196
215
  await handleNavigationAttempt(
@@ -201,6 +220,7 @@ describe('handleNavigationAttempt', () => {
201
220
  isSubmitting,
202
221
  canSubmit,
203
222
  setCanSubmit,
223
+ onNavigationCancelled,
204
224
  );
205
225
 
206
226
  // Assert
@@ -211,5 +231,6 @@ describe('handleNavigationAttempt', () => {
211
231
  expect(setCanSubmit).toHaveBeenCalledTimes(2);
212
232
  expect(setCanSubmit).toHaveBeenNthCalledWith(1, false);
213
233
  expect(setCanSubmit).toHaveBeenNthCalledWith(2, true);
234
+ expect(onNavigationCancelled).not.toHaveBeenCalled();
214
235
  });
215
236
  });
@@ -10,6 +10,7 @@ export const handleNavigationAttempt = async (
10
10
  isSubmitting: boolean,
11
11
  canSubmit: boolean,
12
12
  setCanSubmit: React.Dispatch<React.SetStateAction<boolean>>,
13
+ onNavigationCancelled: () => void,
13
14
  ): Promise<void> => {
14
15
  if (!dirty) {
15
16
  // Form values didn't change, just navigate away
@@ -20,6 +21,7 @@ export const handleNavigationAttempt = async (
20
21
  // Form is invalid, cancel navigation
21
22
  // TODO: Add a message here (probably connected to "Error handling")
22
23
  navigation.cancel();
24
+ onNavigationCancelled();
23
25
  return;
24
26
  }
25
27
  // Form values have changed and form is valid, attempt save
@@ -0,0 +1,5 @@
1
+ import { StationError } from '../models';
2
+
3
+ export interface StationErrorStateType extends StationError {
4
+ type?: string;
5
+ }
@@ -0,0 +1,59 @@
1
+ import { useFormikContext } from 'formik';
2
+ import React, { useCallback, useEffect, useState } from 'react';
3
+ import { StationErrorStateType } from './StationErrorStateType';
4
+
5
+ /**
6
+ * Component that watches for changes in the form validation state
7
+ * and calls the callback with the current state.
8
+ */
9
+ const ValidationWatcher: React.FC<{
10
+ callback: (isValid: boolean) => void;
11
+ }> = ({ callback: isValid }) => {
12
+ const formik = useFormikContext();
13
+ useEffect(() => {
14
+ isValid(formik.isValid);
15
+ }, [formik.isValid, isValid]);
16
+ return null;
17
+ };
18
+ /**
19
+ * Cares for showing (and removing) validation errors.
20
+ * @param stationError the currently showing error
21
+ * @param setStationError the setter for the currently showing error
22
+ * @returns
23
+ * `setValidationError` - call this method to set the validation error.
24
+ * `validationWatcher` - place this JSX somewhere inside the <Formik /> component children.
25
+ */
26
+ export function useValidationError(
27
+ stationError: StationErrorStateType | undefined,
28
+ setStationError: React.Dispatch<
29
+ React.SetStateAction<StationErrorStateType | undefined>
30
+ >,
31
+ ): {
32
+ setValidationError: () => void;
33
+ validationWatcher: JSX.Element;
34
+ } {
35
+ // Track validation state of the from using the validationWatcher
36
+ const [isValid, setIsValid] = useState<boolean>(true);
37
+
38
+ useEffect(() => {
39
+ // if the form became valid, remove the validation error
40
+ if (isValid && stationError && stationError.type === 'validation') {
41
+ setStationError(undefined);
42
+ }
43
+ }, [isValid, setStationError, stationError]);
44
+
45
+ const setValidationError = useCallback((): void => {
46
+ // set the validation error
47
+ setStationError({
48
+ title: 'Please fix the errors in the form to proceed.',
49
+ type: 'validation',
50
+ });
51
+ }, [setStationError]);
52
+
53
+ return {
54
+ setValidationError,
55
+ validationWatcher: (
56
+ <ValidationWatcher callback={(valid) => setIsValid(valid)} />
57
+ ),
58
+ };
59
+ }
@@ -11,7 +11,7 @@
11
11
  row-gap: 10px;
12
12
  }
13
13
 
14
- margin-bottom: 30px;
14
+ margin-bottom: 20px;
15
15
 
16
16
  .title {
17
17
  color: var(--infopanel-subtitle-color, $infopanel-subtitle-color);
@@ -7,12 +7,19 @@
7
7
  grid-template-columns: 1fr;
8
8
 
9
9
  &.hasTitle {
10
- grid-template-rows: max-content max-content;
10
+ grid-template-rows: max-content;
11
+
12
+ padding-top: 6px;
13
+ padding-bottom: 6px;
14
+
15
+ &.expanded {
16
+ grid-template-rows: max-content max-content;
17
+ }
11
18
  }
12
19
 
13
20
  padding: 30px;
14
21
 
15
- row-gap: 30px;
22
+ row-gap: 20px;
16
23
 
17
24
  .title,
18
25
  .main {
@@ -37,4 +44,29 @@
37
44
  --infopanel-background-color,
38
45
  $infopanel-background-color
39
46
  );
47
+
48
+ .collapsibleSection {
49
+ & > div:first-child {
50
+ background-color: unset;
51
+
52
+ div:last-child {
53
+ margin-left: -18px;
54
+ }
55
+
56
+ svg {
57
+ margin-left: -34px;
58
+ }
59
+ }
60
+
61
+ & > div:last-child {
62
+ border-bottom: unset;
63
+ }
64
+
65
+ & > div > div:last-child {
66
+ background-color: var(
67
+ --infopanel-background-color,
68
+ $infopanel-background-color
69
+ );
70
+ }
71
+ }
40
72
  }