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);