All files / src/components/editable useEditableField.ts

98.21% Statements 55/56
97.14% Branches 34/35
90% Functions 9/10
98.18% Lines 54/55

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180                                                                                                                                        52x   52x 52x 52x   52x   52x 10x 10x     52x       52x 7x 7x 7x 7x     52x 7x   7x 2x 2x 2x 2x         5x 7x 1x 1x     4x 4x     52x 15x 15x 15x 15x     52x   3x 1x 1x   2x       1x   1x         52x   3x 1x 2x 1x           52x 52x 47x 52x 52x       52x 52x 1x 1x       52x                              
import { useCallback, useEffect, useRef, useState } from "react";
 
/** Options for the useEditableField hook. */
export interface UseEditableFieldOptions {
  /** Current persisted value. */
  value: string;
  /** Called when the user saves a new value. */
  onSave: (value: string) => void;
  /** Optional validation — return an error string, or undefined if valid. */
  validate?: (value: string) => string | undefined;
  /** Unique identifier for this field (used for coordination). */
  fieldId: string;
  /** Which field is currently being edited (coordination from parent). */
  activeFieldId?: string | null; // eslint-disable-line @rushstack/no-new-null
  /** Callback to tell the parent which field is active. */
  onActivate?: (fieldId: string | null) => void; // eslint-disable-line @rushstack/no-new-null
  /** Whether Enter key triggers save (true for text inputs, false for textarea). */
  enterToSave?: boolean;
  /** Whether to trim whitespace before saving. Default true. */
  trimOnSave?: boolean;
}
 
/** Return type for the useEditableField hook. */
export interface UseEditableFieldReturn {
  /** Whether this field is currently in edit mode. */
  isEditing: boolean;
  /** The current draft value while editing. */
  draft: string;
  /** Validation error message, or empty string. */
  error: string;
  /** Whether the draft differs from the persisted value. */
  isDirty: boolean;
  /** Enter edit mode with the current value as the draft. */
  startEdit: () => void;
  /** Exit edit mode without saving. */
  cancelEdit: () => void;
  /** Validate and save the current draft. */
  save: () => void;
  /** Update the draft value. Also clears any validation error. */
  setDraft: (value: string) => void;
  /** Clear the validation error. */
  clearError: () => void;
  /** Blur handler that auto-saves, respecting ignoreInitialBlur and data-edit-action. */
  handleBlur: (event: React.FocusEvent) => void;
  /** Keyboard handler for Escape (cancel) and optionally Enter (save). */
  handleKeyDown: (event: React.KeyboardEvent) => void;
  /**
   * Ref that prevents the initial blur (caused by clicking the edit button)
   * from triggering a save. Set to true when startEdit is called, reset on
   * first blur.
   */
  ignoreInitialBlurRef: React.RefObject<boolean>;
}
 
/**
 * Shared hook that encapsulates the click-to-edit state machine used by
 * EditableTextField, EditableTextArea, and EditableSelect.
 */
export function useEditableField(options: UseEditableFieldOptions): UseEditableFieldReturn {
  const {
    value,
    onSave,
    validate,
    fieldId,
    activeFieldId,
    onActivate,
    enterToSave = true,
    trimOnSave = true,
  } = options;
 
  const [draft, setDraftRaw] = useState("");
  const [error, setError] = useState("");
  const ignoreInitialBlurRef = useRef<boolean>(false);
 
  const isEditing = activeFieldId === fieldId;
 
  const setDraft = useCallback((v: string) => {
    setDraftRaw(v);
    setError("");
  }, []);
 
  const clearError = useCallback(() => {
    setError("");
  }, []);
 
  const cancelEdit = useCallback(() => {
    ignoreInitialBlurRef.current = false;
    onActivate?.(null);
    setDraftRaw("");
    setError("");
  }, [onActivate]);
 
  const save = useCallback(() => {
    const saveValue = trimOnSave ? draft.trim() : draft;
 
    if (validate) {
      const validationError = validate(draft);
      Eif (validationError) {
        setError(validationError);
        return;
      }
    }
 
    // No-op when value hasn't changed
    const compareValue = trimOnSave ? value.trim() : value;
    if (saveValue === compareValue) {
      cancelEdit();
      return;
    }
 
    onSave(saveValue);
    cancelEdit();
  }, [draft, value, trimOnSave, validate, onSave, cancelEdit]);
 
  const startEdit = useCallback(() => {
    ignoreInitialBlurRef.current = true;
    onActivate?.(fieldId);
    setDraftRaw(value);
    setError("");
  }, [fieldId, value, onActivate]);
 
  const handleBlur = useCallback(
    (event: React.FocusEvent) => {
      if (ignoreInitialBlurRef.current) {
        ignoreInitialBlurRef.current = false;
        return;
      }
      if (
        event.relatedTarget instanceof HTMLElement &&
        event.relatedTarget.dataset.editAction === fieldId
      ) {
        return;
      }
      save();
    },
    [fieldId, save],
  );
 
  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent) => {
      if (event.key === "Escape") {
        cancelEdit();
      } else if (event.key === "Enter" && enterToSave) {
        save();
      }
    },
    [cancelEdit, enterToSave, save],
  );
 
  const isDirty = (() => {
    if (!isEditing) return false;
    const compareValue = trimOnSave ? value.trim() : value;
    const draftValue = trimOnSave ? draft.trim() : draft;
    return draftValue !== compareValue;
  })();
 
  // If another field becomes active, reset our local state
  useEffect(() => {
    if (!isEditing && (draft !== "" || error !== "")) {
      setDraftRaw("");
      setError("");
    }
  }, [isEditing, draft, error]);
 
  return {
    isEditing,
    draft,
    error,
    isDirty,
    startEdit,
    cancelEdit,
    save,
    setDraft,
    clearError,
    handleBlur,
    handleKeyDown,
    ignoreInitialBlurRef,
  };
}