React form with zod validation and a canvas

I was recently asked to help with creating a form to submit certain tax redirections for a non-profit organization.

I recently decided to have less code samples in the articles I write. I worry that much code might distract the reader from the actual point of the articles: which is the overall architecture of what I am building.

However, since this is not an open source project, this article will be an exception. It will not contain the exact implementation, but just enough to help anyone implement their own thing.

You can view the project here:

Prerequisites

What we need our application to do is the following:

  • Be able to save the first name, the last name, an identification number, the email, the phone number, street, street number, county and city, postal code and other details about the location together with some information about the request and a signature;
  • Be able to output a PDF file with a certain format, ready to be saved / printed.

The name of the form which we want completed is D230. You can read more about it on the official page:

This will not be an open source project, not for now, at least. Jumping ahead in time, we will get to the following outcome:

Implementation

We will create a frontend in React.js, with Typescript for type safety and zod for validation. We will use vite as a bundler and an atomic file structure for our components. Since a custom design is not a necessity for this project, we will use picocss for quick good looks.

The backend will be made with SlimPHP. This will be a rather small project with few expected users, so a simple solution like what php can deliver will be just right. It will be easy to maintain and cheap to publish. Also, since the API will consist of few endpoints, the security risk will be low.

For the development environment we will use docker and docker-compose. For ease of use, npm and composer are to be optionally present directly on your dev machine.

Technical solutions

The internet is full with examples of how to build such a project, the following lines will be about how some of the challenges were solved in the project.

The API

As mentioned above, we will have Slimphp and Composer at the root of our API. We will not have a migrations system, as we don’t expect many changes to the database. One thing we need to pay attention to is the encoding, as the characters need to be in UTF-8.

We will not go into detail for the technical solution, but one special thing done in the backend of this project was to edit a PDF file and add the content of the form and the signature automatically.

More information:

The signature

We will use the <canvas /> html element to create our own implementation of drawing the signature. This approach is best because our needs are pretty basic: the ability to draw something, reset if desired to do so and send the drawing to the save function. An alternative open source way would have been a combination between signature_pad and react-signature-canvas.

import debounce from "./debounce";

interface CanvasUtilProps {
  canvas: HTMLCanvasElement;
  update?: UpdateCallback;
}

type EventHandler = (event: MouseEvent | TouchEvent) => void;
export type UpdateCallback = (data?: string) => void;

class CanvasUtil {
  private canvas: HTMLCanvasElement;
  private update?: UpdateCallback;

  private context: CanvasRenderingContext2D;
  private paint = false;
  private clickX: number[] = [];
  private clickY: number[] = [];
  private clickDrag: boolean[] = [];

  private events: [keyof HTMLElementEventMap, EventHandler][] = [];

  constructor({ canvas, update }: CanvasUtilProps) {
    const context = canvas.getContext("2d");
    if (!context) {
      throw new Error("Canvas context not found");
    }

    this.canvas = canvas;
    this.update = debounce(update as unknown as () => void, 100);

    this.context = context;

    this.events = [
      ["mousedown", this.pressEventHandler],
      ["mousemove", this.dragEventHandler],
      ["mouseup", this.releaseEventHandler],
      ["mouseout", this.cancelEventHandler],
      ["touchstart", this.pressEventHandler],
      ["touchmove", this.dragEventHandler],
      ["touchend", this.releaseEventHandler],
      ["touchcancel", this.cancelEventHandler],
    ];
  }

  public toDataURL() {
    return this.canvas.toDataURL();
  }

  private setupContext() {
    this.context.font = "14px Arial";
    this.context.lineCap = "round";
    this.context.lineJoin = "round";
    this.context.strokeStyle = "black";
  }

  public updateCanvasSize() {
    const { width, height } = this.canvas.getBoundingClientRect();

    this.canvas.width = width;
    this.canvas.height = height;

    this.context.canvas.width = this.canvas.width;
    this.context.canvas.height = this.canvas.height;
  }

  private bindEvents() {
    this.events.forEach(([eventName, handler]) => {
      this.canvas.addEventListener(eventName, handler as EventListener);
    });
  }

  private unbindEvents() {
    this.events.forEach(([eventName, handler]) => {
      this.canvas.removeEventListener(eventName, handler as EventListener);
    });
  }

  public init() {
    this.setupContext();
    this.updateCanvasSize();
    this.bindEvents();
  }

  public destroy() {
    this.unbindEvents();
  }

  private redraw() {
    const { context, clickX, clickY, clickDrag } = this;
    context.clearRect(0, 0, this.canvas.width, this.canvas.height);
    clickX.forEach((_, i) => {
      context.beginPath();
      if (clickDrag[i] && i) {
        context.moveTo(clickX[i - 1], clickY[i - 1]);
      } else {
        context.moveTo(clickX[i] - 1, clickY[i]);
      }
      context.lineTo(clickX[i], clickY[i]);
      context.lineWidth = 4;
      context.stroke();
      context.closePath();
    });

    if (this.update) {
      this.update(this.toDataURL());
    }
  }

  private addClick(x: number, y: number, dragging: boolean) {
    this.clickX.push(x);
    this.clickY.push(y);
    this.clickDrag.push(dragging);
  }

  private clearCanvas() {
    this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
    this.clickX = [];
    this.clickY = [];
    this.clickDrag = [];

    if (this.update) {
      this.update(undefined);
    }
  }

  public clearEventHandler = () => {
    this.clearCanvas();
  };

  private releaseEventHandler = () => {
    this.paint = false;
    this.redraw();
  };

  private cancelEventHandler = () => {
    this.paint = false;
  };

  private pressEventHandler: EventHandler = (e) => {
    const [mouseX, mouseY] = this.getCoordinates(e);
    this.paint = true;
    this.addClick(mouseX, mouseY, false);
    this.redraw();
  };

  private dragEventHandler: EventHandler = (e) => {
    if (this.paint) {
      const [mouseX, mouseY] = this.getCoordinates(e);
      this.addClick(mouseX, mouseY, true);
      this.redraw();
    }
    e.preventDefault();
  };

  private getCoordinates(e: MouseEvent | TouchEvent): [number, number] {
    let mouseX = 0;
    let mouseY = 0;

    if ("touches" in e) {
      // Touch event
      const touch = e.touches[0];
      mouseX = touch.pageX - this.canvas.offsetLeft;
      mouseY = touch.pageY - this.canvas.offsetTop;
    } else {
      // Mouse event
      mouseX = (e as MouseEvent).offsetX;
      mouseY = (e as MouseEvent).offsetY;
    }

    return [mouseX, mouseY];
  }
}

export default CanvasUtil;
Code language: TypeScript (typescript)

We will create a helper class to deal with the canvas element, then a React component which will load this class and use a forwardRef() to pass its value to the form which uses it.

More information:

Google reCAPTCHA

The safest CAPTCHA test out there is what google provides. We will use reCAPTCHA v2 in our form, to make sure that spam bots don’t populate our database with rubbish information. For that, we will need to register for credentials on the Google website:

On the frontend we will use thereact-google-recaptcha package. That will give us a component with a rather straightforward usage instructions (setValue() is a destructured prop of useForm() from react-hook-forms):

<ReCAPTCHA
   className="ReCaptcha"
   sitekey="your-public-key-from-google"
   onChange={(token: string | null) => {
    setValue("recaptcha", token || undefined);
  }}
/>Code language: JavaScript (javascript)

On the backend, we will create custom middleware to check for the captcha on the routes we specify:

// Middleware to validate Google reCAPTCHA token
$reCAPTCHAMiddleware = function (Request $request, $handler) use ($config) {
    // Get the raw body
    $rawBody = $request->getBody()->getContents();
    if (empty($rawBody)) {
        return respond("Request is malformed", 400);
    }
    $data = @json_decode($rawBody, true);
    $recaptchaToken = @$data['recaptcha'];
    if (!$recaptchaToken) {
        return respond("reCAPTCHA token is missing", 400);
    }
    $client = new Client();
    $response = $client->post('https://google.com/recaptcha/api/siteverify', [
        'query' => [
            'secret' => "secret-captcha-key-from-google",
            'response' => $recaptchaToken
        ]
    ]);
    $body = @json_decode($response->getBody());
    if (!$body->success) {
        return respond("reCAPTCHA validation failed", 403);
    }
    return $handler->handle($request);
};
// Example route
$app->get('/', function (Request $request, Response $response) {
    return respond("Welcome to the API");
})->add($reCAPTCHAMiddleware);;Code language: PHP (php)

Read more:

CNP validator

In Romania, people have a personal number assigned at birth. It’s a unique sequence of numbers by which somebody can be identified by the authorities. We need this number in this form, so we will change create a custom validator in zod to make sure that the number provided is correct.

The code for the validator will be:

const cnpValidator = (value: string): boolean => {
  let m: RegExpMatchArray | null;
  if (
    (m =
      /^([1-8])(0[1-9]|[1-9][0-9])(0[1-9]|1[0-2])(\d{2})(\d{2})(\d{3})(\d)$/.exec(
        value
      ))
  ) {
    const ll: number = parseInt(m[3], 10);
    const zz: number = parseInt(m[4], 10);
    switch (ll) {
      case 2:
        if (zz > 29) {
          return false;
        }
        break;
      case 4:
      case 6:
      case 9:
      case 11:
        if (zz > 30) {
          return false;
        }
        break;
      default:
        if (zz > 31) {
          return false;
        }
    }
    const jj: number = parseInt(m[5], 10);
    if (jj < 0 || (jj > 46 && jj < 51) || jj > 52) {
      return false;
    }
    const nnn: number = parseInt(m[6], 10);
    if (nnn < 0) {
      return false;
    }
    const constant: string = "279146358279";
    let sum: number = 0;
    for (let i = 0; i < constant.length; i++) {
      sum += parseInt(m[0].charAt(i), 10) * parseInt(constant.charAt(i), 10);
    }
    const remainder: number = sum % 11;
    return (
      (remainder < 10 && remainder.toString() === m[0].charAt(12)) ||
      (remainder === 10 && m[0].charAt(12) === "1")
    );
  }
  return false;
};
export default cnpValidator;
Code language: JavaScript (javascript)

We can use this validator where the schema is defined:

import z from "zod";

import cnpValidator from "@/utils/validators/cnp";

export const personSchema = z.object({
  firstName: z.string(),
  lastName: z.string(),
  fatherInitial: z.string().optional(),
  cnp: z.coerce
    .number()
    .min(1)
    .refine((value) => cnpValidator(value.toString()), {
      message: "CNP invalid",
    }),
  email: z.string().email().optional(),
  phoneNumber: z.string().optional(),
});Code language: JavaScript (javascript)

More information:

zod custom errors

The project is in Romanian, so we need zod to show special error messages, different from the default ones in English.

One first approach is to specify custom values for each validation, by using specific config props (e.g. required_error, invalid_type_error). That can be something like the following code:

import z from "zod";
const REQUIRED_ERROR_MESSAGE = "Informatie necesara";
const INVALID_VALUE_MESSAGE = "Valoare invalida";
export const personSchema = z.object({
  firstName: z.string({
    required_error: REQUIRED_ERROR_MESSAGE,
  }),
  lastName: z.string({
    required_error: REQUIRED_ERROR_MESSAGE,
  })
});Code language: JavaScript (javascript)

However, this can be done in a better way by setting a custom error map in zod:

import { ZodIssueCode, ZodParsedType, defaultErrorMap, ZodErrorMap } from "zod";
// Error messages
const ERROR_REQUIRED = "Informație necesară.";
const ERROR_INVALID = "Informație nevalidă.";
const ERROR_INVALID_PARAMS = "Parametri nevalizi.";
const ERROR_INVALID_DATE = "Dată nevalidă.";
const ERROR_NOT_FINITE = "Nu este finit.";
const ERROR_INVALID_VALUE = "Valoare nevalidă ({0}), opțiuni disponibile: {1}";
const ERROR_TEXT_MUST_START_WITH = "Text nevalid, trebuie să înceapă cu: {0}";
const ERROR_TEXT_MUST_END_WITH = "Text nevalid, trebuie să se termine cu: {0}";
const ERROR_INVALID_TEXT = "Text nevalid: {0}";
const ERROR_TOO_SMALL = "Prea mic, minim: {0}";
const ERROR_TOO_BIG = "Prea mare, maxim: {0}";
const ERROR_NOT_MULTIPLE_OF = "Nu este multiplu de {0}";
const ERROR_CUSTOM = "Eroare.";
// Message formatter
const msg = (
  message: string,
  replacements?: (string | number | bigint)[]
): string => {
  if (replacements) {
    replacements.forEach((replacement, index) => {
      message = message.replace(`{${index}}`, String(replacement));
    });
  }
  return message;
};
const zodCustomErrorMap: ZodErrorMap = (issue, ctx) => {
  let { message } = defaultErrorMap(issue, ctx);
  switch (issue.code) {
    case ZodIssueCode.invalid_type:
      if (
        issue.received === ZodParsedType.undefined ||
        issue.received === ZodParsedType.null
      ) {
        message = ERROR_REQUIRED;
      } else {
        message = ERROR_INVALID;
      }
      break;
    case ZodIssueCode.invalid_literal:
    case ZodIssueCode.unrecognized_keys:
    case ZodIssueCode.invalid_union:
    case ZodIssueCode.invalid_union_discriminator:
    case ZodIssueCode.invalid_return_type:
    case ZodIssueCode.invalid_intersection_types:
      message = ERROR_INVALID;
      break;
    case ZodIssueCode.invalid_enum_value:
      message = msg(ERROR_INVALID_VALUE, [
        issue.received,
        issue.options.join(", "),
      ]);
      break;
    case ZodIssueCode.invalid_arguments:
      message = ERROR_INVALID_PARAMS;
      break;
    case ZodIssueCode.invalid_date:
      message = ERROR_INVALID_DATE;
      break;
    case ZodIssueCode.invalid_string:
      if (typeof issue.validation === "object") {
        if ("startsWith" in issue.validation) {
          message = msg(ERROR_TEXT_MUST_START_WITH, [
            issue.validation.startsWith,
          ]);
        } else if ("endsWith" in issue.validation) {
          message = msg(ERROR_TEXT_MUST_END_WITH, [issue.validation.endsWith]);
        }
      } else {
        message = msg(ERROR_INVALID_TEXT, [issue.validation]);
      }
      break;
    case ZodIssueCode.too_small:
      message = msg(ERROR_TOO_SMALL, [issue.minimum]);
      break;
    case ZodIssueCode.too_big:
      message = msg(ERROR_TOO_BIG, [issue.maximum]);
      break;
    case ZodIssueCode.custom:
      message = issue.params?.message || ERROR_CUSTOM;
      break;
    case ZodIssueCode.not_multiple_of:
      message = msg(ERROR_NOT_MULTIPLE_OF, [issue.multipleOf]);
      break;
    case ZodIssueCode.not_finite:
      message = ERROR_NOT_FINITE;
      break;
    default:
  }
  return { message };
};
export default zodCustomErrorMap;
Code language: JavaScript (javascript)

For projects with locales activated, a library like zod-i18n-map could do the trick even easier.

More information: