15 Commits
v0.1.3 ... main

Author SHA1 Message Date
2ec7705b4b update thresholds
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 12:10:45 +01:00
5877d90f07 fix eslint ver
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 11:23:55 +01:00
2b96277bec fix yml
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 11:21:37 +01:00
063d1073de add code analysis step
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 11:19:44 +01:00
d7e144620e add unit tests
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 11:14:24 +01:00
7e938138ff fix sidebar width, v0.1.5
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-02-24 11:02:17 +01:00
62590c34d1 fix ci
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-02-24 10:55:39 +01:00
01bbaebe5b expose sidebars sizing, v0.1.4
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-02-24 10:53:29 +01:00
83d9bc78f0 update ci
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-02-24 10:16:31 +01:00
ef084b893b fix ci
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-02-23 23:51:24 +01:00
a382bfee01 update drone
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 23:34:16 +01:00
6e4f529446 update eslint
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 15:17:51 +01:00
cbabf43584 update prettier
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 14:23:46 +01:00
33d1425fbb add eslint / prettier
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 14:18:51 +01:00
4d1d2e6ed8 add renovate
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 11:32:38 +01:00
46 changed files with 4947 additions and 1323 deletions

View File

@@ -13,41 +13,65 @@ trigger:
steps:
- name: install
image: node:22
image: node:25
commands:
- corepack enable
- corepack prepare yarn@1.22.22 --activate
- yarn install --frozen-lockfile
- name: build
image: node:22
- name: lint
image: node:25
commands:
- yarn lint
- name: build
image: node:25
commands:
- corepack enable
- corepack prepare yarn@1.22.22 --activate
- yarn build
- name: unit-tests
image: node:25
environment:
NODE_OPTIONS: --no-webstorage
commands:
- yarn test:coverage
- test -f coverage/lcov.info
- name: code-analysis
when:
event:
- push
image: sonarsource/sonar-scanner-cli:latest
commands:
- |
test -f coverage/lcov.info
SONAR_ARGS="-Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.host.url=$SONAR_INSTANCE_URL -Dsonar.token=$SONAR_LOGIN_KEY -Dsonar.sources=src -Dsonar.tests=tests -Dsonar.test.inclusions=tests/**/*.{test,spec}.{ts,tsx,js,jsx} -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info -Dsonar.working.directory=/tmp/.scannerwork"
sonar-scanner $SONAR_ARGS
environment:
SONAR_USER_HOME: /tmp/.sonar
SONAR_PROJECT_KEY:
from_secret: sonar_project_key
SONAR_INSTANCE_URL:
from_secret: sonar_instance_url
SONAR_LOGIN_KEY:
from_secret: sonar_login_key
---
kind: pipeline
type: docker
name: web-core-publish
trigger:
branch:
- main
event:
- promote
target:
- production
- tag
ref:
- refs/tags/v*
steps:
- name: publish-npm
image: node:22
image: node:25
environment:
NEXUS_NPM_TOKEN:
from_secret: nexus_npm_token
commands:
- corepack enable
- corepack prepare yarn@1.22.22 --activate
- yarn install --frozen-lockfile
- npm config set //nexus.beatrice.wtf/repository/npm-hosted/:_authToken "$NEXUS_NPM_TOKEN"
- yarn publish:nexus

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
dist
coverage
node_modules
yarn.lock

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"singleQuote": true,
"semi": true,
"printWidth": 100,
"tabWidth": 4
}

61
eslint.config.mjs Normal file
View File

@@ -0,0 +1,61 @@
import js from '@eslint/js';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: [
'dist',
'coverage',
'node_modules',
'*.config.cjs',
'vite.config.js',
'vite.config.d.ts',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
},
},
rules: {
'no-empty': ['error', { allowEmptyCatch: true }],
},
},
{
files: ['src/**/*.{ts,tsx}'],
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
{
files: ['src/contexts/**/*.{ts,tsx}'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
{
files: ['tests/**/*.{ts,tsx}'],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
...globals.vitest,
},
},
},
);

View File

@@ -1,5 +1,4 @@
GNU Affero General Public License
=================================
# GNU Affero General Public License
_Version 3, 19 November 2007_
_Copyright © 2007 Free Software Foundation, Inc. &lt;<http://fsf.org/>&gt;_
@@ -201,20 +200,20 @@ You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
* **a)** The work must carry prominent notices stating that you modified
- **a)** The work must carry prominent notices stating that you modified
it, and giving a relevant date.
* **b)** The work must carry prominent notices stating that it is
- **b)** The work must carry prominent notices stating that it is
released under this License and any conditions added under section 7.
This requirement modifies the requirement in section 4 to
“keep intact all notices”.
* **c)** You must license the entire work, as a whole, under this
- **c)** You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
* **d)** If the work has interactive user interfaces, each must display
- **d)** If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
@@ -236,11 +235,11 @@ of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
* **a)** Convey the object code in, or embodied in, a physical product
- **a)** Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
* **b)** Convey the object code in, or embodied in, a physical product
- **b)** Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
@@ -251,12 +250,12 @@ in one of these ways:
more than your reasonable cost of physically performing this
conveying of source, or **(2)** access to copy the
Corresponding Source from a network server at no charge.
* **c)** Convey individual copies of the object code with a copy of the
- **c)** Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
* **d)** Convey the object code by offering access from a designated
- **d)** Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
@@ -268,7 +267,7 @@ in one of these ways:
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
* **e)** Convey the object code using peer-to-peer transmission, provided
- **e)** Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
@@ -345,19 +344,19 @@ Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
* **a)** Disclaiming warranty or limiting liability differently from the
- **a)** Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
* **b)** Requiring preservation of specified reasonable legal notices or
- **b)** Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
* **c)** Prohibiting misrepresentation of the origin of that material, or
- **c)** Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
* **d)** Limiting the use for publicity purposes of names of licensors or
- **d)** Limiting the use for publicity purposes of names of licensors or
authors of the material; or
* **e)** Declining to grant rights under trademark law for use of some
- **e)** Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
* **f)** Requiring indemnification of licensors and authors of that
- **f)** Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on

View File

@@ -1,6 +1,6 @@
{
"name": "@panic/web-core",
"version": "0.1.3",
"version": "0.1.5",
"license": "AGPL-3.0-only",
"description": "Core auth and utilities for panic.haus web applications",
"type": "module",
@@ -19,6 +19,13 @@
"scripts": {
"clean": "rm -rf dist",
"build": "yarn clean && vite build && tsc -p tsconfig.build.json",
"test": "vitest run",
"test:coverage": "vitest run --coverage --coverage.reporter=lcov --coverage.reporter=text-summary",
"test:watch": "vitest",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier . --write",
"format:check": "prettier . --check",
"prepublishOnly": "yarn build",
"publish:nexus": "npm publish --registry ${NEXUS_NPM_REGISTRY:-https://nexus.beatrice.wtf/repository/npm-hosted/}"
},
@@ -30,11 +37,25 @@
"react": "^19.0.0"
},
"devDependencies": {
"@eslint/js": "^10",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.0",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^10",
"eslint-plugin-react-hooks": "^7.1.0-canary-ab18f33d-20260220",
"eslint-plugin-react-refresh": "^0.5.1",
"globals": "^17.3.0",
"jsdom": "^28.1.0",
"prettier": "^3.8.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.6.2",
"vite": "^7.0.0"
"typescript-eslint": "^8.56.0",
"vite": "^7.0.0",
"vitest": "^4.0.18"
}
}

3
renovate.json Normal file
View File

@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

View File

@@ -30,7 +30,7 @@ export class ApiError extends Error {
code,
requestId,
details,
rawMessage
rawMessage,
}: {
message: string;
status: number;
@@ -59,7 +59,7 @@ function parseErrorPayload(data: unknown) {
code: undefined as string | undefined,
rawMessage: undefined as string | undefined,
requestId: undefined as string | undefined,
details: undefined as unknown
details: undefined as unknown,
};
}
@@ -88,7 +88,7 @@ export function createApiClient(config: CreateApiClientConfig) {
baseUrl,
resolveError = defaultResolveError,
inferErrorCodeFromStatus,
fetchImpl
fetchImpl,
} = config;
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
@@ -99,9 +99,9 @@ export function createApiClient(config: CreateApiClientConfig) {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined
body: body ? JSON.stringify(body) : undefined,
});
const data = await response.json().catch(() => null);
@@ -112,7 +112,7 @@ export function createApiClient(config: CreateApiClientConfig) {
const message = resolveError({
code,
status: response.status,
fallbackMessage: parsed.rawMessage
fallbackMessage: parsed.rawMessage,
});
throw new ApiError({
@@ -121,7 +121,7 @@ export function createApiClient(config: CreateApiClientConfig) {
code,
requestId: parsed.requestId,
details: parsed.details,
rawMessage: parsed.rawMessage
rawMessage: parsed.rawMessage,
});
}
@@ -129,6 +129,6 @@ export function createApiClient(config: CreateApiClientConfig) {
}
return {
request
request,
};
}

View File

@@ -11,7 +11,7 @@ export function buildListQuery({
page = 1,
pageSize = 10,
sort,
defaultSort
defaultSort,
}: BuildListQueryOptions): string {
const query = new URLSearchParams();
const normalizedQuery = q?.trim();

View File

@@ -41,18 +41,21 @@ export function createAuthContext<TUser>(options: CreateAuthContextOptions = {})
return {
authToken,
refreshToken,
currentUser: null
currentUser: null,
};
}
function AuthProvider({ children }: Readonly<{ children: ReactNode }>) {
const [state, setState] = useState<AuthState<TUser>>(readStoredSession);
const setSession = useCallback((authToken: string, refreshToken: string, currentUser: TUser) => {
const setSession = useCallback(
(authToken: string, refreshToken: string, currentUser: TUser) => {
localStorage.setItem(authTokenKey, authToken);
localStorage.setItem(refreshTokenKey, refreshToken);
setState({ authToken, refreshToken, currentUser });
}, []);
},
[],
);
const clearSession = useCallback(() => {
localStorage.removeItem(authTokenKey);
@@ -64,12 +67,15 @@ export function createAuthContext<TUser>(options: CreateAuthContextOptions = {})
setState((prev) => ({ ...prev, currentUser }));
}, []);
const value = useMemo<AuthContextValue<TUser>>(() => ({
const value = useMemo<AuthContextValue<TUser>>(
() => ({
...state,
setSession,
setCurrentUser,
clearSession
}), [state, setSession, setCurrentUser, clearSession]);
clearSession,
}),
[state, setSession, setCurrentUser, clearSession],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
@@ -85,6 +91,6 @@ export function createAuthContext<TUser>(options: CreateAuthContextOptions = {})
return {
AuthProvider,
useAuth,
AuthContext
AuthContext,
};
}

View File

@@ -7,7 +7,7 @@ import {
useRef,
useState,
type CSSProperties,
type ReactNode
type ReactNode,
} from 'react';
import { isDesktopViewport, useSidePanelMachine } from '../panels/useSidePanelMachine';
@@ -18,6 +18,13 @@ const SIDEBAR_MIN_WIDTH = 220;
const SIDEBAR_MAX_WIDTH = 420;
const SIDEBAR_COLLAPSED_WIDTH = 56;
export type LeftMenuSizing = {
defaultWidth?: number;
minWidth?: number;
maxWidth?: number;
collapsedWidth?: number;
};
export type LeftMenuRenderState = {
collapsed: boolean;
mobileOpen: boolean;
@@ -53,10 +60,40 @@ type LeftMenuProviderProps = {
children: ReactNode;
defaultContent: LeftMenuContent;
closeOnPathname?: string;
sizing?: LeftMenuSizing;
};
const LeftMenuContext = createContext<LeftMenuContextValue | undefined>(undefined);
function readSizingValue(value: number | undefined, fallback: number): number {
if (!Number.isFinite(value) || value == null || value <= 0) {
return fallback;
}
return value;
}
function resolveLeftMenuSizing(sizing: LeftMenuSizing | undefined) {
const requestedMinWidth = readSizingValue(sizing?.minWidth, SIDEBAR_MIN_WIDTH);
const requestedMaxWidth = readSizingValue(sizing?.maxWidth, SIDEBAR_MAX_WIDTH);
const minWidth = Math.min(requestedMinWidth, requestedMaxWidth);
const maxWidth = Math.max(requestedMinWidth, requestedMaxWidth);
const requestedDefaultWidth = readSizingValue(sizing?.defaultWidth, SIDEBAR_DEFAULT_WIDTH);
const defaultWidth = Math.min(maxWidth, Math.max(minWidth, requestedDefaultWidth));
const requestedCollapsedWidth = readSizingValue(
sizing?.collapsedWidth,
SIDEBAR_COLLAPSED_WIDTH,
);
const collapsedWidth = Math.min(minWidth, requestedCollapsedWidth);
return {
defaultWidth,
minWidth,
maxWidth,
collapsedWidth,
};
}
function readStoredCollapsed(): boolean {
if (!globalThis.window) {
return false;
@@ -68,8 +105,10 @@ function readStoredCollapsed(): boolean {
export function LeftMenuProvider({
children,
defaultContent,
closeOnPathname
closeOnPathname,
sizing,
}: Readonly<LeftMenuProviderProps>) {
const { defaultWidth, minWidth, maxWidth, collapsedWidth } = resolveLeftMenuSizing(sizing);
const [collapsed, setCollapsed] = useState<boolean>(() => readStoredCollapsed());
const [mobileOpen, setMobileOpen] = useState(false);
const [content, setContent] = useState<LeftMenuContent>(defaultContent);
@@ -123,7 +162,8 @@ export function LeftMenuProvider({
closeMobile();
}, [collapseMenu, closeMobile]);
const openMenu = useCallback((nextContent?: LeftMenuContent) => {
const openMenu = useCallback(
(nextContent?: LeftMenuContent) => {
if (nextContent) {
setContent(nextContent);
}
@@ -134,9 +174,12 @@ export function LeftMenuProvider({
}
setMobileOpen(true);
}, [expandMenu]);
},
[expandMenu],
);
const toggleMenu = useCallback((nextContent?: LeftMenuContent) => {
const toggleMenu = useCallback(
(nextContent?: LeftMenuContent) => {
if (nextContent) {
setContent(nextContent);
}
@@ -147,7 +190,9 @@ export function LeftMenuProvider({
}
setMobileOpen((previous) => !previous);
}, [toggleCollapsed]);
},
[toggleCollapsed],
);
const handleCloseOnPathname = useCallback(() => {
setMobileOpen(false);
@@ -156,9 +201,9 @@ export function LeftMenuProvider({
const { width, startResize } = useSidePanelMachine({
storageKey: SIDEBAR_WIDTH_KEY,
defaultWidth: SIDEBAR_DEFAULT_WIDTH,
minWidth: SIDEBAR_MIN_WIDTH,
maxWidth: SIDEBAR_MAX_WIDTH,
defaultWidth,
minWidth,
maxWidth,
resizeAxis: 'from-left',
resizingBodyClass: 'auth-sidebar-resizing',
isOpen: mobileOpen,
@@ -166,46 +211,48 @@ export function LeftMenuProvider({
shouldPersistWidth: !collapsed,
closeOnPathname,
onCloseOnPathname: handleCloseOnPathname,
onEscape: closeMobile
onEscape: closeMobile,
});
const desktopMenuStyle = useMemo<LeftMenuStyle>(() => ({
'--auth-sidebar-width': `${collapsed ? SIDEBAR_COLLAPSED_WIDTH : width}px`
}), [collapsed, width]);
const value = useMemo<LeftMenuContextValue>(() => ({
collapsed,
mobileOpen,
content,
desktopMenuStyle,
openMenu,
closeMenu,
toggleMenu,
expandMenu,
collapseMenu,
toggleCollapsed,
setMenuContent,
startResize
}), [
collapsed,
mobileOpen,
content,
desktopMenuStyle,
openMenu,
closeMenu,
toggleMenu,
expandMenu,
collapseMenu,
toggleCollapsed,
setMenuContent,
startResize
]);
return (
<LeftMenuContext.Provider value={value}>
{children}
</LeftMenuContext.Provider>
const desktopMenuStyle = useMemo<LeftMenuStyle>(
() => ({
'--auth-sidebar-width': `${collapsed ? collapsedWidth : width}px`,
}),
[collapsed, collapsedWidth, width],
);
const value = useMemo<LeftMenuContextValue>(
() => ({
collapsed,
mobileOpen,
content,
desktopMenuStyle,
openMenu,
closeMenu,
toggleMenu,
expandMenu,
collapseMenu,
toggleCollapsed,
setMenuContent,
startResize,
}),
[
collapsed,
mobileOpen,
content,
desktopMenuStyle,
openMenu,
closeMenu,
toggleMenu,
expandMenu,
collapseMenu,
toggleCollapsed,
setMenuContent,
startResize,
],
);
return <LeftMenuContext.Provider value={value}>{children}</LeftMenuContext.Provider>;
}
export function useLeftMenu() {

View File

@@ -5,7 +5,7 @@ import {
useMemo,
useState,
type CSSProperties,
type ReactNode
type ReactNode,
} from 'react';
import { isDesktopViewport, useSidePanelMachine } from '../panels/useSidePanelMachine';
@@ -14,6 +14,12 @@ const RIGHT_SIDEBAR_DEFAULT_WIDTH = 320;
const RIGHT_SIDEBAR_MIN_WIDTH = 260;
const RIGHT_SIDEBAR_MAX_WIDTH = 480;
export type RightSidebarSizing = {
defaultWidth?: number;
minWidth?: number;
maxWidth?: number;
};
export type RightSidebarContent = {
title: string;
content: ReactNode;
@@ -39,15 +45,44 @@ type RightSidebarProviderProps = {
children: ReactNode;
closeOnPathname?: string;
onMobileOpenRequest?: () => void;
sizing?: RightSidebarSizing;
};
const RightSidebarContext = createContext<RightSidebarContextValue | undefined>(undefined);
function readSizingValue(value: number | undefined, fallback: number): number {
if (!Number.isFinite(value) || value == null || value <= 0) {
return fallback;
}
return value;
}
function resolveRightSidebarSizing(sizing: RightSidebarSizing | undefined) {
const requestedMinWidth = readSizingValue(sizing?.minWidth, RIGHT_SIDEBAR_MIN_WIDTH);
const requestedMaxWidth = readSizingValue(sizing?.maxWidth, RIGHT_SIDEBAR_MAX_WIDTH);
const minWidth = Math.min(requestedMinWidth, requestedMaxWidth);
const maxWidth = Math.max(requestedMinWidth, requestedMaxWidth);
const requestedDefaultWidth = readSizingValue(
sizing?.defaultWidth,
RIGHT_SIDEBAR_DEFAULT_WIDTH,
);
const defaultWidth = Math.min(maxWidth, Math.max(minWidth, requestedDefaultWidth));
return {
defaultWidth,
minWidth,
maxWidth,
};
}
export function RightSidebarProvider({
children,
closeOnPathname,
onMobileOpenRequest
onMobileOpenRequest,
sizing,
}: Readonly<RightSidebarProviderProps>) {
const { defaultWidth, minWidth, maxWidth } = resolveRightSidebarSizing(sizing);
const [isOpen, setIsOpen] = useState(false);
const [content, setContent] = useState<RightSidebarContent | null>(null);
@@ -60,7 +95,8 @@ export function RightSidebarProvider({
setContent(nextContent);
}, []);
const openSidebar = useCallback((nextContent?: RightSidebarContent) => {
const openSidebar = useCallback(
(nextContent?: RightSidebarContent) => {
const resolvedContent = nextContent ?? content;
if (!resolvedContent) {
return;
@@ -73,22 +109,27 @@ export function RightSidebarProvider({
onMobileOpenRequest?.();
}
setIsOpen(true);
}, [content, onMobileOpenRequest]);
},
[content, onMobileOpenRequest],
);
const toggleSidebar = useCallback((nextContent?: RightSidebarContent) => {
const toggleSidebar = useCallback(
(nextContent?: RightSidebarContent) => {
if (isOpen) {
closeSidebar();
return;
}
openSidebar(nextContent);
}, [isOpen, closeSidebar, openSidebar]);
},
[isOpen, closeSidebar, openSidebar],
);
const { width, startResize } = useSidePanelMachine({
storageKey: RIGHT_SIDEBAR_WIDTH_KEY,
defaultWidth: RIGHT_SIDEBAR_DEFAULT_WIDTH,
minWidth: RIGHT_SIDEBAR_MIN_WIDTH,
maxWidth: RIGHT_SIDEBAR_MAX_WIDTH,
defaultWidth,
minWidth,
maxWidth,
resizeAxis: 'from-right',
resizingBodyClass: 'auth-right-sidebar-resizing',
isOpen,
@@ -96,38 +137,40 @@ export function RightSidebarProvider({
shouldPersistWidth: true,
closeOnPathname,
onCloseOnPathname: closeSidebar,
onEscape: closeSidebar
onEscape: closeSidebar,
});
const desktopSidebarStyle = useMemo<RightSidebarStyle>(() => ({
'--auth-right-sidebar-width': `${width}px`
}), [width]);
const value = useMemo<RightSidebarContextValue>(() => ({
isOpen,
content,
openSidebar,
closeSidebar,
toggleSidebar,
setSidebarContent,
desktopSidebarStyle,
startResize
}), [
isOpen,
content,
openSidebar,
closeSidebar,
toggleSidebar,
setSidebarContent,
desktopSidebarStyle,
startResize
]);
return (
<RightSidebarContext.Provider value={value}>
{children}
</RightSidebarContext.Provider>
const desktopSidebarStyle = useMemo<RightSidebarStyle>(
() => ({
'--auth-right-sidebar-width': `${width}px`,
}),
[width],
);
const value = useMemo<RightSidebarContextValue>(
() => ({
isOpen,
content,
openSidebar,
closeSidebar,
toggleSidebar,
setSidebarContent,
desktopSidebarStyle,
startResize,
}),
[
isOpen,
content,
openSidebar,
closeSidebar,
toggleSidebar,
setSidebarContent,
desktopSidebarStyle,
startResize,
],
);
return <RightSidebarContext.Provider value={value}>{children}</RightSidebarContext.Provider>;
}
export function useRightSidebar() {

View File

@@ -30,7 +30,7 @@ export function createErrorResolver(config: CreateErrorResolverConfig) {
defaultContext = 'default',
contextOverrides = {},
inferCodeFromStatus,
inferCodeFromLegacyMessage
inferCodeFromLegacyMessage,
} = config;
const knownCodes = new Set(Object.keys(catalog));
@@ -51,16 +51,12 @@ export function createErrorResolver(config: CreateErrorResolverConfig) {
}
function resolveErrorMessage(options: ResolveErrorMessageOptions): string {
const {
code,
status,
context = defaultContext,
fallbackMessage
} = options;
const { code, status, context = defaultContext, fallbackMessage } = options;
const resolvedCode = normalizeErrorCode(code)
?? inferCodeFromLegacyMessage?.(fallbackMessage)
?? inferErrorCodeFromStatus(status);
const resolvedCode =
normalizeErrorCode(code) ??
inferCodeFromLegacyMessage?.(fallbackMessage) ??
inferErrorCodeFromStatus(status);
if (resolvedCode) {
const contextMessage = contextOverrides[context]?.[resolvedCode];
@@ -96,7 +92,10 @@ export function createErrorResolver(config: CreateErrorResolverConfig) {
return 'Request failed. Please try again.';
}
function resolveOptionalErrorMessage(code?: string | null, context: string = defaultContext): string | undefined {
function resolveOptionalErrorMessage(
code?: string | null,
context: string = defaultContext,
): string | undefined {
if (!code) {
return undefined;
}
@@ -108,14 +107,15 @@ export function createErrorResolver(config: CreateErrorResolverConfig) {
const errorLike = err as ErrorLike;
const code = typeof errorLike.code === 'string' ? errorLike.code : undefined;
const status = typeof errorLike.status === 'number' ? errorLike.status : undefined;
const rawMessage = typeof errorLike.rawMessage === 'string' ? errorLike.rawMessage : undefined;
const rawMessage =
typeof errorLike.rawMessage === 'string' ? errorLike.rawMessage : undefined;
const message = typeof errorLike.message === 'string' ? errorLike.message : undefined;
return resolveErrorMessage({
code,
status,
context,
fallbackMessage: rawMessage ?? message
fallbackMessage: rawMessage ?? message,
});
}
@@ -127,6 +127,6 @@ export function createErrorResolver(config: CreateErrorResolverConfig) {
inferErrorCodeFromStatus,
resolveErrorMessage,
resolveOptionalErrorMessage,
toErrorMessage
toErrorMessage,
};
}

View File

@@ -23,6 +23,6 @@ export function useCooldownTimer(seconds = 0, enabled = true) {
return {
cooldown,
startCooldown
startCooldown,
};
}

View File

@@ -10,7 +10,7 @@ type UseEditableFormOptions<TValues> = {
export function useEditableForm<TValues extends Record<string, string>>({
initialValues,
validate
validate,
}: UseEditableFormOptions<TValues>) {
const [isEditing, setIsEditing] = useState(false);
@@ -23,30 +23,42 @@ export function useEditableForm<TValues extends Record<string, string>>({
validateAll,
setFieldError,
setErrors,
clearErrors
clearErrors,
} = useValidatedFields({
initialValues,
validate
validate,
});
const startEditing = useCallback((sourceValues: TValues) => {
const startEditing = useCallback(
(sourceValues: TValues) => {
setValues(sourceValues, { validate: true });
setIsEditing(true);
}, [setValues]);
},
[setValues],
);
const discardChanges = useCallback((sourceValues: TValues) => {
const discardChanges = useCallback(
(sourceValues: TValues) => {
setValues(sourceValues, { clearErrors: true });
setIsEditing(false);
}, [setValues]);
},
[setValues],
);
const loadFromSource = useCallback((sourceValues: TValues) => {
const loadFromSource = useCallback(
(sourceValues: TValues) => {
setValues(sourceValues, { clearErrors: true });
}, [setValues]);
},
[setValues],
);
const commitSaved = useCallback((sourceValues: TValues) => {
const commitSaved = useCallback(
(sourceValues: TValues) => {
setValues(sourceValues, { clearErrors: true });
setIsEditing(false);
}, [setValues]);
},
[setValues],
);
return {
values,
@@ -63,6 +75,6 @@ export function useEditableForm<TValues extends Record<string, string>>({
discardChanges,
loadFromSource,
commitSaved,
setIsEditing
setIsEditing,
};
}

View File

@@ -9,7 +9,12 @@ type PaginatedResourceResponse<TItem> = {
};
type UsePaginatedResourceOptions<TItem> = {
load: (params: { q: string; page: number; pageSize: number; sort?: string }) => Promise<PaginatedResourceResponse<TItem>>;
load: (params: {
q: string;
page: number;
pageSize: number;
sort?: string;
}) => Promise<PaginatedResourceResponse<TItem>>;
sort?: string;
debounceMs?: number;
initialQuery?: string;
@@ -23,7 +28,7 @@ export function usePaginatedResource<TItem>({
debounceMs = 250,
initialQuery = '',
initialPage = 1,
initialPageSize = 10
initialPageSize = 10,
}: UsePaginatedResourceOptions<TItem>) {
const [items, setItems] = useState<TItem[]>([]);
const [q, setQ] = useState(initialQuery);
@@ -46,7 +51,7 @@ export function usePaginatedResource<TItem>({
q,
page,
pageSize,
sort
sort,
});
if (cancelled) {
@@ -60,7 +65,11 @@ export function usePaginatedResource<TItem>({
setPageSize(response.pageSize);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Request failed. Please try again.');
setError(
err instanceof Error
? err.message
: 'Request failed. Please try again.',
);
}
} finally {
if (!cancelled) {
@@ -97,6 +106,6 @@ export function usePaginatedResource<TItem>({
isLoading,
setQuery,
setPage,
setPageSize: setPageSizeAndResetPage
setPageSize: setPageSizeAndResetPage,
};
}

View File

@@ -31,7 +31,8 @@ export function useSorting(defaultSort?: SortState | null): UseSortingResult {
const activeSort = overrideSort ?? defaultSort ?? null;
const toggleSort = useCallback((field: string) => {
const toggleSort = useCallback(
(field: string) => {
setOverrideSort((previousOverride) => {
const baselineSort = defaultSort ?? null;
const currentSort = previousOverride ?? baselineSort;
@@ -56,7 +57,9 @@ export function useSorting(defaultSort?: SortState | null): UseSortingResult {
return { field, direction: 'desc' };
});
}, [defaultSort]);
},
[defaultSort],
);
const setSort = useCallback((next: SortState | null) => {
setOverrideSort(next);
@@ -73,6 +76,6 @@ export function useSorting(defaultSort?: SortState | null): UseSortingResult {
sortParam,
toggleSort,
setSort,
resetSort
resetSort,
};
}

View File

@@ -26,6 +26,6 @@ export function useSubmitState<TStatus = string | null>(initialStatus: TStatus)
finishSubmitting,
setSubmitError,
setStatus,
clearFeedback
clearFeedback,
};
}

View File

@@ -28,7 +28,7 @@ function hasErrors<TValues>(errors: FieldErrors<TValues>): boolean {
function pickTouchedErrors<TValues>(
errors: FieldErrors<TValues>,
touched: TouchedFields<TValues>
touched: TouchedFields<TValues>,
): FieldErrors<TValues> {
const next: FieldErrors<TValues> = {};
@@ -53,13 +53,14 @@ function touchAll<TValues extends Record<string, string>>(values: TValues): Touc
export function useValidatedFields<TValues extends Record<string, string>>({
initialValues,
validate
validate,
}: UseValidatedFieldsOptions<TValues>) {
const [values, setValues] = useState<TValues>(initialValues);
const [allErrors, setAllErrors] = useState<FieldErrors<TValues>>(() => validate(initialValues));
const [touched, setTouched] = useState<TouchedFields<TValues>>({});
const updateValues = useCallback((nextValues: TValues, options: SetValuesOptions = {}) => {
const updateValues = useCallback(
(nextValues: TValues, options: SetValuesOptions = {}) => {
const { validate: shouldValidate = false, clearErrors = false } = options;
setValues(nextValues);
@@ -70,26 +71,29 @@ export function useValidatedFields<TValues extends Record<string, string>>({
if (clearErrors) {
setTouched({});
}
}, [validate]);
},
[validate],
);
const setFieldValue = useCallback(<K extends keyof TValues>(
const setFieldValue = useCallback(
<K extends keyof TValues>(
key: K,
value: TValues[K],
options: SetFieldValueOptions = {}
options: SetFieldValueOptions = {},
) => {
const { validate: shouldValidate = true, touch = true } = options;
if (touch) {
setTouched((current) => ({
...current,
[key]: true
[key]: true,
}));
}
setValues((current) => {
const nextValues = {
...current,
[key]: value
[key]: value,
};
if (shouldValidate) {
@@ -98,9 +102,12 @@ export function useValidatedFields<TValues extends Record<string, string>>({
return nextValues;
});
}, [validate]);
},
[validate],
);
const validateAll = useCallback((options: ValidateAllOptions = {}) => {
const validateAll = useCallback(
(options: ValidateAllOptions = {}) => {
const { touchAll: shouldTouchAll = true } = options;
const nextErrors = validate(values);
@@ -111,17 +118,19 @@ export function useValidatedFields<TValues extends Record<string, string>>({
}
return nextErrors;
}, [validate, values]);
},
[validate, values],
);
const setFieldError = useCallback(<K extends keyof TValues>(key: K, message?: string) => {
setTouched((current) => ({
...current,
[key]: true
[key]: true,
}));
setAllErrors((current) => ({
...current,
[key]: message
[key]: message,
}));
}, []);
@@ -136,7 +145,7 @@ export function useValidatedFields<TValues extends Record<string, string>>({
setTouched((current) => ({
...current,
...nextTouched
...nextTouched,
}));
setAllErrors(nextErrors);
}, []);
@@ -161,6 +170,6 @@ export function useValidatedFields<TValues extends Record<string, string>>({
validateAll,
setFieldError,
setErrors: updateErrors,
clearErrors
clearErrors,
};
}

View File

@@ -1,15 +1,27 @@
export { createAuthContext } from './auth/createAuthContext';
export type { AuthContextValue, AuthState, CreateAuthContextOptions } from './auth/createAuthContext';
export type {
AuthContextValue,
AuthState,
CreateAuthContextOptions,
} from './auth/createAuthContext';
export { decodeJwtPayload, isJwtExpired } from './auth/jwt';
export { createApiClient, ApiError } from './api/createApiClient';
export type { CreateApiClientConfig, RequestOptions, ResolveErrorInput } from './api/createApiClient';
export type {
CreateApiClientConfig,
RequestOptions,
ResolveErrorInput,
} from './api/createApiClient';
export { buildListQuery } from './api/query';
export { createErrorResolver } from './errors/createErrorResolver';
export type { CreateErrorResolverConfig, ErrorCatalog, ResolveErrorMessageOptions } from './errors/createErrorResolver';
export type {
CreateErrorResolverConfig,
ErrorCatalog,
ResolveErrorMessageOptions,
} from './errors/createErrorResolver';
export { useValidatedFields } from './hooks/useValidatedFields';
export { useEditableForm } from './hooks/useEditableForm';
@@ -20,9 +32,18 @@ export type { SortDirection, SortState } from './hooks/useSorting';
export { useCooldownTimer } from './hooks/useCooldownTimer';
export { LeftMenuProvider, useLeftMenu } from './contexts/LeftMenuContext';
export type { LeftMenuContent, LeftMenuRenderState, LeftMenuStyle } from './contexts/LeftMenuContext';
export type {
LeftMenuContent,
LeftMenuRenderState,
LeftMenuSizing,
LeftMenuStyle,
} from './contexts/LeftMenuContext';
export { RightSidebarProvider, useRightSidebar } from './contexts/RightSidebarContext';
export type { RightSidebarContent, RightSidebarStyle } from './contexts/RightSidebarContext';
export type {
RightSidebarContent,
RightSidebarSizing,
RightSidebarStyle,
} from './contexts/RightSidebarContext';
export { formatDate, capitalize, splitAndCapitalize } from './utils/formatting';
export type { SplitMode } from './utils/formatting';

View File

@@ -1,4 +1,10 @@
import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from 'react';
const DEFAULT_DESKTOP_BREAKPOINT = 1024;
@@ -51,15 +57,18 @@ export function useSidePanelMachine({
closeOnPathname,
onCloseOnPathname,
onEscape,
desktopBreakpoint = DEFAULT_DESKTOP_BREAKPOINT
desktopBreakpoint = DEFAULT_DESKTOP_BREAKPOINT,
}: SidePanelMachineOptions): SidePanelMachineResult {
const isResizingRef = useRef(false);
const resizeStartXRef = useRef(0);
const resizeStartWidthRef = useRef(0);
const clampWidth = useCallback((value: number) => {
const clampWidth = useCallback(
(value: number) => {
return Math.min(maxWidth, Math.max(minWidth, value));
}, [maxWidth, minWidth]);
},
[maxWidth, minWidth],
);
const readStoredWidth = useCallback(() => {
if (!globalThis.window) {
@@ -67,6 +76,10 @@ export function useSidePanelMachine({
}
const storedValue = localStorage.getItem(storageKey);
if (storedValue == null || storedValue.trim() === '') {
return defaultWidth;
}
const parsed = Number(storedValue);
if (!Number.isFinite(parsed)) {
return defaultWidth;
@@ -154,7 +167,8 @@ export function useSidePanelMachine({
};
}, [isOpen, onEscape]);
const startResize = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const startResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!canResize || !isDesktopViewport(desktopBreakpoint)) {
return;
}
@@ -164,11 +178,13 @@ export function useSidePanelMachine({
resizeStartWidthRef.current = width;
document.body.classList.add(resizingBodyClass);
event.preventDefault();
}, [canResize, desktopBreakpoint, resizingBodyClass, width]);
},
[canResize, desktopBreakpoint, resizingBodyClass, width],
);
return {
width,
isDesktop: isDesktopViewport(desktopBreakpoint),
startResize
startResize,
};
}

View File

@@ -1,21 +1,24 @@
export function formatDate(value: string, seconds = false): string {
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
...(seconds ? { second: "2-digit" } : {}),
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
...(seconds ? { second: '2-digit' } : {}),
};
return new Date(value).toLocaleString("it-IT", options);
return new Date(value).toLocaleString('it-IT', options);
}
export const capitalize = (str: string) =>
str.toLowerCase().split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
str
.toLowerCase()
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
export type SplitMode = "underscore" | "camel" | "auto";
export type SplitMode = 'underscore' | 'camel' | 'auto';
/** Title-case a string while preserving short all-caps acronyms (e.g., XML) */
const toTitleCase = (s: string) =>
@@ -23,23 +26,23 @@ const toTitleCase = (s: string) =>
.trim()
.toLowerCase()
.split(/\s+/)
.map(w =>
/^[A-Z]{2,4}$/.test(w) ? w : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
.map((w) =>
/^[A-Z]{2,4}$/.test(w) ? w : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(),
)
.join(" ");
.join(' ');
const splitUnderscoreHyphen = (s: string) => s.replaceAll(/[_-]+/g, " ");
const splitUnderscoreHyphen = (s: string) => s.replaceAll(/[_-]+/g, ' ');
/** Insert spaces at camelCase boundaries and around digit/letter edges */
const splitCamel = (s: string) =>
s
// fooBar -> foo Bar ; foo2D -> foo 2D
.replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2")
.replaceAll(/([a-z0-9])([A-Z])/g, '$1 $2')
// XMLHttp -> XML Http (acronym + word)
.replaceAll(/([A-Z])([A-Z][a-z])/g, "$1 $2")
.replaceAll(/([A-Z])([A-Z][a-z])/g, '$1 $2')
// letter<->digit boundaries
.replaceAll(/([a-zA-Z])([0-9])/g, "$1 $2")
.replaceAll(/([0-9])([a-zA-Z])/g, "$1 $2");
.replaceAll(/([a-zA-Z])([0-9])/g, '$1 $2')
.replaceAll(/([0-9])([a-zA-Z])/g, '$1 $2');
/**
* Split and capitalize either by underscores/hyphens or camelCase.
@@ -48,17 +51,15 @@ const splitCamel = (s: string) =>
* - "camel": split on camelCase boundaries
* - "auto": pick underscore if present, otherwise camel
*/
export function splitAndCapitalize(str?: string, mode: SplitMode = "auto"): string {
if (!str) return "";
export function splitAndCapitalize(str?: string, mode: SplitMode = 'auto'): string {
if (!str) return '';
// normalize underscores/hyphens first for auto decision
const hasUnderscoreLike = /[_-]/.test(str);
const chosen: SplitMode =
mode === "auto" ? (hasUnderscoreLike ? "underscore" : "camel") : mode;
const chosen: SplitMode = mode === 'auto' ? (hasUnderscoreLike ? 'underscore' : 'camel') : mode;
const spaced =
chosen === "underscore" ? splitUnderscoreHyphen(str) : splitCamel(str);
const spaced = chosen === 'underscore' ? splitUnderscoreHyphen(str) : splitCamel(str);
// collapse extra spaces, then title-case
return toTitleCase(spaced.replaceAll(/\s+/g, " ").trim());
return toTitleCase(spaced.replaceAll(/\s+/g, ' ').trim());
}

View File

@@ -0,0 +1,154 @@
import { describe, expect, it, vi } from 'vitest';
import { ApiError, createApiClient } from '../../src/api/createApiClient';
describe('createApiClient', () => {
it('sends json requests and returns parsed payload', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({ ok: true }),
} as unknown as Response);
const client = createApiClient({
baseUrl: 'https://api.example.com',
fetchImpl: fetchMock as typeof fetch,
});
const result = await client.request<{ ok: boolean }>('/users', {
method: 'POST',
token: 'token-123',
body: { hello: 'world' },
});
expect(result).toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer token-123',
},
body: JSON.stringify({ hello: 'world' }),
});
});
it('maps api error payload through custom resolver', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: vi.fn().mockResolvedValue({
code: 'AUTH_UNAUTHORIZED',
error: 'unauthorized',
requestId: 'req-1',
details: { reason: 'expired' },
}),
} as unknown as Response);
const resolveError = vi.fn(() => 'Unauthorized access. Please sign in again.');
const client = createApiClient({
baseUrl: 'https://api.example.com',
fetchImpl: fetchMock as typeof fetch,
resolveError,
});
await expect(client.request('/users')).rejects.toMatchObject({
name: 'ApiError',
status: 401,
code: 'AUTH_UNAUTHORIZED',
requestId: 'req-1',
details: { reason: 'expired' },
rawMessage: 'unauthorized',
message: 'Unauthorized access. Please sign in again.',
});
expect(resolveError).toHaveBeenCalledWith({
code: 'AUTH_UNAUTHORIZED',
status: 401,
fallbackMessage: 'unauthorized',
});
});
it('infers an error code from status when payload has no code', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 404,
json: vi.fn().mockResolvedValue({ error: 'missing resource' }),
} as unknown as Response);
const inferErrorCodeFromStatus = vi.fn((status?: number | null) =>
status === 404 ? 'USER_NOT_FOUND' : undefined,
);
const client = createApiClient({
baseUrl: 'https://api.example.com',
fetchImpl: fetchMock as typeof fetch,
inferErrorCodeFromStatus,
resolveError: ({ code }) => (code === 'USER_NOT_FOUND' ? 'User not found.' : 'Unknown'),
});
await expect(client.request('/users/missing')).rejects.toMatchObject({
code: 'USER_NOT_FOUND',
message: 'User not found.',
});
});
it('falls back to default messages when response is not valid json', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: vi.fn().mockRejectedValue(new Error('invalid json')),
} as unknown as Response);
const client = createApiClient({
baseUrl: 'https://api.example.com',
fetchImpl: fetchMock as typeof fetch,
});
await expect(client.request('/users')).rejects.toMatchObject({
code: undefined,
rawMessage: undefined,
message: 'Request failed (500).',
});
});
it('uses generic default message when status is unavailable', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: undefined,
json: vi.fn().mockResolvedValue(null),
} as unknown as Response);
const client = createApiClient({
baseUrl: 'https://api.example.com',
fetchImpl: fetchMock as typeof fetch,
});
let thrown: unknown;
try {
await client.request('/users');
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(ApiError);
expect((thrown as ApiError).message).toBe('Request failed. Please try again.');
});
it('uses raw fallback error text with the default resolver when present', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: vi.fn().mockResolvedValue({
error: 'Validation failed in backend.',
}),
} as unknown as Response);
const client = createApiClient({
baseUrl: 'https://api.example.com',
fetchImpl: fetchMock as typeof fetch,
});
await expect(client.request('/users')).rejects.toMatchObject({
message: 'Validation failed in backend.',
rawMessage: 'Validation failed in backend.',
});
});
});

25
tests/api/query.test.ts Normal file
View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { buildListQuery } from '../../src/api/query';
describe('buildListQuery', () => {
it('builds query with trimmed search and explicit sort', () => {
const result = buildListQuery({
q: ' jane ',
page: 3,
pageSize: 20,
sort: ' createdAt ',
defaultSort: '-createdAt',
});
expect(result).toBe('q=jane&page=3&pageSize=20&sort=createdAt');
});
it('falls back to defaults when query is blank and sort missing', () => {
const result = buildListQuery({
q: ' ',
defaultSort: '-createdAt',
});
expect(result).toBe('page=1&pageSize=10&sort=-createdAt');
});
});

View File

@@ -0,0 +1,143 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { createAuthContext } from '../../src/auth/createAuthContext';
type User = {
id: string;
username: string;
};
const defaultAuth = createAuthContext<User>();
function createUser(username: string): User {
return {
id: `id-${username}`,
username,
};
}
function AuthHarness() {
const { authToken, refreshToken, currentUser, setSession, setCurrentUser, clearSession } =
defaultAuth.useAuth();
return (
<div>
<span data-testid="auth-token">{authToken ?? 'none'}</span>
<span data-testid="refresh-token">{refreshToken ?? 'none'}</span>
<span data-testid="username">{currentUser?.username ?? 'none'}</span>
<button
type="button"
onClick={() => setSession('auth-next', 'refresh-next', createUser('after-set'))}
>
set-session
</button>
<button type="button" onClick={() => setCurrentUser(createUser('patched'))}>
set-user
</button>
<button type="button" onClick={clearSession}>
clear
</button>
</div>
);
}
describe('createAuthContext', () => {
it('throws when hook is used outside the provider', () => {
function Invalid() {
defaultAuth.useAuth();
return null;
}
expect(() => render(<Invalid />)).toThrow('useAuth must be used within AuthProvider');
});
it('reads persisted tokens and cleans default legacy keys', () => {
localStorage.setItem('authToken', 'auth-1');
localStorage.setItem('refreshToken', 'refresh-1');
localStorage.setItem('auth_token', 'legacy');
localStorage.setItem('auth_user', 'legacy');
localStorage.setItem('token', 'legacy');
render(
<defaultAuth.AuthProvider>
<AuthHarness />
</defaultAuth.AuthProvider>,
);
expect(screen.getByTestId('auth-token')).toHaveTextContent('auth-1');
expect(screen.getByTestId('refresh-token')).toHaveTextContent('refresh-1');
expect(localStorage.getItem('auth_token')).toBeNull();
expect(localStorage.getItem('auth_user')).toBeNull();
expect(localStorage.getItem('token')).toBeNull();
});
it('supports session lifecycle updates', () => {
render(
<defaultAuth.AuthProvider>
<AuthHarness />
</defaultAuth.AuthProvider>,
);
fireEvent.click(screen.getByRole('button', { name: 'set-session' }));
expect(screen.getByTestId('auth-token')).toHaveTextContent('auth-next');
expect(screen.getByTestId('refresh-token')).toHaveTextContent('refresh-next');
expect(screen.getByTestId('username')).toHaveTextContent('after-set');
expect(localStorage.getItem('authToken')).toBe('auth-next');
expect(localStorage.getItem('refreshToken')).toBe('refresh-next');
fireEvent.click(screen.getByRole('button', { name: 'set-user' }));
expect(screen.getByTestId('username')).toHaveTextContent('patched');
fireEvent.click(screen.getByRole('button', { name: 'clear' }));
expect(screen.getByTestId('auth-token')).toHaveTextContent('none');
expect(screen.getByTestId('refresh-token')).toHaveTextContent('none');
expect(screen.getByTestId('username')).toHaveTextContent('none');
expect(localStorage.getItem('authToken')).toBeNull();
expect(localStorage.getItem('refreshToken')).toBeNull();
});
it('supports custom token keys and legacy cleanup keys', () => {
const customAuth = createAuthContext<User>({
authTokenKey: 'custom-auth',
refreshTokenKey: 'custom-refresh',
legacyKeys: ['legacy-a', 'legacy-b'],
});
function CustomHarness() {
const { authToken, refreshToken, setSession } = customAuth.useAuth();
return (
<div>
<span data-testid="custom-auth-token">{authToken ?? 'none'}</span>
<span data-testid="custom-refresh-token">{refreshToken ?? 'none'}</span>
<button
type="button"
onClick={() => setSession('next-auth', 'next-refresh', createUser('custom'))}
>
set-custom-session
</button>
</div>
);
}
localStorage.setItem('custom-auth', 'auth-1');
localStorage.setItem('custom-refresh', 'refresh-1');
localStorage.setItem('legacy-a', 'legacy');
localStorage.setItem('legacy-b', 'legacy');
render(
<customAuth.AuthProvider>
<CustomHarness />
</customAuth.AuthProvider>,
);
expect(screen.getByTestId('custom-auth-token')).toHaveTextContent('auth-1');
expect(screen.getByTestId('custom-refresh-token')).toHaveTextContent('refresh-1');
expect(localStorage.getItem('legacy-a')).toBeNull();
expect(localStorage.getItem('legacy-b')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'set-custom-session' }));
expect(localStorage.getItem('custom-auth')).toBe('next-auth');
expect(localStorage.getItem('custom-refresh')).toBe('next-refresh');
});
});

60
tests/auth/jwt.test.ts Normal file
View File

@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { decodeJwtPayload, isJwtExpired } from '../../src/auth/jwt';
function createJwt(payload: Record<string, unknown>) {
const header = { alg: 'HS256', typ: 'JWT' };
const encode = (value: object) =>
btoa(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
return `${encode(header)}.${encode(payload)}.signature`;
}
describe('decodeJwtPayload', () => {
it('returns null for malformed tokens or invalid payloads', () => {
expect(decodeJwtPayload('not-a-jwt')).toBeNull();
expect(decodeJwtPayload('a.b')).toBeNull();
expect(decodeJwtPayload('a.b.c.d')).toBeNull();
const nonJson = 'header.' + btoa('nope') + '.sig';
expect(decodeJwtPayload(nonJson)).toBeNull();
const nonObjectPayload = createJwt({ value: 1 }).replace(
/\.[^.]+\./,
`.${btoa('1').replace(/=+$/g, '')}.`,
);
expect(decodeJwtPayload(nonObjectPayload)).toBeNull();
});
it('decodes valid base64url payloads', () => {
const payload = { sub: 'user-1', role: 'ADMIN', exp: 1735689600 };
expect(decodeJwtPayload(createJwt(payload))).toEqual(payload);
});
});
describe('isJwtExpired', () => {
beforeEach(() => {
vi.useFakeTimers();
});
it('returns false for malformed tokens and invalid exp claim', () => {
expect(isJwtExpired('not-a-jwt')).toBe(false);
expect(isJwtExpired(createJwt({}))).toBe(false);
expect(isJwtExpired(createJwt({ exp: 'nope' }))).toBe(false);
expect(isJwtExpired(createJwt({ exp: Number.POSITIVE_INFINITY }))).toBe(false);
});
it('returns true when token is expired', () => {
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const exp = Math.floor(new Date('2025-12-31T23:59:00Z').getTime() / 1000);
expect(isJwtExpired(createJwt({ exp }))).toBe(true);
});
it('applies skew seconds when evaluating expiration', () => {
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const exp = Math.floor(new Date('2026-01-01T00:00:10Z').getTime() / 1000);
expect(isJwtExpired(createJwt({ exp }), 15)).toBe(true);
expect(isJwtExpired(createJwt({ exp }), 5)).toBe(false);
});
});

View File

@@ -0,0 +1,246 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { act } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
LeftMenuProvider,
type LeftMenuContent,
useLeftMenu,
} from '../../src/contexts/LeftMenuContext';
function buildDefaultContent(label = 'Default menu'): LeftMenuContent {
return {
ariaLabel: label,
render: ({ collapsed }) => <div>{collapsed ? `${label} (collapsed)` : `${label} (expanded)`}</div>,
};
}
function setViewportWidth(width: number) {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: width,
});
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: undefined,
});
}
function renderLeftMenuHarness(initialPathname = '/users') {
let currentPathname = initialPathname;
let currentValue: ReturnType<typeof useLeftMenu> | null = null;
const defaultContent = buildDefaultContent();
function Probe() {
currentValue = useLeftMenu();
const renderState = {
collapsed: currentValue.collapsed,
mobileOpen: currentValue.mobileOpen,
isDesktop: window.innerWidth >= 1024,
closeMenu: currentValue.closeMenu,
};
return <div data-testid="left-menu-content">{currentValue.content.render(renderState)}</div>;
}
function Wrapper({ pathname }: Readonly<{ pathname: string }>) {
return (
<LeftMenuProvider defaultContent={defaultContent} closeOnPathname={pathname}>
<Probe />
</LeftMenuProvider>
);
}
const rendered = render(<Wrapper pathname={currentPathname} />);
return {
getCurrent() {
if (!currentValue) {
throw new Error('Left menu context value not initialized');
}
return currentValue;
},
reroute(nextPathname: string) {
currentPathname = nextPathname;
rendered.rerender(<Wrapper pathname={currentPathname} />);
},
};
}
describe('LeftMenuContext', () => {
beforeEach(() => {
localStorage.removeItem('authSidebarWidth');
localStorage.removeItem('authSidebarCollapsed');
setViewportWidth(1024);
});
it('throws when useLeftMenu is used outside provider', () => {
function Invalid() {
useLeftMenu();
return null;
}
expect(() => render(<Invalid />)).toThrow('useLeftMenu must be used within LeftMenuProvider');
});
it('supports desktop collapse/expand/toggle semantics', () => {
const harness = renderLeftMenuHarness('/users');
expect(harness.getCurrent().collapsed).toBe(false);
act(() => {
harness.getCurrent().closeMenu();
});
expect(harness.getCurrent().collapsed).toBe(true);
act(() => {
harness.getCurrent().openMenu();
});
expect(harness.getCurrent().collapsed).toBe(false);
act(() => {
harness.getCurrent().toggleMenu();
});
expect(harness.getCurrent().collapsed).toBe(true);
});
it('restores collapsed state from storage and clamps persisted width', () => {
localStorage.setItem('authSidebarCollapsed', '1');
localStorage.setItem('authSidebarWidth', '999');
const harness = renderLeftMenuHarness('/users');
expect(harness.getCurrent().collapsed).toBe(true);
expect(harness.getCurrent().desktopMenuStyle['--auth-sidebar-width']).toBe('56px');
});
it('locks body scroll on mobile open and unlocks on close', () => {
setViewportWidth(768);
const harness = renderLeftMenuHarness('/users');
act(() => {
harness.getCurrent().openMenu();
});
expect(harness.getCurrent().mobileOpen).toBe(true);
expect(document.body.style.overflow).toBe('hidden');
act(() => {
harness.getCurrent().closeMenu();
});
expect(harness.getCurrent().mobileOpen).toBe(false);
expect(document.body.style.overflow).toBe('');
});
it('reads, clamps, and persists sidebar width through pointer resize', async () => {
localStorage.setItem('authSidebarWidth', '999');
const harness = renderLeftMenuHarness('/users');
expect(harness.getCurrent().desktopMenuStyle['--auth-sidebar-width']).toBe('420px');
const preventDefault = vi.fn();
act(() => {
harness.getCurrent().startResize({
clientX: 420,
preventDefault,
} as never);
});
act(() => {
fireEvent.pointerMove(window, { clientX: -1000 });
fireEvent.pointerUp(window);
});
await act(async () => {
await Promise.resolve();
});
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(localStorage.getItem('authSidebarWidth')).toBe('220');
expect(harness.getCurrent().desktopMenuStyle['--auth-sidebar-width']).toBe('220px');
});
it('accepts custom content and resets to default on route changes', () => {
setViewportWidth(768);
const harness = renderLeftMenuHarness('/users');
act(() => {
harness.getCurrent().openMenu();
harness.getCurrent().setMenuContent({
ariaLabel: 'Custom menu',
render: () => <div>Custom menu content</div>,
});
});
expect(harness.getCurrent().mobileOpen).toBe(true);
expect(screen.getByText('Custom menu content')).toBeInTheDocument();
harness.reroute('/posts');
expect(harness.getCurrent().mobileOpen).toBe(false);
expect(screen.queryByText('Custom menu content')).not.toBeInTheDocument();
expect(screen.getByText('Default menu (expanded)')).toBeInTheDocument();
});
it('applies provided content in openMenu/toggleMenu and toggles mobile open state', () => {
setViewportWidth(768);
const harness = renderLeftMenuHarness('/users');
act(() => {
harness.getCurrent().openMenu({
render: () => <div>Open payload</div>,
});
});
expect(screen.getByText('Open payload')).toBeInTheDocument();
expect(harness.getCurrent().mobileOpen).toBe(true);
act(() => {
harness.getCurrent().toggleMenu({
render: () => <div>Toggle payload</div>,
});
});
expect(screen.getByText('Toggle payload')).toBeInTheDocument();
expect(harness.getCurrent().mobileOpen).toBe(false);
});
it('updates default content only when current content is still default', () => {
function Harness() {
const menu = useLeftMenu();
const state = {
collapsed: menu.collapsed,
mobileOpen: menu.mobileOpen,
isDesktop: true,
closeMenu: menu.closeMenu,
};
return (
<div>
<button type="button" onClick={() => menu.setMenuContent({ render: () => <div>Custom</div> })}>
custom
</button>
<div data-testid="content">{menu.content.render(state)}</div>
</div>
);
}
function Wrapper({ label }: Readonly<{ label: string }>) {
return (
<LeftMenuProvider defaultContent={buildDefaultContent(label)}>
<Harness />
</LeftMenuProvider>
);
}
const rendered = render(<Wrapper label="Menu A" />);
expect(screen.getByTestId('content')).toHaveTextContent('Menu A (expanded)');
rendered.rerender(<Wrapper label="Menu B" />);
expect(screen.getByTestId('content')).toHaveTextContent('Menu B (expanded)');
fireEvent.click(screen.getByRole('button', { name: 'custom' }));
expect(screen.getByTestId('content')).toHaveTextContent('Custom');
rendered.rerender(<Wrapper label="Menu C" />);
expect(screen.getByTestId('content')).toHaveTextContent('Custom');
});
});

View File

@@ -0,0 +1,249 @@
import { fireEvent, render } from '@testing-library/react';
import { act } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
RightSidebarProvider,
useRightSidebar,
} from '../../src/contexts/RightSidebarContext';
type RightSidebarHarnessOptions = {
pathname?: string;
onMobileOpenRequest?: () => void;
};
function setViewportWidth(width: number) {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: width,
});
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: undefined,
});
}
function renderRightSidebarHarness(options: RightSidebarHarnessOptions = {}) {
const { pathname = '/users', onMobileOpenRequest } = options;
let currentPathname = pathname;
let currentValue: ReturnType<typeof useRightSidebar> | null = null;
function Probe() {
currentValue = useRightSidebar();
return null;
}
function Wrapper({ pathname }: Readonly<{ pathname: string }>) {
return (
<RightSidebarProvider
closeOnPathname={pathname}
onMobileOpenRequest={onMobileOpenRequest}
>
<Probe />
</RightSidebarProvider>
);
}
const rendered = render(<Wrapper pathname={currentPathname} />);
return {
getCurrent() {
if (!currentValue) {
throw new Error('Right sidebar context value not initialized');
}
return currentValue;
},
reroute(nextPathname: string) {
currentPathname = nextPathname;
rendered.rerender(<Wrapper pathname={currentPathname} />);
},
unmount: rendered.unmount,
};
}
describe('RightSidebarContext', () => {
beforeEach(() => {
localStorage.removeItem('authRightSidebarWidth');
setViewportWidth(1024);
});
it('throws when useRightSidebar is used outside provider', () => {
function Invalid() {
useRightSidebar();
return null;
}
expect(() => render(<Invalid />)).toThrow('useRightSidebar must be used within RightSidebarProvider');
});
it('opens and closes with content', () => {
const harness = renderRightSidebarHarness({ pathname: '/users' });
act(() => {
harness.getCurrent().openSidebar({
title: 'Meta',
content: <div>Body</div>,
});
});
expect(harness.getCurrent().isOpen).toBe(true);
expect(harness.getCurrent().content?.title).toBe('Meta');
act(() => {
harness.getCurrent().closeSidebar();
});
expect(harness.getCurrent().isOpen).toBe(false);
expect(harness.getCurrent().content).toBeNull();
});
it('does not open without current or next content', () => {
const harness = renderRightSidebarHarness({ pathname: '/users' });
act(() => {
harness.getCurrent().openSidebar();
});
expect(harness.getCurrent().isOpen).toBe(false);
expect(harness.getCurrent().content).toBeNull();
});
it('updates content live and supports toggle semantics', () => {
const harness = renderRightSidebarHarness({ pathname: '/users' });
act(() => {
harness.getCurrent().openSidebar({
title: 'Meta',
content: <div>Body</div>,
});
});
act(() => {
harness.getCurrent().setSidebarContent({
title: 'Meta Updated',
content: <div>Updated</div>,
});
});
expect(harness.getCurrent().isOpen).toBe(true);
expect(harness.getCurrent().content?.title).toBe('Meta Updated');
act(() => {
harness.getCurrent().toggleSidebar();
});
expect(harness.getCurrent().isOpen).toBe(false);
expect(harness.getCurrent().content).toBeNull();
});
it('toggleSidebar opens with provided content when closed', () => {
const harness = renderRightSidebarHarness({ pathname: '/users' });
act(() => {
harness.getCurrent().toggleSidebar({
title: 'Toggle open',
content: <div>Toggle body</div>,
});
});
expect(harness.getCurrent().isOpen).toBe(true);
expect(harness.getCurrent().content?.title).toBe('Toggle open');
});
it('closes on pathname changes', () => {
const harness = renderRightSidebarHarness({ pathname: '/users' });
act(() => {
harness.getCurrent().openSidebar({
title: 'Meta',
content: <div>Body</div>,
});
});
expect(harness.getCurrent().isOpen).toBe(true);
harness.reroute('/posts');
expect(harness.getCurrent().isOpen).toBe(false);
expect(harness.getCurrent().content).toBeNull();
});
it('reads/clamps persisted width, persists resized widths, and reacts to escape', async () => {
localStorage.setItem('authRightSidebarWidth', '999');
const harness = renderRightSidebarHarness({ pathname: '/users' });
expect(harness.getCurrent().desktopSidebarStyle['--auth-right-sidebar-width']).toBe(
'480px',
);
act(() => {
harness.getCurrent().openSidebar({
title: 'Meta',
content: <div>Body</div>,
});
});
const preventDefault = vi.fn();
act(() => {
harness.getCurrent().startResize({
clientX: 480,
preventDefault,
} as never);
});
act(() => {
fireEvent.pointerMove(window, { clientX: -400 });
fireEvent.pointerUp(window);
});
await act(async () => {
await Promise.resolve();
});
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(localStorage.getItem('authRightSidebarWidth')).toBe('480');
act(() => {
fireEvent.keyDown(window, { key: 'Escape' });
});
expect(harness.getCurrent().isOpen).toBe(false);
});
it('notifies mobile open requests when opening on mobile', () => {
setViewportWidth(768);
const onMobileOpenRequest = vi.fn();
const harness = renderRightSidebarHarness({
pathname: '/users',
onMobileOpenRequest,
});
act(() => {
harness.getCurrent().openSidebar({
title: 'Meta',
content: <div>Body</div>,
});
});
expect(harness.getCurrent().isOpen).toBe(true);
expect(onMobileOpenRequest).toHaveBeenCalledTimes(1);
});
it('normalizes custom sizing values', () => {
function Probe() {
const sidebar = useRightSidebar();
return <div data-testid="width">{sidebar.desktopSidebarStyle['--auth-right-sidebar-width']}</div>;
}
const { getByTestId } = render(
<RightSidebarProvider
sizing={{
defaultWidth: 1000,
minWidth: 500,
maxWidth: 300,
}}
>
<Probe />
</RightSidebarProvider>,
);
expect(getByTestId('width').textContent).toBe('500px');
});
});

View File

@@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest';
import { createErrorResolver } from '../../src/errors/createErrorResolver';
const CATALOG = {
AUTH_UNAUTHORIZED: 'Unauthorized access. Please sign in again.',
FORBIDDEN: 'You do not have permission to perform this action.',
USER_NOT_FOUND: 'User not found.',
INTERNAL_ERROR: 'Unexpected request error.',
REQUEST_FAILED: 'Request failed. Please try again.',
};
const resolver = createErrorResolver({
catalog: CATALOG,
fallbackCode: 'REQUEST_FAILED',
defaultContext: 'default',
contextOverrides: {
session: {
AUTH_UNAUTHORIZED: 'Session expired. Please sign in again.',
},
},
inferCodeFromStatus: (status?: number | null) => {
switch (status) {
case 401:
return 'AUTH_UNAUTHORIZED';
case 403:
return 'FORBIDDEN';
case 404:
return 'USER_NOT_FOUND';
default:
if (status != null && status >= 500) {
return 'INTERNAL_ERROR';
}
return undefined;
}
},
inferCodeFromLegacyMessage: (message?: string | null) => {
if (message?.toLowerCase() === 'unauthorized') {
return 'AUTH_UNAUTHORIZED';
}
return undefined;
},
});
describe('createErrorResolver', () => {
it('recognizes known error codes', () => {
expect(resolver.isKnownErrorCode('AUTH_UNAUTHORIZED')).toBe(true);
expect(resolver.isKnownErrorCode('NOPE')).toBe(false);
});
it('returns context override for known code', () => {
expect(
resolver.resolveErrorMessage({
code: 'AUTH_UNAUTHORIZED',
context: 'session',
}),
).toBe('Session expired. Please sign in again.');
});
it('falls back from unknown code to legacy message inference', () => {
expect(
resolver.resolveErrorMessage({
code: 'UNKNOWN_CODE',
fallbackMessage: 'unauthorized',
}),
).toBe('Unauthorized access. Please sign in again.');
});
it('falls back to status mapping when code is missing', () => {
expect(resolver.resolveErrorMessage({ status: 404 })).toBe('User not found.');
});
it('falls back to status mapping when legacy inference returns an unmapped code', () => {
const resolverWithUnmappedLegacyCode = createErrorResolver({
catalog: CATALOG,
inferCodeFromStatus: resolver.inferErrorCodeFromStatus,
inferCodeFromLegacyMessage: () => 'LEGACY_ONLY_CODE',
});
expect(
resolverWithUnmappedLegacyCode.resolveErrorMessage({
status: 403,
fallbackMessage: 'legacy message',
}),
).toBe('You do not have permission to perform this action.');
});
it('uses fallbackCode when no code/status resolve and fallback message is missing', () => {
expect(resolver.resolveErrorMessage({ status: 418 })).toBe('Request failed. Please try again.');
});
it('uses fallback message when no mapping exists and fallback code is unavailable', () => {
const noFallbackCodeResolver = createErrorResolver({
catalog: CATALOG,
inferCodeFromStatus: () => undefined,
});
expect(
noFallbackCodeResolver.resolveErrorMessage({
code: 'UNKNOWN',
fallbackMessage: 'raw backend message',
}),
).toBe('raw backend message');
});
it('returns default request failure message when no signal is available', () => {
const emptyResolver = createErrorResolver({
catalog: {},
});
expect(emptyResolver.resolveErrorMessage({})).toBe('Request failed. Please try again.');
});
it('resolveOptionalErrorMessage returns undefined for empty inputs', () => {
expect(resolver.resolveOptionalErrorMessage(undefined)).toBeUndefined();
expect(resolver.resolveOptionalErrorMessage(null)).toBeUndefined();
});
it('resolveOptionalErrorMessage resolves known codes', () => {
expect(resolver.resolveOptionalErrorMessage('FORBIDDEN')).toBe(
'You do not have permission to perform this action.',
);
});
it('toErrorMessage prefers rawMessage over message and handles unknown values', () => {
expect(
resolver.toErrorMessage({
status: 403,
rawMessage: 'unauthorized',
message: 'ignored message',
}),
).toBe('Unauthorized access. Please sign in again.');
expect(resolver.toErrorMessage('boom')).toBe('Request failed. Please try again.');
expect(resolver.toErrorMessage(null)).toBe('Request failed. Please try again.');
});
it('exposes inferErrorCodeFromStatus', () => {
expect(resolver.inferErrorCodeFromStatus(401)).toBe('AUTH_UNAUTHORIZED');
expect(resolver.inferErrorCodeFromStatus(500)).toBe('INTERNAL_ERROR');
expect(resolver.inferErrorCodeFromStatus(418)).toBeUndefined();
expect(resolver.inferErrorCodeFromStatus(null)).toBeUndefined();
});
});

View File

@@ -0,0 +1,36 @@
import { act } from 'react';
import { createRoot } from 'react-dom/client';
export function renderHook<T>(useHook: () => T) {
let currentValue: T;
function TestComponent() {
currentValue = useHook();
return null;
}
const container = document.createElement('div');
const root = createRoot(container);
act(() => {
root.render(<TestComponent />);
});
return {
result: {
get current() {
return currentValue;
},
},
rerender() {
act(() => {
root.render(<TestComponent />);
});
},
unmount() {
act(() => {
root.unmount();
});
},
};
}

View File

@@ -0,0 +1,64 @@
import { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useCooldownTimer } from '../../src/hooks/useCooldownTimer';
import { renderHook } from '../helpers/renderHook';
describe('useCooldownTimer', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('decrements cooldown every second while enabled', () => {
const { result } = renderHook(() => useCooldownTimer(2, true));
expect(result.current.cooldown).toBe(2);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current.cooldown).toBe(1);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current.cooldown).toBe(0);
});
it('does not decrement when disabled', () => {
const { result } = renderHook(() => useCooldownTimer(2, false));
act(() => {
vi.advanceTimersByTime(3000);
});
expect(result.current.cooldown).toBe(2);
});
it('startCooldown floors values and clamps negatives', () => {
const { result } = renderHook(() => useCooldownTimer(0, true));
act(() => {
result.current.startCooldown(2.8);
});
expect(result.current.cooldown).toBe(2);
act(() => {
result.current.startCooldown(-4);
});
expect(result.current.cooldown).toBe(0);
});
it('stays at zero when already expired', () => {
const { result } = renderHook(() => useCooldownTimer(0, true));
act(() => {
vi.advanceTimersByTime(2000);
});
expect(result.current.cooldown).toBe(0);
});
});

View File

@@ -0,0 +1,81 @@
import { act } from 'react';
import { describe, expect, it } from 'vitest';
import { useEditableForm } from '../../src/hooks/useEditableForm';
import { renderHook } from '../helpers/renderHook';
type FormValues = {
username: string;
email: string;
};
const INITIAL_VALUES: FormValues = {
username: '',
email: '',
};
function validate(values: FormValues) {
return {
username: values.username.trim().length < 3 ? 'Username too short' : undefined,
email: values.email.includes('@') ? undefined : 'Invalid email',
};
}
describe('useEditableForm', () => {
it('supports load/start/discard/commit edit lifecycle', () => {
const { result } = renderHook(() =>
useEditableForm({
initialValues: INITIAL_VALUES,
validate,
}),
);
expect(result.current.isEditing).toBe(false);
act(() => {
result.current.loadFromSource({ username: 'alice', email: 'alice@example.com' });
});
expect(result.current.values).toEqual({ username: 'alice', email: 'alice@example.com' });
expect(result.current.errors).toEqual({});
act(() => {
result.current.startEditing({ username: 'al', email: 'aliceexample.com' });
});
expect(result.current.isEditing).toBe(true);
act(() => {
result.current.setFieldValue('username', 'a');
});
expect(result.current.errors.username).toBe('Username too short');
act(() => {
result.current.discardChanges({ username: 'alice', email: 'alice@example.com' });
});
expect(result.current.isEditing).toBe(false);
expect(result.current.values).toEqual({ username: 'alice', email: 'alice@example.com' });
act(() => {
result.current.startEditing({ username: 'alice', email: 'alice@example.com' });
result.current.setFieldValue('username', 'alice_2');
result.current.commitSaved({ username: 'alice_2', email: 'alice@example.com' });
});
expect(result.current.isEditing).toBe(false);
expect(result.current.values.username).toBe('alice_2');
});
it('exposes direct editing toggles and field error injection', () => {
const { result } = renderHook(() =>
useEditableForm({
initialValues: INITIAL_VALUES,
validate,
}),
);
act(() => {
result.current.setIsEditing(true);
result.current.setFieldError('username', 'Username already taken');
});
expect(result.current.isEditing).toBe(true);
expect(result.current.errors.username).toBe('Username already taken');
});
});

View File

@@ -0,0 +1,170 @@
import { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { usePaginatedResource } from '../../src/hooks/usePaginatedResource';
import { renderHook } from '../helpers/renderHook';
type Item = {
id: string;
};
async function flushDebounce(delay = 250) {
await act(async () => {
vi.advanceTimersByTime(delay);
await Promise.resolve();
});
}
describe('usePaginatedResource', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('loads first page after debounce and stores response fields', async () => {
const loadMock = vi.fn(async () => ({
items: [{ id: '1' }] as Item[],
page: 1,
pageSize: 10,
total: 25,
totalPages: 3,
}));
const { result } = renderHook(() =>
usePaginatedResource<Item>({
load: loadMock,
sort: '-createdAt',
}),
);
await flushDebounce();
expect(loadMock).toHaveBeenCalledWith({
q: '',
page: 1,
pageSize: 10,
sort: '-createdAt',
});
expect(result.current.items).toEqual([{ id: '1' }]);
expect(result.current.total).toBe(25);
expect(result.current.totalPages).toBe(3);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
});
it('resets page to 1 when query or page size changes', async () => {
const loadMock = vi.fn(
async ({ q, page, pageSize }: { q: string; page: number; pageSize: number }) => ({
items: [{ id: `${q}:${page}:${pageSize}` }] as Item[],
page,
pageSize,
total: 1,
totalPages: 1,
}),
);
const { result } = renderHook(() =>
usePaginatedResource<Item>({
load: loadMock,
}),
);
await flushDebounce();
act(() => {
result.current.setPage(2);
});
await flushDebounce();
expect(loadMock).toHaveBeenLastCalledWith({
q: '',
page: 2,
pageSize: 10,
sort: undefined,
});
act(() => {
result.current.setQuery('search');
});
await flushDebounce();
expect(loadMock).toHaveBeenLastCalledWith({
q: 'search',
page: 1,
pageSize: 10,
sort: undefined,
});
act(() => {
result.current.setPage(3);
});
await flushDebounce();
act(() => {
result.current.setPageSize(20);
});
await flushDebounce();
expect(loadMock).toHaveBeenLastCalledWith({
q: 'search',
page: 1,
pageSize: 20,
sort: undefined,
});
});
it('cancels stale debounce timers when inputs change quickly', async () => {
const loadMock = vi.fn(async () => ({
items: [] as Item[],
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
}));
const { result } = renderHook(() =>
usePaginatedResource<Item>({
load: loadMock,
debounceMs: 100,
}),
);
act(() => {
result.current.setQuery('latest');
});
await flushDebounce(100);
expect(loadMock).toHaveBeenCalledTimes(1);
expect(loadMock).toHaveBeenCalledWith({
q: 'latest',
page: 1,
pageSize: 10,
sort: undefined,
});
});
it('maps thrown errors into string error state', async () => {
const loadMock = vi
.fn()
.mockRejectedValueOnce(new Error('Load failed'))
.mockRejectedValueOnce('unknown');
const { result } = renderHook(() =>
usePaginatedResource<Item>({
load: loadMock,
}),
);
await flushDebounce();
expect(result.current.error).toBe('Load failed');
expect(result.current.isLoading).toBe(false);
act(() => {
result.current.setPage(2);
});
await flushDebounce();
expect(result.current.error).toBe('Request failed. Please try again.');
expect(result.current.isLoading).toBe(false);
});
});

View File

@@ -0,0 +1,97 @@
import { act } from 'react';
import { describe, expect, it } from 'vitest';
import { formatSortParam, useSorting } from '../../src/hooks/useSorting';
import { renderHook } from '../helpers/renderHook';
describe('useSorting', () => {
it('starts from default sort and cycles asc/desc/default', () => {
const { result } = renderHook(() => useSorting({ field: 'createdAt', direction: 'asc' }));
expect(result.current.activeSort).toEqual({ field: 'createdAt', direction: 'asc' });
expect(result.current.sortParam).toBe('createdAt');
act(() => {
result.current.toggleSort('name');
});
expect(result.current.activeSort).toEqual({ field: 'name', direction: 'asc' });
expect(result.current.sortParam).toBe('name');
act(() => {
result.current.toggleSort('name');
});
expect(result.current.activeSort).toEqual({ field: 'name', direction: 'desc' });
expect(result.current.sortParam).toBe('-name');
act(() => {
result.current.toggleSort('name');
});
expect(result.current.activeSort).toEqual({ field: 'createdAt', direction: 'asc' });
expect(result.current.sortParam).toBe('createdAt');
});
it('cycles sort state without a baseline default', () => {
const { result } = renderHook(() => useSorting(null));
expect(result.current.activeSort).toBeNull();
expect(result.current.sortParam).toBeUndefined();
act(() => {
result.current.toggleSort('updatedAt');
});
expect(result.current.sortParam).toBe('updatedAt');
act(() => {
result.current.toggleSort('updatedAt');
});
expect(result.current.sortParam).toBe('-updatedAt');
act(() => {
result.current.toggleSort('updatedAt');
});
expect(result.current.sortParam).toBeUndefined();
});
it('supports manual setSort and resetSort', () => {
const { result } = renderHook(() => useSorting({ field: 'createdAt', direction: 'desc' }));
expect(result.current.sortParam).toBe('-createdAt');
act(() => {
result.current.setSort({ field: 'title', direction: 'asc' });
});
expect(result.current.sortParam).toBe('title');
act(() => {
result.current.resetSort();
});
expect(result.current.sortParam).toBe('-createdAt');
});
it('toggles baseline field directly between baseline and opposite direction', () => {
const { result } = renderHook(() => useSorting({ field: 'createdAt', direction: 'desc' }));
expect(result.current.sortParam).toBe('-createdAt');
act(() => {
result.current.toggleSort('createdAt');
});
expect(result.current.sortParam).toBe('createdAt');
act(() => {
result.current.toggleSort('createdAt');
});
expect(result.current.sortParam).toBe('-createdAt');
act(() => {
result.current.toggleSort('createdAt');
});
expect(result.current.sortParam).toBe('createdAt');
});
it('formats sort params safely', () => {
expect(formatSortParam(null)).toBeUndefined();
expect(formatSortParam(undefined)).toBeUndefined();
expect(formatSortParam({ field: 'updatedAt', direction: 'desc' })).toBe('-updatedAt');
expect(formatSortParam({ field: 'updatedAt', direction: 'asc' })).toBe('updatedAt');
});
});

View File

@@ -0,0 +1,44 @@
import { act } from 'react';
import { describe, expect, it } from 'vitest';
import { useSubmitState } from '../../src/hooks/useSubmitState';
import { renderHook } from '../helpers/renderHook';
describe('useSubmitState', () => {
it('tracks submit lifecycle and feedback state', () => {
const { result } = renderHook(() => useSubmitState<string | null>(null));
expect(result.current.isSubmitting).toBe(false);
expect(result.current.submitError).toBeNull();
expect(result.current.status).toBeNull();
act(() => {
result.current.startSubmitting();
result.current.setSubmitError('Oops');
result.current.setStatus('Done');
});
expect(result.current.isSubmitting).toBe(true);
expect(result.current.submitError).toBe('Oops');
expect(result.current.status).toBe('Done');
act(() => {
result.current.finishSubmitting();
result.current.clearFeedback();
});
expect(result.current.isSubmitting).toBe(false);
expect(result.current.submitError).toBeNull();
expect(result.current.status).toBeNull();
});
it('restores the typed initial status in clearFeedback', () => {
const { result } = renderHook(() => useSubmitState<'idle' | 'done'>('idle'));
act(() => {
result.current.setStatus('done');
result.current.clearFeedback();
});
expect(result.current.status).toBe('idle');
});
});

View File

@@ -0,0 +1,158 @@
import { act } from 'react';
import { describe, expect, it } from 'vitest';
import { useValidatedFields } from '../../src/hooks/useValidatedFields';
import { renderHook } from '../helpers/renderHook';
type FormValues = {
password: string;
confirmPassword: string;
};
function validate(values: FormValues) {
return {
password: values.password.length < 3 ? 'Password too short' : undefined,
confirmPassword:
values.confirmPassword !== values.password ? 'Passwords do not match' : undefined,
};
}
describe('useValidatedFields', () => {
it('initializes values and keeps errors hidden until fields are touched', () => {
const { result } = renderHook(() =>
useValidatedFields({
initialValues: { password: '', confirmPassword: '' },
validate,
}),
);
expect(result.current.values).toEqual({ password: '', confirmPassword: '' });
expect(result.current.errors).toEqual({});
expect(result.current.isValid).toBe(false);
});
it('setFieldValue touches and validates by default', () => {
const { result } = renderHook(() =>
useValidatedFields({
initialValues: { password: '', confirmPassword: '' },
validate,
}),
);
act(() => {
result.current.setFieldValue('password', 'ab');
});
expect(result.current.values.password).toBe('ab');
expect(result.current.errors.password).toBe('Password too short');
});
it('setFieldValue can skip touch and validation', () => {
const { result } = renderHook(() =>
useValidatedFields({
initialValues: { password: '', confirmPassword: '' },
validate,
}),
);
act(() => {
result.current.setFieldValue('password', 'ab', { touch: false, validate: false });
});
expect(result.current.values.password).toBe('ab');
expect(result.current.errors).toEqual({});
});
it('validateAll can avoid touching fields when requested', () => {
const { result } = renderHook(() =>
useValidatedFields({
initialValues: { password: 'abcd', confirmPassword: 'abce' },
validate,
}),
);
let validationErrors: ReturnType<typeof validate> | undefined;
act(() => {
validationErrors = result.current.validateAll({ touchAll: false });
});
expect(validationErrors).toEqual({
password: undefined,
confirmPassword: 'Passwords do not match',
});
expect(result.current.errors).toEqual({});
});
it('validateAll touches all fields by default', () => {
const { result } = renderHook(() =>
useValidatedFields({
initialValues: { password: '', confirmPassword: '' },
validate,
}),
);
act(() => {
result.current.validateAll();
});
expect(result.current.errors.password).toBe('Password too short');
expect(result.current.errors.confirmPassword).toBeUndefined();
});
it('supports setFieldError, setErrors and clearErrors', () => {
const { result } = renderHook(() =>
useValidatedFields({
initialValues: { password: 'abcd', confirmPassword: 'abcd' },
validate,
}),
);
act(() => {
result.current.setFieldError('password', 'Server-side password issue');
});
expect(result.current.errors.password).toBe('Server-side password issue');
act(() => {
result.current.setErrors({
password: undefined,
confirmPassword: 'Still invalid',
});
});
expect(result.current.errors.confirmPassword).toBe('Still invalid');
act(() => {
result.current.clearErrors();
});
expect(result.current.errors).toEqual({});
});
it('setValues with clearErrors resets touched state and revalidates', () => {
const { result } = renderHook(() =>
useValidatedFields({
initialValues: { password: '', confirmPassword: '' },
validate,
}),
);
act(() => {
result.current.setFieldValue('password', 'ab');
});
expect(result.current.errors.password).toBe('Password too short');
act(() => {
result.current.setValues(
{
password: 'abcd',
confirmPassword: 'abcd',
},
{ clearErrors: true },
);
});
expect(result.current.values).toEqual({
password: 'abcd',
confirmPassword: 'abcd',
});
expect(result.current.errors).toEqual({});
expect(result.current.isValid).toBe(true);
});
});

View File

@@ -0,0 +1,239 @@
import { fireEvent } from '@testing-library/react';
import { act } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { isDesktopViewport, useSidePanelMachine } from '../../src/panels/useSidePanelMachine';
import { renderHook } from '../helpers/renderHook';
function setViewportWidth(width: number) {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: width,
});
}
function resetMatchMedia() {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: undefined,
});
}
describe('isDesktopViewport', () => {
it('uses matchMedia when available', () => {
const matchMedia = vi.fn(() => ({ matches: false }));
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: matchMedia,
});
expect(isDesktopViewport(1200)).toBe(false);
expect(matchMedia).toHaveBeenCalledWith('(min-width: 1200px)');
});
it('falls back to window.innerWidth when matchMedia is unavailable', () => {
resetMatchMedia();
setViewportWidth(1024);
expect(isDesktopViewport(1024)).toBe(true);
setViewportWidth(800);
expect(isDesktopViewport(1024)).toBe(false);
});
});
describe('useSidePanelMachine', () => {
it('reads stored width, clamps resize values, and persists final width', async () => {
resetMatchMedia();
setViewportWidth(1200);
localStorage.setItem('panel-width', '999');
const preventDefault = vi.fn();
const { result } = renderHook(() =>
useSidePanelMachine({
storageKey: 'panel-width',
defaultWidth: 300,
minWidth: 200,
maxWidth: 400,
resizeAxis: 'from-left',
resizingBodyClass: 'resizing',
isOpen: false,
canResize: true,
shouldPersistWidth: true,
closeOnPathname: undefined,
}),
);
expect(result.current.width).toBe(400);
expect(result.current.isDesktop).toBe(true);
expect(localStorage.getItem('panel-width')).toBe('400');
act(() => {
result.current.startResize({
clientX: 400,
preventDefault,
} as never);
});
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(document.body.classList.contains('resizing')).toBe(true);
act(() => {
fireEvent.pointerMove(window, { clientX: 100 });
fireEvent.pointerUp(window);
});
await act(async () => {
await Promise.resolve();
});
expect(result.current.width).toBe(200);
expect(localStorage.getItem('panel-width')).toBe('200');
expect(document.body.classList.contains('resizing')).toBe(false);
});
it('falls back to default width for empty or invalid stored values', () => {
resetMatchMedia();
setViewportWidth(1200);
localStorage.setItem('panel-width', '');
const invalid = renderHook(() =>
useSidePanelMachine({
storageKey: 'panel-width',
defaultWidth: 310,
minWidth: 200,
maxWidth: 400,
resizeAxis: 'from-left',
resizingBodyClass: 'resizing',
isOpen: false,
canResize: true,
shouldPersistWidth: false,
closeOnPathname: undefined,
}),
);
expect(invalid.result.current.width).toBe(310);
invalid.unmount();
localStorage.setItem('panel-width', 'NaN-value');
const nonFinite = renderHook(() =>
useSidePanelMachine({
storageKey: 'panel-width',
defaultWidth: 315,
minWidth: 200,
maxWidth: 400,
resizeAxis: 'from-left',
resizingBodyClass: 'resizing',
isOpen: false,
canResize: true,
shouldPersistWidth: false,
closeOnPathname: undefined,
}),
);
expect(nonFinite.result.current.width).toBe(315);
});
it('does not start resizing when viewport is mobile or resizing is disabled', () => {
resetMatchMedia();
setViewportWidth(700);
const preventDefault = vi.fn();
const { result } = renderHook(() =>
useSidePanelMachine({
storageKey: 'panel-width',
defaultWidth: 300,
minWidth: 200,
maxWidth: 400,
resizeAxis: 'from-left',
resizingBodyClass: 'resizing',
isOpen: false,
canResize: false,
shouldPersistWidth: false,
closeOnPathname: undefined,
}),
);
act(() => {
result.current.startResize({
clientX: 300,
preventDefault,
} as never);
});
expect(preventDefault).not.toHaveBeenCalled();
expect(document.body.classList.contains('resizing')).toBe(false);
expect(localStorage.getItem('panel-width')).toBeNull();
});
it('ignores pointer events when not currently resizing', () => {
resetMatchMedia();
setViewportWidth(1200);
renderHook(() =>
useSidePanelMachine({
storageKey: 'panel-width',
defaultWidth: 300,
minWidth: 200,
maxWidth: 400,
resizeAxis: 'from-left',
resizingBodyClass: 'resizing',
isOpen: false,
canResize: true,
shouldPersistWidth: true,
closeOnPathname: undefined,
}),
);
act(() => {
fireEvent.pointerMove(window, { clientX: 1000 });
fireEvent.pointerUp(window);
});
expect(document.body.classList.contains('resizing')).toBe(false);
});
it('handles closeOnPathname, escape key callbacks and body overflow lock', () => {
resetMatchMedia();
setViewportWidth(700);
const onCloseOnPathname = vi.fn();
const onEscape = vi.fn();
let options = {
storageKey: 'panel-width',
defaultWidth: 300,
minWidth: 200,
maxWidth: 400,
resizeAxis: 'from-right' as const,
resizingBodyClass: 'resizing',
isOpen: true,
canResize: true,
shouldPersistWidth: false,
closeOnPathname: '/users',
onCloseOnPathname,
onEscape,
};
const { rerender } = renderHook(() => useSidePanelMachine(options));
expect(onCloseOnPathname).toHaveBeenCalledTimes(1);
expect(document.body.style.overflow).toBe('hidden');
act(() => {
fireEvent.keyDown(window, { key: 'Escape' });
});
expect(onEscape).toHaveBeenCalledTimes(1);
options = {
...options,
closeOnPathname: '/posts',
};
rerender();
expect(onCloseOnPathname).toHaveBeenCalledTimes(2);
options = {
...options,
isOpen: false,
};
rerender();
expect(document.body.style.overflow).toBe('');
});
});

15
tests/setup.ts Normal file
View File

@@ -0,0 +1,15 @@
// Required by React to silence act(...) warnings in jsdom tests.
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
afterEach(() => {
cleanup();
localStorage.clear();
document.body.className = '';
document.body.style.overflow = '';
vi.restoreAllMocks();
vi.useRealTimers();
});

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { capitalize, formatDate, splitAndCapitalize } from '../../src/utils/formatting';
describe('formatDate', () => {
it('formats date in it-IT locale and includes seconds when requested', () => {
const withoutSeconds = formatDate('2026-01-01T12:34:56.000Z');
const withSeconds = formatDate('2026-01-01T12:34:56.000Z', true);
expect(withoutSeconds).toContain('2026');
expect(withSeconds).toContain('56');
});
});
describe('capitalize', () => {
it('capitalizes every space-delimited word', () => {
expect(capitalize('hello WORLD')).toBe('Hello World');
expect(capitalize('mUlTi SPACES')).toBe('Multi Spaces');
});
});
describe('splitAndCapitalize', () => {
it('splits underscore and hyphen mode', () => {
expect(splitAndCapitalize('hello_world-test', 'underscore')).toBe('Hello World Test');
});
it('splits camel mode with acronym and number boundaries', () => {
expect(splitAndCapitalize('helloWorldXML2Http', 'camel')).toBe('Hello World Xml 2 Http');
});
it('auto mode prefers underscore splitting when underscore-like chars exist', () => {
expect(splitAndCapitalize('user_name')).toBe('User Name');
expect(splitAndCapitalize('kebab-case')).toBe('Kebab Case');
});
it('auto mode falls back to camel splitting when underscore-like chars are absent', () => {
expect(splitAndCapitalize('helloWorldTest')).toBe('Hello World Test');
});
it('returns empty string for empty or missing input', () => {
expect(splitAndCapitalize('')).toBe('');
expect(splitAndCapitalize(undefined)).toBe('');
});
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { shouldShowVerifiedEmailBadge } from '../../src/utils/verifiedEmail';
describe('shouldShowVerifiedEmailBadge', () => {
it('returns false when email has not been verified', () => {
expect(
shouldShowVerifiedEmailBadge({
verifiedAt: null,
persistedEmail: 'a@example.com',
currentEmail: 'a@example.com',
isEditing: false,
}),
).toBe(false);
});
it('returns true when verified and not editing', () => {
expect(
shouldShowVerifiedEmailBadge({
verifiedAt: '2025-01-01T10:00:00Z',
persistedEmail: 'a@example.com',
currentEmail: 'other@example.com',
isEditing: false,
}),
).toBe(true);
});
it('while editing, returns true only when trimmed current email matches persisted email', () => {
expect(
shouldShowVerifiedEmailBadge({
verifiedAt: '2025-01-01T10:00:00Z',
persistedEmail: 'a@example.com',
currentEmail: ' a@example.com ',
isEditing: true,
}),
).toBe(true);
expect(
shouldShowVerifiedEmailBadge({
verifiedAt: '2025-01-01T10:00:00Z',
persistedEmail: 'a@example.com',
currentEmail: 'other@example.com',
isEditing: true,
}),
).toBe(false);
});
});

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'vite';
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { resolve } from 'node:path';
@@ -9,10 +9,24 @@ export default defineConfig({
entry: resolve(__dirname, 'src/index.ts'),
name: 'PanicCore',
formats: ['es'],
fileName: () => 'index.js'
fileName: () => 'index.js',
},
rollupOptions: {
external: ['react']
}
}
external: ['react'],
},
},
test: {
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/index.ts'],
thresholds: {
lines: 80,
functions: 75,
branches: 70,
},
},
},
});

1337
yarn.lock

File diff suppressed because it is too large Load Diff