PAD + React

Forms checked by PAD

One list of rules for both sides: the browser checks the form with them first, then React posts it with the session's CSRF token in a header and padValidate answers a message per field, or the new list of messages.

padValidatepadValidateClient$padCsrfpadSession controlled inputsX-CSRF-Token
Live

The form needs JavaScript - it posts with fetch().

How it is built

PAD, on the server React, in the browser

<div data-island="FeedbackWall" data-props="{^start}">
  <p class="island-fallback">The form needs JavaScript - it posts with fetch().</p>
</div>

{validator}
<script type="text/babel" src="{$padRoot}react/examples/feedback.js"></script>
<?php

  // The wall is this visitor's own, kept in the session: padSession reads it, padSessionPut
  // writes it. A post comes from the component's fetch - with $padCsrf on in the application's
  // configuration it carries the session's token in an X-CSRF-Token header, or PAD answers 403
  // before this file runs. padValidate answers a message per field that broke its rules;
  // with none the message goes on the wall. The answer to the post is the JSON of $padExpose.
  //
  // One list of rules for both sides: padValidate checks the post with it, and the component
  // gets it as padValidateClient exports it, for the browser's checker that {validator} in
  // the template brings - the same rules, the same messages, before the post leaves.

  $rules = [ 'name'    => 'required|max:40',
             'email'   => 'required|email',
             'mood'    => 'required|in:happy,curious,puzzled',
             'message' => 'required|min:10|max:280' ];

  $wall   = padSession ( 'reactWall', [] );
  $errors = [];
  $saved  = FALSE;

  if ( padRequestIs ( 'POST' ) ) {

    $errors = padValidate ( $rules );

    if ( ! $errors ) {

      array_unshift ( $wall, [ 'name'    => trim ( padRequest ( 'name' ) ),
                               'mood'    => padRequest ( 'mood' ),
                               'message' => trim ( padRequest ( 'message' ) ),
                               'at'      => date ( 'H:i' ) ] );

      $wall  = array_slice ( $wall, 0, 12 );
      $saved = padSessionPut ( 'reactWall', $wall );

    }

  }

  $start     = [ 'wall' => $wall, 'rules' => padValidateClient ( $rules ) ];
  $padExpose = [ 'saved', 'errors', 'wall' ];

?>
// A form React holds and PAD checks. The rules are the server's, in feedback.php - and the
// component has the same list in its props (padValidateClient), which the checker {validator}
// brought judges in the browser first: a field when it is left, the whole form before it is
// posted, with the messages the server would give. Switch that off and the post goes as it
// is - the server's answer names the field that broke a rule. The post carries the session's
// CSRF token in a header; the second button leaves it out, and PAD refuses the post before
// the page's PHP ever runs.

const feedbackMoods = { happy: '😊', curious: '🤔', puzzled: '😵‍💫' };
const feedbackEmpty = { name: '', email: '', mood: 'happy', message: '' };

function FeedbackWall({ wall: first, rules }) {
  const [form, setForm] = React.useState(feedbackEmpty);
  const [errors, setErrors] = React.useState({});
  const [wall, setWall] = React.useState(first);
  const [busy, setBusy] = React.useState(false);
  const [browser, setBrowser] = React.useState(true);
  const [toast, say] = useToast();

  // a field left: the browser's verdict on it, in the server's words
  const leave = name => () => {
    if (browser && form[name] !== '')
      setErrors(now => ({ ...now, [name]: padValidate.field(rules[name], form[name], form) || undefined }));
  };

  // an edited field drops its message - the next answer of the server says whether it holds
  const set = name => event => {
    setForm({ ...form, [name]: event.target.value });
    setErrors({ ...errors, [name]: undefined });
  };

  const send = async token => {
    if (browser && token) {
      const found = padValidate.check(rules, form);
      setErrors(found);
      if (Object.keys(found).length) {
        say('The browser stopped it - the same rules the server checks');
        return;
      }
    }
    setBusy(true);
    try {
      const answer = await PadReact.post('examples/feedback', form, { token });
      setErrors(answer.errors || {});
      setWall(answer.wall);
      if (answer.saved) {
        setForm(feedbackEmpty);
        say('Kept in your session - reload the page and it is still there');
      }
    } catch (error) {
      say(error.status === 403 ? '403 - PAD refused a post without the token' : error.message);
    } finally {
      setBusy(false);
    }
  };

  const field = (name, label, input) => (
    <div className="field">
      <label htmlFor={'feedback-' + name}>{label}</label>
      {input}
      {errors[name] && <span className="error" role="alert">{errors[name]}</span>}
    </div>
  );

  const left = 280 - form.message.length;

  return (
    <div className="cols-2">
      <form noValidate onSubmit={event => { event.preventDefault(); send(true); }}>
        {field('name', 'Name',
          <input id="feedback-name" className={'input' + (errors.name ? ' is-invalid' : '')} value={form.name} onChange={set('name')} onBlur={leave('name')} />)}
        {field('email', 'E-mail',
          <input id="feedback-email" className={'input' + (errors.email ? ' is-invalid' : '')} value={form.email} onChange={set('email')} onBlur={leave('email')} />)}

        <div className="field">
          <span className="label">Mood</span>
          <div className="pills" role="radiogroup" aria-label="Mood">
            {Object.entries(feedbackMoods).map(([mood, face]) => (
              <button type="button" key={mood} role="radio" aria-checked={form.mood === mood}
                      className={'pill' + (form.mood === mood ? ' is-on' : '')} onClick={() => setForm({ ...form, mood })}>
                {face} {mood}
              </button>
            ))}
          </div>
          {errors.mood && <span className="error">{errors.mood}</span>}
        </div>

        {field('message', 'Message',
          <textarea id="feedback-message" className={'input' + (errors.message ? ' is-invalid' : '')} value={form.message} onChange={set('message')} onBlur={leave('message')} />)}
        <div className="muted small" style={{ marginTop: -8, marginBottom: 14, color: left < 0 ? 'var(--bad)' : undefined }}>
          {left} characters left - the server says at least 10, at most 280
        </div>

        <label className="small" style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 14 }}>
          <input type="checkbox" checked={browser} onChange={event => setBrowser(event.target.checked)} />
          Check in the browser first - with the rules the server checks
        </label>

        <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
          <button type="submit" disabled={busy}>{busy ? <span className="spinner" /> : '✉️'} Post it</button>
          <button type="button" className="btn-ghost" disabled={busy} onClick={() => send(false)}>Post without the token</button>
        </div>

        <RequestLog />
      </form>

      <div>
        <div className="panel-title">Your wall - {wall.length} {wall.length === 1 ? 'message' : 'messages'}</div>
        {wall.length === 0 && (
          <div className="empty-state panel"><span className="big">🗒️</span>Empty. Post something - or post nothing, and read what PAD says.</div>
        )}
        <div style={{ display: 'grid', gap: 10 }}>
          {wall.map((note, index) => (
            <div key={wall.length - index} className="card rise" style={{ padding: 16 }}>
              <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
                <strong>{feedbackMoods[note.mood]} {note.name}</strong>
                <span className="muted small">{note.at}</span>
              </div>
              <p style={{ margin: '6px 0 0' }}>{note.message}</p>
            </div>
          ))}
        </div>
      </div>
      {toast}
    </div>
  );
}

PadReact.island('FeedbackWall', FeedbackWall);