tutorbookapp/tutorbook

View on GitHub
components/user/request-form.tsx

Summary

Maintainability
D
1 day
Test Coverage
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import axios, { AxiosError, AxiosResponse } from 'axios';
import { TextField } from '@rmwc/textfield';
import { dequal } from 'dequal';
import to from 'await-to-js';
import useTranslation from 'next-translate/useTranslation';

import Button from 'components/button';
import Loader from 'components/loader';
import SubjectSelect from 'components/subject-select';
import TimeSelect from 'components/time-select';
import UserSelect from 'components/user-select';

import { Meeting, MeetingJSON } from 'lib/model/meeting';
import { User, UserJSON } from 'lib/model/user';
import { join, translate } from 'lib/utils';
import { APIErrorJSON } from 'lib/model/error';
import { Subject } from 'lib/model/subject';
import { Timeslot } from 'lib/model/timeslot';
import { getErrorMessage } from 'lib/fetch';
import { loginWithGoogle } from 'lib/firebase/login';
import { useOrg } from 'lib/context/org';
import { useUser } from 'lib/context/user';

import styles from './request-form.module.scss';

export interface RequestFormProps {
  user: User;
}

export default function RequestForm({
  user: volunteer,
}: RequestFormProps): JSX.Element {
  const [loading, setLoading] = useState<boolean>(false);
  const [checked, setChecked] = useState<boolean>(false);
  const [error, setError] = useState<string | undefined>();

  const { org } = useOrg();
  const { user, updateUser } = useUser();
  const { t, lang: locale } = useTranslation();

  const me = useMemo(() => new User({ ...user, name: 'Me' }), [user]);
  const [students, setStudents] = useState<User[]>([me]);
  useEffect(() => {
    setStudents((prev) => {
      const idx = prev.findIndex((p) => p.id === me.id);
      if (idx < 0) return prev;
      return [...prev.slice(0, idx), me, ...prev.slice(idx + 1)];
    });
  }, [me]);

  const [creating, setCreating] = useState<User>();
  const onCreate = useCallback(() => setCreating(new User()), []);

  const [subjects, setSubjects] = useState<Subject[]>([]);
  const [message, setMessage] = useState<string>('');
  const [time, setTime] = useState<Timeslot>();

  const [phone, setPhone] = useState<string>(user.phone);
  const [reference, setReference] = useState<string>(user.reference);

  useEffect(() => {
    async function validatePhone(): Promise<void> {
      const { default: getPhone } = await import('phone');
      setPhone((prev) => getPhone(prev).phoneNumber || prev);
    }
    void validatePhone();
  }, [phone]);

  useEffect(() => setPhone(user.phone), [user.phone]);
  useEffect(() => setReference(user.reference), [user.reference]);

  // TODO: Fix the `await-to-js` types so that TypeScript knows that `res` is
  // defined whenever `err` is not defined (that way I can get rid of all the
  // unnecessary `if (res)` statements).
  const onSubmit = useCallback(
    async (evt: FormEvent<HTMLFormElement>) => {
      evt.preventDefault();
      setError('');
      setLoading(true);

      // 1. Ensure that the user is logged in and has all the required info.
      const orgs = org ? [...new Set([...user.orgs, org.id])] : user.orgs;
      let updatedUser = new User({ ...user, phone, reference, orgs });
      if (!user.id) {
        const [err, signedUpUser] = await to(loginWithGoogle(updatedUser));
        if (err) {
          setLoading(false);
          setError(
            t('error:response', {
              action: 'logging in with Google',
              error: err.message,
            })
          );
          return;
        }
        if (signedUpUser) updatedUser = signedUpUser;
      } else if (!dequal(updatedUser, user)) {
        const [err, res] = await to<
          AxiosResponse<UserJSON>,
          AxiosError<APIErrorJSON>
        >(axios.put('/api/account', updatedUser.toJSON()));
        if (err) {
          setLoading(false);
          setError(getErrorMessage(err, 'updating profile', t));
          return;
        }
        if (res) {
          updatedUser = User.fromJSON(res.data);
          await updateUser(updatedUser);
        }
      }

      // 2. Replace the "Me" student with the `updatedUser` if necessary.
      const meIdx = students.findIndex((s) => s.name === 'Me');
      if (meIdx >= 0) students[meIdx] = updatedUser;

      // 3. Ensure that the students are all created.
      const creator = new User({ ...updatedUser, roles: [] });
      const people = [new User({ ...volunteer, roles: ['tutor'] })];
      if (!org?.members.includes(updatedUser.id)) creator.roles = ['parent'];
      if (creating) {
        const student = {
          ...creating.toJSON(),
          roles: ['tutee'],
          orgs: [org?.id || 'default'],
          parents: creator.roles.includes('parent') ? [creator.id] : [],
        };
        const [err, res] = await to<
          AxiosResponse<UserJSON>,
          AxiosError<APIErrorJSON>
        >(axios.post('/api/users', student));
        if (err) {
          setLoading(false);
          setError(getErrorMessage(err, 'creating student account', t));
          return;
        }
        if (res) people.push(User.fromJSON({ ...res.data, roles: ['tutee'] }));
      } else {
        students.forEach((student) => {
          if (student.id === creator.id) {
            creator.roles = ['tutee'];
            return;
          }
          people.push(new User({ ...student, roles: ['tutee'] }));
        });
      }
      if (creator.roles.length) people.push(creator);

      // 4. Create the meeting.
      const meeting = new Meeting({
        time,
        people,
        creator,
        subjects,
        description: message,
        org: org?.id || 'default',
      });
      const [err] = await to<
        AxiosResponse<MeetingJSON>,
        AxiosError<APIErrorJSON>
      >(axios.post('/api/meetings', meeting.toJSON()));
      if (err) {
        setLoading(false);
        setError(getErrorMessage(err, 'creating meeting', t));
        return;
      }
      setChecked(true);
    },
    [
      user,
      students,
      creating,
      volunteer,
      org,
      time,
      message,
      subjects,
      phone,
      reference,
      updateUser,
      t,
    ]
  );

  const i18nPrefix = useMemo(
    () =>
      students.length === 1 && students[0].name === 'Me' && !creating
        ? ''
        : 'for-others-',
    [students, creating]
  );
  const messagePlaceholder = useMemo(() => {
    const studentIsMe = students.length === 1 && students[0].id === user.id;
    const studentFirstNames = join(students.map((s) => s.firstName));
    const data = {
      person: studentIsMe ? 'I' : studentFirstNames || 'They',
      subject: join(subjects.map((s) => s.name)) || 'Computer Science',
    };
    if (org?.booking[locale]?.message)
      return translate(org.booking[locale].message, data);
    return t('match3rd:message-placeholder', data);
  }, [t, locale, org, students, user.id, subjects]);
  const userMissingData = useMemo(
    () =>
      (!user.phone && org?.profiles.includes('phone')) ||
      (!user.reference && org?.profiles.includes('reference')),
    [user.phone, user.reference, org]
  );
  const userSelectQuery = useMemo(
    () =>
      org && org.members.includes(user.id)
        ? { orgs: [org.id], visible: undefined }
        : { parents: [user.id], visible: undefined },
    [org, user.id]
  );

  return (
    <form className={styles.card} onSubmit={onSubmit}>
      <Loader active={loading} checked={checked}>
        That’s it! You’ve been CC-ed on an email notifying {volunteer.firstName}{' '}
        of your booking. Feel free to reach out directly; their contact info is
        in that email.
      </Loader>
      <div className={styles.inputs}>
        {!creating && (
          <UserSelect
            required
            label={t('match3rd:students')}
            query={userSelectQuery}
            onUsersChange={setStudents}
            users={students}
            className={styles.field}
            autoOpenMenu
            outlined
            create='Create student'
            onCreate={onCreate}
          />
        )}
        {creating && (
          <>
            <TextField
              label='Student name'
              value={creating.name}
              onChange={(evt) => {
                const name = evt.currentTarget.value;
                setCreating((p) => new User({ ...p, name }));
              }}
              className={styles.field}
              outlined
              required
            />
            <TextField
              label='Student age'
              value={creating.age}
              onChange={(evt) => {
                const age = Number(evt.currentTarget.value);
                setCreating((p) => new User({ ...p, age }));
              }}
              className={styles.field}
              type='number'
              outlined
              required
            />
            <TextField
              label='Student email address'
              value={creating.email}
              onChange={(evt) => {
                const email = evt.currentTarget.value;
                setCreating((p) => new User({ ...p, email }));
              }}
              className={styles.field}
              type='email'
              outlined
            />
          </>
        )}
      </div>
      <div className={styles.divider} />
      <div className={styles.inputs}>
        <SubjectSelect
          required
          outlined
          autoOpenMenu
          label={t(`match3rd:${i18nPrefix}subjects`)}
          className={styles.field}
          onChange={setSubjects}
          value={subjects}
          options={volunteer.subjects}
        />
        <TimeSelect
          required
          outlined
          label={t(`match3rd:${i18nPrefix}time`)}
          className={styles.field}
          onChange={setTime}
          value={time}
          uid={volunteer.id}
        />
        <TextField
          outlined
          textarea
          rows={4}
          required
          placeholder={messagePlaceholder}
          label={t(`match3rd:${i18nPrefix}message`)}
          className={styles.field}
          onChange={(evt) => setMessage(evt.currentTarget.value)}
          value={message}
        />
        {!userMissingData && (
          <>
            <Button
              className={styles.btn}
              label={
                !user.id ? t('match3rd:login-btn') : t('match3rd:send-btn')
              }
              disabled={loading}
              google={!user.id}
              raised
              arrow
            />
            {!!error && (
              <div data-cy='error' className={styles.error}>
                {error}
              </div>
            )}
          </>
        )}
      </div>
      {userMissingData && (
        <>
          <div className={styles.divider} />
          <div className={styles.inputs}>
            {!user.phone && org?.profiles.includes('phone') && (
              <TextField
                label={t('user3rd:phone')}
                value={phone}
                onChange={(evt) => setPhone(evt.currentTarget.value)}
                className={styles.field}
                type='tel'
                outlined
                required
              />
            )}
            {!user.reference && org?.profiles.includes('reference') && (
              <TextField
                className={styles.field}
                label={t('user3rd:reference', {
                  org: org.name || 'Tutorbook',
                })}
                placeholder={t('common:reference-placeholder', {
                  org: org.name || 'Tutorbook',
                })}
                value={reference}
                onChange={(evt) => setReference(evt.currentTarget.value)}
                rows={3}
                textarea
                outlined
                required
              />
            )}
            <Button
              className={styles.btn}
              label={
                !user.id ? t('match3rd:login-btn') : t('match3rd:send-btn')
              }
              disabled={loading}
              google={!user.id}
              raised
              arrow
            />
            {!!error && (
              <div data-cy='error' className={styles.error}>
                {error}
              </div>
            )}
          </div>
        </>
      )}
    </form>
  );
}