diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json index f4926fa1284a..ea42e45ed995 100644 --- a/frontend/.oxlintrc.json +++ b/frontend/.oxlintrc.json @@ -21,7 +21,8 @@ "rules": { "explicit-function-return-type": "off", "no-explicit-any": "off", - "no-unsafe-assignment": "off" + "no-unsafe-assignment": "off", + "no-empty-function": "off" } }, { diff --git a/frontend/__tests__/components/ui/ValidatedInput.spec.tsx b/frontend/__tests__/components/ui/ValidatedInput.spec.tsx deleted file mode 100644 index 90f48a74ac67..000000000000 --- a/frontend/__tests__/components/ui/ValidatedInput.spec.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { render, waitFor } from "@solidjs/testing-library"; -import userEvent from "@testing-library/user-event"; -import { createSignal } from "solid-js"; -import { describe, expect, it, vi } from "vitest"; -import { z } from "zod"; - -import { ValidatedInput } from "../../../src/ts/components/ui/ValidatedInput"; - -vi.mock("../../../src/ts/config", () => ({})); - -describe("ValidatedInput", () => { - it("renders with valid initial value", async () => { - const schema = z.string().min(4); - const { container } = render(() => ( - - )); - - await waitFor(() => container.querySelector(".inputAndIndicator") !== null); - - const wrapper = container.querySelector(".inputAndIndicator"); - const input = container.querySelector("input"); - console.log(container?.innerHTML); - expect(wrapper).toHaveClass("inputAndIndicator"); - expect(wrapper).toHaveAttribute("data-indicator-status", "success"); - expect(input).toHaveValue("Kevin"); - - const indicator = wrapper?.querySelector("div.indicator:not(.hidden)"); - expect(indicator).toBeInTheDocument(); - expect(indicator).toHaveAttribute("data-option-id", "success"); - expect(indicator?.querySelector("i")).toHaveClass("fa-check"); - }); - - it("renders with invalid initial value", async () => { - const schema = z.string().min(4); - const { container } = render(() => ( - - )); - - await waitFor(() => container.querySelector(".inputAndIndicator") !== null); - - const wrapper = container.querySelector(".inputAndIndicator"); - const input = container.querySelector("input"); - console.log(container?.innerHTML); - expect(wrapper).toHaveClass("inputAndIndicator"); - expect(wrapper).toHaveAttribute("data-indicator-status", "failed"); - expect(input).toHaveValue("Bob"); - - const indicator = wrapper?.querySelector("div.indicator:not(.hidden)"); - expect(indicator).toBeInTheDocument(); - expect(indicator).toHaveAttribute("data-option-id", "failed"); - expect(indicator?.querySelector("i")).toHaveClass("fa-times"); - }); - - it("updates callback", async () => { - const [value, setValue] = createSignal("Bob"); - const schema = z.string().min(4); - const { container } = render(() => ( - - )); - - await waitFor(() => container.querySelector(".inputAndIndicator") !== null); - console.log(container.innerHTML); - const input = container.querySelector("input") as HTMLInputElement; - expect(container.querySelector(".inputAndIndicator")).toHaveAttribute( - "data-indicator-status", - "failed", - ); - - await userEvent.type(input, "ington"); - - expect(value()).toEqual("Bobington"); - expect(container.querySelector(".inputAndIndicator")).toHaveAttribute( - "data-indicator-status", - "success", - ); - }); -}); diff --git a/frontend/package.json b/frontend/package.json index e93a1ac5b9b4..8cb6cf5abed2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@date-fns/utc": "1.2.0", - "@leonabcd123/modern-caps-lock": "2.2.2", + "@leonabcd123/modern-caps-lock": "3.0.4", "@monkeytype/contracts": "workspace:*", "@monkeytype/funbox": "workspace:*", "@monkeytype/schemas": "workspace:*", @@ -37,6 +37,7 @@ "@tanstack/pacer-lite": "0.2.1", "@tanstack/query-db-collection": "1.0.27", "@tanstack/solid-db": "0.2.10", + "@tanstack/solid-form": "1.28.4", "@tanstack/solid-query": "5.90.23", "@tanstack/solid-query-devtools": "5.91.3", "@tanstack/solid-table": "8.21.3", diff --git a/frontend/src/styles/tailwind.css b/frontend/src/styles/tailwind.css index 42a8040255cd..68ae21a5ea8d 100644 --- a/frontend/src/styles/tailwind.css +++ b/frontend/src/styles/tailwind.css @@ -91,3 +91,14 @@ padding: 0; } } + +@layer utilities { + .autofill-fix:-webkit-autofill, + .autofill-fix:-webkit-autofill:hover, + .autofill-fix:-webkit-autofill:focus { + @apply border-none font-(--font) caret-(--text-color) font-[inherit]; + outline: 0.15em solid var(--main-color); + -webkit-text-fill-color: var(--text-color); + -webkit-box-shadow: 0 0 0 1000000px var(--sub-alt-color) inset; + } +} diff --git a/frontend/src/ts/auth.tsx b/frontend/src/ts/auth.tsx index 8723c68c1c2a..6656513afe67 100644 --- a/frontend/src/ts/auth.tsx +++ b/frontend/src/ts/auth.tsx @@ -34,6 +34,15 @@ import { } from "./stores/notifications"; import { createErrorMessage } from "./utils/misc"; +export type AuthResult = + | { + success: true; + } + | { + success: false; + message: string; + }; + export const gmailProvider = new GoogleAuthProvider(); export const githubProvider = new GithubAuthProvider(); @@ -156,15 +165,7 @@ export async function signIn( email: string, password: string, rememberMe: boolean, -): Promise< - | { - success: true; - } - | { - success: false; - message: string; - } -> { +): Promise { if (!isAuthAvailable()) { return { success: false, message: "Authentication uninitialized" }; } @@ -182,15 +183,7 @@ export async function signIn( async function signInWithProvider( provider: AuthProvider, rememberMe: boolean, -): Promise< - | { - success: true; - } - | { - success: false; - message: string; - } -> { +): Promise { if (!isAuthAvailable()) { return { success: false, message: "Authentication uninitialized" }; } @@ -203,27 +196,15 @@ async function signInWithProvider( return { success: true }; } -export async function signInWithGoogle(rememberMe: boolean): Promise< - | { - success: true; - } - | { - success: false; - message: string; - } -> { +export async function signInWithGoogle( + rememberMe: boolean, +): Promise { return signInWithProvider(gmailProvider, rememberMe); } -export async function signInWithGitHub(rememberMe: boolean): Promise< - | { - success: true; - } - | { - success: false; - message: string; - } -> { +export async function signInWithGitHub( + rememberMe: boolean, +): Promise { return signInWithProvider(githubProvider, rememberMe); } @@ -272,15 +253,7 @@ export async function signUp( name: string, email: string, password: string, -): Promise< - | { - success: true; - } - | { - success: false; - message: string; - } -> { +): Promise { if (!isAuthAvailable()) { return { success: false, message: "Authentication uninitialized" }; } diff --git a/frontend/src/ts/components/common/Button.tsx b/frontend/src/ts/components/common/Button.tsx index 04e307295f9d..545a4209a990 100644 --- a/frontend/src/ts/components/common/Button.tsx +++ b/frontend/src/ts/components/common/Button.tsx @@ -14,13 +14,14 @@ type BaseProps = { balloon?: BalloonProps; "router-link"?: true; onClick?: () => void; + type?: HTMLButtonElement["type"]; onMouseEnter?: () => void; onMouseLeave?: () => void; dataset?: Record; active?: boolean; }; -type ButtonProps = BaseProps & { +export type ButtonProps = BaseProps & { type?: "button" | "submit" | "reset"; href?: never; sameTarget?: true; @@ -31,6 +32,7 @@ type AnchorProps = BaseProps & { href: string; // onClick?: never; disabled?: never; + type?: never; }; export function Button(props: ButtonProps | AnchorProps): JSXElement { diff --git a/frontend/src/ts/components/core/DevTools.tsx b/frontend/src/ts/components/core/DevTools.tsx index ce32cc39bacf..5e64f9d98e9a 100644 --- a/frontend/src/ts/components/core/DevTools.tsx +++ b/frontend/src/ts/components/core/DevTools.tsx @@ -1,6 +1,5 @@ import { SolidQueryDevtools } from "@tanstack/solid-query-devtools"; import { JSXElement } from "solid-js"; - export function DevTools(): JSXElement { return ; } diff --git a/frontend/src/ts/components/pages/login/Login.tsx b/frontend/src/ts/components/pages/login/Login.tsx index b0a219bc996c..898ea6206413 100644 --- a/frontend/src/ts/components/pages/login/Login.tsx +++ b/frontend/src/ts/components/pages/login/Login.tsx @@ -1,77 +1,70 @@ -import { createSignal, JSXElement } from "solid-js"; +import { createForm } from "@tanstack/solid-form"; +import { JSXElement } from "solid-js"; -import { signIn, signInWithGitHub, signInWithGoogle } from "../../../auth"; +import { + AuthResult, + signIn, + signInWithGitHub, + signInWithGoogle, +} from "../../../auth"; import * as ForgotPasswordModal from "../../../modals/forgot-password"; -import * as ConnectionState from "../../../states/connection"; import { - showLoginPageLoader, - hideLoginPageLoader, disableLoginPageInputs, enableLoginPageInputs, getLoginPageInputsEnabled, } from "../../../stores/login"; import { - showNoticeNotification, showErrorNotification, + showNoticeNotification, } from "../../../stores/notifications"; import { Button } from "../../common/Button"; import { H3 } from "../../common/Headers"; import { Separator } from "../../common/Separator"; +import { Checkbox } from "../../ui/form/Checkbox"; +import { InputField } from "../../ui/form/InputField"; +import { SubmitButton } from "../../ui/form/SubmitButton"; +import { allFieldsMandatory } from "../../ui/form/utils"; export function Login(): JSXElement { - const [loginEmail, setLoginEmail] = createSignal(""); - const [loginPassword, setLoginPassword] = createSignal(""); - const [rememberMe, setRememberMe] = createSignal(true); - - const handleSignInWithGoogle = async () => { - if (!ConnectionState.get()) { - showNoticeNotification("You are offline"); - return; - } - showLoginPageLoader(); - disableLoginPageInputs(); - const data = await signInWithGoogle(rememberMe()); - hideLoginPageLoader(); - if (!data.success) { - showErrorNotification("Failed to sign in with Google: " + data.message); - enableLoginPageInputs(); - } - }; - - const handleSignInWithGitHub = async () => { - if (!ConnectionState.get()) { - showNoticeNotification("You are offline"); - return; - } - showLoginPageLoader(); - disableLoginPageInputs(); - const data = await signInWithGitHub(rememberMe()); - hideLoginPageLoader(); - if (!data.success) { - showErrorNotification("Failed to sign in with GitHub: " + data.message); - enableLoginPageInputs(); - } - }; + const form = createForm(() => ({ + defaultValues: { + email: "", + password: "", + rememberMe: true, + }, + onSubmit: async ({ value, meta }) => { + disableLoginPageInputs(); + const action = (meta as { action: "Google" | "GitHub" })?.action; + try { + let data: AuthResult; - const handleSignIn = async (e: SubmitEvent) => { - e.preventDefault(); - if (!ConnectionState.get()) { - showNoticeNotification("You are offline"); - return; - } - if (loginEmail() === "" || loginPassword() === "") { + if (action === "Google") { + data = await signInWithGoogle(value.rememberMe); + } else if (action === "GitHub") { + data = await signInWithGitHub(value.rememberMe); + } else { + if (value.email === "" || value.password === "") { + showNoticeNotification("Please fill in all fields"); + return; + } + data = await signIn(value.email, value.password, value.rememberMe); + } + if (!data.success) { + showErrorNotification( + `Failed to sign in${action !== undefined ? " with " + action : ""} : ${data.message}`, + ); + } + } finally { + enableLoginPageInputs(); + } + }, + onSubmitInvalid: () => { showNoticeNotification("Please fill in all fields"); - return; - } - showLoginPageLoader(); - disableLoginPageInputs(); - const data = await signIn(loginEmail(), loginPassword(), rememberMe()); - hideLoginPageLoader(); - if (!data.success) { - showErrorNotification("Failed to sign in: " + data.message); - enableLoginPageInputs(); - } - }; + }, + validators: { + onChange: allFieldsMandatory(), + }, + })); return (
@@ -84,67 +77,69 @@ export function Login(): JSXElement { />
-
+ { + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > - setLoginEmail(e.target.value)} + ( + + )} /> - setLoginPassword(e.target.value)} + ( + + )} /> -
- -
-
diff --git a/frontend/src/ts/components/pages/profile/ProfileSearchPage.tsx b/frontend/src/ts/components/pages/profile/ProfileSearchPage.tsx index 0e444db2b50e..d5aa1e6a1903 100644 --- a/frontend/src/ts/components/pages/profile/ProfileSearchPage.tsx +++ b/frontend/src/ts/components/pages/profile/ProfileSearchPage.tsx @@ -1,39 +1,52 @@ import { UserNameSchema } from "@monkeytype/schemas/users"; +import { createForm } from "@tanstack/solid-form"; import { createEffect, createSignal, JSXElement, Show } from "solid-js"; import { useRefWithUtils } from "../../../hooks/useRefWithUtils"; import * as NavigationEvent from "../../../observables/navigation-event"; import { queryClient } from "../../../queries"; import { getUserProfile } from "../../../queries/profile"; -import { - getActivePage, - getSelectedProfileName, - setSelectedProfileName, -} from "../../../signals/core"; -import { Button } from "../../common/Button"; +import { getActivePage } from "../../../signals/core"; +import { showNoticeNotification } from "../../../stores/notifications"; import { H2 } from "../../common/Headers"; -import { ValidatedInput } from "../../ui/ValidatedInput"; +import { InputField } from "../../ui/form/InputField"; +import { SubmitButton } from "../../ui/form/SubmitButton"; +import { fromSchema } from "../../ui/form/utils"; export function ProfileSearchPage(): JSXElement { - const [isValid, setValid] = createSignal(false); + const [isEditable, setEditable] = createSignal(true); const isOpen = () => getActivePage() === "profileSearch"; // Refs are assigned by SolidJS via the ref attribute const [inputRef, inputEl] = useRefWithUtils(); - const goToPage = () => { - if (isValid()) { - NavigationEvent.dispatch(`/profile/${getSelectedProfileName()}`, {}); - } - }; + const form = createForm(() => ({ + defaultValues: { + username: "", + }, + onSubmit: async ({ value }) => { + setEditable(false); + try { + console.log("### onSubmit"); + NavigationEvent.dispatch(`/profile/${value.username}`, {}); + } finally { + setEditable(true); + } + }, + onSubmitInvalid: () => { + showNoticeNotification("Please fill in all fields"); + }, + })); createEffect(() => { if (isOpen()) { + form.reset(); requestAnimationFrame(() => { inputEl()?.qs("input")?.focus({ preventScroll: true }); }); } }); + return (
@@ -41,7 +54,8 @@ export function ProfileSearchPage(): JSXElement { class="inline-grid w-96 gap-2" onSubmit={(e) => { e.preventDefault(); - goToPage(); + e.stopPropagation(); + void form.handleSubmit(); }} >
@@ -50,31 +64,38 @@ export function ProfileSearchPage(): JSXElement {
- setValid(result.success)} - // fine unless we read a reactive state after the await - // eslint-disable-next-line solid/reactivity - isValid={async (name: string) => { - try { - const result = await queryClient.fetchQuery( - getUserProfile(name), - ); - setSelectedProfileName(name); - return result !== null || "Unknown user"; - } catch (e) { - return "Unknown user or error fetching."; - } + { + try { + const result = await queryClient.fetchQuery( + getUserProfile(field.value), + ); + return result !== null ? undefined : "Unknown user"; + } catch (e) { + return "Unknown user"; + } + }, }} + children={(field) => ( + + )} />
-
diff --git a/frontend/src/ts/components/ui/ValidatedInput.tsx b/frontend/src/ts/components/ui/ValidatedInput.tsx deleted file mode 100644 index db8a720c7b47..000000000000 --- a/frontend/src/ts/components/ui/ValidatedInput.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { - splitProps, - createEffect, - JSXElement, - on, - onCleanup, - onMount, -} from "solid-js"; - -import { - ValidatedHtmlInputElement, - ValidationOptions, -} from "../../elements/input-validation"; -import { useRefWithUtils } from "../../hooks/useRefWithUtils"; - -export function ValidatedInput( - props: ValidationOptions & { - value?: string; - placeholder?: string; - class?: string; - type?: string; - autocomplete?: string; - name?: string; - onInput?: (value: T) => void; - onFocus?: () => void; - disabled?: boolean; - revalidateOn?: () => unknown; - }, -): JSXElement { - // Refs are assigned by SolidJS via the ref attribute - const [inputRef, inputEl] = useRefWithUtils(); - - let validatedInput: ValidatedHtmlInputElement | undefined; - - createEffect(() => { - validatedInput?.setValue(props.value ?? null); - }); - - onMount(() => { - const element = inputEl(); - if (element === undefined) return; - - const [_, others] = splitProps(props, [ - "value", - "class", - "placeholder", - "type", - "autocomplete", - "name", - "onFocus", - "revalidateOn", - ]); - validatedInput = new ValidatedHtmlInputElement( - element, - others as ValidationOptions, - ); - validatedInput.setValue(props.value ?? null); - }); - - createEffect( - on( - () => props.revalidateOn?.(), - () => { - if (validatedInput && inputEl()?.getValue() !== "") { - validatedInput.triggerValidation(); - } - }, - { defer: true }, - ), - ); - - onCleanup(() => { - validatedInput?.destroy(); - }); - - return ( -
- props.onInput?.(e.target.value as T)} - onFocus={() => props.onFocus?.()} - /> -
- ); -} diff --git a/frontend/src/ts/components/ui/form/Checkbox.tsx b/frontend/src/ts/components/ui/form/Checkbox.tsx new file mode 100644 index 000000000000..c7c89a1f329a --- /dev/null +++ b/frontend/src/ts/components/ui/form/Checkbox.tsx @@ -0,0 +1,54 @@ +import { AnyFieldApi } from "@tanstack/solid-form"; +import { Accessor, JSXElement } from "solid-js"; + +import { cn } from "../../../utils/cn"; +import { Fa } from "../../common/Fa"; + +export function Checkbox(props: { + field: Accessor; + label?: string; + class?: string; + disabled?: boolean; +}): JSXElement { + const checked = () => props.field().state.value as boolean; + + return ( +
+ +
+ ); +} diff --git a/frontend/src/ts/components/ui/form/FieldIndicator.tsx b/frontend/src/ts/components/ui/form/FieldIndicator.tsx new file mode 100644 index 000000000000..0e34dac1677e --- /dev/null +++ b/frontend/src/ts/components/ui/form/FieldIndicator.tsx @@ -0,0 +1,60 @@ +import type { AnyFieldApi } from "@tanstack/solid-form"; + +import { Match, Switch } from "solid-js"; + +import { Balloon } from "../../common/Balloon"; +import { Fa } from "../../common/Fa"; +import { LoadingCircle } from "../../common/LoadingCircle"; + +export type FieldIndicatorProps = { + field: AnyFieldApi; +}; + +export function FieldIndicator(props: FieldIndicatorProps) { + //@ts-expect-error custom meta attributes + const hasWarning = () => props.field.getMeta().hasWarning as boolean; + //@ts-expect-error custom meta attributes + const getWarnings = () => props.field.getMeta().warnings as string[]; + return ( +
+ + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/frontend/src/ts/components/ui/form/InputField.tsx b/frontend/src/ts/components/ui/form/InputField.tsx new file mode 100644 index 000000000000..e2f5d03563c2 --- /dev/null +++ b/frontend/src/ts/components/ui/form/InputField.tsx @@ -0,0 +1,45 @@ +import { AnyFieldApi } from "@tanstack/solid-form"; +import { Accessor, JSXElement, Show } from "solid-js"; + +import { cn } from "../../../utils/cn"; +import { FieldIndicator } from "./FieldIndicator"; + +export function InputField(props: { + field: Accessor; + placeholder?: string; + showIndicator?: true; + autocomplete?: string; + type?: string; + disabled?: boolean; + class?: string; + onFocus?: () => void; +}): JSXElement { + return ( +
+ props.field().handleBlur()} + onInput={(e) => props.field().handleChange(e.target.value)} + disabled={props.disabled} + onFocus={() => props.onFocus?.()} + /> + + + +
+ ); +} diff --git a/frontend/src/ts/components/ui/form/SubmitButton.tsx b/frontend/src/ts/components/ui/form/SubmitButton.tsx new file mode 100644 index 000000000000..bca2f98f2817 --- /dev/null +++ b/frontend/src/ts/components/ui/form/SubmitButton.tsx @@ -0,0 +1,53 @@ +import { JSXElement, splitProps } from "solid-js"; + +import { Button, ButtonProps } from "../../common/Button"; + +type FormStateSlice = { + canSubmit: boolean; + isSubmitting: boolean; + isValid: boolean; + isDirty: boolean; +}; + +type SubscribableForm = { + Subscribe: (props: { + selector: (state: { + canSubmit: boolean; + isSubmitting: boolean; + isValid: boolean; + isDirty: boolean; + }) => FormStateSlice; + children: (state: () => FormStateSlice) => JSXElement; + }) => JSXElement; +}; + +export function SubmitButton( + props: { + form: SubscribableForm; + } & Omit, +): JSXElement { + const [local, others] = splitProps(props, ["disabled"]); + return ( + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + isValid: state.isValid, + isDirty: state.isDirty, + })} + children={(state) => ( +