refactor(frontend): bring shared/ui and pages/public under the 4-file rule
Two directories had grown past the plan's ≤4 files per folder constraint: shared/ui (5 primitives, and the kit keeps growing) and pages/public (5 pages, counting auth/ as a subfolder). Split shared/ui by concept — text-field/ and share-code/ each with their own tests/ — and move PageTitle to shared/layout, where the rest of the page chrome lives. Flat UI tests become per-component tests; the PageTitle test follows the component. Move LoginPage and RegisterPage into the existing pages/public/auth/ beside the AuthSplit layout they both use. No behaviour change: 83 tests, clean tsc build, 1 known oxlint warning.
This commit is contained in:
parent
456366006e
commit
1c94e0ebea
21 changed files with 69 additions and 66 deletions
|
|
@ -95,9 +95,10 @@ frontend/src/
|
||||||
shared/
|
shared/
|
||||||
ui/
|
ui/
|
||||||
Button.tsx
|
Button.tsx
|
||||||
TextField.tsx
|
text-field/
|
||||||
tests/
|
TextField.tsx
|
||||||
ui.test.tsx
|
tests/
|
||||||
|
text-field.test.tsx
|
||||||
```
|
```
|
||||||
|
|
||||||
Источники: [Robin Wieruch — React Folder Structure Best Practices 2026](https://www.robinwieruch.de/react-folder-structure/), [Mastering Modern React + Vite Folder Structure, Medium](https://sandeshrathnayake.medium.com/mastering-modern-react-vite-folder-structure-a-production-ready-guide-for-scalable-applications-9ad8e233f8b9).
|
Источники: [Robin Wieruch — React Folder Structure Best Practices 2026](https://www.robinwieruch.de/react-folder-structure/), [Mastering Modern React + Vite Folder Structure, Medium](https://sandeshrathnayake.medium.com/mastering-modern-react-vite-folder-structure-a-production-ready-guide-for-scalable-applications-9ad8e233f8b9).
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import type { RouteObject } from 'react-router'
|
||||||
import { RequireAuth } from '../features/auth/RequireAuth'
|
import { RequireAuth } from '../features/auth/RequireAuth'
|
||||||
import { AppShell } from '../shared/layout/AppShell'
|
import { AppShell } from '../shared/layout/AppShell'
|
||||||
import HomePage from '../pages/public/HomePage'
|
import HomePage from '../pages/public/HomePage'
|
||||||
import LoginPage from '../pages/public/LoginPage'
|
import LoginPage from '../pages/public/auth/LoginPage'
|
||||||
import RegisterPage from '../pages/public/RegisterPage'
|
import RegisterPage from '../pages/public/auth/RegisterPage'
|
||||||
import ProfilePage from '../pages/public/ProfilePage'
|
import ProfilePage from '../pages/public/ProfilePage'
|
||||||
import NotFoundPage from '../pages/public/NotFoundPage'
|
import NotFoundPage from '../pages/public/NotFoundPage'
|
||||||
// Code-split: the download page pulls in react-markdown, so it loads on demand.
|
// Code-split: the download page pulls in react-markdown, so it loads on demand.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Link, useLocation, useNavigate } from 'react-router'
|
||||||
import { describeError } from '../../../shared/api/errors'
|
import { describeError } from '../../../shared/api/errors'
|
||||||
import { Button } from '../../../shared/ui/Button'
|
import { Button } from '../../../shared/ui/Button'
|
||||||
import { Notice } from '../../../shared/ui/Notice'
|
import { Notice } from '../../../shared/ui/Notice'
|
||||||
import { TextField } from '../../../shared/ui/TextField'
|
import { TextField } from '../../../shared/ui/text-field/TextField'
|
||||||
import { useSession } from '../session'
|
import { useSession } from '../session'
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Link, useNavigate } from 'react-router'
|
||||||
import { describeError } from '../../../shared/api/errors'
|
import { describeError } from '../../../shared/api/errors'
|
||||||
import { Button } from '../../../shared/ui/Button'
|
import { Button } from '../../../shared/ui/Button'
|
||||||
import { Notice } from '../../../shared/ui/Notice'
|
import { Notice } from '../../../shared/ui/Notice'
|
||||||
import { TextField } from '../../../shared/ui/TextField'
|
import { TextField } from '../../../shared/ui/text-field/TextField'
|
||||||
import { useSession } from '../session'
|
import { useSession } from '../session'
|
||||||
import { validateEmail, validateNick, validatePassword } from '../validation'
|
import { validateEmail, validateNick, validatePassword } from '../validation'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Button } from '../../../shared/ui/Button'
|
import { Button } from '../../../shared/ui/Button'
|
||||||
import { TextField } from '../../../shared/ui/TextField'
|
import { TextField } from '../../../shared/ui/text-field/TextField'
|
||||||
import { publish } from '../api'
|
import { publish } from '../api'
|
||||||
|
|
||||||
const MAX_DESCRIPTION = 500
|
const MAX_DESCRIPTION = 500
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useId, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Button } from '../../../shared/ui/Button'
|
import { Button } from '../../../shared/ui/Button'
|
||||||
import { Notice } from '../../../shared/ui/Notice'
|
import { Notice } from '../../../shared/ui/Notice'
|
||||||
import { ShareCode } from '../../../shared/ui/ShareCode'
|
import { ShareCode } from '../../../shared/ui/share-code/ShareCode'
|
||||||
import { regenerateCode, unpublish } from '../api'
|
import { regenerateCode, unpublish } from '../api'
|
||||||
import type { SlotSummary } from '../types'
|
import type { SlotSummary } from '../types'
|
||||||
import { PublishForm } from './PublishForm'
|
import { PublishForm } from './PublishForm'
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
|
||||||
import { ParticleField } from '../../features/landing/effects/ParticleField'
|
import { ParticleField } from '../../features/landing/effects/ParticleField'
|
||||||
import { VoxelScene } from '../../features/landing/effects/VoxelScene'
|
import { VoxelScene } from '../../features/landing/effects/VoxelScene'
|
||||||
import { SlotGrid } from '../../features/configs/components/SlotGrid'
|
import { SlotGrid } from '../../features/configs/components/SlotGrid'
|
||||||
import { PageTitle } from '../../shared/ui/PageTitle'
|
import { PageTitle } from '../../shared/layout/PageTitle'
|
||||||
|
|
||||||
const ACCENT = 'rgb(92, 200, 231)'
|
const ACCENT = 'rgb(92, 200, 231)'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { LinkDeviceForm } from '../../features/device-link/LinkDeviceForm'
|
import { LinkDeviceForm } from '../../features/device-link/LinkDeviceForm'
|
||||||
import { PageTitle } from '../../shared/ui/PageTitle'
|
import { PageTitle } from '../../shared/layout/PageTitle'
|
||||||
|
|
||||||
const STEPS = ['one', 'two', 'three'] as const
|
const STEPS = ['one', 'two', 'three'] as const
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||||
import { DownloadButton } from '../../features/download/DownloadButton'
|
import { DownloadButton } from '../../features/download/DownloadButton'
|
||||||
import { ReleaseNotes } from '../../features/download/ReleaseNotes'
|
import { ReleaseNotes } from '../../features/download/ReleaseNotes'
|
||||||
import { GITHUB_REPO, fetchReleases, releaseSize } from '../../features/download/api'
|
import { GITHUB_REPO, fetchReleases, releaseSize } from '../../features/download/api'
|
||||||
import { PageTitle } from '../../shared/ui/PageTitle'
|
import { PageTitle } from '../../shared/layout/PageTitle'
|
||||||
|
|
||||||
// Requirement versions mirror mod/gradle.properties (the mod's build source of truth).
|
// Requirement versions mirror mod/gradle.properties (the mod's build source of truth).
|
||||||
const REQUIREMENTS = [
|
const REQUIREMENTS = [
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Link } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
import { NOT_FOUND_ART } from '../../shared/easter/art.generated'
|
import { NOT_FOUND_ART } from '../../shared/easter/art.generated'
|
||||||
import { PageTitle } from '../../shared/ui/PageTitle'
|
import { PageTitle } from '../../shared/layout/PageTitle'
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
export default function NotFoundPage() {
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { LoginForm } from '../../features/auth/forms/LoginForm'
|
import { LoginForm } from '../../../features/auth/forms/LoginForm'
|
||||||
import { AuthSplit } from './auth/AuthSplit'
|
import { AuthSplit } from './AuthSplit'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { t } = useTranslation('auth')
|
const { t } = useTranslation('auth')
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RegisterForm } from '../../features/auth/forms/RegisterForm'
|
import { RegisterForm } from '../../../features/auth/forms/RegisterForm'
|
||||||
import { AuthSplit } from './auth/AuthSplit'
|
import { AuthSplit } from './AuthSplit'
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const { t } = useTranslation('auth')
|
const { t } = useTranslation('auth')
|
||||||
|
|
@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
|
||||||
import { ParticleField } from '../../features/landing/effects/ParticleField'
|
import { ParticleField } from '../../features/landing/effects/ParticleField'
|
||||||
import { VoxelScene } from '../../features/landing/effects/VoxelScene'
|
import { VoxelScene } from '../../features/landing/effects/VoxelScene'
|
||||||
import { ListingList } from '../../features/showcase/components/ListingList'
|
import { ListingList } from '../../features/showcase/components/ListingList'
|
||||||
import { PageTitle } from '../../shared/ui/PageTitle'
|
import { PageTitle } from '../../shared/layout/PageTitle'
|
||||||
|
|
||||||
const ACCENT = 'rgb(92, 200, 231)'
|
const ACCENT = 'rgb(92, 200, 231)'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||||
import { ThemeEditor } from '../../features/themes/editor/ThemeEditor'
|
import { ThemeEditor } from '../../features/themes/editor/ThemeEditor'
|
||||||
import { fromShareHash } from '../../features/themes/editor/model/codec'
|
import { fromShareHash } from '../../features/themes/editor/model/codec'
|
||||||
import { PRESETS } from '../../features/themes/presets.generated'
|
import { PRESETS } from '../../features/themes/presets.generated'
|
||||||
import { PageTitle } from '../../shared/ui/PageTitle'
|
import { PageTitle } from '../../shared/layout/PageTitle'
|
||||||
import '../../features/themes/editor/editor.css'
|
import '../../features/themes/editor/editor.css'
|
||||||
|
|
||||||
/** `/themes` — pick a preset (or load one from a `#t=` share link) and edit it live. */
|
/** `/themes` — pick a preset (or load one from a `#t=` share link) and edit it live. */
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { screen, within } from '@testing-library/react'
|
import { render, screen, within } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { getI18n } from 'react-i18next'
|
import { getI18n } from 'react-i18next'
|
||||||
import { afterEach, expect, test } from 'vitest'
|
import { afterEach, expect, test } from 'vitest'
|
||||||
import { routes } from '../../../app/routes'
|
import { routes } from '../../../app/routes'
|
||||||
import { renderApp } from '../../../test/render'
|
import { renderApp } from '../../../test/render'
|
||||||
import { json, mockFetch } from '../../../test/fetch'
|
import { json, mockFetch } from '../../../test/fetch'
|
||||||
|
import { PageTitle } from '../PageTitle'
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await getI18n().changeLanguage('ru')
|
await getI18n().changeLanguage('ru')
|
||||||
|
|
@ -103,3 +104,11 @@ test('footer carries the github link and the disclaimer', async () => {
|
||||||
const gh = screen.getByRole('link', { name: 'GitHub' })
|
const gh = screen.getByRole('link', { name: 'GitHub' })
|
||||||
expect(gh.getAttribute('href')).toMatch(/^https:\/\/github\.com\//)
|
expect(gh.getAttribute('href')).toMatch(/^https:\/\/github\.com\//)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('page title renders one h1 with an optional lead', () => {
|
||||||
|
render(
|
||||||
|
<PageTitle lead="Короткое описание">Мои конфиги</PageTitle>,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('heading', { level: 1, name: 'Мои конфиги' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Короткое описание')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Button } from './Button'
|
import { Button } from '../Button'
|
||||||
|
|
||||||
export function ShareCode({ code, label }: { code: string; label?: string }) {
|
export function ShareCode({ code, label }: { code: string; label?: string }) {
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
14
frontend/src/shared/ui/share-code/tests/share-code.test.tsx
Normal file
14
frontend/src/shared/ui/share-code/tests/share-code.test.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { expect, test, vi } from 'vitest'
|
||||||
|
import { ShareCode } from '../ShareCode'
|
||||||
|
|
||||||
|
test('share code copies to clipboard and confirms', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
|
||||||
|
render(<ShareCode code="7KQ3M9XA" />)
|
||||||
|
expect(screen.getByText('7KQ3M9XA')).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Скопировать' }))
|
||||||
|
expect(writeText).toHaveBeenCalledWith('7KQ3M9XA')
|
||||||
|
expect(await screen.findByText('Скопировано')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { expect, test, vi } from 'vitest'
|
|
||||||
import { Button } from '../Button'
|
|
||||||
import { PageTitle } from '../PageTitle'
|
|
||||||
import { TextField } from '../TextField'
|
|
||||||
import { ShareCode } from '../ShareCode'
|
|
||||||
import { Notice } from '../Notice'
|
|
||||||
|
|
||||||
test('busy button is disabled and announces busy', () => {
|
|
||||||
render(<Button busy>Сохранить</Button>)
|
|
||||||
const b = screen.getByRole('button', { name: 'Сохранить' })
|
|
||||||
expect(b).toBeDisabled()
|
|
||||||
expect(b).toHaveAttribute('aria-busy', 'true')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('text field links label and error', () => {
|
|
||||||
render(<TextField label="Почта" error="Введи почту" />)
|
|
||||||
const input = screen.getByLabelText('Почта')
|
|
||||||
expect(input).toHaveAttribute('aria-invalid', 'true')
|
|
||||||
expect(input).toHaveAccessibleDescription('Введи почту')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('share code copies to clipboard and confirms', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
|
|
||||||
render(<ShareCode code="7KQ3M9XA" />)
|
|
||||||
expect(screen.getByText('7KQ3M9XA')).toBeInTheDocument()
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Скопировать' }))
|
|
||||||
expect(writeText).toHaveBeenCalledWith('7KQ3M9XA')
|
|
||||||
expect(await screen.findByText('Скопировано')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('error notice is an alert', () => {
|
|
||||||
render(<Notice tone="error">Неверный код</Notice>)
|
|
||||||
expect(screen.getByRole('alert')).toHaveTextContent('Неверный код')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('page title renders one h1 with an optional lead', () => {
|
|
||||||
render(
|
|
||||||
<PageTitle lead="Короткое описание">Мои конфиги</PageTitle>,
|
|
||||||
)
|
|
||||||
expect(screen.getByRole('heading', { level: 1, name: 'Мои конфиги' })).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Короткое описание')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
24
frontend/src/shared/ui/text-field/tests/text-field.test.tsx
Normal file
24
frontend/src/shared/ui/text-field/tests/text-field.test.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { expect, test } from 'vitest'
|
||||||
|
import { Button } from '../../Button'
|
||||||
|
import { Notice } from '../../Notice'
|
||||||
|
import { TextField } from '../TextField'
|
||||||
|
|
||||||
|
test('busy button is disabled and announces busy', () => {
|
||||||
|
render(<Button busy>Сохранить</Button>)
|
||||||
|
const b = screen.getByRole('button', { name: 'Сохранить' })
|
||||||
|
expect(b).toBeDisabled()
|
||||||
|
expect(b).toHaveAttribute('aria-busy', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('text field links label and error', () => {
|
||||||
|
render(<TextField label="Почта" error="Введи почту" />)
|
||||||
|
const input = screen.getByLabelText('Почта')
|
||||||
|
expect(input).toHaveAttribute('aria-invalid', 'true')
|
||||||
|
expect(input).toHaveAccessibleDescription('Введи почту')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('error notice is an alert', () => {
|
||||||
|
render(<Notice tone="error">Неверный код</Notice>)
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('Неверный код')
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue