All files / src/shared/HFInputs/HFRichInput LinkModal.tsx

72.97% Statements 27/37
66.66% Branches 4/6
55.55% Functions 5/9
74.28% Lines 26/35

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 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200                                                  46x 46x 46x 46x 46x   46x                                     13x   13x                         566x                                 566x     566x   566x 81x   81x   6x 6x   6x         566x         76x 71x         566x                                                       566x   10x                                                         566x                                       46x               46x  
import { yupResolver } from "@hookform/resolvers/yup";
import { Box } from "@mui/material";
import GiveButton from "@shared/Button/GiveButton";
import * as Yup from "yup";
import {
  Control,
  FormProvider,
  useForm,
  useFormState,
} from "react-hook-form";
import { HFGiveInput } from "../HFGiveInput/HFGiveInput";
import { HFCheckbox } from "../HFGiveCheckbox/HFGiveCheckbox";
import { WrapperActionComponent } from "./components/Wrapper";
import { LinkIcon } from "@phosphor-icons/react";
import IconWrapper from "./IconWrapper";
import { URL_REGEX } from "@validation/regex";
import { EditorState } from "react-draft-wysiwyg-next";
import { useCallback, useEffect, useMemo } from "react";
import { VALIDATION_MESSAGES } from "@validation/messages";
 
interface Props {
  editorState: EditorState;
  onSubmit: (url: string, text: string, openInNewWindow: boolean) => void;
}
 
const LINK_TITLE_LABEL = "Link Title";
const LINK_URL_LABEL = "URL";
const OPEN_IN_NEW_WINDOW_LABEL = "Open link in new window";
const CANCEL_LABEL = "Cancel";
const ADD_LABEL = "Add";
 
const defaultValues = {
  text: "",
  link: "",
  openInNewWindow: false,
};
type FormValues = typeof defaultValues;
 
// Subscribes to `isValid` on its own via `useFormState`, so the "Add" button
// re-renders when validity flips WITHOUT `LinkModal`'s `renderContent`
// render-prop having to depend on `isValid` (see the note above
// `renderContent` for why that identity has to stay stable across
// keystrokes).
function SubmitButton({
  control,
  onClick,
}: {
  control: Control<FormValues>;
  onClick: () => void;
}) {
  const { isValid } = useFormState({ control });
 
  return (
    <GiveButton
      type="submit"
      variant="filled"
      label={ADD_LABEL}
      size="large"
      disabled={!isValid}
      onClick={onClick}
    />
  );
}
 
function LinkModal({ onSubmit, editorState }: Props) {
  const methods = useForm<FormValues>({
    resolver: yupResolver(validationSchema),
    defaultValues,
    mode: "onChange",
  });
  // react-hook-form's `formState` is a Proxy: a field only receives live
  // updates once something reads it during render, and reading it is also
  // what keeps `LinkModal` itself re-rendering as the user types (so the
  // `dirtyFields` guard below sees up-to-date values on every change, not
  // just the ones that happen to touch a currently-subscribed key). Read
  // `isValid` here too for that reason — but do NOT put `isValid` in
  // `renderContent`'s deps below, or the popover remounts (and the input
  // loses focus) the instant validity flips. The Add button's disabled
  // state is wired independently via `SubmitButton`, which subscribes to
  // `isValid` on its own.
  const {
    formState: { dirtyFields, isValid },
  } = methods;
  // Not used directly below — destructuring it is enough to flag it as
  // subscribed in react-hook-form's formState Proxy (see comment above).
  void isValid;
 
  const getSelectedText = useMemo(() => {
    const selection = editorState.getSelection();
    // No text selected
    if (selection.isCollapsed()) return "";
 
    const contentState = editorState.getCurrentContent();
    const block = contentState.getBlockForKey(selection.getStartKey());
 
    return block
      .getText()
      .slice(selection.getStartOffset(), selection.getEndOffset());
  }, [editorState]);
 
  useEffect(() => {
    // react-draft-wysiwyg-next re-clones toolbar custom buttons with a fresh
    // `editorState` reference on every editor re-render, which recomputes
    // `getSelectedText` (often back to ""). Once the user has edited the
    // field, stop syncing it from the selection or we'd clobber their input.
    if (!dirtyFields.text) {
      methods.setValue("text", getSelectedText);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [getSelectedText, dirtyFields.text]);
 
  const handleSubmit = useCallback(
    (onClose: () => void) => {
      const { text, link, openInNewWindow } = methods.getValues();
      if (!isValidHttpsUrl(link)) {
        methods.setError("link", { message: VALIDATION_MESSAGES.INVALID_URL });
        return;
      }
      onSubmit(link, text, !!openInNewWindow);
      methods.reset();
      onClose();
    },
    [methods, onSubmit],
  );
 
  // `WrapperActionComponent` renders `customContent` AS A COMPONENT
  // (`<CustomContent onClose={...} />` in components/Wrapper.tsx). An inline
  // arrow function defined in LinkModal's render body gets a brand-new
  // identity every time LinkModal re-renders, so React sees a different
  // component type at that JSX position and unmounts + remounts the whole
  // popover subtree — which drops DOM focus out of whatever input the user
  // was typing in. Stabilizing this with `useCallback` keeps the popover
  // (and its focused input) mounted across LinkModal re-renders.
  //
  // `isValid` must stay OUT of these deps: it flips as soon as the user
  // finishes a valid URL, and if it were a dep, `renderContent` would get a
  // new identity at that exact moment, remounting the popover and losing
  // focus right as the user finishes typing. The Add button instead reads
  // validity live via its own subscription in `SubmitButton`.
  const renderContent = useCallback(
    ({ onClose }: { onClose: () => void }) => (
      <FormProvider {...methods}>
        <Box
          gap="16px"
          display="flex"
          flexDirection="column"
          style={{ padding: "16px" }}
        >
          <HFGiveInput name="text" label={LINK_TITLE_LABEL} />
          <HFGiveInput name="link" label={LINK_URL_LABEL} />
          <HFCheckbox name="openInNewWindow" label={OPEN_IN_NEW_WINDOW_LABEL} />
 
          <Box display="flex" alignItems="center" justifyContent="flex-end">
            <GiveButton
              onClick={onClose}
              variant="ghost"
              size="large"
              label={CANCEL_LABEL}
            />
            <SubmitButton
              control={methods.control}
              onClick={() => handleSubmit(onClose)}
            />
          </Box>
        </Box>
      </FormProvider>
    ),
    [methods, handleSubmit],
  );
 
  return (
    <WrapperActionComponent
      value={""}
      menuWidth={392}
      activeIndex={-1}
      customContent={renderContent}
      anchor={
        <IconWrapper
          onClick={() => {
            //onclick gets overided
            return;
          }}
          icon={LinkIcon}
        />
      }
    />
  );
}
export default LinkModal;
 
const validationSchema = Yup.object().shape({
  text: Yup.string().required(VALIDATION_MESSAGES.TEXT_REQUIRED),
  link: Yup.string()
    .matches(URL_REGEX, VALIDATION_MESSAGES.LINK_HTTPS_REQUIRED)
    .url(VALIDATION_MESSAGES.INVALID_URL)
    .required(VALIDATION_MESSAGES.LINK_REQUIRED),
});
 
const isValidHttpsUrl = (url: string) => URL_REGEX.test(url);