opcotech/elemo

View on GitHub
web/components/todos/TodoForm.tsx

Summary

Maintainability
C
7 hrs
Test Coverage
'use client';

import { z } from 'zod';
import { Fragment, useEffect, useState } from 'react';
import { Listbox, Transition } from '@headlessui/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';

import { Button } from '@/components/blocks/Button';
import { Icon } from '@/components/blocks/Icon';
import { Link } from '@/components/blocks/Link';
import { concat, formatErrorMessage, toCapitalCase } from '@/lib/helpers';
import useStore from '@/store';
import { $Todo, Todo, TodoPriority } from '@/lib/api';
import { normalizeData } from '@/lib/helpers/schema';

const PRIORITY_ORDER: TodoPriority[] = [
  TodoPriority.NORMAL,
  TodoPriority.IMPORTANT,
  TodoPriority.URGENT,
  TodoPriority.CRITICAL
];

const PRIORITY_COLORS: { [key in TodoPriority]: string } = {
  normal: 'text-gray-700',
  important: 'text-blue-600',
  urgent: 'text-yellow-600',
  critical: 'text-red-600'
};

const CREATE_TODO_SCHEMA = z.object({
  title: z
    .string()
    .min($Todo.properties.title.minLength, 'Title is required')
    .max($Todo.properties.title.maxLength, 'Title must be less than 250 characters.'),
  description: z
    .string()
    .min($Todo.properties.description.minLength, 'Description must be at least 10 characters.')
    .max($Todo.properties.description.maxLength, 'Description must be less than 500 characters.')
    .optional()
    .or(z.literal('')),
  completed: z.boolean().default(false),
  priority: z.enum([PRIORITY_ORDER[0], ...PRIORITY_ORDER.slice(1)]).default(TodoPriority.NORMAL),
  due_date: z.string().optional().or(z.literal(''))
});

export interface NewTodoFormProps {
  editing: Todo | undefined;
  onCancel: () => void;
  onHide: () => void;
}

export function TodoForm(props: NewTodoFormProps) {
  const [loading, setLoading] = useState(false);

  const createTodo = useStore((state) => state.createTodo);
  const updateTodo = useStore((state) => state.updateTodo);

  const isEditing = props.editing?.id !== undefined;
  const todoId = props.editing?.id || undefined;

  const [priority, setPriority] = useState<TodoPriority>(props.editing?.priority || PRIORITY_ORDER[0]);

  const {
    register,
    handleSubmit,
    reset,
    clearErrors,
    setValue,
    getValues,
    setFocus,
    formState: { errors }
  } = useForm<Todo>({
    resolver: zodResolver(CREATE_TODO_SCHEMA)
  });

  function resetFormState() {
    setPriority(PRIORITY_ORDER[0]);
    reset();
    clearErrors();
    props = { editing: undefined, onCancel: props.onCancel, onHide: props.onHide };
  }

  async function onSubmit(todo: Todo) {
    setLoading(true);

    // Fix the due_date field format
    if (todo.due_date) {
      todo = { ...todo, due_date: new Date(todo.due_date).toISOString() };
    } else {
      todo = { ...todo, due_date: null };
    }

    if (!todoId) {
      await createTodo(normalizeData(todo, CREATE_TODO_SCHEMA));
    } else {
      await updateTodo(todoId, normalizeData(todo, CREATE_TODO_SCHEMA));
      handleCancel();
    }

    resetFormState();
    setLoading(false);
  }

  function handleCancel() {
    props.onCancel();
    resetFormState();
  }

  function handleHide() {
    props.onHide();
    resetFormState();
  }

  function handlePriorityChange(priority: TodoPriority) {
    setPriority(priority);
    setValue('priority', priority);
  }

  // Set default values for form fields even when editing
  useEffect(() => {
    setPriority(props.editing?.priority || PRIORITY_ORDER[0]);
  }, [props.editing?.priority, props.editing?.due_date]);

  useEffect(() => {
    setFocus('title');
    setValue('title', props.editing?.title || getValues('title'));
    setValue('description', props.editing?.description || getValues('description'));
    setValue('priority', priority || getValues('priority'));
    setValue('completed', getValues('completed'));
    setValue('due_date', props.editing?.due_date?.split('T')[0] || getValues('due_date'));
  }, [
    props.editing?.title,
    props.editing?.description,
    priority,
    setValue,
    getValues,
    setFocus,
    props.editing?.due_date
  ]);

  return (
    <form id="form-add-todo-item" action={'#'} className="relative" onSubmit={handleSubmit(onSubmit)}>
      <div
        className={
          'overflow-hidden rounded-lg border border-gray-300 shadow-sm focus-within:border-gray-500 focus-within:ring-1 focus-within:ring-gray-500'
        }
      >
        <label htmlFor="title" className="sr-only">
          Title
        </label>
        <input
          id="title"
          type="text"
          className="block w-full border-0 pt-2.5 text-lg font-medium placeholder-gray-500 focus:ring-0"
          placeholder="Title"
          autoComplete="off"
          aria-invalid={errors.title ? 'true' : 'false'}
          aria-describedby={errors.title ? 'title-error' : undefined}
          required={true}
          {...register('title')}
        />

        <label htmlFor="description" className="sr-only">
          Description
        </label>
        <textarea
          rows={3}
          id="description"
          className="block w-full resize-none border-0 py-0 placeholder-gray-500 focus:ring-0 sm:text-sm"
          placeholder="Today I'll complete..."
          autoComplete="off"
          aria-invalid={errors.description ? 'true' : 'false'}
          aria-describedby={errors.description ? 'description-error' : undefined}
          {...register('description')}
        />

        {Object.entries(errors).filter(([, value]) => value.message).length > 0 && (
          <div className="space-y-2 mt-4 px-3">
            {Object.entries(errors).map(([key, value]) => (
              <p id={`${key}-error`} key={key} className="text-sm text-red-600">
                {formatErrorMessage(key, value.message)}
              </p>
            ))}
          </div>
        )}

        {/* Spacer element to match the height of the toolbar */}
        <div aria-hidden="true">
          <div className="py-2">
            <div className="h-4" />
          </div>
          <div className="h-px" />
          <div className="py-2">
            <div className="py-px">
              <div className="h-4" />
            </div>
          </div>
        </div>
      </div>

      <div className="absolute inset-x-px bottom-0">
        <div className="flex items-center justify-between space-x-3 border-t border-gray-200 px-2 py-2 sm:px-3">
          <div className="flex items-center">
            <Button variant="secondary" loading={loading} type="submit">
              {isEditing ? 'Update' : 'Add'}
            </Button>
            {isEditing && (
              <Link className={'ml-3 text-sm'} onClick={handleCancel}>
                Cancel
              </Link>
            )}
            {!isEditing && (
              <Link className={'ml-3 text-sm'} onClick={handleHide}>
                Hide
              </Link>
            )}
          </div>
          <div className="flex items-center space-x-2">
            <Listbox as="div" value={priority} onChange={handlePriorityChange} className="flex-shrink-0">
              {({ open }) => (
                <>
                  <Listbox.Label className="sr-only"> Add a label </Listbox.Label>
                  <div className="relative">
                    <Listbox.Button
                      id="btn-todo-priority"
                      className="relative inline-flex items-center whitespace-nowrap rounded-full bg-gray-50 py-2 px-4 text-sm text-gray-500 hover:bg-gray-100"
                    >
                      <Icon
                        size={'xs'}
                        variant="FlagIcon"
                        className={concat(PRIORITY_COLORS[priority], 'h-4 w-4 flex-shrink-0 sm:-ml-1')}
                        aria-hidden="true"
                      />
                      <span className="hidden truncate sm:ml-2 sm:block text-gray-900">{toCapitalCase(priority)}</span>
                    </Listbox.Button>

                    <Transition
                      show={open}
                      as={Fragment}
                      enter="transition ease-out duration-200"
                      enterFrom="transform opacity-0 scale-95"
                      enterTo="transform opacity-100 scale-100"
                      leave="transition ease-in duration-75"
                      leaveFrom="transform opacity-100 scale-100"
                      leaveTo="transform opacity-0 scale-95"
                    >
                      <Listbox.Options
                        id="menu-todo-priority"
                        className="absolute -left-8 right-0 z-10 mt-1 max-h-56 w-52 overflow-auto rounded-lg bg-white py-3 text-base shadow ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm sm:left-auto"
                      >
                        {PRIORITY_ORDER.map((label) => (
                          <Listbox.Option
                            key={label}
                            className={({ active }) =>
                              concat(
                                active ? 'bg-gray-100' : 'bg-white',
                                'relative cursor-default select-none py-2 px-3'
                              )
                            }
                            value={label}
                            data-value={label}
                          >
                            <div className="flex items-center">
                              <span className="block truncate">{toCapitalCase(label)}</span>
                            </div>
                          </Listbox.Option>
                        ))}
                      </Listbox.Options>
                    </Transition>
                  </div>
                </>
              )}
            </Listbox>

            <input
              id="due_date"
              type="date"
              className="relative inverse-datepicker inline-flex items-center whitespace-nowrap rounded-full border-none bg-gray-50 py-2 px-2 text-sm text-gray-900 hover:bg-gray-100 sm:px-3 focus:ring-0"
              autoComplete="off"
              aria-invalid={errors.due_date ? 'true' : 'false'}
              {...register('due_date')}
              min={new Date().toISOString().split('T')[0]}
            />
          </div>
        </div>
      </div>
    </form>
  );
}