-
Notifications
You must be signed in to change notification settings - Fork 0
feat(web,shared): Wave 3 frontend structure — D36 dedup UI + D37 error page consolidation + D40 complete i18n #110
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+5,425
−838
Merged
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
13ce61b
feat(web,shared): Wave 3 frontend structure — D36 dedup UI + D37 erro…
Ardakilic ea3a899
fix(web,shared,openspec): batch fix all inline change-review findings
Ardakilic e18b07e
fix(web,shared): resolve change-review findings — Field typography sc…
Ardakilic File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { render, screen } from '@testing-library/react'; | ||
| import { createMemoryRouter, RouterProvider } from 'react-router'; | ||
| import { RootErrorBoundary } from './ErrorBoundary.tsx'; | ||
|
|
||
| vi.mock('@/utils/logger.ts', () => ({ | ||
| createLogger: () => ({ | ||
| debug: vi.fn(), | ||
| info: vi.fn(), | ||
| warn: vi.fn(), | ||
| error: vi.fn(), | ||
| trace: vi.fn(), | ||
| fatal: vi.fn(), | ||
| }), | ||
| })); | ||
|
|
||
| vi.mock('../contexts/I18nContext.tsx', () => ({ | ||
| useTranslation: () => ({ | ||
| t: (k: string) => k, | ||
| locale: 'en', | ||
| setLocale: vi.fn(), | ||
| availableLocales: ['en', 'tr'], | ||
| }), | ||
| })); | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| function renderWithLoader(loader: () => never) { | ||
| const router = createMemoryRouter( | ||
| [ | ||
| { | ||
| path: '/', | ||
| element: <div data-testid='page-content'>Page</div>, | ||
| loader, | ||
| errorElement: <RootErrorBoundary />, | ||
| }, | ||
| ], | ||
| { initialEntries: ['/'] }, | ||
| ); | ||
| return render(<RouterProvider router={router} />); | ||
| } | ||
|
|
||
| describe('RootErrorBoundary', () => { | ||
| it('renders NotFoundPage for 404 route errors', async () => { | ||
| renderWithLoader(() => { | ||
| throw new Response(null, { status: 404 }); | ||
| }); | ||
|
|
||
| expect(await screen.findByText('404')).toBeInTheDocument(); | ||
| expect(screen.getByText('error.404')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('renders ServerErrorPage for 500 route errors', async () => { | ||
| renderWithLoader(() => { | ||
| throw new Response(null, { status: 500 }); | ||
| }); | ||
|
|
||
| expect(await screen.findByText('500')).toBeInTheDocument(); | ||
| expect(screen.getByText('error.500')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('renders ServerErrorPage for 503 route errors', async () => { | ||
| renderWithLoader(() => { | ||
| throw new Response(null, { status: 503 }); | ||
| }); | ||
|
|
||
| expect(await screen.findByText('500')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('renders generic fallback with t() strings for non-route errors', async () => { | ||
| const router = createMemoryRouter( | ||
| [ | ||
| { | ||
| path: '/', | ||
| element: <ThrowError />, | ||
| errorElement: <RootErrorBoundary />, | ||
| }, | ||
| ], | ||
| { initialEntries: ['/'] }, | ||
| ); | ||
| render(<RouterProvider router={router} />); | ||
|
|
||
| expect(await screen.findByText('error.boundary.oops')).toBeInTheDocument(); | ||
| expect(screen.getByRole('link', { name: 'common.goHome' })).toBeInTheDocument(); | ||
| expect(screen.getByRole('button', { name: 'error.boundary.reload' })).toBeInTheDocument(); | ||
| }); | ||
| }); | ||
|
|
||
| function ThrowError() { | ||
| throw new Error('boom'); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { render, screen } from '@testing-library/react'; | ||
| import userEvent from '@testing-library/user-event'; | ||
| import { BanDialog } from './BanDialog.tsx'; | ||
|
|
||
| vi.mock('@/utils/logger.ts', () => ({ | ||
| createLogger: () => ({ | ||
| debug: vi.fn(), | ||
| info: vi.fn(), | ||
| warn: vi.fn(), | ||
| error: vi.fn(), | ||
| trace: vi.fn(), | ||
| fatal: vi.fn(), | ||
| }), | ||
| })); | ||
|
|
||
| vi.mock('../../contexts/I18nContext.tsx', () => ({ | ||
| useTranslation: () => ({ | ||
| t: (k: string) => k, | ||
| locale: 'en', | ||
| setLocale: vi.fn(), | ||
| availableLocales: ['en', 'tr'], | ||
| }), | ||
| })); | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| const defaultProps = { | ||
| user: { id: 'u1', username: 'alice', displayName: 'Alice A' }, | ||
| open: true, | ||
| onClose: vi.fn(), | ||
| onConfirm: vi.fn(), | ||
| processing: false, | ||
| }; | ||
|
|
||
| describe('BanDialog', () => { | ||
| it('renders user name (displayName) in title when open', () => { | ||
| render(<BanDialog {...defaultProps} />); | ||
| expect(screen.getByText(/admin\.users\.banDialogTitle: Alice A/)).toBeInTheDocument(); | ||
| const textarea = screen.getByPlaceholderText('admin.users.banReasonPlaceholder'); | ||
| expect(textarea).toHaveValue(''); | ||
| }); | ||
|
|
||
| it('renders username in title when displayName is null', () => { | ||
| render( | ||
| <BanDialog | ||
| {...defaultProps} | ||
| user={{ id: 'u1', username: 'bob', displayName: null }} | ||
| />, | ||
| ); | ||
| expect(screen.getByText(/admin\.users\.banDialogTitle: bob/)).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('confirm button is disabled when reason is empty', () => { | ||
| render(<BanDialog {...defaultProps} />); | ||
| const confirmButton = screen.getByRole('button', { name: 'admin.users.confirmBan' }); | ||
| expect(confirmButton).toBeDisabled(); | ||
| }); | ||
|
|
||
| it('typing a reason and clicking confirm calls onConfirm with the reason', async () => { | ||
| const user = userEvent.setup(); | ||
| render(<BanDialog {...defaultProps} />); | ||
| const textarea = screen.getByPlaceholderText('admin.users.banReasonPlaceholder'); | ||
| await user.type(textarea, 'Spam'); | ||
| const confirmButton = screen.getByRole('button', { name: 'admin.users.confirmBan' }); | ||
| await user.click(confirmButton); | ||
| expect(defaultProps.onConfirm).toHaveBeenCalledWith('Spam'); | ||
| }); | ||
|
|
||
| it('clicking cancel calls onClose', async () => { | ||
| const user = userEvent.setup(); | ||
| render(<BanDialog {...defaultProps} />); | ||
| const cancelButton = screen.getByRole('button', { name: 'common.cancel' }); | ||
| await user.click(cancelButton); | ||
| expect(defaultProps.onClose).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('processing true disables buttons and shows banning label', () => { | ||
| render(<BanDialog {...defaultProps} processing />); | ||
| const confirmButton = screen.getByRole('button', { name: 'admin.users.banning' }); | ||
| expect(confirmButton).toBeDisabled(); | ||
| const cancelButton = screen.getByRole('button', { name: 'common.cancel' }); | ||
| expect(cancelButton).toBeDisabled(); | ||
| }); | ||
|
|
||
| it('renders nothing when open is false', () => { | ||
| render(<BanDialog {...defaultProps} open={false} />); | ||
| expect(screen.queryByText(/admin\.users\.banDialogTitle/)).not.toBeInTheDocument(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import { useState } from 'react'; | ||
| import { useTranslation } from '../../contexts/I18nContext.tsx'; | ||
| import { createLogger } from '@/utils/logger.ts'; | ||
|
|
||
| const log = createLogger('BanDialog'); | ||
|
|
||
| /** | ||
| * Props for the {@link BanDialog} component. | ||
| */ | ||
| interface BanDialogProps { | ||
| /** The user targeted by the ban action. */ | ||
| user: { id: string; username: string; displayName: string | null }; | ||
| /** Whether the dialog is currently visible. */ | ||
| open: boolean; | ||
| /** Called when the user requests to close the dialog without confirming. */ | ||
| onClose: () => void; | ||
| /** Called with the entered reason when the user confirms the ban. */ | ||
| onConfirm: (reason: string) => void; | ||
| /** When true, both buttons are disabled and the confirm button shows a "banning" label. */ | ||
| processing: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * Controlled modal dialog for confirming a ban action against a user. Owns the | ||
| * reason textarea state internally and reports the entered reason via | ||
| * `onConfirm`. The parent controls visibility (`open`) and the processing state. | ||
| */ | ||
| export function BanDialog({ user, open, onClose, onConfirm, processing }: BanDialogProps) { | ||
| const { t } = useTranslation(); | ||
| const [reason, setReason] = useState(''); | ||
|
|
||
| if (!open) return null; | ||
|
|
||
| log.debug({ userId: user.id, open }, 'BanDialog render'); | ||
|
|
||
| return ( | ||
| <div | ||
| className='fixed inset-0 flex items-center justify-center z-50' | ||
| style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} | ||
| > | ||
| <div className='card max-w-md w-full mx-4'> | ||
| <h3 className='font-semibold mb-4' style={{ color: 'var(--text-primary)' }}> | ||
| {`${t('admin.users.banDialogTitle')}: ${user.displayName || user.username}`} | ||
| </h3> | ||
| <label | ||
| className='block text-sm font-medium mb-1' | ||
| style={{ color: 'var(--text-secondary)' }} | ||
| > | ||
| {t('admin.users.banReason')} | ||
| <textarea | ||
| className='input-field' | ||
| rows={3} | ||
| placeholder={t('admin.users.banReasonPlaceholder')} | ||
| autoFocus | ||
| value={reason} | ||
| onChange={(e) => setReason(e.target.value)} | ||
| /> | ||
| </label> | ||
| <div className='flex gap-2 justify-end'> | ||
| <button | ||
| type='button' | ||
| onClick={onClose} | ||
| className='btn-secondary' | ||
| disabled={processing} | ||
| > | ||
| {t('common.cancel')} | ||
| </button> | ||
| <button | ||
| type='button' | ||
| onClick={() => onConfirm(reason)} | ||
| disabled={processing || !reason.trim()} | ||
| className='btn-primary' | ||
| style={{ backgroundColor: 'var(--error)' }} | ||
| > | ||
| {processing ? t('admin.users.banning') : t('admin.users.confirmBan')} | ||
| </button> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import type { ReactNode } from 'react'; | ||
|
|
||
| interface FieldProps { | ||
| label: string; | ||
| required?: boolean; | ||
| children: ReactNode; | ||
| } | ||
|
|
||
| /** | ||
| * Form field layout primitive. Renders a `<label>` (with optional required | ||
| * indicator) followed by the field children. The `label` prop is already | ||
| * translated by the caller (this component does not call `t()`). | ||
| */ | ||
| export function Field({ label, required, children }: FieldProps) { | ||
| return ( | ||
| <div> | ||
| <label className='block text-sm font-medium mb-1' style={{ color: 'var(--text-secondary)' }}> | ||
| {label} | ||
| {required && ' *'} | ||
| </label> | ||
| {children} | ||
| </div> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import type { ReactNode } from 'react'; | ||
|
|
||
| interface SectionProps { | ||
| title: string; | ||
| children: ReactNode; | ||
| } | ||
|
|
||
| /** | ||
| * Form section layout primitive. Renders a `<div className='card'>` wrapper | ||
| * with a section heading. The `title` prop is already translated by the caller | ||
| * (this component does not call `t()`). | ||
| */ | ||
| export function Section({ title, children }: SectionProps) { | ||
| return ( | ||
| <div className='card'> | ||
| <h2 className='font-semibold mb-4' style={{ color: 'var(--text-primary)' }}>{title}</h2> | ||
| {children} | ||
| </div> | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.