· 6 years ago · Nov 08, 2019, 12:42 AM
1/*
2 * Host Record
3 * This component is the lowest leaf of the dns record tree and displays
4 * information about one specific host record, allows edits, or removal.
5 * It divides the various host records into three types: Simple, MX, and SRV.
6 * */
7
8import React, { useReducer } from 'react';
9import { string, bool, shape, number, func } from 'prop-types';
10import cn from 'classnames';
11import Button from 'react-bootstrap/lib/Button';
12import Dropdown from 'react-bootstrap/lib/Dropdown';
13import MenuItem from 'react-bootstrap/lib/MenuItem';
14import { toast } from 'react-toastify';
15import { connect } from 'react-redux';
16import { updateHostRecord, addHostRecord } from '@eigi/domains/src/actions/dns';
17
18import { IconEllipsVert } from '@eigi/main/common/assets/images/bluecons';
19import { ReactComponent as IconEdit } from '../assets/edit-regular.svg';
20import { ReactComponent as IconTrash } from '../assets/trash-regular.svg';
21import { getTTLOptions, getHRFields } from '../utils/index';
22
23const TTLOptions = getTTLOptions();
24const HRFields = getHRFields();
25
26const propTypes = {
27 data: shape({
28 type: string.isRequired,
29 }).isRequired,
30 startsOpen: bool,
31 parentStateUpdater: func.isRequired,
32 domainName: string.isRequired,
33 userId: number.isRequired,
34 hrType: string.isRequired,
35};
36
37const defaultProps = {
38 startsOpen: false,
39};
40
41function reducer(state, action) {
42 const newState = { ...state };
43 switch (action.type) {
44 case 'mobileOpen':
45 newState.displayState = 'open';
46 return newState;
47 case 'mobileClose':
48 newState.displayState = 'closed';
49 return newState;
50 case 'openForm':
51 newState.displayState = 'edit';
52 return newState;
53 case 'formUpdate':
54 newState.form[action.name] = action.value;
55 return newState;
56 case 'formReset':
57 newState.form = action.initial;
58 newState.displayState = 'closed';
59 return newState;
60 case 'formSave':
61 newState.loading = true;
62 newState.hasError = false;
63 return newState;
64 case 'formError':
65 newState.loading = false;
66 newState.hasError = true;
67 return newState;
68 case 'formSuccess':
69 newState.loading = false;
70 newState.displayState = 'closed';
71 newState.formInitialState = action.form;
72 return newState;
73 default:
74 throw new Error();
75 }
76}
77
78const HostRecord = ({ data, hrType, startsOpen, domainName, userId, parentStateUpdater }) => {
79 console.log('begin HostRecord!', data);
80 /*
81 * I need a conglomerate state object so this component can change many things in a single operation
82 * - Possible display states: 'open', 'closed', 'edit', 'add'
83 * */
84 let displayInitial = 'closed';
85 if (typeof data.add !== 'undefined' && data.add) {
86 displayInitial = 'add';
87 } else if (startsOpen) {
88 displayInitial = 'open';
89 }
90 const [hrConfig, dispatch] = useReducer(reducer, {
91 displayState: displayInitial,
92 formInitialState: data,
93 form: data,
94 loading: false,
95 success: false,
96 hasError: false,
97 });
98 console.log('hrConfig', hrConfig);
99
100 const { form } = hrConfig;
101 const isClosed = hrConfig.displayState === 'closed';
102 const isDirty = hrConfig.formInitialState !== form;
103 const fields = HRFields[hrType];
104 const fieldNames = Object.keys(fields);
105 let key = `${data.type}--`;
106 if (data.type !== 'SRV') {
107 key += data.name;
108 } else {
109 key += data.service;
110 }
111
112 const handleSetForm = ({ target: { name, value } }) => {
113 dispatch({ type: 'formUpdate', name, value });
114 };
115
116 const handleCancel = () => {
117 if (hrConfig.displayState === 'add') {
118 parentStateUpdater({ displayState: 'initial' });
119 } else {
120 dispatch({ type: 'formReset', initial: hrConfig.formInitialState });
121 }
122 };
123
124 /*
125 * Submits form data to the API
126 * */
127 const submit = async e => {
128 e.preventDefault();
129 dispatch({ type: 'formSave' });
130
131 if (hrConfig.displayState === 'edit') {
132 // send api request
133 await updateHostRecord(userId, domainName, form, hrConfig.formInitialState)
134 .then(() => {
135 dispatch({ type: 'formSuccess', form });
136 })
137 .catch(() => {
138 toast('Something went horribly wrong!', { autoClose: true });
139 dispatch({ type: 'formError' });
140 });
141 } else {
142 delete form.add; // may not need this but just in case...
143 // send api request
144 await addHostRecord(userId, domainName, form)
145 .then(() => {
146 parentStateUpdater({ displayState: 'initial' }, form);
147 })
148 .catch(() => {
149 toast('Something went horribly wrong!', { autoClose: true });
150 dispatch({ type: 'formError' });
151 });
152 }
153 };
154
155 /*
156 * This assumes that newState will only be ever set to 'edit' or left null.
157 * If set to 'edit' then it changes to edit mode.
158 * Otherwise, it toggles between 'open' and 'closed'
159 * If you try to exit edit mode with unsaved changes, you can't.
160 * */
161 const stateToggler = newState => {
162 if (typeof newState === 'undefined') {
163 if (hrConfig.displayState === 'edit') {
164 if (isDirty) {
165 toast.error('You have unsaved changes', { autoClose: true });
166 } else {
167 dispatch({ type: 'mobileClose' });
168 }
169 } else if(hrConfig.displayState === 'open') {
170 dispatch({ type: 'mobileClose' });
171 } else {
172 dispatch({ type: 'mobileOpen' });
173 }
174 } else if (newState === 'edit' && hrConfig.displayState !== 'edit') {
175 dispatch({ type: 'openForm' });
176 }
177 };
178
179 const renderActions = ds => {
180 if (ds === 'edit' || ds === 'add') {
181 return (
182 <>
183 <button
184 className={`HostRecordForm__Action HostRecordForm__Action--${ds}-cancel btn btn-default`}
185 type="button"
186 onClick={handleCancel}
187 >
188 Cancel
189 </button>
190 <button
191 className={`HostRecordForm__Action HostRecordForm__Action--${ds}-save btn btn-default`}
192 type="submit"
193 >
194 {hrConfig.loading ? 'Saving...' : 'Save'}
195 </button>
196 </>
197 );
198 }
199
200 return (
201 <>
202 <Button
203 onClick={() => {
204 stateToggler('edit');
205 }}
206 data-testid="hr-mobile-edit"
207 className="HostRecordForm__Action HostRecordForm__Action--edit"
208 >
209 <IconEdit />
210 </Button>
211 <Button
212 data-testid="hr-mobile-remove"
213 className="HostRecordForm__Action HostRecordForm__Action--remove"
214 >
215 <IconTrash />
216 </Button>
217 </>
218 );
219 };
220
221 /*
222 * formatTTL
223 * - Takes a TTL as a parameter and returns a formatted string.
224 * @currentTTL
225 * - May be a number, a string, or undefined.
226 * returns
227 * - If the ttl is a value in the dropdown list, return that label
228 * - otherwise, return the numeric value as a string.
229 * */
230 const formatTTL = currentTTL => {
231 // default an unset ttl to 4h
232 let displayLabel = 14400;
233 if (typeof currentTTL !== 'undefined') {
234 displayLabel = parseInt(currentTTL, 10);
235 }
236
237 // displayLabel should now be a number
238 // search the TTLOptions for a match.
239 for (let i = 0; i < TTLOptions.length; i += 1) {
240 const TTLOption = TTLOptions[i];
241 if (displayLabel === TTLOption.value) {
242 // set the string label into displayLabel
243 displayLabel = TTLOption.label;
244 break;
245 }
246 }
247 // displayLabel now may be a number or a string
248 if (typeof displayLabel === 'number') {
249 // make it a string
250 displayLabel = displayLabel.toString();
251 }
252
253 // In this context, I don't want to see the (min) and (max).
254 // It's easier to remove them here, than add them in the pulldown
255 return displayLabel.replace(' (min)', '').replace(' (max)', '');
256 };
257
258 /*
259 * Renders a field label
260 * */
261 const renderLabel = fieldName => {
262 // Don't display type on the form
263 if (fieldName === 'type') return null;
264
265 let fieldLabel = fields[fieldName];
266 // That one odd exception for TXT
267 if (form.type === 'TXT' && fieldName === 'content') {
268 fieldLabel = 'TXT Value';
269 }
270
271 // add the label
272 return (
273 <label
274 hidden={isClosed}
275 data-testid={`${key}--${fieldName.toUpperCase()}`}
276 className={`HostRecordForm--label HostRecordForm__${fieldName.toUpperCase()}`}
277 htmlFor={`${key}--${fieldName}`}
278 >
279 {fieldLabel}
280 </label>
281 );
282 };
283
284 /*
285 * Renders a field.
286 * - select for TTL
287 * - input for the rest
288 * */
289 const renderField = fieldName => {
290 // Don't display type on the form
291 if (fieldName === 'type') return null;
292
293 // add the field (select)
294 if (fieldName === 'ttl') {
295 return (
296 <div className="HostRecordForm--input" title={formatTTL(form[fieldName])}>
297 <select
298 id={`${key}--${fieldName}`}
299 onChange={handleSetForm}
300 value={form[fieldName]}
301 name={fieldName}
302 >
303 {TTLOptions.map(option => (
304 <option value={option.value} key={option.value}>
305 {option.label}
306 </option>
307 ))}
308 </select>
309 <span>{formatTTL(form[fieldName])}</span>
310 </div>
311 );
312 }
313 // add the field (input type=text)
314 // adds the class HostRecordForm--input-label for fields 'name' or 'service'
315 return (
316 <div
317 className={cn(
318 'HostRecordForm--input',
319 fieldName === 'name' && 'HostRecordForm--input-label',
320 fieldName === 'service' && 'HostRecordForm--input-label',
321 )}
322 title={form[fieldName]}
323 >
324 <input
325 id={`${key}--${fieldName}`}
326 onChange={handleSetForm}
327 value={form[fieldName]}
328 name={fieldName}
329 />
330 <span>{form[fieldName]}</span>
331 </div>
332 );
333 };
334
335 return (
336 <div
337 className={`HostRecord HostRecord--${hrType}`}
338 data-displaystate={hrConfig.displayState}
339 data-testid="HostRecord"
340 >
341 <form
342 onSubmit={submit}
343 onReset={handleCancel}
344 data-testid={`hr--${form.type}--${form.type !== 'SRV' ? form.name : form.service}`}
345 className={cn(
346 hrConfig.success && 'success',
347 hrConfig.hasError && 'fail',
348 'HostRecordForm',
349 `hr-grid--${hrType}`,
350 )}
351 >
352 {fieldNames.map(fn => (
353 <React.Fragment key={`${form.type}--HR--${fn}`}>
354 {renderLabel(fn)}
355 {renderField(fn)}
356 </React.Fragment>
357 ))}
358 <div className="HostRecordForm__ActionContainer">
359 {renderActions(hrConfig.displayState)}
360 </div>
361 <Dropdown
362 pullRight
363 bsStyle="link"
364 id="host-record-form-desktop-menu"
365 className="HostRecordForm__DesktopMenu"
366 data-element-location="host record"
367 data-element-label="host record desktop menu"
368 data-element-type="icon"
369 >
370 <Dropdown.Toggle noCaret data-testid="DesktopMenuToggle" className="btn-link-dark">
371 <IconEllipsVert />
372 </Dropdown.Toggle>
373 <Dropdown.Menu>
374 <MenuItem
375 onClick={() => {
376 stateToggler('edit');
377 }}
378 >
379 Edit
380 </MenuItem>
381 <MenuItem
382 onClick={() => {
383 stateToggler();
384 }}
385 >
386 Remove
387 </MenuItem>
388 </Dropdown.Menu>
389 </Dropdown>
390 <div
391 role="button"
392 aria-pressed={hrConfig.displayState === 'open' ? 'true' : 'false'}
393 tabIndex={0}
394 className="HostRecordForm__Opener"
395 onClick={() => {
396 stateToggler();
397 }}
398 onKeyDown={event => {
399 if (event.key === ' ' || event.key === 'Enter') {
400 stateToggler();
401 }
402 }}
403 data-testid={`${key}--Opener`}
404 >
405 {hrConfig.displayState === 'open' ? (
406 <i data-testid={`${key}--Opener--Up`} className="icon-chevron-up" />
407 ) : (
408 <i data-testid={`${key}--Opener--Down`} className="icon-chevron-down" />
409 )}
410 </div>
411 </form>
412 </div>
413 );
414};
415
416const mapStateToProps = ({ domains: { domainDetails }, user: { user_id: userId } }) => ({
417 userId,
418 domainName: domainDetails.domain,
419});
420
421HostRecord.propTypes = propTypes;
422HostRecord.defaultProps = defaultProps;
423
424export default connect(mapStateToProps)(HostRecord);