diff --git a/.github/workflows/pr-quality-gates.yml b/.github/workflows/pr-quality-gates.yml index 7b98c39e..f58e411d 100644 --- a/.github/workflows/pr-quality-gates.yml +++ b/.github/workflows/pr-quality-gates.yml @@ -18,9 +18,9 @@ jobs: with: script: | const body = context.payload.pull_request?.body || ''; - // Accept common keywords: close/closes/closed/fix/fixes/fixed/resolve/resolves/resolved - // Require a github issue reference like: "Closes #123" - const re = /(close[sd]?|fix(e[sd])?|resolve[sd]?)\s+#\d+/i; + // Accept common keywords: close, closes, closed, fix, fixes, fixed, resolve, resolves, resolved + // Require a GitHub issue reference like: "Closes #123" + const re = /(close[sd]?|fix(ed|es)?|resolve[sd]?)\s+#\d+/i; if (!re.test(body)) { core.setFailed('PR description must reference an issue using e.g. "Closes #123".'); - } + } \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 88ec6473..56b70e90 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,11 +1,16 @@ # Contributing to TeachLink Frontend + + Thanks for contributing to TeachLink. - + ## Branching & workflow + - **Do not push directly** to protected branches (`main`, `develop`). @@ -38,9 +43,11 @@ Your PR will be blocked from merging unless it meets the following: - All review conversations must be resolved before merge. -5. **Issue must be referenced** - - PR description must reference a GitHub issue and include one of: - - `Close #` / `Closes #` / `Fixes #` +5. 5. **Issue must be referenced** + - PR description must reference a GitHub issue and include one of the standard closing keywords: + - **Close variants:** `Close #`, `Closes #`, `Closed #` + - **Fix variants:** `Fix #`, `Fixes #`, `Fixed #` + - **Resolve variants:** `Resolve #`, `Resolves #`, `Resolved #` ## Local checks (run before pushing) diff --git a/package-lock.json b/package-lock.json index 67bf5950..78136c1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,6 +103,7 @@ "eslint-config-prettier": "^8.10.2", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-unused-imports": "^4.4.1", + "fake-indexeddb": "^6.2.5", "fast-check": "^3.22.0", "husky": "^8.0.3", "jsdom": "^26.1.0", @@ -13398,6 +13399,16 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", @@ -32511,6 +32522,12 @@ "yauzl": "^2.10.0" } }, + "fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true + }, "fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", @@ -35062,6 +35079,7 @@ "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-unused-imports": "^4.4.1", "ethers": "^6.12.0", + "fake-indexeddb": "^6.2.5", "fast-check": "^3.22.0", "framer-motion": "^12.23.0", "graphql": "^16.8.0", @@ -43796,6 +43814,12 @@ "yauzl": "^2.10.0" } }, + "fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true + }, "fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index f9a062e7..2dc9ce42 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; const STORAGE_KEY = 'teachlink-keyboard-shortcuts-v1'; @@ -176,6 +176,10 @@ export function useKeyboardShortcuts( return new Map(commands.map((command) => [command.id, command.run])); }, [commands]); + // Keep a stable ref of shortcuts and command handlers to prevent listener re-subscription + const shortcutsRef = useRef([]); + const commandMapRef = useRef(commandMap); + const shortcuts = useMemo(() => { return DEFAULT_SHORTCUTS.map((item) => ({ ...item, @@ -183,12 +187,18 @@ export function useKeyboardShortcuts( })); }, [customBindings]); + // Keep refs synchronized on every render + useEffect(() => { + shortcutsRef.current = shortcuts; + commandMapRef.current = commandMap; + }, [shortcuts, commandMap]); + const runShortcutAction = useCallback( (id: ShortcutActionId) => { - const handler = commandMap.get(id); + const handler = commandMapRef.current.get(id); if (handler) handler(); }, - [commandMap], + [], ); useEffect(() => { @@ -198,7 +208,7 @@ export function useKeyboardShortcuts( if (isInputLike(event.target)) return; const pressed = eventToBinding(event); - const targetShortcut = shortcuts.find((shortcut) => { + const targetShortcut = shortcutsRef.current.find((shortcut: ShortcutDefinition) => { const candidates = resolveModBinding(shortcut.binding); return candidates.includes(pressed); }); @@ -206,17 +216,18 @@ export function useKeyboardShortcuts( if (!targetShortcut) return; event.preventDefault(); - runShortcutAction(targetShortcut.id); + const handler = commandMapRef.current.get(targetShortcut.id); + if (handler) handler(); }; document.addEventListener('keydown', listener); return () => document.removeEventListener('keydown', listener); - }, [enabled, runShortcutAction, shortcuts]); + }, [enabled]); const setShortcutBinding = useCallback((id: ShortcutActionId, binding: string) => { const normalized = normalizeBinding(binding); if (!normalized) return; - setCustomBindings((prev) => { + setCustomBindings((prev: Partial>) => { const next = { ...prev, [id]: normalized }; if (typeof window !== 'undefined') { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); @@ -226,7 +237,7 @@ export function useKeyboardShortcuts( }, []); const resetShortcutBinding = useCallback((id: ShortcutActionId) => { - setCustomBindings((prev) => { + setCustomBindings((prev: Partial>) => { const next = { ...prev }; delete next[id]; if (typeof window !== 'undefined') { @@ -250,4 +261,4 @@ export function useKeyboardShortcuts( resetAllShortcutBindings, runShortcutAction, }; -} +} \ No newline at end of file diff --git a/src/tsconfig.json b/src/tsconfig.json index 451de78b..b745d8d5 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -13,8 +13,12 @@ "isolatedModules": true, "jsx": "preserve", "incremental": true, - "baseUrl": ".", - "types": ["vitest/globals", "@testing-library/jest-dom"], + "types": [ + "react", + "react-dom", + "vitest/globals", + "@testing-library/jest-dom" + ], "plugins": [ { "name": "next" @@ -35,4 +39,4 @@ "**/*.spec.tsx" ], "exclude": ["node_modules"] -} +} \ No newline at end of file