fix(frontend): resolve lint blockers for gitea frontend-ci

This commit is contained in:
WorkClub Automation
2026-03-06 22:26:55 +01:00
parent ad6a23621d
commit 5cf43976f6
16 changed files with 112 additions and 96 deletions

View File

@@ -15,7 +15,7 @@ import { test, expect } from '@playwright/test';
/**
* Robust club selection helper with fallback locators
*/
async function selectClubIfPresent(page: any) {
async function selectClubIfPresent(page: import('@playwright/test').Page) {
const isOnSelectClub = page.url().includes('/select-club');
if (!isOnSelectClub) {
@@ -182,7 +182,7 @@ test.describe('Authentication Flow', () => {
});
});
async function authenticateUser(page: any, email: string, password: string) {
async function authenticateUser(page: import('@playwright/test').Page, email: string, password: string) {
await page.goto('/login');
await page.click('button:has-text("Sign in with Keycloak")');

View File

@@ -11,7 +11,7 @@ import { test, expect } from '@playwright/test';
* - Visual capacity indicators (progress bar, spot counts)
*/
async function selectClubIfPresent(page: any) {
async function selectClubIfPresent(page: import('@playwright/test').Page) {
const isOnSelectClub = page.url().includes('/select-club');
if (!isOnSelectClub) {
@@ -51,7 +51,7 @@ async function selectClubIfPresent(page: any) {
}
}
async function loginAs(page: any, email: string, password: string) {
async function loginAs(page: import('@playwright/test').Page, email: string, password: string) {
await page.goto('/login');
await page.click('button:has-text("Sign in with Keycloak")');

View File

@@ -1,8 +1,6 @@
import { AuthGuard } from '@/components/auth-guard';
import { ClubSwitcher } from '@/components/club-switcher';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { LogOut } from 'lucide-react';
import { SignOutButton } from '@/components/sign-out-button';
export default function ProtectedLayout({

View File

@@ -2,7 +2,6 @@
import { use } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useTask, useUpdateTask } from '@/hooks/useTasks';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -25,7 +24,6 @@ const statusColors: Record<string, string> = {
export default function TaskDetailPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = use(params);
const router = useRouter();
const { data: task, isLoading, error } = useTask(resolvedParams.id);
const { mutate: updateTask, isPending } = useUpdateTask();

View File

@@ -31,7 +31,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
async jwt({ token, account }) {
if (account) {
// Add clubs claim from Keycloak access token
token.clubs = (account as any).clubs || {}
token.clubs = (account as Record<string, unknown>).clubs as Record<string, string> || {}
token.accessToken = account.access_token
}
return token

View File

@@ -22,28 +22,28 @@ describe('AuthGuard', () => {
beforeEach(() => {
vi.clearAllMocks();
(useRouter as any).mockReturnValue({ push: mockPush } as any);
(useRouter as ReturnType<typeof vi.fn>).mockReturnValue({ push: mockPush });
});
it('renders loading state when session is loading', () => {
(useSession as any).mockReturnValue({ data: null, status: 'loading' } as any);
(useTenant as any).mockReturnValue({ activeClubId: null, clubs: [], setActiveClub: vi.fn(), userRole: null });
(useSession as ReturnType<typeof vi.fn>).mockReturnValue({ data: null, status: 'loading' });
(useTenant as ReturnType<typeof vi.fn>).mockReturnValue({ activeClubId: null, clubs: [], setActiveClub: vi.fn(), userRole: null });
render(<AuthGuard><div>Protected</div></AuthGuard>);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('redirects to /login when unauthenticated', () => {
(useSession as any).mockReturnValue({ data: null, status: 'unauthenticated' } as any);
(useTenant as any).mockReturnValue({ activeClubId: null, clubs: [], setActiveClub: vi.fn(), userRole: null });
(useSession as ReturnType<typeof vi.fn>).mockReturnValue({ data: null, status: 'unauthenticated' });
(useTenant as ReturnType<typeof vi.fn>).mockReturnValue({ activeClubId: null, clubs: [], setActiveClub: vi.fn(), userRole: null });
render(<AuthGuard><div>Protected</div></AuthGuard>);
expect(mockPush).toHaveBeenCalledWith('/login');
});
it('shows Contact admin when 0 clubs', () => {
(useSession as any).mockReturnValue({ data: { user: {} }, status: 'authenticated' } as any);
(useTenant as any).mockReturnValue({ activeClubId: null, clubs: [], setActiveClub: vi.fn(), userRole: null });
(useSession as ReturnType<typeof vi.fn>).mockReturnValue({ data: { user: {} }, status: 'authenticated' });
(useTenant as ReturnType<typeof vi.fn>).mockReturnValue({ activeClubId: null, clubs: [], setActiveClub: vi.fn(), userRole: null });
render(<AuthGuard><div>Protected</div></AuthGuard>);
expect(screen.getByText('Contact admin to get access to a club')).toBeInTheDocument();
@@ -51,39 +51,39 @@ describe('AuthGuard', () => {
it('auto-selects when 1 club and no active club', () => {
const mockSetActiveClub = vi.fn();
(useSession as any).mockReturnValue({ data: { user: {} }, status: 'authenticated' } as any);
(useTenant as any).mockReturnValue({
(useSession as ReturnType<typeof vi.fn>).mockReturnValue({ data: { user: {} }, status: 'authenticated' });
(useTenant as ReturnType<typeof vi.fn>).mockReturnValue({
activeClubId: null,
clubs: [{ id: 'club-1', name: 'Club 1' }],
setActiveClub: mockSetActiveClub,
userRole: null
} as any);
});
render(<AuthGuard><div>Protected</div></AuthGuard>);
expect(mockSetActiveClub).toHaveBeenCalledWith('club-1');
});
it('redirects to /select-club when multiple clubs and no active club', () => {
(useSession as any).mockReturnValue({ data: { user: {} }, status: 'authenticated' } as any);
(useTenant as any).mockReturnValue({
(useSession as ReturnType<typeof vi.fn>).mockReturnValue({ data: { user: {} }, status: 'authenticated' });
(useTenant as ReturnType<typeof vi.fn>).mockReturnValue({
activeClubId: null,
clubs: [{ id: 'club-1', name: 'Club 1' }, { id: 'club-2', name: 'Club 2' }],
setActiveClub: vi.fn(),
userRole: null
} as any);
});
render(<AuthGuard><div>Protected</div></AuthGuard>);
expect(mockPush).toHaveBeenCalledWith('/select-club');
});
it('renders children when authenticated and active club is set', () => {
(useSession as any).mockReturnValue({ data: { user: {} }, status: 'authenticated' } as any);
(useTenant as any).mockReturnValue({
(useSession as ReturnType<typeof vi.fn>).mockReturnValue({ data: { user: {} }, status: 'authenticated' });
(useTenant as ReturnType<typeof vi.fn>).mockReturnValue({
activeClubId: 'club-1',
clubs: [{ id: 'club-1', name: 'Club 1' }],
setActiveClub: vi.fn(),
userRole: 'admin'
} as any);
});
render(<AuthGuard><div>Protected Content</div></AuthGuard>);
expect(screen.getByText('Protected Content')).toBeInTheDocument();

View File

@@ -9,7 +9,7 @@ vi.mock('../../contexts/tenant-context', () => ({
vi.mock('../ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children, asChild }: { children: React.ReactNode, asChild?: boolean }) => <div data-testid="trigger">{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <div data-testid="trigger">{children}</div>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div data-testid="content">{children}</div>,
DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => <div onClick={onClick} data-testid="menu-item">{children}</div>,
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
@@ -22,19 +22,19 @@ describe('ClubSwitcher', () => {
});
it('renders loading state when clubs is empty', () => {
(useTenant as any).mockReturnValue({
(useTenant as ReturnType<typeof vi.fn>).mockReturnValue({
activeClubId: null,
clubs: [],
setActiveClub: vi.fn(),
userRole: null
} as any);
});
render(<ClubSwitcher />);
expect(screen.getByRole('button')).toHaveTextContent('Select Club');
});
it('renders current club name and sport type badge', () => {
(useTenant as any).mockReturnValue({
(useTenant as ReturnType<typeof vi.fn>).mockReturnValue({
activeClubId: 'club-1',
clubs: [
{ id: 'club-1', name: 'Tennis Club', sportType: 'Tennis' },
@@ -42,7 +42,7 @@ describe('ClubSwitcher', () => {
],
setActiveClub: vi.fn(),
userRole: 'admin'
} as any);
});
render(<ClubSwitcher />);
expect(screen.getAllByText('Tennis Club')[0]).toBeInTheDocument();
@@ -50,7 +50,7 @@ describe('ClubSwitcher', () => {
it('calls setActiveClub when club is selected', () => {
const mockSetActiveClub = vi.fn();
(useTenant as any).mockReturnValue({
(useTenant as ReturnType<typeof vi.fn>).mockReturnValue({
activeClubId: 'club-1',
clubs: [
{ id: 'club-1', name: 'Tennis Club', sportType: 'Tennis' },
@@ -58,7 +58,7 @@ describe('ClubSwitcher', () => {
],
setActiveClub: mockSetActiveClub,
userRole: 'admin'
} as any);
});
render(<ClubSwitcher />);

View File

@@ -38,12 +38,12 @@ describe('ShiftDetailPage', () => {
beforeEach(() => {
vi.clearAllMocks();
(useSignUpShift as any).mockReturnValue({ mutateAsync: mockSignUp, isPending: false });
(useCancelSignUp as any).mockReturnValue({ mutateAsync: mockCancel, isPending: false });
(useSignUpShift as ReturnType<typeof vi.fn>).mockReturnValue({ mutateAsync: mockSignUp, isPending: false });
(useCancelSignUp as ReturnType<typeof vi.fn>).mockReturnValue({ mutateAsync: mockCancel, isPending: false });
});
it('shows "Sign Up" button if capacity available', async () => {
(useShift as any).mockReturnValue({
(useShift as ReturnType<typeof vi.fn>).mockReturnValue({
data: {
id: '1',
title: 'Detail Shift',
@@ -69,7 +69,7 @@ describe('ShiftDetailPage', () => {
});
it('shows "Cancel Sign-up" button if user is signed up', async () => {
(useShift as any).mockReturnValue({
(useShift as ReturnType<typeof vi.fn>).mockReturnValue({
data: {
id: '1',
title: 'Detail Shift',
@@ -95,7 +95,7 @@ describe('ShiftDetailPage', () => {
});
it('calls sign up mutation on click', async () => {
(useShift as any).mockReturnValue({
(useShift as ReturnType<typeof vi.fn>).mockReturnValue({
data: {
id: '1',
title: 'Detail Shift',

View File

@@ -20,18 +20,18 @@ describe('TaskDetailPage', () => {
const mockMutate = vi.fn();
beforeEach(() => {
(useUpdateTask as any).mockReturnValue({
(useUpdateTask as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: mockMutate,
isPending: false,
} as any);
});
});
it('shows valid transitions for Open status', async () => {
(useTask as any).mockReturnValue({
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({
data: { id: '1', title: 'Task 1', status: 'Open', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
isLoading: false,
error: null,
} as any);
});
const params = Promise.resolve({ id: '1' });
await act(async () => {
@@ -44,11 +44,11 @@ describe('TaskDetailPage', () => {
});
it('shows valid transitions for InProgress status', async () => {
(useTask as any).mockReturnValue({
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({
data: { id: '1', title: 'Task 1', status: 'InProgress', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
isLoading: false,
error: null,
} as any);
});
const params = Promise.resolve({ id: '1' });
await act(async () => {
@@ -60,11 +60,11 @@ describe('TaskDetailPage', () => {
});
it('shows valid transitions for Review status (including back transition)', async () => {
(useTask as any).mockReturnValue({
(useTask as ReturnType<typeof vi.fn>).mockReturnValue({
data: { id: '1', title: 'Task 1', status: 'Review', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
isLoading: false,
error: null,
} as any);
});
const params = Promise.resolve({ id: '1' });
await act(async () => {

View File

@@ -24,7 +24,7 @@ vi.mock('@/hooks/useTasks', () => ({
describe('TaskListPage', () => {
beforeEach(() => {
(useTasks as any).mockReturnValue({
(useTasks as ReturnType<typeof vi.fn>).mockReturnValue({
data: {
items: [
{ id: '1', title: 'Test Task 1', status: 'Open', assigneeId: null, createdAt: '2024-01-01' },
@@ -37,7 +37,7 @@ describe('TaskListPage', () => {
},
isLoading: false,
error: null,
} as any);
});
});
it('renders task list with 3 data rows', () => {

View File

@@ -6,7 +6,7 @@ import { ReactNode, useEffect } from 'react';
import { useTenant } from '../contexts/tenant-context';
export function AuthGuard({ children }: { children: ReactNode }) {
const { data: session, status } = useSession();
const { status } = useSession();
const { activeClubId, clubs, setActiveClub, clubsLoading } = useTenant();
const router = useRouter();

View File

@@ -1,6 +1,6 @@
'use client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { createContext, useContext, useEffect, useState, useMemo, ReactNode } from 'react';
import { useSession } from 'next-auth/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
@@ -22,10 +22,31 @@ type TenantContextType = {
const TenantContext = createContext<TenantContextType | undefined>(undefined);
function getInitialClubId(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('activeClubId');
}
function determineActiveClub(clubs: Club[], currentActiveId: string | null): string | null {
if (!clubs.length) return null;
const stored = getInitialClubId();
if (stored && clubs.find(c => c.id === stored)) {
return stored;
}
if (currentActiveId && clubs.find(c => c.id === currentActiveId)) {
return currentActiveId;
}
return clubs[0].id;
}
export function TenantProvider({ children }: { children: ReactNode }) {
const { data: session, status } = useSession();
const [activeClubId, setActiveClubId] = useState<string | null>(null);
const queryClient = useQueryClient();
const [activeClubId, setActiveClubId] = useState<string | null>(getInitialClubId);
const { data: clubs = [], isLoading: clubsLoading, error: clubsError } = useQuery<Club[]>({
queryKey: ['my-clubs', session?.accessToken],
@@ -43,25 +64,19 @@ export function TenantProvider({ children }: { children: ReactNode }) {
retryDelay: (attemptIndex) => Math.min(1000 * Math.pow(2, attemptIndex), 10000),
});
useEffect(() => {
if (status === 'authenticated' && clubs.length > 0) {
const stored = localStorage.getItem('activeClubId');
if (stored && clubs.find(c => c.id === stored)) {
setActiveClubId(stored);
} else if (!activeClubId) {
setActiveClubId(clubs[0].id);
}
}
const computedActiveClubId = useMemo(() => {
if (status !== 'authenticated' || !clubs.length) return activeClubId;
return determineActiveClub(clubs, activeClubId);
}, [status, clubs, activeClubId]);
useEffect(() => {
if (activeClubId) {
const selectedClub = clubs.find(c => c.id === activeClubId);
if (computedActiveClubId) {
const selectedClub = clubs.find(c => c.id === computedActiveClubId);
if (selectedClub) {
document.cookie = `X-Tenant-Id=${selectedClub.tenantId}; path=/; max-age=86400`;
}
}
}, [activeClubId, clubs]);
}, [computedActiveClubId, clubs]);
const handleSetActiveClub = (clubId: string) => {
setActiveClubId(clubId);
@@ -73,10 +88,10 @@ export function TenantProvider({ children }: { children: ReactNode }) {
queryClient.invalidateQueries();
};
const userRole = activeClubId && session?.user?.clubs ? session.user.clubs[activeClubId] || null : null;
const userRole = computedActiveClubId && session?.user?.clubs ? session.user.clubs[computedActiveClubId] || null : null;
return (
<TenantContext.Provider value={{ activeClubId, setActiveClub: handleSetActiveClub, userRole, clubs, clubsLoading, clubsError: clubsError || null }}>
<TenantContext.Provider value={{ activeClubId: computedActiveClubId, setActiveClub: handleSetActiveClub, userRole, clubs, clubsLoading, clubsError: clubsError || null }}>
{children}
</TenantContext.Provider>
);

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useActiveClub } from '../useActiveClub';
import type { Session } from 'next-auth';
const mockUseSession = vi.fn();
@@ -33,15 +32,15 @@ describe('useActiveClub', () => {
status: 'authenticated',
});
(localStorage.getItem as any).mockImplementation((key: string) => {
(localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) => {
return localStorageData[key] || null;
});
(localStorage.setItem as any).mockImplementation((key: string, value: string) => {
(localStorage.setItem as ReturnType<typeof vi.fn>).mockImplementation((key: string, value: string) => {
localStorageData[key] = value;
});
(localStorage.clear as any).mockImplementation(() => {
(localStorage.clear as ReturnType<typeof vi.fn>).mockImplementation(() => {
localStorageData = {};
});
});

View File

@@ -1,10 +1,26 @@
'use client';
import { useSession } from 'next-auth/react';
import { useState, useEffect } from 'react';
import { useState, useMemo } from 'react';
const ACTIVE_CLUB_KEY = 'activeClubId';
function getStoredClubId(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(ACTIVE_CLUB_KEY);
}
function determineActiveId(clubs: Record<string, string> | undefined, currentId: string | null): string | null {
if (!clubs || Object.keys(clubs).length === 0) return null;
const stored = getStoredClubId();
if (stored && clubs[stored]) return stored;
if (currentId && clubs[currentId]) return currentId;
return Object.keys(clubs)[0];
}
export interface ActiveClubData {
activeClubId: string | null;
role: string | null;
@@ -14,23 +30,13 @@ export interface ActiveClubData {
export function useActiveClub(): ActiveClubData {
const { data: session, status } = useSession();
const [activeClubId, setActiveClubIdState] = useState<string | null>(null);
useEffect(() => {
if (status === 'authenticated' && session?.user?.clubs) {
const clubs = session.user.clubs;
const storedClubId = localStorage.getItem(ACTIVE_CLUB_KEY);
if (storedClubId && clubs[storedClubId]) {
setActiveClubIdState(storedClubId);
} else {
const firstClubId = Object.keys(clubs)[0];
if (firstClubId) {
setActiveClubIdState(firstClubId);
}
}
}
}, [session, status]);
const [activeClubId, setActiveClubIdState] = useState<string | null>(getStoredClubId);
const computedActiveId = useMemo(() => {
if (status !== 'authenticated' || !session?.user?.clubs) return activeClubId;
return determineActiveId(session.user.clubs, activeClubId);
}, [session, status, activeClubId]);
const setActiveClub = (clubId: string) => {
if (session?.user?.clubs && session.user.clubs[clubId]) {
@@ -40,10 +46,10 @@ export function useActiveClub(): ActiveClubData {
};
const clubs = session?.user?.clubs || null;
const role = activeClubId && clubs ? clubs[activeClubId] : null;
const role = computedActiveId && clubs ? clubs[computedActiveId] : null;
return {
activeClubId,
activeClubId: computedActiveId,
role,
clubs,
setActiveClub,

View File

@@ -31,7 +31,7 @@ describe('apiClient', () => {
configurable: true,
});
(global.fetch as any).mockResolvedValue({
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ data: 'test' }),
@@ -145,7 +145,7 @@ describe('apiClient', () => {
await apiClient('/api/test');
const callHeaders = (global.fetch as any).mock.calls[0][1].headers;
const callHeaders = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].headers;
expect(callHeaders.Authorization).toBeUndefined();
});
@@ -158,7 +158,7 @@ describe('apiClient', () => {
await apiClient('/api/test');
const callHeaders = (global.fetch as any).mock.calls[0][1].headers;
const callHeaders = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].headers;
expect(callHeaders['X-Tenant-Id']).toBeUndefined();
});
});

View File

@@ -6,13 +6,13 @@ const localStorageMock = {
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
length: 0,
key: vi.fn(),
};
// Ensure localStorage is available on both global and globalThis
global.localStorage = localStorageMock as any;
globalThis.localStorage = localStorageMock as any;
global.localStorage = localStorageMock as unknown as Storage;
globalThis.localStorage = localStorageMock as unknown as Storage;
// Ensure document is available if jsdom hasn't set it up yet
if (typeof document === 'undefined') {
Object.defineProperty(globalThis, 'document', {
value: {