Skip to content
Open
286 changes: 286 additions & 0 deletions src/components/JsonField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
import * as React from "react";
import {
useState,
useRef,
useEffect,
useImperativeHandle,
forwardRef,
} from "react";
import CopyIcon from "./icons/CopyIcon";
import XCloseIcon from "./icons/XCloseIcon";
import { SettingData } from "../interfaces";

export interface JsonFieldProps {
setting: SettingData;
value?: any;
disabled?: boolean;
readOnly?: boolean;
onChange?: (value: any) => void;
}

export interface JsonFieldHandle {
getValue(): any;
clear(): void;
}

const JsonField = forwardRef<JsonFieldHandle, JsonFieldProps>(
function JsonField({ setting, value, disabled, readOnly, onChange }, ref) {
const [text, setText] = useState(() => valueToText(value));
const [jsonError, setJsonError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [copyFailed, setCopyFailed] = useState(false);
const [previousText, setPreviousText] = useState<string | null>(null);
const [clearFeedback, setClearFeedback] = useState(false);
const [focused, setFocused] = useState(false);

const copyTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearFeedbackTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(
null
);

// Sync external value changes to internal text (replaces UNSAFE_componentWillReceiveProps).
// Uses reference equality: callers must not pass a new object literal on every render
// (e.g. value={{ key: "val" }}), or in-progress edits will be wiped. Values sourced
// from Redux state are stable references and satisfy this requirement.
const [prevValue, setPrevValue] = useState(value);
if (prevValue !== value) {
setPrevValue(value);
setText(valueToText(value));
setJsonError(null);
setPreviousText(null);
setClearFeedback(false);
setCopied(false);
setCopyFailed(false);
}

useTimeoutCleanup({
refs: [copyTimeoutId, clearFeedbackTimeoutId],
deps: [value],
});

useImperativeHandle(
ref,
() => ({
getValue() {
const { parsed, error } = parseJson(text);
return error ? undefined : parsed;
},
clear() {
cancelTimer(clearFeedbackTimeoutId);
cancelTimer(copyTimeoutId);
setText("");
setJsonError(null);
setCopied(false);
setCopyFailed(false);
setPreviousText(null);
setClearFeedback(false);
if (onChange) onChange(null);
},
}),
[text, onChange]
);

function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const newText = e.target.value;
const { parsed, error } = parseJson(newText);
cancelTimer(clearFeedbackTimeoutId);
cancelTimer(copyTimeoutId);
setText(newText);
setJsonError(error);
setPreviousText(null);
setClearFeedback(false);
setCopied(false);
setCopyFailed(false);
if (!error && onChange) {
onChange(newText.trim() ? parsed : null);
}
}

function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if ((e.ctrlKey || e.metaKey) && e.key === "z" && previousText !== null) {
e.preventDefault();
cancelTimer(clearFeedbackTimeoutId);
const { parsed, error } = parseJson(previousText);
setText(previousText);
setJsonError(error);
setPreviousText(null);
setClearFeedback(false);
if (!error && onChange) {
onChange(previousText.trim() ? parsed : null);
}
}
}

function handleCopy() {
cancelTimer(copyTimeoutId);

function applyCopyResult(success: boolean) {
setCopied(success);
setCopyFailed(!success);
copyTimeoutId.current = setTimeout(() => {
setCopied(false);
setCopyFailed(false);
copyTimeoutId.current = null;
}, 2000);
}

if (!navigator.clipboard) {
applyCopyResult(false);
return;
}
navigator.clipboard.writeText(text).then(
() => applyCopyResult(true),
() => applyCopyResult(false)
);
}

function handleClearClick() {
cancelTimer(clearFeedbackTimeoutId);
cancelTimer(copyTimeoutId);
setClearFeedback(true);
clearFeedbackTimeoutId.current = setTimeout(() => {
setClearFeedback(false);
clearFeedbackTimeoutId.current = null;
}, 5000);
setText("");
setJsonError(null);
setCopied(false);
setCopyFailed(false);
setPreviousText(text);
if (onChange) onChange(null);
}

// Prevents buttons from stealing focus from the textarea.
function preventButtonBlur(e: React.MouseEvent) {
e.preventDefault();
}

const textareaId = `json-field-${setting.key}`;
const descId = setting.description ? `json-desc-${setting.key}` : undefined;
const errorId = `json-error-${setting.key}`;
const showErrorMsg = focused || !!jsonError;
const describedBy =
[descId, jsonError ? errorId : undefined].filter(Boolean).join(" ") ||
undefined;
const isEmpty = !text;

return (
<div
className={`form-group json-field-group${
jsonError ? " field-error" : ""
}`}
>
<label className="control-label" htmlFor={textareaId}>
{setting.label}
{setting.required && <span className="required-field">Required</span>}
</label>
<div className="json-field-textarea-wrap">
<textarea
id={textareaId}
className="form-control"
name={setting.key}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
disabled={disabled}
readOnly={readOnly}
aria-invalid={jsonError ? true : undefined}
aria-describedby={describedBy}
/>
<div className="json-field-actions">
<span className="json-field-copied-feedback" aria-live="polite">
{copied ? "Copied!" : ""}
</span>
<span
className="json-field-copy-failed-feedback"
aria-live="assertive"
>
{copyFailed ? "Copy failed." : ""}
</span>
<span className="json-field-cleared-feedback" aria-live="polite">
{clearFeedback ? "Cleared! Ctrl-Z / Cmd-Z to recover." : ""}
</span>
<button
type="button"
aria-label="Copy to clipboard"
onClick={handleCopy}
onMouseDown={preventButtonBlur}
disabled={isEmpty}
>
<CopyIcon />
</button>
<button
type="button"
aria-label="Clear field"
onClick={handleClearClick}
onMouseDown={preventButtonBlur}
disabled={disabled || readOnly || isEmpty}
>
<XCloseIcon />
</button>
</div>
</div>
{setting.description && (
<p
id={descId}
className="description"
dangerouslySetInnerHTML={{ __html: setting.description }}
/>
)}
{showErrorMsg && (
<p id={errorId} className="json-field-error-msg">
{jsonError ?? ""}
</p>
)}
</div>
);
}
);

JsonField.displayName = "JsonField";

/**
* Hook to set up timer cleanup when the specified dependency array changes.
*
* Note that there's no body in the contained `useEffect`. It just returns a
* cleanup function that will be invoked before the next run or unmount.
*
* @param refs - Refs holding active timeout IDs to cancel on cleanup.
* @param deps - Dependency array that triggers the cleanup; defaults to `[]` (unmount only).
*/
function useTimeoutCleanup({
refs,
deps = [],
}: {
refs: React.MutableRefObject<ReturnType<typeof setTimeout> | null>[];
deps?: React.DependencyList;
}) {
useEffect(() => () => refs.forEach(cancelTimer), deps);
}

function cancelTimer(
ref: React.MutableRefObject<ReturnType<typeof setTimeout> | null>
) {
if (ref.current) {
clearTimeout(ref.current);
ref.current = null;
}
}

function valueToText(value: any): string {
if (value === null || value === undefined) return "";
return JSON.stringify(value, null, 2);
}

function parseJson(s: string): { parsed: any; error: string | null } {
if (!s.trim()) return { parsed: null, error: null };
try {
return { parsed: JSON.parse(s), error: null };
} catch (err) {
return { parsed: null, error: (err as Error).message };
}
}

export default JsonField;
27 changes: 27 additions & 0 deletions src/components/ProtocolFormField.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from "react";
import EditableInput from "./EditableInput";
import ColorPicker from "./ColorPicker";
import JsonField, { JsonFieldHandle } from "./JsonField";
import { Button } from "library-simplified-reusable-components";
import InputList from "./InputList";
import { SettingData, CustomListsSetting } from "../interfaces";
Expand All @@ -13,6 +14,8 @@ export interface ProtocolFormFieldProps {
| string
| string[]
| object[]
| object
| null
| Array<string | object | JSX.Element>
| JSX.Element;
altValue?: string;
Expand Down Expand Up @@ -43,6 +46,7 @@ export default class ProtocolFormField extends React.Component<
private inputListRef = React.createRef<InputList>();
private colorPickerRef = React.createRef<ColorPicker>();
private elementRef = React.createRef<EditableInput>();
private jsonFieldRef = React.createRef<JsonFieldHandle>();
static defaultProps = {
readOnly: false,
};
Expand All @@ -63,6 +67,8 @@ export default class ProtocolFormField extends React.Component<
? this.renderListSetting(setting)
: setting.type === "color-picker"
? this.renderColorPickerSetting(setting)
: setting.type === "json"
? this.renderJsonSetting(setting)
: this.renderSetting(setting);
// Special handling for hidden settings.
return setting.hidden ? this.renderHiddenElement(element) : element;
Expand Down Expand Up @@ -208,6 +214,19 @@ export default class ProtocolFormField extends React.Component<
);
}

renderJsonSetting(setting: SettingData): JSX.Element {
return (
<JsonField
ref={this.jsonFieldRef}
setting={setting}
value={defaultValueIfMissing(this.props.value, setting.default)}
disabled={this.props.disabled}
readOnly={this.props.readOnly}
onChange={this.props.onChange}
/>
);
}

labelAndDescription(setting: SettingData): JSX.Element[] {
const label = <label key={setting.label}>{setting.label}</label>;
const description = setting.description && (
Expand Down Expand Up @@ -236,6 +255,9 @@ export default class ProtocolFormField extends React.Component<
}

getValue() {
if (this.jsonFieldRef.current) {
return this.jsonFieldRef.current.getValue();
}
return this.findRef().getValue();
}

Expand All @@ -249,13 +271,18 @@ export default class ProtocolFormField extends React.Component<
element?.setState({ value: random });
}

// Excludes jsonFieldRef: JsonFieldHandle lacks setState, which randomize() calls.
findRef() {
return (this.inputListRef?.current ||
this.elementRef?.current ||
this.colorPickerRef?.current) as any;
}

clear() {
if (this.jsonFieldRef.current) {
this.jsonFieldRef.current.clear();
return;
}
const element = this.findRef();
if (element && element.clear) {
element.clear();
Expand Down
13 changes: 13 additions & 0 deletions src/components/icons/CopyIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as React from "react";

const CopyIcon = (): JSX.Element => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M16 1H4C2.9 1 2 1.9 2 3v14h2V3h12V1zm3 4H8C6.9 5 6 5.9 6 7v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" />
</svg>
);

export default CopyIcon;
1 change: 1 addition & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ export type SpecificSettingType =
| "date"
| "date-picker"
| "image"
| "json"
| "list"
| "menu"
| "select"
Expand Down
1 change: 1 addition & 0 deletions src/stylesheets/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ $fontfamily: 'Open Sans', sans-serif;
@import "header";
@import "icon";
@import "individual_admin_edit_form";
@import "json_field";
@import "input_list";
@import "lane_editor";
@import "lanes";
Expand Down
Loading
Loading