Skip to content

Commit bff575c

Browse files
gribnoysupaddaleax
andauthored
chore(compass-crud, compass-components): Move Document card footer implementation to compass-components COMPASS-5593 (#3199)
* chore(compass-crud, compass-components): Move Document card footer implementation to compass-components * chore(compass-components): Change ... to … Co-authored-by: Anna Henningsen <[email protected]> * chore(compass-components): Change enum to string union Co-authored-by: Anna Henningsen <[email protected]>
1 parent 0794aae commit bff575c

18 files changed

+489
-709
lines changed
Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
import React, { useCallback, useEffect, useRef, useState } from 'react';
2+
import type { default as HadronDocumentType } from 'hadron-document';
3+
import { Element } from 'hadron-document';
4+
import { Button } from '../leafygreen';
5+
import { css, spacing, uiColors } from '../..';
6+
7+
type Status =
8+
| 'Initial'
9+
| 'Editing'
10+
| 'Modified'
11+
| 'ContainsErrors'
12+
| 'UpdateStart'
13+
| 'UpdateBlocked'
14+
| 'UpdateSuccess'
15+
| 'UpdateError'
16+
| 'Deleting'
17+
| 'DeleteStart'
18+
| 'DeleteSuccess'
19+
| 'DeleteError';
20+
21+
function isDeleting(status: Status): boolean {
22+
return ['Deleting', 'DeleteStart', 'DeleteSuccess', 'DeleteError'].includes(
23+
status
24+
);
25+
}
26+
27+
function isSuccess(status: Status): boolean {
28+
return ['DeleteSuccess', 'UpdateSuccess'].includes(status);
29+
}
30+
31+
function isPrimaryActionDisabled(status: Status): boolean {
32+
return [
33+
'Editing',
34+
'ContainsErrors',
35+
'UpdateStart',
36+
'UpdateSuccess',
37+
'DeleteStart',
38+
'DeleteSuccess',
39+
].includes(status);
40+
}
41+
42+
function isCancelDisabled(status: Status): boolean {
43+
return [
44+
'UpdateStart',
45+
'UpdateSuccess',
46+
'DeleteStart',
47+
'DeleteSuccess',
48+
].includes(status);
49+
}
50+
51+
const StatusMessages: Record<Status, string> = {
52+
['Initial']: '',
53+
['Editing']: '',
54+
['Deleting']: 'Document flagged for deletion.',
55+
['Modified']: 'Document modified.',
56+
['ContainsErrors']: 'Update not permitted while document contains errors.',
57+
['UpdateStart']: 'Updating document…',
58+
['UpdateError']: '',
59+
['UpdateBlocked']:
60+
'Document was modified in the background or it longer exists. Do you wish to continue and possibly overwrite new changes?',
61+
['UpdateSuccess']: 'Document updated.',
62+
['DeleteStart']: 'Removing document…',
63+
['DeleteError']: '',
64+
['DeleteSuccess']: 'Document deleted.',
65+
};
66+
67+
function useHadronDocumentStatus(
68+
doc: HadronDocumentType,
69+
editing: boolean,
70+
deleting: boolean
71+
) {
72+
const [status, setStatus] = useState<Status>(() => {
73+
return editing
74+
? doc.isModified()
75+
? 'Modified'
76+
: 'Editing'
77+
: deleting
78+
? 'Deleting'
79+
: 'Initial';
80+
});
81+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
82+
const invalidElementsRef = useRef(new Set());
83+
84+
const updateStatus = useCallback((newStatus: Status, errorMessage = null) => {
85+
setStatus(newStatus);
86+
setErrorMessage(errorMessage);
87+
}, []);
88+
89+
useEffect(() => {
90+
if (status !== 'Initial') {
91+
return;
92+
}
93+
94+
if (editing) {
95+
updateStatus('Editing');
96+
} else if (deleting) {
97+
updateStatus('Deleting');
98+
}
99+
}, [status, updateStatus, editing, deleting]);
100+
101+
useEffect(() => {
102+
const onUpdate = () => {
103+
updateStatus(
104+
invalidElementsRef.current.size === 0
105+
? doc.isModified()
106+
? 'Modified'
107+
: 'Editing'
108+
: 'ContainsErrors'
109+
);
110+
};
111+
const onElementValid = (el: Element) => {
112+
invalidElementsRef.current.delete(el);
113+
onUpdate();
114+
};
115+
const onElementInvalid = (el: Element) => {
116+
invalidElementsRef.current.add(el);
117+
onUpdate();
118+
};
119+
const onUpdateStart = () => {
120+
updateStatus('UpdateStart');
121+
};
122+
const onUpdateBlocked = () => {
123+
updateStatus('UpdateBlocked');
124+
};
125+
const onUpdateSuccess = () => {
126+
updateStatus('UpdateSuccess');
127+
};
128+
const onUpdateError = (err: string) => {
129+
updateStatus('UpdateError', err);
130+
};
131+
const onRemoveStart = () => {
132+
updateStatus('DeleteStart');
133+
};
134+
const onRemoveSuccess = () => {
135+
updateStatus('DeleteSuccess');
136+
};
137+
const onRemoveError = (err: string) => {
138+
updateStatus('DeleteError', err);
139+
};
140+
141+
doc.on(Element.Events.Added, onUpdate);
142+
doc.on(Element.Events.Edited, onUpdate);
143+
doc.on(Element.Events.Removed, onUpdate);
144+
doc.on(Element.Events.Reverted, onUpdate);
145+
doc.on(Element.Events.Invalid, onElementInvalid);
146+
doc.on(Element.Events.Valid, onElementValid);
147+
doc.on('update-start', onUpdateStart);
148+
doc.on('update-blocked', onUpdateBlocked);
149+
doc.on('update-success', onUpdateSuccess);
150+
doc.on('update-error', onUpdateError);
151+
doc.on('remove-start', onRemoveStart);
152+
doc.on('remove-success', onRemoveSuccess);
153+
doc.on('remove-error', onRemoveError);
154+
155+
return () => {
156+
doc.on(Element.Events.Added, onUpdate);
157+
doc.off(Element.Events.Edited, onUpdate);
158+
doc.off(Element.Events.Removed, onUpdate);
159+
doc.off(Element.Events.Reverted, onUpdate);
160+
doc.off(Element.Events.Invalid, onElementInvalid);
161+
doc.off(Element.Events.Valid, onElementValid);
162+
doc.off('update-start', onUpdateStart);
163+
doc.off('update-blocked', onUpdateBlocked);
164+
doc.off('update-success', onUpdateSuccess);
165+
doc.off('update-error', onUpdateError);
166+
doc.off('remove-start', onRemoveStart);
167+
doc.off('remove-success', onRemoveSuccess);
168+
doc.off('remove-error', onRemoveError);
169+
};
170+
}, [doc, updateStatus]);
171+
172+
useEffect(() => {
173+
if (isSuccess(status)) {
174+
const timeoutId = setTimeout(() => {
175+
updateStatus('Initial');
176+
}, 2000);
177+
return () => {
178+
clearTimeout(timeoutId);
179+
};
180+
}
181+
}, [status, updateStatus]);
182+
183+
return { status, updateStatus, errorMessage };
184+
}
185+
186+
const container = css({
187+
display: 'flex',
188+
paddingTop: spacing[2],
189+
paddingRight: spacing[2],
190+
paddingBottom: spacing[2],
191+
paddingLeft: spacing[3],
192+
alignItems: 'center',
193+
gap: spacing[2],
194+
});
195+
196+
const message = css({
197+
overflow: 'hidden',
198+
textOverflow: 'ellipsis',
199+
});
200+
201+
const buttonGroup = css({
202+
display: 'flex',
203+
marginLeft: 'auto',
204+
gap: spacing[2],
205+
});
206+
207+
const button = css({
208+
flex: 'none',
209+
});
210+
211+
function getColorStyles(status: Status): React.CSSProperties {
212+
switch (status) {
213+
case 'Editing':
214+
return { backgroundColor: uiColors.gray.light2 };
215+
case 'ContainsErrors':
216+
case 'UpdateError':
217+
case 'UpdateBlocked':
218+
case 'Deleting':
219+
case 'DeleteError':
220+
case 'DeleteStart':
221+
return {
222+
backgroundColor: uiColors.red.light2,
223+
color: uiColors.red.dark3,
224+
};
225+
case 'UpdateStart':
226+
return {
227+
backgroundColor: uiColors.blue.light2,
228+
color: uiColors.blue.dark3,
229+
};
230+
case 'DeleteSuccess':
231+
case 'UpdateSuccess':
232+
return {
233+
backgroundColor: uiColors.green.light2,
234+
color: uiColors.green.dark3,
235+
};
236+
default:
237+
return {
238+
backgroundColor: uiColors.yellow.light2,
239+
color: uiColors.yellow.dark3,
240+
};
241+
}
242+
}
243+
244+
const EditActionsFooter: React.FunctionComponent<{
245+
doc: HadronDocumentType;
246+
editing: boolean;
247+
deleting: boolean;
248+
modified?: boolean;
249+
containsErrors?: boolean;
250+
alwaysForceUpdate?: boolean;
251+
onUpdate(force: boolean): void;
252+
onDelete(): void;
253+
onCancel?: () => void;
254+
}> = ({
255+
doc,
256+
editing,
257+
deleting,
258+
modified = false,
259+
containsErrors = false,
260+
alwaysForceUpdate = false,
261+
onUpdate,
262+
onDelete,
263+
onCancel,
264+
}) => {
265+
const {
266+
status: _status,
267+
updateStatus,
268+
errorMessage,
269+
} = useHadronDocumentStatus(doc, editing, deleting);
270+
271+
// Allow props to override event based status of the document (helpful for
272+
// JSON editor where changing the document text doesn't really generate any
273+
// changes of the HadronDocument)
274+
const status = containsErrors
275+
? 'ContainsErrors'
276+
: modified
277+
? 'Modified'
278+
: _status;
279+
280+
const statusMessage = StatusMessages[status];
281+
282+
if (status === 'Initial') {
283+
return null;
284+
}
285+
286+
return (
287+
<div
288+
className={container}
289+
style={getColorStyles(status)}
290+
data-testid="document-footer"
291+
data-status={status}
292+
>
293+
<div className={message} data-testid="document-footer-message">
294+
{errorMessage ?? statusMessage}
295+
</div>
296+
{!isSuccess(status) && (
297+
<div className={buttonGroup}>
298+
<Button
299+
type="button"
300+
size="xsmall"
301+
className={button}
302+
data-testid="cancel-button"
303+
onClick={() => {
304+
doc.cancel();
305+
onCancel?.();
306+
updateStatus('Initial');
307+
}}
308+
disabled={isCancelDisabled(status)}
309+
>
310+
Cancel
311+
</Button>
312+
<Button
313+
type="button"
314+
size="xsmall"
315+
className={button}
316+
data-testid={isDeleting(status) ? 'delete-button' : 'update-button'}
317+
onClick={() => {
318+
if (isDeleting(status)) {
319+
onDelete();
320+
} else {
321+
onUpdate(alwaysForceUpdate || status === 'UpdateBlocked');
322+
}
323+
}}
324+
disabled={isPrimaryActionDisabled(status)}
325+
>
326+
{isDeleting(status)
327+
? 'Delete'
328+
: alwaysForceUpdate || status === 'UpdateBlocked'
329+
? 'Replace'
330+
: 'Update'}
331+
</Button>
332+
</div>
333+
)}
334+
</div>
335+
);
336+
};
337+
338+
export default EditActionsFooter;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { default as DocumentActionsGroup } from './document-actions-group';
22
export { default as DocumentFieldsToggleGroup } from './document-fields-toggle-group';
33
export { default as Document } from './document';
4+
export { default as DocumentEditActionsFooter } from './document-edit-actions-footer';

0 commit comments

Comments
 (0)