diff --git a/.oxfmtrc-editor.json b/.oxfmtrc-editor.json new file mode 100644 index 000000000000..b7fb86cd64de --- /dev/null +++ b/.oxfmtrc-editor.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/refs/heads/main/npm/oxfmt/configuration_schema.json", + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "htmlWhitespaceSensitivity": "ignore", + "endOfLine": "lf", + "trailingComma": "all", + "ignorePatterns": [ + "pnpm-lock.yaml", + "node_modules", + ".turbo", + "dist", + "build", + "logs", + "coverage", + "*.md" + ], + "overrides": [ + { + "files": ["**/*.tsx"], + "options": { + "experimentalSortImports": { + "groups": [ + "type-import", + ["value-builtin", "value-external"], + "type-internal", + "value-internal", + ["type-parent", "type-sibling", "type-index"], + ["value-parent", "value-sibling", "value-index"], + "unknown" + ] + } + } + } + ] +} diff --git a/backend/.vscode/settings.json b/backend/.vscode/settings.json new file mode 100644 index 000000000000..02015d68e590 --- /dev/null +++ b/backend/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "oxc.fmt.configPath": "../.oxfmtrc-editor.json" +} diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 1915930207fb..ff0eb0954a0f 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -13,7 +13,7 @@ ## Getting Started -When contributing to Monkeytype, it's good to know our best practices, tips, and tricks. First, Monkeytype is written in ~~JavaScript~~ TypeScript, CSS, and HTML (in order of language usage within the project); thus, we assume you are comfortable with these languages or have basic knowledge of them. Our backend is in NodeJS and we use MongoDB to store our user data. Firebase is used for authentication. Redis is used to store ephemeral data (daily leaderboards, jobs via BullMQ, OAuth state parameters). Furthermore, we use Prettier to format our code. +When contributing to Monkeytype, it's good to know our best practices, tips, and tricks. First, Monkeytype is written in ~~JavaScript~~ TypeScript, HTML, and CSS (in order of language usage within the project); thus, we assume you are comfortable with these languages or have basic knowledge of them. Our backend is in NodeJS and we use MongoDB to store our user data. Firebase is used for authentication. Redis is used to store ephemeral data (daily leaderboards, jobs via BullMQ, OAuth state parameters). Furthermore, we use Prettier to format our code. ## How to Contribute diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 000000000000..02015d68e590 --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "oxc.fmt.configPath": "../.oxfmtrc-editor.json" +} diff --git a/frontend/__tests__/components/ui/ValidatedInput.spec.tsx b/frontend/__tests__/components/ui/ValidatedInput.spec.tsx new file mode 100644 index 000000000000..90f48a74ac67 --- /dev/null +++ b/frontend/__tests__/components/ui/ValidatedInput.spec.tsx @@ -0,0 +1,82 @@ +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/__tests__/utils/local-storage-with-schema.spec.ts b/frontend/__tests__/utils/local-storage-with-schema.spec.ts index 112776c50aa2..369f4d90b9ef 100644 --- a/frontend/__tests__/utils/local-storage-with-schema.spec.ts +++ b/frontend/__tests__/utils/local-storage-with-schema.spec.ts @@ -75,6 +75,7 @@ describe("local-storage-with-schema.ts", () => { const update = { ...defaultObject, fontSize: 5 }; ls.set(update); + getItemMock.mockReset(); expect(ls.get()).toStrictEqual(update); @@ -83,6 +84,7 @@ describe("local-storage-with-schema.ts", () => { it("should get last valid value if schema is incorrect", () => { ls.set(defaultObject); + getItemMock.mockReset(); ls.set({ hi: "hello" } as any); @@ -91,6 +93,15 @@ describe("local-storage-with-schema.ts", () => { expect(setItemMock).toHaveBeenCalledOnce(); expect(getItemMock).not.toHaveBeenCalled(); }); + + it("should not set if value has not changed", () => { + ls.set(defaultObject); + setItemMock.mockReset(); + + ls.set(defaultObject); + + expect(setItemMock).not.toHaveBeenCalled(); + }); }); describe("get", () => { diff --git a/frontend/src/ts/components/ui/ValidatedInput.tsx b/frontend/src/ts/components/ui/ValidatedInput.tsx new file mode 100644 index 000000000000..05865863b52c --- /dev/null +++ b/frontend/src/ts/components/ui/ValidatedInput.tsx @@ -0,0 +1,56 @@ +import { + splitProps, + createEffect, + JSXElement, + 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; + onInput?: (value: T) => void; + }, +): 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"]); + validatedInput = new ValidatedHtmlInputElement( + element, + others as ValidationOptions, + ); + + validatedInput.setValue(props.value ?? null); + }); + + onCleanup(() => validatedInput?.remove()); + return ( + props.onInput?.(e.target.value as T)} + /> + ); +} diff --git a/frontend/src/ts/firebase.ts b/frontend/src/ts/firebase.ts index 85b562673397..f2d5c74db7c8 100644 --- a/frontend/src/ts/firebase.ts +++ b/frontend/src/ts/firebase.ts @@ -35,6 +35,7 @@ import { import { tryCatch } from "@monkeytype/util/trycatch"; import { dispatch as dispatchSignUpEvent } from "./observables/google-sign-up-event"; import { addBanner } from "./stores/banners"; +import { setUserId } from "./signals/core"; let app: FirebaseApp | undefined; let Auth: AuthType | undefined; @@ -76,6 +77,7 @@ export async function init(callback: ReadyCallback): Promise { onAuthStateChanged(Auth, async (user) => { if (!ignoreAuthCallback) { await callback(true, user); + setUserId(user?.uid ?? null); } }); } catch (e) { @@ -83,6 +85,7 @@ export async function init(callback: ReadyCallback): Promise { Auth = undefined; console.error("Firebase failed to initialize", e); await callback(false, null); + setUserId(null); if (isDevEnvironment()) { addBanner({ level: "notice", diff --git a/frontend/src/ts/hooks/useLocalStorage.ts b/frontend/src/ts/hooks/useLocalStorage.ts index ce8413215fb1..f17a1d705753 100644 --- a/frontend/src/ts/hooks/useLocalStorage.ts +++ b/frontend/src/ts/hooks/useLocalStorage.ts @@ -1,4 +1,4 @@ -import { createSignal, createEffect, Accessor, Setter } from "solid-js"; +import { createSignal, Accessor, Setter, onCleanup } from "solid-js"; import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; export type UseLocalStorageOptions = { @@ -60,26 +60,21 @@ export function useLocalStorage( // Sync changes across tabs/windows if (syncAcrossTabs) { - createEffect(() => { - const handleStorageChange = (e: StorageEvent): void => { - if (e.key === key && e.newValue !== null) { - console.debug(`LS ${key} Storage event detected from another tab`); - try { - const parsed = schema.parse(JSON.parse(e.newValue)); - setValueInternal(() => parsed); - } catch (error) { - console.error( - `LS ${key} Failed to parse storage event value`, - error, - ); - } + const handleStorageChange = (e: StorageEvent): void => { + if (e.key === key && e.newValue !== null) { + console.debug(`LS ${key} Storage event detected from another tab`); + try { + const parsed = schema.parse(JSON.parse(e.newValue)); + setValueInternal(() => parsed); + } catch (error) { + console.error(`LS ${key} Failed to parse storage event value`, error); } - }; + } + }; - window.addEventListener("storage", handleStorageChange); - return () => { - window.removeEventListener("storage", handleStorageChange); - }; + window.addEventListener("storage", handleStorageChange); + onCleanup(() => { + window.removeEventListener("storage", handleStorageChange); }); } diff --git a/frontend/src/ts/hooks/useLocalStorageStore.ts b/frontend/src/ts/hooks/useLocalStorageStore.ts new file mode 100644 index 000000000000..36b1d3704236 --- /dev/null +++ b/frontend/src/ts/hooks/useLocalStorageStore.ts @@ -0,0 +1,81 @@ +import { createEffect, onCleanup } from "solid-js"; +import { createStore, reconcile, SetStoreFunction } from "solid-js/store"; +import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; + +export type UseLocalStorageStoreOptions = { + key: LocalStorageWithSchema["key"]; + schema: LocalStorageWithSchema["schema"]; + fallback: LocalStorageWithSchema["fallback"]; + migrate?: LocalStorageWithSchema["migrate"]; + /** + * Whether to sync changes across tabs/windows using storage events. + * @default true + */ + syncAcrossTabs?: boolean; +}; + +/** + * SolidJS hook for reactive localStorage with Zod schema validation. + * Wraps LocalStorageWithSchema in a reactive SolidJS store. + * + * @example + * ```tsx + * const [state, setState] = useLocalStorageStore({ + * key: "myKey", + * schema: z.object({value:z.string()}), + * fallback: {value:"default"}, + * }); + * + * return
setState("value", "new value")}>{state.value}
; + * ``` + */ +export function useLocalStorageStore( + options: UseLocalStorageStoreOptions, +): [T, SetStoreFunction] { + const { key, schema, fallback, migrate, syncAcrossTabs = true } = options; + + // Create the underlying localStorage manager + const storage = new LocalStorageWithSchema({ + key, + schema, + fallback, + migrate, + }); + + // Create store with initial value from storage + const [value, setValue] = createStore(structuredClone(storage.get())); + + // Guard to prevent redundant persist during cross-tab sync + let isSyncing = false; + + // Persist entire store to localStorage whenever it changes + createEffect(() => { + if (!isSyncing) { + storage.set(value); + } + }); + + // Sync changes across tabs/windows + if (syncAcrossTabs) { + const handleStorageChange = (e: StorageEvent): void => { + if (e.key === key && e.newValue !== null) { + console.debug(`LS ${key} Storage event detected from another tab`); + try { + const parsed = schema.parse(JSON.parse(e.newValue)); + isSyncing = true; + setValue(reconcile(parsed)); + isSyncing = false; + } catch (error) { + console.error(`LS ${key} Failed to parse storage event value`, error); + } + } + }; + + window.addEventListener("storage", handleStorageChange); + onCleanup(() => { + window.removeEventListener("storage", handleStorageChange); + }); + } + + return [value, setValue]; +} diff --git a/frontend/src/ts/signals/core.ts b/frontend/src/ts/signals/core.ts index f3c6127b08e0..175691f4316d 100644 --- a/frontend/src/ts/signals/core.ts +++ b/frontend/src/ts/signals/core.ts @@ -29,3 +29,6 @@ export const [getCommandlineSubgroup, setCommandlineSubgroup] = createSignal< export const [getFocus, setFocus] = createSignal(false); export const [getGlobalOffsetTop, setGlobalOffsetTop] = createSignal(0); export const [getIsScreenshotting, setIsScreenshotting] = createSignal(false); + +export const [getUserId, setUserId] = createSignal(null); +export const isLoggedIn = () => getUserId() !== null; diff --git a/frontend/src/ts/utils/local-storage-with-schema.ts b/frontend/src/ts/utils/local-storage-with-schema.ts index 00365b510115..a52a4b41b86a 100644 --- a/frontend/src/ts/utils/local-storage-with-schema.ts +++ b/frontend/src/ts/utils/local-storage-with-schema.ts @@ -91,9 +91,12 @@ export class LocalStorageWithSchema { try { console.debug(`LS ${this.key} Parsing to set in localStorage`); const parsed = this.schema.parse(data); - console.debug(`LS ${this.key} Setting in localStorage`); - window.localStorage.setItem(this.key, JSON.stringify(parsed)); - this.cache = parsed; + const newValue = JSON.stringify(parsed); + if (newValue !== JSON.stringify(this.cache)) { + console.debug(`LS ${this.key} Setting in localStorage`); + window.localStorage.setItem(this.key, newValue); + this.cache = parsed; + } return true; } catch (e) { let message = "Unknown error occurred"; diff --git a/monkeytype.code-workspace b/monkeytype.code-workspace index 7fd0101d54a9..335d042b84a0 100644 --- a/monkeytype.code-workspace +++ b/monkeytype.code-workspace @@ -45,6 +45,7 @@ "vitest.maximumConfigs": 10, "oxc.typeAware": true, "typescript.format.enable": false, + "oxc.fmt.configPath": ".oxfmtrc-editor.json", "[json]": { "editor.defaultFormatter": "oxc.oxc-vscode", }, diff --git a/packages/.vscode/settings.json b/packages/.vscode/settings.json new file mode 100644 index 000000000000..02015d68e590 --- /dev/null +++ b/packages/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "oxc.fmt.configPath": "../.oxfmtrc-editor.json" +}