Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .oxfmtrc-editor.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
}
]
}
3 changes: 3 additions & 0 deletions backend/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"oxc.fmt.configPath": "../.oxfmtrc-editor.json"
}
2 changes: 1 addition & 1 deletion docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions frontend/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"oxc.fmt.configPath": "../.oxfmtrc-editor.json"
}
82 changes: 82 additions & 0 deletions frontend/__tests__/components/ui/ValidatedInput.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(() => (
<ValidatedInput class="test" value="Kevin" schema={schema} />
));

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(() => (
<ValidatedInput class="test" value="Bob" schema={schema} />
));

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(() => (
<ValidatedInput
class="test"
value={value()}
onInput={setValue}
schema={schema}
/>
));

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",
);
});
});
11 changes: 11 additions & 0 deletions frontend/__tests__/utils/local-storage-with-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);

Expand All @@ -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", () => {
Expand Down
56 changes: 56 additions & 0 deletions frontend/src/ts/components/ui/ValidatedInput.tsx
Original file line number Diff line number Diff line change
@@ -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<T = string>(
props: ValidationOptions<T> & {
value?: string;
placeholder?: string;
class?: string;
onInput?: (value: T) => void;
},
): JSXElement {
// Refs are assigned by SolidJS via the ref attribute
const [inputRef, inputEl] = useRefWithUtils<HTMLInputElement>();

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

validatedInput.setValue(props.value ?? null);
});

onCleanup(() => validatedInput?.remove());
return (
<input
ref={inputRef}
type="text"
class={props.class}
placeholder={props.placeholder}
value={props.value ?? ""}
onInput={(e) => props.onInput?.(e.target.value as T)}
/>
);
}
3 changes: 3 additions & 0 deletions frontend/src/ts/firebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -76,13 +77,15 @@ export async function init(callback: ReadyCallback): Promise<void> {
onAuthStateChanged(Auth, async (user) => {
if (!ignoreAuthCallback) {
await callback(true, user);
setUserId(user?.uid ?? null);
}
});
} catch (e) {
app = undefined;
Auth = undefined;
console.error("Firebase failed to initialize", e);
await callback(false, null);
setUserId(null);
if (isDevEnvironment()) {
addBanner({
level: "notice",
Expand Down
33 changes: 14 additions & 19 deletions frontend/src/ts/hooks/useLocalStorage.ts
Original file line number Diff line number Diff line change
@@ -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<T> = {
Expand Down Expand Up @@ -60,26 +60,21 @@ export function useLocalStorage<T>(

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

Expand Down
81 changes: 81 additions & 0 deletions frontend/src/ts/hooks/useLocalStorageStore.ts
Original file line number Diff line number Diff line change
@@ -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<T extends object> = {
key: LocalStorageWithSchema<T>["key"];
schema: LocalStorageWithSchema<T>["schema"];
fallback: LocalStorageWithSchema<T>["fallback"];
migrate?: LocalStorageWithSchema<T>["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 <div onClick={() => setState("value", "new value")}>{state.value}</div>;
* ```
*/
export function useLocalStorageStore<T extends object>(
options: UseLocalStorageStoreOptions<T>,
): [T, SetStoreFunction<T>] {
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<T>(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];
}
3 changes: 3 additions & 0 deletions frontend/src/ts/signals/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
export const isLoggedIn = () => getUserId() !== null;
Loading
Loading