Skip to content

Commit

Permalink
client-side validation of required fields
Browse files Browse the repository at this point in the history
  • Loading branch information
jbellerb committed Apr 14, 2024
1 parent b83f9bd commit 7ddf30c
Show file tree
Hide file tree
Showing 9 changed files with 123 additions and 55 deletions.
4 changes: 2 additions & 2 deletions components/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ export default function TextInput(
name={name}
id={`input-${name}`}
class={classnames(
"block w-full py-1 bg-transparent border-b-2 border-gray-600 transition-border-color transition-color",
"block w-full py-1 bg-transparent border-b-2 border-gray-600 transition-colors",
disabled
? "text-gray-600 border-gray-700"
: error
? "border-red-400"
: "border-gray-600 hover:border-gray-500 focus-visible:border-white",
: "border-gray-600 invalid:!border-red-400 hover:border-gray-500 focus-visible:border-white",
)}
disabled={disabled}
{...props}
Expand Down
2 changes: 2 additions & 0 deletions fresh.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import * as $oauth_callback from "./routes/oauth/callback.ts";
import * as $oauth_login from "./routes/oauth/login.ts";
import * as $oauth_logout from "./routes/oauth/logout.ts";
import * as $privacy from "./routes/privacy.tsx";
import * as $CheckboxGroup from "./islands/CheckboxGroup.tsx";
import * as $FormResetter from "./islands/FormResetter.tsx";
import * as $GrowableTextArea from "./islands/GrowableTextArea.tsx";
import * as $admin_forms_form_id_islands_OptionsEditor from "./routes/admin/forms/[form_id]/(_islands)/OptionsEditor.tsx";
Expand Down Expand Up @@ -61,6 +62,7 @@ const manifest = {
"./routes/privacy.tsx": $privacy,
},
islands: {
"./islands/CheckboxGroup.tsx": $CheckboxGroup,
"./islands/FormResetter.tsx": $FormResetter,
"./islands/GrowableTextArea.tsx": $GrowableTextArea,
"./routes/admin/forms/[form_id]/(_islands)/OptionsEditor.tsx":
Expand Down
79 changes: 79 additions & 0 deletions islands/CheckboxGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useRef } from "preact/hooks";
import { batch, useSignal, useSignalEffect } from "@preact/signals";

import Checkbox from "../components/Checkbox.tsx";
import classnames from "../utils/classnames.ts";

type Props = {
name: string;
comment?: string;
options: string[];
checked?: string[];
required?: boolean;
error?: boolean;
};

export default function CheckboxGroup(props: Props) {
const setRef = useRef<HTMLFieldSetElement>(null);
const untouched = useSignal<boolean>(true);
const checked = useSignal<Record<string, boolean>>(
Object.fromEntries(props.options
.map((option) => [option, props.checked?.includes(option) ?? false])),
);

useSignalEffect(() => {
if (setRef.current && props.required) {
const finalBox = setRef.current.lastElementChild?.firstElementChild;
if (finalBox instanceof HTMLInputElement) {
finalBox.setCustomValidity(
Object.values(checked.value).some((x) => x)
? ""
: "At least one box must be checked.",
);
}
}
});

return (
<fieldset
id={`question-${props.name}`}
class="space-y-2 group"
ref={setRef}
>
{(props.comment != null || props.required) && (
<legend class="text-lg">
{props.comment}
{props.required && (
<span
class={classnames(
"block mt-0 text-sm font-semibold transition-color",
{ "group-has-invalid:!text-red-400": !untouched.value },
props.error ? "text-red-400" : "text-gray-400",
)}
>
* Required
</span>
)}
</legend>
)}
{props.options.map((option, idx) => (
<Checkbox
name={`question-${props.name}`}
id={`checkbox-${props.name}-${idx}`}
label={option}
checked={checked.value[option]}
onChange={(e) =>
batch(() => {
untouched.value = false;
checked.value = {
...checked.value,
[option]: e.currentTarget.checked,
};
})}
value={option}
required={props.options.length === 1 ? props.required : undefined}
/>
))}
</fieldset>
);
}
44 changes: 10 additions & 34 deletions routes/[slug]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import type { Handlers, PageProps, RouteConfig } from "$fresh/server.ts";

import UserBanner from "./(_components)/UserBanner.tsx";
import Button from "../../components/Button.tsx";
import Checkbox from "../../components/Checkbox.tsx";
import TextInput from "../../components/TextInput.tsx";
import CheckboxGroup from "../../islands/CheckboxGroup.tsx";
import classnames from "../../utils/classnames.ts";
import db, { FormResponse } from "../../utils/db/mod.ts";
import { assignRole } from "../../utils/discord/guild.ts";
Expand Down Expand Up @@ -229,40 +229,16 @@ function FormCheckboxQuestion(
const checked = props.value?.split(", ");

return (
<fieldset id={`question-${props.question.name}`} class="space-y-2">
{props.question.comment != null && (
<legend class="text-lg">
{props.question.comment}
{props.question.required && (
<span
class={classnames(
"block mt-0 text-sm font-semibold",
props.issues?.includes("required")
? "text-red-400"
: "text-gray-400",
)}
>
* Required
</span>
)}
</legend>
)}
{(props.question.type === "checkbox_roles"
<CheckboxGroup
name={props.question.name}
comment={props.question.comment}
options={props.question.type === "checkbox_roles"
? props.question.options.map((option) => option.label)
: props.question.options)
.map((option, idx) => (
<Checkbox
name={`question-${props.question.name}`}
id={`checkbox-${props.question.name}-${idx}`}
label={option}
checked={checked?.includes(option)}
value={option}
required={props.question.options.length === 1
? props.question.required
: undefined}
/>
))}
</fieldset>
: props.question.options}
checked={checked}
required={props.question.required}
error={props.issues?.includes("required")}
/>
);
}

Expand Down
7 changes: 6 additions & 1 deletion routes/admin/forms/[form_id]/(_islands)/OptionsEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import IconButton from "../../../../../components/IconButton.tsx";
import TextInput from "../../../../../components/TextInput.tsx";
import { validSnowflake } from "../../../../../utils/discord/snowflake.ts";

type Props = {
name: string;
Expand All @@ -24,7 +25,10 @@ export default function OptionsEditor(props: Props) {
onClick={() => addOption("New Option")}
/>
<div class="flex flex-col w-full ml-2">
<label class="text-sm font-semibold text-gray-400">
<label
id={`${props.name}-options`}
class="text-sm font-semibold text-gray-400"
>
Options<span class="ml-1">*</span>
</label>
<ul class="-mt-1">
Expand All @@ -34,6 +38,7 @@ export default function OptionsEditor(props: Props) {
name={`${props.name}-options-${idx}`}
value={option}
onChange={(e) => updateOption(e.currentTarget.value, idx)}
aria-labelledby={`${props.name}-options`}
required
/>
<IconButton
Expand Down
15 changes: 2 additions & 13 deletions routes/admin/forms/[form_id]/(_islands)/OptionsRolesEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import IconButton from "../../../../../components/IconButton.tsx";
import TextInput from "../../../../../components/TextInput.tsx";
import { toSnowflake } from "../../../../../utils/discord/snowflake.ts";
import { validSnowflake } from "../../../../../utils/discord/snowflake.ts";

type Props = {
name: string;
Expand All @@ -9,15 +9,6 @@ type Props = {
onChange?: (options: string[], roles: string[]) => void;
};

function validateRole(role: string): boolean {
try {
toSnowflake(role);
return true;
} catch {
return false;
}
}

export default function OptionsEditor(props: Props) {
const addOption = (option: string, role: string) =>
props.onChange &&
Expand Down Expand Up @@ -61,7 +52,6 @@ export default function OptionsEditor(props: Props) {
name={`${props.name}-labels-${idx}`}
class="mr-2"
value={option}
error={!option}
onChange={(e) =>
updateOption(idx, e.currentTarget.value, props.roles[idx])}
aria-labelledby={`${props.name}-options`}
Expand All @@ -70,12 +60,11 @@ export default function OptionsEditor(props: Props) {
<TextInput
name={`${props.name}-roles-${idx}`}
value={props.roles[idx]}
error={!props.roles[idx] && !validateRole(props.roles[idx])}
onChange={(e) =>
updateOption(idx, option, e.currentTarget.value)}
onInput={(e) =>
e.currentTarget.setCustomValidity(
validateRole(e.currentTarget.value)
validSnowflake(e.currentTarget.value)
? ""
: "Please enter a valid Discord role ID.",
)}
Expand Down
4 changes: 1 addition & 3 deletions routes/admin/forms/[form_id]/(_islands)/QuestionEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import type {
CheckboxRolesQuestion,
Question,
} from "../../../../../utils/form/types.ts";
import { fromSnowflake } from "../../../../../utils/discord/snowflake.ts";

export type Props = {
questions?: Question[];
Expand All @@ -34,8 +33,7 @@ function loosen(
if (type === "checkbox_roles" && key === "options") {
const options = value as CheckboxRolesQuestion["options"];
stringifiedProps.options = options.map((option) => option.label);
stringifiedProps.roles = options
.map((option) => fromSnowflake(option.role));
stringifiedProps.roles = options.map((option) => option.role);
} else {
stringifiedProps[key] = Array.isArray(value) ? value : [value];
}
Expand Down
14 changes: 12 additions & 2 deletions routes/admin/forms/[form_id]/(_islands)/SubmitterRoleField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { useSignal } from "@preact/signals";
import Checkbox from "../../../../../components/Checkbox.tsx";
import TextInput from "../../../../../components/TextInput.tsx";
import classnames from "../../../../../utils/classnames.ts";
import { validSnowflake } from "../../../../../utils/discord/snowflake.ts";

type Props = { class: string; submitterRole?: string };

export default function SubmitterRoleField(props: Props) {
const submitterRole = useSignal(props.submitterRole);
const assignsRole = useSignal(Boolean(props.submitterRole));

return (
Expand All @@ -22,10 +24,18 @@ export default function SubmitterRoleField(props: Props) {
<TextInput
name={assignsRole.value ? "submitter_role" : ""}
id="input-submitter_role"
label="Role ID"
label="Role ID *"
class="ml-3"
onChange={(e) => submitterRole.value = e.currentTarget.value}
onInput={(e) =>
e.currentTarget.setCustomValidity(
validSnowflake(e.currentTarget.value)
? ""
: "Please enter a valid Discord role ID.",
)}
disabled={!assignsRole.value}
value={props.submitterRole}
value={submitterRole.value}
required
/>
</div>
);
Expand Down
9 changes: 9 additions & 0 deletions utils/discord/snowflake.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
const MAX_I64 = 9223372036854775807n;
const RANGE_U64 = 18446744073709551616n;

export function validSnowflake(snowflake: string): boolean {
try {
toSnowflake(snowflake);
return true;
} catch {
return false;
}
}

export function toSnowflake(snowflake: string): bigint {
let int = BigInt(snowflake);
if (int < 0 || int >= RANGE_U64) {
Expand Down

0 comments on commit 7ddf30c

Please sign in to comment.