Home

Seksowne formularze w Formik część 2: Dynamiczne pola

W poprzednim wpisie opisałem podstawy używania biblioteki Formik w React, używając do tego Typescriptu.

Dzisiaj kontynuuję temat, tym razem pokazując dość życiowy przykład (nie Todo-app!), mianowicie dynamiczną listę tagów, które można wybrać z listy lub dodać nowe (od razu aktualizując ich listę np. na serwerze). Tego typu logika pojawia się często chociażby w CMSach, gdzie możemy dodawać tagi do wpisów – ważne jest, że jeśli jakiś tag nie istnieje, to możemy wpisać go w polu by dodać do listy i zaznaczyć. Do dzieła!

Zacznijmy od wstępnych założeń

  • W naszej aplikacji posiadamy listę istniejących tagów. Dla uproszczenia, przyjmiemy prostą listę stringów, które oznaczają ich nazwę. W rzeczywistym projekcie mielibyśmy jeszcze co najmniej ID.
  • Komponent formularza będzie wyświetlać listę wszystkich tagów wraz z checkboxami, które można zaznaczyć. Oprócz tego wyświetli pole tekstowe na wpisanie nowego taga. Będzie zawierać przycisk dodania taga oraz wysłania formularza.
  • Komponent-rodzic, wstrzyknie do komponentu-formularza listę tagów do wyświetlenia oraz listę tagów, które domyślnie są zaznaczone.

Najpierw struktury danych

// Lista zaznaczonych tagów, w formie klucz: wartość
interface ISelectedTags {
[tagName: string]: boolean;
}

// Model formularza - zaznaczone tagi oraz pole na nowy tag
interface IValues {
selectedTags: ISelectedTagsDict;
newTag: string;
}

// Zewnętrzne propsy, które rodzic przekaże formularzowi.
// Jak widać listę dostępnych tagów trzymamy w komponencie rodzica // (lub np. Reduxie)
interface IFormOuterProps {
preloadedTags: string[];
initiallySelectedTags: ITagsDict;
onNewTagAdded: (tagName: string) => any;
onSubmit: (tags: ISelectedTagsDict) => any;
}

Potem inicjalne wartości

Stwórzmy inicjalne dane, które na potrzeby przykładu będą statyczne. W życiowym przykładzie to będą prawdopodobnie dane wyciągnięte z Reduxa, a wcześnie wczytane z serwera. Wstępnie zaznaczone dane możemy zostawić jako pusty obiekt (zapewne ma to najwięcej sensu), ale dla przykładu zaznaczymy jeden z nich. W rzeczywistym przykładzie pre-zaznaczanie może mieć miejsce na przykład gdy automatycznie wykryjemy tagi w naszym artykule.

const someTagsAlreadyLoaded: string[] = ["React", "Typescript"];

const initiallySelectedTags: ISelectedTags = {
  React: true
};

Następnie komponent rodzic

Stwórzmy nasz komponent/kontener, w którym umieścimy właściwy formularz. Jedyne co musi wyświetlać to właśnie jego dziecko. Będzie też zawierać stan wszystkich dostępnym tagów. Oczywiście jest to uproszczenie i raczej użylibyśmy tutaj np. Reduxa. Dodamy też zamockowany onSubmit, który po prostu wypisze w konsoli zaznaczone przez użytkownika tagi.

Do trzymania stanu użyliśmy hooki – jeśli ich nie znasz, zerknij na dokumentację.

const ParentComponent = () => {

  // Stan z przykładowymi danymi z poprzedniego przykładu
  const [availableTags, appendTag] = useState<string[]>([
    ...someTagsAlreadyLoaded
  ]);

  // Callback, który doda nowy tag do listy
  const onNewTagAdded = (newTag: string) => {
    appendTag((state: string[]) => [...state, newTag]);
  };

  // Zamockowany submit. Tutaj np. wywołamy
  // akcję (effect) wysyłania danych na serwer
  const onSubmit = (tags: string[]) => {
    console.log("Submitting: ", tags);
  };

  return (
    <TagsFormWithFormik
      preloadedTags={availableTags}
      initiallySelectedTags={initiallySelectedTags}
      onNewTagAdded={onNewTagAdded}
      onSubmit={onSubmit}
    />
  );
};

A więc po kolei:

  1. Stworzyliśmy stan przy użyciu hooka useState. Zawierać on będzie listę wszystkich dostępnych tagów (niekoniecznie zaznaczonych, po prostu dostępnych).
  2. Tworzymy callback onNewTagAdded, który doda do listy nowy tag. Zostanie on wywołany gdy w formularzu stworzymy nowy, nieistniejący wpis.
  3. Tworzymy callback onSubmit, który wywołamy gdy formularz zostanie „wysłany”.
  4. Renderujemy komponent. Przekazujemy mu listę wszystkich tagów (preloadedTags z availableTags z poprzedniego przykładu), ponadto inicjalnie zaznaczone tagi w propsie initiallySelectedTags, gdzie wstrzykujemy initiallySelectedTags, także z poprzedniego przykładu. Na końcu przekazujemy oba callbacki. Propsy te zdefiniowaliśmy wcześniej w interfejsie IFormOuterProps.

Przygotowaliśmy już wszystko co potrzebne by zaimplementować komponent formularza. A więc…

Czas na formularz

Standardowo, użyję dwóch niezależnych komponentów – czysty formularz oraz HOC withFormik. Zaczniemy od tego pierwszego.

const TagsForm = ({
  // Propsy przekazane z Formika
  values,
  handleSubmit,
  handleChange,
  setFieldValue,
  // Propsy przekazane od rodzica
  onNewTagAdded,
  preloadedTags,
}: FormikProps<IValues> & IFormOuterProps) => (
  <form onSubmit={handleSubmit}>
    <div>
      <h1>Todos:</h1>
      {preloadedTags.map((tag: string) => (
        <div key={tag}>
          <input
            type="checkbox"
            onChange={handleChange}
            name={`selectedTags.${tag}`}
            checked={values.selectedTags[tag]}
          />
          <span>{tag}</span>
        </div>
      ))}
    </div>
    <input
      placeholder="Add new tag"
      name="newTag"
      onChange={handleChange}
      value={values.newTag}
    />
    <button
      type="button"
      onClick={() => {
        onNewTagAdded(values.newTag);
        setFieldValue(`selectedTags.${values.newTag}`, true);
        setFieldValue("newTag", "");
      }}
    >
      Add tag +
    </button>
    <button type="submit">Continue</button>
  </form>
);

Omówmy teraz po kolei cały ten fragment.

Na początku tworzymy komponent, czyli funkcję, która przyjmuje następujące propsy: FormikProps<Values> & IFormOuterProps. Tworzymy tutaj unię dwóch interfejsów – propsów Formika, który wtypie generycznym otrzymuje zdefiniowany przez nas model formularza. Łączymy go z propsami zewnętrznymi, ale nie wszystkie będą nam potrzebne – więc nie wypisujemy ich w destructuringu obiektu.

Następnie renderujemy nasz komponent. Fragment <form onSubmit={handleSubmit}> powinien być Ci znany z poprzedniego wpisu – przekazujemy Formikowi logikę obsługi eventu submit wywołanego na elemencie <form>.

Następnie renderujemy listę wszystkich tagów, używając do tego przekazanej z komponentu rodzica listy – {preloadedTags.map((tag: string) => (...).

Kluczowy dla dynamicznych formularzy będzie dla nas sposób mapowania kontrolek HTML z Formikiem, robimy to w tym miejscu:

<input
  type="checkbox"
  onChange={handleChange}
  name={`selectedTags.${tag}`}
  checked={values.selectedTags[tag]}
  />

Jest to kontrolka typu checkbox, obok której wyświetlamy (w pełnym przykładzie) nazwę taga. Input ten przyjmuje onChange={handleChange}, który podobnie jak w poprzednim przykładzie (i wpisie), przekazuje Formikowi kontrolę, tym razem nad eventem change. Formik używa do tego atrybutu name, który wygląda tak:

name={`selectedTags.${tag}`}

Zwróć uwagę, że używamy tutaj template stringów, które dynamicznie zapisują name komponentu jako klucz obiektutag – który pochodzi z .map()owania po dostępnych tagach. A więc dla tagów ['React', 'Typescript'], będziemy kolejno zapisywać obiekt values.React = true itd. To parsowanie przetwarza właśnie Formik.

Dla tego przykładu użyłem modelu opartego na obiekcie, jednak Formik obsługuje też formę tablicy (która notabene jest obiektem, po prostu kluczem są indexy), a więc jeśli wolelibyśmy model string[], to pole name wyglądałoby mniej więcej tak:

name={`selectedTags[${index}]`}

Gdzie index to drugi argument funkcji map() – lub (w rzeczywistości) jakieś pole ID.

Mamy już załatwione wypisywanie tagów i zapisywanie ich w stanie Formika.

Czas na dynamiczne pole dla nowego taga

Pora na kolejny kawałek kodu z poprzedniego, dużego przykładu:

<input
  placeholder="Add new tag"
  name="newTag"
  onChange={handleChange}
  value={values.newTag}
/>
<button
  type="button"
  onClick={() => {
    onNewTagAdded(values.newTag);
    setFieldValue(`selectedTags.${values.newTag}`, true);
    setFieldValue("newTag", "");
  }}
>
  Add tag +
</button>

Mamy tutaj dwa elementy – input oraz button. Ten pierwszy posiada atrybut name="newTag", co jak pamiętamy jest potrzebne by przypisać jego wartość dla naszego modelu IValues. Standardowo przypisujemy mu także atrybuty onChange i value i przekazujemy im helpery Formika. To wystarczy by Formik trzymał stan tego pola.

Musimy jeszcze obsłużyć dodawanie taga do listy wszystkich tagów oraz zaznaczanie go by był aktywny (zakładam że to logiczne zachowanie). Ponadto, pole powinno zostać wyczyszczone po dodaniu, by móc wpisać kolejną wartość.

Zapinamy się jedynie na evencie onClick, gdzie wykonujemy trzy akcje:

  1. onNewTagAdded(values.newTag) – to funkcja wstrzyknięta przez komponent rodzica, przekazujemy jej nową nazwę.
  2. setFieldValue(selectedTags.${values.newTag}, true) – to jeden z helperów Formika, który pozwala manualnie ustawić wartość dla danego pola – w tym przypadku wybieramy kontretny tag (ten który właśnie wpisaliśmy) u stawiamy jego wartość na true, co oznacza że jest zaznaczony.
  3. setFieldValue("newTag", "") – na samym końcu ustawiamy manualnie wartość pola tekstowego na puste – voila!

Ten kawałek logiki wyśle komponentowi rodzica informację, że dodano nowy tag i zaktualizuje swój stan.

Na szarym końcu dodajemy przycisk z type="submit", który triggeruje event submit na formularzu.

To tyle w zakresie czystego komponentu formularza

Czas na inicjalizację Formika

Jak pisałem w poprzednim wpisie, możemy wyciągnąć setup Formika do osobnej warstwy, używając HOC-a withFormik. Spójrzmy na kod:

const TagsFormWithFormik = withFormik<IValues, IFormOuterProps>({
  mapPropsToValues: (props: IFormOuterProps): IValues => ({
    selectedTags: props.initiallySelectedTags,
    newTag: ""
  }),
  handleSubmit: (
    values: IValues,
    formikBag: FormikBag<IValues, IFormOuterProps>
  ) => {
    const tagsList = Object.entries(values.selectedTags)
      .filter(([tagName, isSelected]: [string, boolean]) => isSelected)
      .map(([tagName, isSelected]: [string, boolean]) => tagName);

    formikBag.props.onSubmit(tagsList);
  }
})(TagsForm);

Na początku, tworzymy nowy komponent przy użyciu Higher Order Componentu:

const TagsFormWithFormik = withFormik<IValues, IFormOuterProps>({...});

Jako pierwszy tym generyczny przekazujemy model formularza, jako drugi – zewnętrzne propsy, które przyjmuje komponent.

Następnie czas na obowiązkowy mapping, który wytworzy inicjalne wartości dla formularza:

mapPropsToValues: (props: IFormOuterProps): IValues => ({
    selectedTags: props.initiallySelectedTags,
    newTag: ""
  }),

Jak widać przekazujemy listę initiallySelectedTags, którą wstrzyknęliśmy z komponentu rodzica, tworząc w naszym przypadku obiekt:

{
  React: true,
  Typescript: undefined
}

Oprócz tego, przypisujemy pusty string jako wartość pola dla nowego taga.

Drugim obowiązkowym parametrem konfiguracji jest obsługa submittowania, co wygląda tak:

handleSubmit: (
    values: IValues,
    formikBag: FormikBag<IValues, IFormOuterProps>
  ) => {
    const tagsList = Object.entries(values.selectedTags)
      .filter(([tagName, isSelected]: [string, boolean]) => isSelected)
      .map(([tagName, isSelected]: [string, boolean]) => tagName);

    formikBag.props.onSubmit(tagsList);
  }

Funkcja ta przyjmuje dwa atrybuty, model formularza i FormikBag, omawiałem je w poprzednim wpisie.

Następnie, czysto opcjonalnie mapujemy obiekt na tablicę. Jeśli chcemy, możemy oczywiście przekazać do rodzica obiekt i operować na nim gdzie indziej – wszystko zależy od naszych potrzeb. Załóżmy, że formularz zwraca po prostu tablicę tych tagów, które były zaznaczone. Używamy Object.entries by iterować po kluczach i wartościach, następnie filtrujemy by zostawić tylko te zaznaczone, a na końcu mapujemy tuple na string.

Na samym końcu wywołujemy przekazany w propsach callback onSubmit, który wypisuje nam w konsoli wynik formularza. W życiowym przykładzie te funkcja mogłaby zwracać Promise (lub Observable), aby nasz formularz mógł obsłużyć ewentualne błędy lub stan ładowania.

Ale to nie koniec

… bo czas napisać testy ᕙ(˵ ಠ ਊ ಠ ˵)ᕗ

Oto test, który z grubsza testuje całą funkcjonalność. Testuję tutaj dodawanie taga, dodawanie go do listy, zaznaczanie i wysyłanie.

 it("Works as expected", async () => {
    const allTags: string[] = ["React", "Angular"];

    const onNewTagAdded = jest.fn((newTag: string) => allTags.push(newTag));
    const mockOnSubmit = jest.fn();

    const { getByText, getByPlaceholderText, rerender, debug } = render(
      <TagsFormWithFormik
        preloadedTags={allTags}
        initiallySelectedTags={{}}
        onNewTagAdded={onNewTagAdded}
        onSubmit={mockOnSubmit}
      />
    );

    const reactCheckbox = getByText("React").parentNode.querySelector("input");

    fireEvent.change(reactCheckbox);

    expect(reactCheckbox.getAttribute("checked")).toBe("");

    fireEvent.input(getByPlaceholderText("Add new tag"), {
      target: {
        value: "Node.js"
      }
    });

    fireEvent.click(getByText(/Add tag/));

    debug();

    await wait(() => {
      expect(onNewTagAdded).toBeCalledWith("Node.js");
      expect(allTags).toHaveLength(3);
    });

    rerender(
      <TagsFormWithFormik
        preloadedTags={allTags}
        initiallySelectedTags={{}}
        onNewTagAdded={onNewTagAdded}
        onSubmit={mockOnSubmit}
      />
    );

    const nodeCheckbox = getByText("Node.js").parentNode.querySelector("input");

    expect(nodeCheckbox.getAttribute("checked")).toBe("");
    expect(getByPlaceholderText("Add new tag").value).toBe("");

    fireEvent.click(getByText("Continue"));

    expect(mockOnSubmit).toBeCalledWith(["React", "Node.js"]);
  });

Cały działający przykład można zobaczyć tutaj. Zwróć uwagę, że dodanie dwóch tagów o tej samej nazwie spowoduje zepsucie logiki – dlatego właśnie w realnym projekcie użyjemy unikalnych ID. Niestety miałem problemy z Code Sandbox by prawidłowo uruchomić testy z react-testing-library, więc musicie mi tutaj wierzyć na słowo.

Następnym razem omówię walidację formularza, stan ładowania i takie tam.

Zobacz poprzedni wpis