All files / src/components/editable useEditableField.ts

32.14% Statements 18/56
14.81% Branches 4/27
30% Functions 3/10
30.9% Lines 17/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                                                                                                                                        22x   22x 22x 22x   22x   22x         22x       22x             22x                                           22x             22x                                 22x                     22x 22x             22x 22x           22x                              
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;
 
    Iif (validate) {
      const validationError = validate(draft);
      Iif (validationError) {
        setError(validationError);
        return;
      }
    }
 
    // No-op when value hasn't changed
    const compareValue = trimOnSave ? value.trim() : value;
    Iif (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) => {
      Iif (ignoreInitialBlurRef.current) {
        ignoreInitialBlurRef.current = false;
        return;
      }
      Iif (
        event.relatedTarget instanceof HTMLElement &&
        event.relatedTarget.dataset.editAction === fieldId
      ) {
        return;
      }
      save();
    },
    [fieldId, save],
  );
 
  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent) => {
      if (event.key === "Escape") {
        cancelEdit();
      I} 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(() => {
    Iif (!isEditing && (draft !== "" || error !== "")) {
      setDraftRaw("");
      setError("");
    }
  }, [isEditing, draft, error]);
 
  return {
    isEditing,
    draft,
    error,
    isDirty,
    startEdit,
    cancelEdit,
    save,
    setDraft,
    clearError,
    handleBlur,
    handleKeyDown,
    ignoreInitialBlurRef,
  };
}