Skip to content

11 Frontend Mutations

JP Barbosa edited this page Apr 15, 2023 · 1 revision

Frontend Mutations

API

code ./packages/web/src/api/movies.ts
import axios from 'axios';
import { Movie } from '@neo4j-crud/shared';

const url = `${import.meta.env.VITE_API_URL}/movies`;

export const movies = {
  getAll: (search: string) =>
    axios.get<Movie[]>(`${url}?search=${search}`).then((res) => res.data),

  getById: (id: number) =>
    axios.get<Movie>(`${url}/${id}`).then((res) => res.data),

  create: (movie: Movie) =>
    axios.post<Movie>(url, movie).then((res) => res.data),

  update: (id: number, movie: Movie) =>
    axios.put<Movie>(`${url}/${id}`, movie).then((res) => res.data),

  remove: (id: number) =>
    axios.delete<Movie>(`${url}/${id}`).then((res) => res.data),
};
code ./packages/web/src/api/index.ts
export * from './movies';

Hooks

code ./packages/web/src/hooks/useMovieMutation.ts
import { MutationKey, useMutation } from 'react-query';
import { AxiosCustomError, Movie } from '@neo4j-crud/shared';
import * as api from '../api';

export const useMovieMutation = (
  mutationKey: MutationKey,
  callback: (message: string) => void
) => {
  const upsert = useMutation<Movie, AxiosCustomError, Movie>(
    mutationKey,
    (movie) => {
      if (movie.id) {
        return api.movies.update(movie.id, movie);
      } else {
        return api.movies.create(movie);
      }
    },
    {
      onSuccess: () => {
        callback(`Movie created/updated successfully`);
      },
    }
  );

  const remove = useMutation<Movie | void, AxiosCustomError, Movie>(
    mutationKey,
    (movie) => {
      if (movie.id) {
        return api.movies.remove(movie.id);
      } else {
        return Promise.resolve();
      }
    },
    {
      onSuccess: () => {
        callback(`Movie deleted successfully`);
      },
    }
  );

  const isSuccess = upsert.isSuccess || remove.isSuccess;

  const error = upsert.error || remove.error;

  return {
    upsert,
    remove,
    isSuccess,
    error,
  };
};

Page Components

code ./packages/web/src/pages/movies/index.tsx
import { Route, Routes } from 'react-router-dom';
import { List } from './List';
import { Edit } from './Edit';
import { New } from './New';

export const Movies = () => {
  return (
    <Routes>
      <Route path="/" element={<List />} />
      <Route path="/:id/edit" element={<Edit />} />
      <Route path="/new" element={<New />} />
    </Routes>
  );
};
code ./packages/web/src/pages/movies/List/index.tsx
...
import { Link } from 'react-router-dom';

export const List = () => {
  ...

  return (
    <div>
      <div className="actions-bar">
        <h2>Movies</h2>
        <div className="filter">
          ...
        </div>
        <div>
          <Link to="new" className="button primary">
            Create Movie
          </Link>
        </div>
      </div>
      <NavigationAlert />
      <Content search={search} />
    </div>
  );
};
code ./packages/web/src/pages/movies/Edit.tsx
import { useQuery } from 'react-query';
import { useParams } from 'react-router-dom';
import { AxiosCustomError, Movie } from '@neo4j-crud/shared';
import * as api from '../../api';
import { AlertCombo } from '../../components';
import { Form } from './Form';

export const Edit = () => {
  const params = useParams();
  const id = params.id ? parseInt(params.id) : undefined;

  const { data, error, isLoading } = useQuery<Movie, AxiosCustomError>(
    ['movies', id],
    () => api.movies.getById(Number(id))
  );

  if (error || isLoading || !data) {
    return <AlertCombo error={error} isLoading={isLoading} noData={!data} />;
  }

  return <Form movie={data} />;
};
code ./packages/web/src/pages/movies/New.tsx
import { Form } from './Form';

export const New = () => {
  return <Form />;
};
code ./packages/web/src/pages/movies/Form/index.tsx
import { useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Link, useNavigate } from 'react-router-dom';
import { Movie } from '@neo4j-crud/shared';
import { useMovieMutation } from '../../../hooks/useMovieMutation';
import { ErrorAlert } from '../../../components';

type FormProps = {
  movie?: Movie;
};

export const Form: React.FC<FormProps> = ({ movie }) => {
  const navigate = useNavigate();

  const defaultValues: Movie = useMemo(
    () =>
      movie || {
        title: '',
        tagline: '',
        released: 0,
        people: {
          actors: [],
          directors: [],
          producers: [],
          writers: [],
          reviewers: [],
        },
      },
    [movie]
  );

  const callback = (message: string) => {
    return navigate('/movies', {
      state: {
        message,
      },
    });
  };

  const { upsert, remove, error } = useMovieMutation(
    ['movies', movie?.id],
    callback
  );

  const { handleSubmit, control, reset } = useForm<Movie>({
    defaultValues,
  });

  useEffect(() => {
    reset(defaultValues);
  }, [reset, defaultValues]);

  return (
    <div>
      <div className="actions-bar">
        <h2>{movie ? 'Edit' : 'Create'} Movie</h2>
        <Link to="/movies" className="button">
          Back to Movies
        </Link>
      </div>
      {error && <ErrorAlert error={error} />}
      <div className="pd-16">
        <form onSubmit={handleSubmit((data) => upsert.mutate(data))}>
          <fieldset className="basic-info">
            <legend>Basic Info</legend>
            <div>
              <label>Title</label>
              <Controller
                name="title"
                control={control}
                rules={{ required: true }}
                render={(props) => <input type="text" {...props.field} />}
              />
            </div>
            <div>
              <label>Tagline</label>
              <Controller
                name="tagline"
                control={control}
                rules={{ required: true }}
                render={(props) => <input type="text" {...props.field} />}
              />
            </div>
            <div>
              <label>Released</label>
              <Controller
                name="released"
                control={control}
                rules={{ required: true }}
                render={(props) => <input type="text" {...props.field} className="number" />}
              />
            </div>
          </fieldset>
          <div className="bottom-actions-bar">
            <input type="submit" />
            {movie && (
              <button
                type="button"
                className="danger"
                onClick={() => remove.mutate(movie)}
              >
                Delete
              </button>
            )}
          </div>
        </form>
      </div>
    </div>
  );
};

Update Content to use API abstraction

code ./packages/web/src/pages/movies/List/Content.tsx
...
import * as api from '../../../api';

...

export const Content: React.FC<ContentProps> = ({ search }) => {
  ...

  const { data, error, isLoading } = useQuery<Movie[], AxiosCustomError>(
    ['movies', debouncedSearch],
    () => api.movies.getAll(search)
  );

  ...
};

Obs.: import axios and url are not used anymore, so we can remove them.

Add Navigation Alert to Movies List

code ./packages/web/src/pages/movies/List/index.tsx
...
import { NavigationAlert } from '../../../components';

export const List = () => {
  const [search, setSearch] = useState<string>('');

  return (
    <div>
      <div className="actions-bar">
        ...
      </div>
      <NavigationAlert />
      <Content search={search} />
    </div>
  );
};

Test

open http://localhost:4200
Create Edit

Commit

git add .
git commit -m "Frontend Mutations"

Next step: People Types