@axinom/mosaic-ui 0.74.0 → 0.74.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.
- package/dist/cjs/components/FormStation/FormStation.d.ts +17 -1
- package/dist/cjs/components/FormStation/FormStation.d.ts.map +1 -1
- package/dist/cjs/components/FormStation/FormStationHeader/FormStationHeader.d.ts +2 -0
- package/dist/cjs/components/FormStation/FormStationHeader/FormStationHeader.d.ts.map +1 -1
- package/dist/cjs/components/FormStation/ModalForm/ModalForm.d.ts +41 -0
- package/dist/cjs/components/FormStation/ModalForm/ModalForm.d.ts.map +1 -0
- package/dist/cjs/components/FormStation/ModalForm/useModalForm.d.ts +19 -0
- package/dist/cjs/components/FormStation/ModalForm/useModalForm.d.ts.map +1 -0
- package/dist/cjs/components/FormStation/index.d.ts +2 -0
- package/dist/cjs/components/FormStation/index.d.ts.map +1 -1
- package/dist/cjs/index.js +4 -4
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/components/FormStation/FormStation.d.ts +17 -1
- package/dist/esm/components/FormStation/FormStation.d.ts.map +1 -1
- package/dist/esm/components/FormStation/FormStationHeader/FormStationHeader.d.ts +2 -0
- package/dist/esm/components/FormStation/FormStationHeader/FormStationHeader.d.ts.map +1 -1
- package/dist/esm/components/FormStation/ModalForm/ModalForm.d.ts +41 -0
- package/dist/esm/components/FormStation/ModalForm/ModalForm.d.ts.map +1 -0
- package/dist/esm/components/FormStation/ModalForm/useModalForm.d.ts +19 -0
- package/dist/esm/components/FormStation/ModalForm/useModalForm.d.ts.map +1 -0
- package/dist/esm/components/FormStation/index.d.ts +2 -0
- package/dist/esm/components/FormStation/index.d.ts.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/FormStation/FormStation.tsx +22 -1
- package/src/components/FormStation/FormStationHeader/FormStationHeader.tsx +34 -2
- package/src/components/FormStation/ModalForm/ModalForm.module.scss +18 -0
- package/src/components/FormStation/ModalForm/ModalForm.spec.tsx +290 -0
- package/src/components/FormStation/ModalForm/ModalForm.stories.tsx +191 -0
- package/src/components/FormStation/ModalForm/ModalForm.tsx +80 -0
- package/src/components/FormStation/ModalForm/useModalForm.tsx +30 -0
- package/src/components/FormStation/index.ts +5 -0
- package/src/components/Modal/Modal.module.scss +4 -3
- package/src/styles/variables.scss +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axinom/mosaic-ui",
|
|
3
|
-
"version": "0.74.
|
|
3
|
+
"version": "0.74.1",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=20.19.0"
|
|
6
6
|
},
|
|
@@ -112,5 +112,5 @@
|
|
|
112
112
|
"publishConfig": {
|
|
113
113
|
"access": "public"
|
|
114
114
|
},
|
|
115
|
-
"gitHead": "
|
|
115
|
+
"gitHead": "4e25b38a8dac4aaada88ec0ca96c94bfcb12b19c"
|
|
116
116
|
}
|
|
@@ -67,6 +67,22 @@ export interface FormStationProps<
|
|
|
67
67
|
validationSchema?: OptionalObjectSchema<ObjectSchemaDefinition<TValues>>;
|
|
68
68
|
/** URL to navigate to when the form is reset (usually on a create station) */
|
|
69
69
|
cancelNavigationUrl?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Called when the header's 'Cancel' action was selected and the form was
|
|
72
|
+
* reset. Takes precedence over `cancelNavigationUrl`.
|
|
73
|
+
*/
|
|
74
|
+
onCancel?: () => void;
|
|
75
|
+
/**
|
|
76
|
+
* Called once the header's save action saved the form successfully. Setting
|
|
77
|
+
* it also makes that action submit the form directly, instead of saving on
|
|
78
|
+
* navigation.
|
|
79
|
+
*/
|
|
80
|
+
onSaveCompleted?: () => void;
|
|
81
|
+
/**
|
|
82
|
+
* If set to true, a dirty form is saved when the user navigates away, and
|
|
83
|
+
* navigation is cancelled while the form is invalid. (default: true)
|
|
84
|
+
*/
|
|
85
|
+
saveOnNavigate?: boolean;
|
|
70
86
|
/** Component which contains additional information */
|
|
71
87
|
infoPanel?: JSX.Element;
|
|
72
88
|
/** Optional flag for removing the padding around child components. */
|
|
@@ -101,6 +117,9 @@ export const FormStation = <TValues extends Data, TSubmitResponse = unknown>({
|
|
|
101
117
|
actions,
|
|
102
118
|
actionsWidth,
|
|
103
119
|
cancelNavigationUrl,
|
|
120
|
+
onCancel,
|
|
121
|
+
onSaveCompleted,
|
|
122
|
+
saveOnNavigate = true,
|
|
104
123
|
initialData,
|
|
105
124
|
saveData,
|
|
106
125
|
validationSchema,
|
|
@@ -191,13 +210,15 @@ export const FormStation = <TValues extends Data, TSubmitResponse = unknown>({
|
|
|
191
210
|
defaultTitle={defaultTitle}
|
|
192
211
|
subtitle={subtitle}
|
|
193
212
|
cancelNavigationUrl={cancelNavigationUrl}
|
|
213
|
+
onCancel={onCancel}
|
|
214
|
+
onSaveCompleted={onSaveCompleted}
|
|
194
215
|
className={classes.header}
|
|
195
216
|
setTabTitle={setTabTitle}
|
|
196
217
|
showSaveHeaderAction={showSaveHeaderAction}
|
|
197
218
|
saveHeaderActionConfig={saveHeaderActionConfig}
|
|
198
219
|
setValidationError={setValidationError}
|
|
199
220
|
/>
|
|
200
|
-
{!bulkEditContext && (
|
|
221
|
+
{saveOnNavigate && !bulkEditContext && (
|
|
201
222
|
<SaveOnNavigate
|
|
202
223
|
isSubmitting={isFormSubmitting}
|
|
203
224
|
onNavigationCancelled={setValidationError}
|
|
@@ -21,6 +21,8 @@ export const FormStationHeader: React.FC<
|
|
|
21
21
|
titleProperty?: string;
|
|
22
22
|
defaultTitle?: string;
|
|
23
23
|
cancelNavigationUrl?: string;
|
|
24
|
+
onCancel?: () => void;
|
|
25
|
+
onSaveCompleted?: () => void;
|
|
24
26
|
setTabTitle?: boolean;
|
|
25
27
|
showSaveHeaderAction?: boolean;
|
|
26
28
|
saveHeaderActionConfig?: Pick<PageHeaderJsActionProps, 'label' | 'icon'>;
|
|
@@ -31,6 +33,8 @@ export const FormStationHeader: React.FC<
|
|
|
31
33
|
defaultTitle,
|
|
32
34
|
subtitle,
|
|
33
35
|
cancelNavigationUrl,
|
|
36
|
+
onCancel,
|
|
37
|
+
onSaveCompleted,
|
|
34
38
|
className,
|
|
35
39
|
setTabTitle,
|
|
36
40
|
showSaveHeaderAction,
|
|
@@ -71,7 +75,23 @@ export const FormStationHeader: React.FC<
|
|
|
71
75
|
kind: 'action',
|
|
72
76
|
actionType: PageHeaderActionType.Context,
|
|
73
77
|
onClick: async () => {
|
|
74
|
-
if (
|
|
78
|
+
if (onSaveCompleted) {
|
|
79
|
+
// No navigation happens, so `SaveOnNavigate` cannot do the save.
|
|
80
|
+
const errors = await validateForm();
|
|
81
|
+
if (Object.keys(errors).length > 0) {
|
|
82
|
+
setValidationError();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
await submitForm();
|
|
88
|
+
} catch {
|
|
89
|
+
// The station error is already set by the submit handler.
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
onSaveCompleted();
|
|
94
|
+
} else if (quickEditContext?.isQuickEditMode) {
|
|
75
95
|
quickEditContext.refresh();
|
|
76
96
|
} else if (bulkEditContext?.isBulkEditMode) {
|
|
77
97
|
const errors = await validateForm();
|
|
@@ -111,7 +131,17 @@ export const FormStationHeader: React.FC<
|
|
|
111
131
|
});
|
|
112
132
|
}
|
|
113
133
|
|
|
114
|
-
if (
|
|
134
|
+
if (onCancel) {
|
|
135
|
+
actionItems.push({
|
|
136
|
+
label: 'Cancel',
|
|
137
|
+
icon: IconName.X,
|
|
138
|
+
kind: 'action',
|
|
139
|
+
onClick: () => {
|
|
140
|
+
resetForm();
|
|
141
|
+
onCancel();
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
} else if (cancelNavigationUrl) {
|
|
115
145
|
actionItems.push({
|
|
116
146
|
label: 'Cancel',
|
|
117
147
|
icon: IconName.X,
|
|
@@ -130,6 +160,8 @@ export const FormStationHeader: React.FC<
|
|
|
130
160
|
cancelNavigationUrl,
|
|
131
161
|
dirty,
|
|
132
162
|
history,
|
|
163
|
+
onCancel,
|
|
164
|
+
onSaveCompleted,
|
|
133
165
|
quickEditContext,
|
|
134
166
|
resetForm,
|
|
135
167
|
saveHeaderActionConfig.icon,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
@import '../../../styles/common.scss';
|
|
2
|
+
|
|
3
|
+
.container {
|
|
4
|
+
@include boxSizing;
|
|
5
|
+
|
|
6
|
+
display: grid;
|
|
7
|
+
grid-template-rows: minmax(0, 1fr);
|
|
8
|
+
|
|
9
|
+
// The modal sizes itself to its content, so the form claims its own width.
|
|
10
|
+
width: calc(100vw - #{$modal-padding-side} - #{$modal-padding-side});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// FormStation sizes itself to the viewport minus the app header, which is the
|
|
14
|
+
// wrong box inside a modal. Doubled class to outrank it regardless of
|
|
15
|
+
// stylesheet order.
|
|
16
|
+
.station.station {
|
|
17
|
+
height: calc(100vh - #{$modal-padding-top} - #{$modal-padding-bottom});
|
|
18
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { useFormikContext } from 'formik';
|
|
3
|
+
import React, { useEffect } from 'react';
|
|
4
|
+
import { act } from 'react-dom/test-utils';
|
|
5
|
+
import { Link, MemoryRouter, Route } from 'react-router-dom';
|
|
6
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import * as Yup from 'yup';
|
|
8
|
+
import { noop } from '../../../helpers/utils';
|
|
9
|
+
import { initializeUi } from '../../../initialize';
|
|
10
|
+
import { TextButton } from '../../Buttons';
|
|
11
|
+
import type { ObjectSchemaDefinition } from '../FormStation.models';
|
|
12
|
+
import { ModalForm } from './ModalForm';
|
|
13
|
+
import { useModalForm } from './useModalForm';
|
|
14
|
+
|
|
15
|
+
vi.mock('../../../initialize');
|
|
16
|
+
|
|
17
|
+
interface FormValues {
|
|
18
|
+
something: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const initialData = {
|
|
22
|
+
loading: false,
|
|
23
|
+
data: { something: 'initial' },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Makes the form dirty, so the header shows its 'Save' action. */
|
|
27
|
+
const ChangeValue: React.FC = () => {
|
|
28
|
+
const context = useFormikContext();
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
context.setFieldValue('something', 'changed');
|
|
31
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
32
|
+
}, []);
|
|
33
|
+
return null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Waits for Formik's asynchronous state updates to settle. */
|
|
37
|
+
const settle = async (): Promise<void> => {
|
|
38
|
+
await act(async () => {
|
|
39
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const headerActions = (): HTMLElement[] =>
|
|
44
|
+
Array.from(
|
|
45
|
+
document.querySelectorAll<HTMLElement>(
|
|
46
|
+
'[data-test-id="page-header-actions"] [data-test-id="action"]',
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
describe('ModalForm', () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.clearAllMocks();
|
|
53
|
+
|
|
54
|
+
initializeUi({
|
|
55
|
+
showNotification: () => -1,
|
|
56
|
+
addIndicator: () => -1,
|
|
57
|
+
removeIndicator: noop,
|
|
58
|
+
on: () => noop,
|
|
59
|
+
setTitle: noop,
|
|
60
|
+
setSaveIndicator: noop,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const renderModalForm = (
|
|
65
|
+
props: Partial<React.ComponentProps<typeof ModalForm<FormValues>>> = {},
|
|
66
|
+
): { closeModal: () => void; saveData: () => void } => {
|
|
67
|
+
const closeModal = vi.fn();
|
|
68
|
+
const saveData = vi.fn();
|
|
69
|
+
|
|
70
|
+
render(
|
|
71
|
+
<MemoryRouter>
|
|
72
|
+
<ModalForm<FormValues>
|
|
73
|
+
title="Modal Form"
|
|
74
|
+
closeModal={closeModal}
|
|
75
|
+
initialData={initialData}
|
|
76
|
+
saveData={saveData}
|
|
77
|
+
{...props}
|
|
78
|
+
>
|
|
79
|
+
<ChangeValue />
|
|
80
|
+
</ModalForm>
|
|
81
|
+
</MemoryRouter>,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return { closeModal, saveData };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
it('shows the given title', async () => {
|
|
88
|
+
renderModalForm();
|
|
89
|
+
await settle();
|
|
90
|
+
|
|
91
|
+
expect(screen.getByTestId('page-header-title')).toHaveTextContent(
|
|
92
|
+
'Modal Form',
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('closes the modal without saving when cancelled', async () => {
|
|
97
|
+
const { closeModal, saveData } = renderModalForm();
|
|
98
|
+
await settle();
|
|
99
|
+
|
|
100
|
+
// 'Undo Changes', 'Save' and 'Cancel'
|
|
101
|
+
const actions = headerActions();
|
|
102
|
+
expect(actions).toHaveLength(3);
|
|
103
|
+
|
|
104
|
+
fireEvent.click(actions[2]);
|
|
105
|
+
await settle();
|
|
106
|
+
|
|
107
|
+
expect(saveData).not.toHaveBeenCalled();
|
|
108
|
+
expect(closeModal).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('saves the data and closes the modal when saved', async () => {
|
|
112
|
+
const { closeModal, saveData } = renderModalForm();
|
|
113
|
+
await settle();
|
|
114
|
+
|
|
115
|
+
fireEvent.click(headerActions()[1]);
|
|
116
|
+
await settle();
|
|
117
|
+
|
|
118
|
+
expect(saveData).toHaveBeenCalledTimes(1);
|
|
119
|
+
expect(saveData).toHaveBeenCalledWith(
|
|
120
|
+
{ something: 'changed' },
|
|
121
|
+
initialData,
|
|
122
|
+
expect.anything(),
|
|
123
|
+
);
|
|
124
|
+
expect(closeModal).toHaveBeenCalledTimes(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('keeps the modal open and shows the error when saving fails', async () => {
|
|
128
|
+
const { closeModal } = renderModalForm({
|
|
129
|
+
saveData: () => {
|
|
130
|
+
throw { title: 'Saving failed' };
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
await settle();
|
|
134
|
+
|
|
135
|
+
fireEvent.click(headerActions()[1]);
|
|
136
|
+
await settle();
|
|
137
|
+
|
|
138
|
+
expect(closeModal).not.toHaveBeenCalled();
|
|
139
|
+
expect(screen.getByText('Saving failed')).toBeInTheDocument();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('keeps the modal open and does not save an invalid form', async () => {
|
|
143
|
+
const { closeModal, saveData } = renderModalForm({
|
|
144
|
+
validationSchema: Yup.object<ObjectSchemaDefinition<FormValues>>({
|
|
145
|
+
something: Yup.string().max(3, 'Too long'),
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
await settle();
|
|
149
|
+
|
|
150
|
+
fireEvent.click(headerActions()[1]);
|
|
151
|
+
await settle();
|
|
152
|
+
|
|
153
|
+
expect(saveData).not.toHaveBeenCalled();
|
|
154
|
+
expect(closeModal).not.toHaveBeenCalled();
|
|
155
|
+
expect(
|
|
156
|
+
screen.getByText('Please fix the errors in the form to proceed.'),
|
|
157
|
+
).toBeInTheDocument();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('does not show a save action while the form is pristine', async () => {
|
|
161
|
+
render(
|
|
162
|
+
<MemoryRouter>
|
|
163
|
+
<ModalForm<FormValues>
|
|
164
|
+
title="Modal Form"
|
|
165
|
+
closeModal={noop}
|
|
166
|
+
initialData={initialData}
|
|
167
|
+
saveData={noop}
|
|
168
|
+
/>
|
|
169
|
+
</MemoryRouter>,
|
|
170
|
+
);
|
|
171
|
+
await settle();
|
|
172
|
+
|
|
173
|
+
// Only 'Cancel' - the others appear once the form is dirty.
|
|
174
|
+
expect(headerActions()).toHaveLength(1);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('discards the changes instead of saving them when navigating away', async () => {
|
|
178
|
+
let path = '';
|
|
179
|
+
const closeModal = vi.fn();
|
|
180
|
+
const saveData = vi.fn();
|
|
181
|
+
|
|
182
|
+
render(
|
|
183
|
+
<MemoryRouter>
|
|
184
|
+
<ModalForm<FormValues>
|
|
185
|
+
title="Modal Form"
|
|
186
|
+
closeModal={closeModal}
|
|
187
|
+
initialData={initialData}
|
|
188
|
+
saveData={saveData}
|
|
189
|
+
>
|
|
190
|
+
<ChangeValue />
|
|
191
|
+
</ModalForm>
|
|
192
|
+
<Link to="/somewhere-else">Go</Link>
|
|
193
|
+
<Route
|
|
194
|
+
path="*"
|
|
195
|
+
render={({ location }) => {
|
|
196
|
+
path = location.pathname;
|
|
197
|
+
return null;
|
|
198
|
+
}}
|
|
199
|
+
/>
|
|
200
|
+
</MemoryRouter>,
|
|
201
|
+
);
|
|
202
|
+
await settle();
|
|
203
|
+
|
|
204
|
+
fireEvent.click(screen.getByText('Go'));
|
|
205
|
+
await settle();
|
|
206
|
+
|
|
207
|
+
expect(saveData).not.toHaveBeenCalled();
|
|
208
|
+
expect(path).toBe('/somewhere-else');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('does not block navigation while the form is invalid', async () => {
|
|
212
|
+
let path = '';
|
|
213
|
+
|
|
214
|
+
render(
|
|
215
|
+
<MemoryRouter>
|
|
216
|
+
<ModalForm<FormValues>
|
|
217
|
+
title="Modal Form"
|
|
218
|
+
closeModal={noop}
|
|
219
|
+
initialData={initialData}
|
|
220
|
+
saveData={noop}
|
|
221
|
+
validationSchema={Yup.object<ObjectSchemaDefinition<FormValues>>({
|
|
222
|
+
something: Yup.string().max(3, 'Too long'),
|
|
223
|
+
})}
|
|
224
|
+
>
|
|
225
|
+
<ChangeValue />
|
|
226
|
+
</ModalForm>
|
|
227
|
+
<Link to="/somewhere-else">Go</Link>
|
|
228
|
+
<Route
|
|
229
|
+
path="*"
|
|
230
|
+
render={({ location }) => {
|
|
231
|
+
path = location.pathname;
|
|
232
|
+
return null;
|
|
233
|
+
}}
|
|
234
|
+
/>
|
|
235
|
+
</MemoryRouter>,
|
|
236
|
+
);
|
|
237
|
+
await settle();
|
|
238
|
+
|
|
239
|
+
fireEvent.click(screen.getByText('Go'));
|
|
240
|
+
await settle();
|
|
241
|
+
|
|
242
|
+
expect(path).toBe('/somewhere-else');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('useModalForm', () => {
|
|
246
|
+
const Host: React.FC = () => {
|
|
247
|
+
const { ModalWrapper, openModal } = useModalForm<FormValues>({
|
|
248
|
+
title: 'Modal Form',
|
|
249
|
+
initialData,
|
|
250
|
+
saveData: noop,
|
|
251
|
+
children: <ChangeValue />,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<>
|
|
256
|
+
<TextButton text="Open" onButtonClicked={openModal} />
|
|
257
|
+
<ModalWrapper />
|
|
258
|
+
</>
|
|
259
|
+
);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
it('opens the form in a modal and closes it again when cancelled', async () => {
|
|
263
|
+
render(
|
|
264
|
+
<MemoryRouter>
|
|
265
|
+
<Host />
|
|
266
|
+
</MemoryRouter>,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
|
270
|
+
|
|
271
|
+
fireEvent.click(screen.getByText('Open'));
|
|
272
|
+
await settle();
|
|
273
|
+
|
|
274
|
+
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
|
275
|
+
expect(screen.getByTestId('page-header-title')).toHaveTextContent(
|
|
276
|
+
'Modal Form',
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const actions = headerActions();
|
|
280
|
+
fireEvent.click(actions[actions.length - 1]);
|
|
281
|
+
await settle();
|
|
282
|
+
|
|
283
|
+
// The modal plays an exit animation before it unmounts.
|
|
284
|
+
fireEvent.animationEnd(screen.getByTestId('modal'));
|
|
285
|
+
await settle();
|
|
286
|
+
|
|
287
|
+
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { Field } from 'formik';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { MemoryRouter } from 'react-router';
|
|
5
|
+
import { action } from 'storybook/actions';
|
|
6
|
+
import * as Yup from 'yup';
|
|
7
|
+
import { createGroups } from '../../../helpers/storybook';
|
|
8
|
+
import { TextButton } from '../../Buttons';
|
|
9
|
+
import {
|
|
10
|
+
CustomTagsField,
|
|
11
|
+
SelectField,
|
|
12
|
+
SingleLineTextField,
|
|
13
|
+
TagsField,
|
|
14
|
+
} from '../../FormElements';
|
|
15
|
+
import { useModal } from '../../Modal';
|
|
16
|
+
import type { ObjectSchemaDefinition } from '../FormStation.models';
|
|
17
|
+
import { ModalForm } from './ModalForm';
|
|
18
|
+
import { useModalForm } from './useModalForm';
|
|
19
|
+
|
|
20
|
+
interface MovieValues {
|
|
21
|
+
title?: string;
|
|
22
|
+
genres?: string[];
|
|
23
|
+
ratings?: string;
|
|
24
|
+
shortDescription?: string;
|
|
25
|
+
cast?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ModalFormStoryType = typeof ModalForm<MovieValues>;
|
|
29
|
+
|
|
30
|
+
const groups = createGroups({
|
|
31
|
+
Content: [],
|
|
32
|
+
Behavior: [],
|
|
33
|
+
Styling: [],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const meta: Meta<ModalFormStoryType> = {
|
|
37
|
+
title: 'Primary Components/Station Controls/FormStation/ModalForm',
|
|
38
|
+
component: ModalForm,
|
|
39
|
+
argTypes: {
|
|
40
|
+
...groups,
|
|
41
|
+
closeModal: {
|
|
42
|
+
control: false,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
decorators: [
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
|
47
|
+
(Story) => (
|
|
48
|
+
<MemoryRouter>
|
|
49
|
+
<Story />
|
|
50
|
+
</MemoryRouter>
|
|
51
|
+
),
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
export default meta;
|
|
55
|
+
|
|
56
|
+
const formFields = (
|
|
57
|
+
<>
|
|
58
|
+
<Field name="title" label="Title" as={SingleLineTextField} />
|
|
59
|
+
<Field
|
|
60
|
+
name="genres"
|
|
61
|
+
label="Genre(s)"
|
|
62
|
+
tagsOptions={['Crime', 'Drama', 'Thriller']}
|
|
63
|
+
as={TagsField}
|
|
64
|
+
/>
|
|
65
|
+
<Field
|
|
66
|
+
name="ratings"
|
|
67
|
+
label="Age Rating"
|
|
68
|
+
options={[
|
|
69
|
+
{ value: 'PG', label: 'Parental Guidance Suggested (PG)' },
|
|
70
|
+
{ value: 'PG-13', label: 'Parents Strongly Cautioned (PG-13)' },
|
|
71
|
+
{ value: 'R', label: 'Restricted (R)' },
|
|
72
|
+
]}
|
|
73
|
+
as={SelectField}
|
|
74
|
+
/>
|
|
75
|
+
<Field
|
|
76
|
+
name="shortDescription"
|
|
77
|
+
label="Short Description"
|
|
78
|
+
placeholder="Enter a short description..."
|
|
79
|
+
as={SingleLineTextField}
|
|
80
|
+
/>
|
|
81
|
+
<Field name="cast" label="Cast" as={CustomTagsField} />
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const defaultArgs = {
|
|
86
|
+
title: 'Edit Movie',
|
|
87
|
+
subtitle: 'Movies',
|
|
88
|
+
saveData: (...args: unknown[]): Promise<void> => {
|
|
89
|
+
action('saveData')(args);
|
|
90
|
+
return new Promise<void>((resolve) => setTimeout(resolve, 500));
|
|
91
|
+
},
|
|
92
|
+
validationSchema: Yup.object<ObjectSchemaDefinition<MovieValues>>({
|
|
93
|
+
title: Yup.string().required().max(25).label('Title'),
|
|
94
|
+
shortDescription: Yup.string().required().label('Short Description'),
|
|
95
|
+
}),
|
|
96
|
+
initialData: {
|
|
97
|
+
loading: false,
|
|
98
|
+
data: {
|
|
99
|
+
title: 'The Silent Sea',
|
|
100
|
+
genres: ['Drama'],
|
|
101
|
+
ratings: 'PG-13',
|
|
102
|
+
shortDescription: 'A crew searches an abandoned lunar research station.',
|
|
103
|
+
cast: ['Bae Doona'],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
children: formFields,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** The form hosted in a modal by the `useModal` hook. */
|
|
110
|
+
export const Default: StoryObj<ModalFormStoryType> = {
|
|
111
|
+
args: defaultArgs,
|
|
112
|
+
render: (args) =>
|
|
113
|
+
React.createElement(() => {
|
|
114
|
+
const { ModalWrapper, openModal } = useModal(({ closeModal }) => (
|
|
115
|
+
<ModalForm<MovieValues> {...args} closeModal={closeModal} />
|
|
116
|
+
));
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div>
|
|
120
|
+
<TextButton text="Edit Movie" onButtonClicked={openModal} />
|
|
121
|
+
<ModalWrapper />
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** The same form, set up with the `useModalForm` hook instead. */
|
|
128
|
+
export const WithUseModalForm: StoryObj<ModalFormStoryType> = {
|
|
129
|
+
args: defaultArgs,
|
|
130
|
+
render: (args) =>
|
|
131
|
+
React.createElement(() => {
|
|
132
|
+
const { ModalWrapper, openModal } = useModalForm<MovieValues>(args);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div>
|
|
136
|
+
<TextButton text="Edit Movie" onButtonClicked={openModal} />
|
|
137
|
+
<ModalWrapper />
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** A failing save keeps the modal open and shows the error on the form. */
|
|
144
|
+
export const SaveFailing: StoryObj<ModalFormStoryType> = {
|
|
145
|
+
args: {
|
|
146
|
+
...defaultArgs,
|
|
147
|
+
saveData: () => {
|
|
148
|
+
throw {
|
|
149
|
+
title: 'Unable to save the movie.',
|
|
150
|
+
body: 'The service did not respond. Please try again later.',
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
render: (args) =>
|
|
155
|
+
React.createElement(() => {
|
|
156
|
+
const { ModalWrapper, openModal } = useModalForm<MovieValues>(args);
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div>
|
|
160
|
+
<TextButton text="Edit Movie" onButtonClicked={openModal} />
|
|
161
|
+
<ModalWrapper />
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}),
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** A narrower form, requested with the `width` prop. */
|
|
168
|
+
export const CustomWidth: StoryObj<ModalFormStoryType> = {
|
|
169
|
+
args: {
|
|
170
|
+
...defaultArgs,
|
|
171
|
+
title: 'Add Genre',
|
|
172
|
+
subtitle: undefined,
|
|
173
|
+
width: '800px',
|
|
174
|
+
validationSchema: Yup.object<ObjectSchemaDefinition<MovieValues>>({
|
|
175
|
+
title: Yup.string().required().max(25).label('Title'),
|
|
176
|
+
}),
|
|
177
|
+
initialData: { loading: false, data: { title: '' } },
|
|
178
|
+
children: <Field name="title" label="Title" as={SingleLineTextField} />,
|
|
179
|
+
},
|
|
180
|
+
render: (args) =>
|
|
181
|
+
React.createElement(() => {
|
|
182
|
+
const { ModalWrapper, openModal } = useModalForm<MovieValues>(args);
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div>
|
|
186
|
+
<TextButton text="Add Genre" onButtonClicked={openModal} />
|
|
187
|
+
<ModalWrapper />
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}),
|
|
191
|
+
};
|