feat: migrate to VitePress from monorepo docs, add test-contour section

This commit is contained in:
sova-bootstrap
2026-05-28 12:29:31 +03:00
parent e90dfe1bd4
commit e3e438df68
76 changed files with 11998 additions and 60 deletions
+416
View File
@@ -0,0 +1,416 @@
# adminPanel: CRUD контентных сущностей
Подробное описание UI для [backend CRUD контента](/apps/backend-content-crud). Общие соглашения админки (layout, RTK, переиспользуемые компоненты): [adminPanel: обзор](/apps/admin-panel).
## Задача (#27)
Дать контент-менеджерам экраны в `apps/adminPanel` для шести сущностей backend API:
| Сущность | Раздел меню | Backend API |
| --- | --- | --- |
| Новости | Новости | `/news` |
| Промо (контент) | Промо (контент) | `/promo` |
| Заболевания | Заболевания | `/disease` |
| Медцентры | Медцентры | `/medical-center` |
| Статьи | Статьи | `/article` |
| Услуги сайта | Услуги сайта | `/site-services` |
**Не путать** с разделом **«Акции»** (`/promotions`) — это сущность `stock` (отдельные `apiStock.js`, `StoksListPage`, `EditStockPage`).
Требования к UI:
- единый CRUD-подход для всех шести ресурсов (один код списка и формы, конфиг полей);
- список с поиском и серверной пагинацией;
- создание / редактирование / удаление;
- ошибки валидации **на форме** (красная подсветка полей), **без** `window.alert` при сохранении;
- успешное сохранение — `Modal`, как у акций.
## Ветки Git
| Ветка | Подход | Назначение |
| --- | --- | --- |
| **`issues/27-future`** | Generic: `ContentListPage`, `ContentEditPage`, `apiContent` | Рекомендуемая реализация с виджетами полей и `parseSaveError` |
| `issues/27` | Копия `/promotions`: отдельные `*ListPage` / `Edit*Page` / `api*` на ресурс | Интеграционная / альтернативная ветка |
| `issues/27-refactor` | То же, что `issues/27` (от `dev`) | Исторический MR «как акции» |
| `dev` | Без контентного CRUD | База |
Backend: [ветки и MR](/apps/backend-content-crud#ветки-git-и-mr-файлы) — `issues/27` на backend **не трогать**.
Дальше в документе описан код ветки **`issues/27-future`**.
## Ресурсы: UI-маршруты и API
У каждого ресурса **три маршрута** в React Router и один `basePath` в RTK Query.
| Ключ конфига | `slug` (UI) | Список | Создание | Редактирование | `basePath` (API) |
| --- | --- | --- | --- | --- | --- |
| `news` | `/news` | `/news` | `/news/create` | `/news/edit/:id` | `/news` |
| `promo` | `/site-promo` | `/site-promo` | `/site-promo/create` | `/site-promo/edit/:id` | `/promo` |
| `disease` | `/disease` | `/disease` | `/disease/create` | `/disease/edit/:id` | `/disease` |
| `medical-center` | `/medical-center` | `/medical-center` | `/medical-center/create` | `/medical-center/edit/:id` | `/medical-center` |
| `article` | `/article` | `/article` | `/article/create` | `/article/edit/:id` | `/article` |
| `site-services` | `/site-services` | `/site-services` | `/site-services/create` | `/site-services/edit/:id` | `/site-services` |
`slug` — сегмент в URL админки. `basePath` — префикс HTTP к backend (может отличаться, напр. промо: UI `site-promo`, API `promo`).
### Соответствие HTTP backend
| Действие в UI | RTK hook | HTTP |
| --- | --- | --- |
| Список | `useListQuery({ search, page, perPage })` | `GET {basePath}/list?...` |
| Карточка | `useItemQuery(id)` | `GET {basePath}/{id}` |
| Создание | `useCreateMutation(data)` | `POST {basePath}/create` |
| Обновление | `useUpdateMutation({ id, data })` | `PUT {basePath}/{id}` |
| Удаление | `useDeleteMutation(id)` | `DELETE {basePath}/{id}` |
Write-запросы отправляют `Authorization: Bearer` через `authHeader()` в `apiContent.js`.
Пагинация списка:
- по умолчанию: `?page=1&perPage=20&search=...`;
- **статьи**: `listUsesLimit: true``limit` вместо `perPage`;
- ответ: `{ data, pagination }` или для статей `{ data, meta }` — UI нормализует в `normalizePagination()`.
## Общая схема
```mermaid
flowchart TD
subgraph routes [React Router]
List["/{slug}"]
Create["/{slug}/create"]
Edit["/{slug}/edit/:id"]
end
subgraph pages [pages/content]
Index["index.jsx → NewsListPage, …"]
CLP[ContentListPage]
CEP[ContentEditPage]
end
subgraph config [Конфигурация]
CR[contentResources.js]
Hooks[contentHooks в apiContent.js]
end
List --> Index --> CLP
Create --> Index --> CEP
Edit --> Index --> CEP
CLP --> Hooks
CEP --> Hooks
CLP --> CR
CEP --> CR
Hooks --> Slice[apiSlice + baseUrl]
Slice --> API[Backend Symfony CRUD]
CEP --> Parse[parseSaveError]
CEP --> Widgets[ContentField / FieldHint]
CEP --> Shell[EditElementForm + Modal]
```
## Ключевой принцип
Страница **не знает**, сколько полей у «новости» или «услуги». Она получает `config` и `hooks` и рисует форму по `config.fields` и таблицу по `config.listColumns`.
Добавление седьмого ресурса = запись в `CONTENT_RESOURCES` + автоматически те же `ContentListPage` / `ContentEditPage` (через `pages/content/index.jsx`).
Контраст с `/promotions`: там отдельный файл страницы и `apiStock.js` на домен. Здесь — **один** список и **одна** форма на все ресурсы.
## Файлы проекта
| Файл | Назначение |
| --- | --- |
| `src/config/contentResources.js` | Описание 6 ресурсов: `slug`, `basePath`, колонки списка, поля формы |
| `src/api/apiContent.js` | RTK Query: `injectEndpoints` × 6, экспорт `contentHooks` |
| `src/pages/content/ContentListPage.jsx` | Универсальная страница списка |
| `src/pages/content/ContentEditPage.jsx` | Универсальная форма create/edit + виджеты полей |
| `src/pages/content/index.jsx` | Связывает ресурс → `NewsListPage`, `NewsEditPage`, … |
| `src/utils/parseSaveError.js` | Разбор ошибок API → `fieldErrors` / `globalMessage` |
| `src/styles/theme-override.scss` | Классы `.content-field--has-error`, `.content-field-error-msg` |
| `src/App.jsx` | 18 маршрутов (6 × list/create/edit) |
| `src/components/Sidebar/Sidebar.jsx` | Пункты меню |
| `src/components/Navbar/Navbar.jsx` | Мобильное меню |
| `src/store/store.js` | `import '../api/apiContent'` |
| `src/config/api.js` | `VITE_API_BASE_URL` (локальная разработка) |
## Конфигурация ресурса (`contentResources.js`)
Каждый ресурс — объект в `CONTENT_RESOURCES`:
| Поле | Тип | Назначение |
| --- | --- | --- |
| `slug` | string | URL в админке (`news`, `site-promo`, …) |
| `basePath` | string | Префикс API (`/news`, `/promo`, …) |
| `title` | string | Заголовок списка |
| `titleSingle` | string | Подпись в форме («новость», «услугу») |
| `icon` | string | Font Awesome для меню (не используется в `ContentListPage`, только в Sidebar) |
| `listColumns` | array | Колонки таблицы списка |
| `fields` | array | Поля формы create/edit |
| `listUsesLimit` | boolean? | Только `article`: `limit` в query |
| `listUsesMeta` | boolean? | Только `article`: ответ с `meta` |
### Колонки списка (`listColumns`)
```javascript
{ key: 'id', label: 'ID' }
{ key: 'name', label: 'Название' }
{ key: 'active', label: 'Активно', format: 'bool' } // → «Да» / «Нет»
{ key: 'regionId', label: 'Регион' } // → имя из regionSlice
```
### Поля формы (`fields`)
Общий набор для большинства ресурсов (`baseContentFields`):
| `key` | `type` | Виджет |
| --- | --- | --- |
| `name` | `text` | input text |
| `active` | `checkbox` | checkbox |
| `regionId` | `region` | select регионов |
| `alias` | `text` | input text |
| `anons` | `html` | `TextEditor` |
| `content` | `html` | `TextEditor` |
Дополнительные поля задаются хелперами `text(key, label)` и `json(key, label)` — см. таблицу ресурсов ниже.
## Виджеты полей (форма)
Виджеты объявлены **внутри** `ContentEditPage.jsx` (не отдельные файлы в `components/`). Это осознанный «мини-движок формы» для контента.
### Вспомогательные компоненты
| Виджет | Назначение |
| --- | --- |
| `FieldHint` | Текст ошибки под полем: `<span class="content-field-error-msg">` |
| `fieldWrapperClass(hasError, extra)` | Собирает классы: `form-group`, `content-field--has-error`, `form-check` |
| `ContentField` | Рендер одного поля по `field.type` |
Атрибут `data-field-key={field.key}` на обёртке — для скролла к первому полю с ошибкой (`querySelector`).
### Типы полей (`field.type`)
| type | UI | Значение в state | Отправка в API |
| --- | --- | --- | --- |
| `text` | `<input type="text">` | string | string или `null` если пусто |
| `number` | `<input type="number">` | string в форме | `Number` или `null` |
| `checkbox` | `<input type="checkbox">` | boolean | boolean |
| `region` | `<select>` из `selectRegions` | string id | `Number(regionId)` или `null` |
| `html` | `TextEditor` | string HTML | string; props **`content`**, **`setContent`** |
| `json` | `<textarea class="font-monospace">` | string (pretty JSON) | `JSON.parse` → object/array или `null` |
При ошибке у поля:
- обёртка `content-field--has-error` (красная рамка, фон `#fff5f5`);
- label `content-field-error-label`;
- control с Bootstrap `is-invalid`;
- подпись `FieldHint`.
Стили в `theme-override.scss` (SB Admin перебивает обычный `.text-danger` на label).
### Сериализация формы
| Функция | Когда | Что делает |
| --- | --- | --- |
| `emptyFormFromConfig(fields)` | mount / create | Пустой объект формы |
| `itemToForm(item, fields)` | после `GET` detail | API → строки формы, JSON → formatted string |
| `formToPayload(form, fields)` | перед save | форма → тело PUT/POST; невалидный JSON → throw с `fieldKey` |
## Страницы
### `ContentListPage`
Пропсы: `{ config, hooks }`.
| Блок UI | Поведение |
| --- | --- |
| Заголовок | `config.title` |
| «Добавить» | `navigate(\`/${config.slug}/create\`)` |
| Поиск | `searchValue`, сброс страницы на 1 |
| Таблица | Колонки из `config.listColumns`, клик по строке → `expandedId` |
| Раскрытая строка | Кнопка «Редактировать» → `/${slug}/edit/${id}` |
| Пагинация | Серверная, `pagination` / `meta` |
| Загрузка / ошибка | `LoadingComponent` / `ErrorComponent` |
Хуки: `hooks.useListQuery({ search, page, perPage: 20 })`, `useOutsideClick` для сброса раскрытия.
### `ContentEditPage`
Пропсы: `{ config, hooks, isCreate }`.
| Режим | Загрузка данных | `id` |
| --- | --- | --- |
| create | не грузит detail | нет |
| edit | `hooks.useItemQuery(id)` | из `useParams()` |
Состояние:
| state | Назначение |
| --- | --- |
| `form` | все поля кроме отдельной логики |
| `fieldErrors` | `Record<fieldKey, message>` |
| `globalError` | строка для блока вверху формы |
| `isModalSuccess` | модалка успеха |
Оболочка: `EditElementForm` (кнопки Сохранить / Отмена / Удалить).
#### Валидация и ошибки (без alert на save)
1. **Клиент** (`validateClient`): если в `config.fields` есть `name` / `alias` / `regionId` — проверка на пустое; результат в `fieldErrors`, **без** `window.alert`.
2. **JSON** в `formToPayload`: catch → `fieldErrors[fieldKey]` или `globalError`.
3. **API** (`parseSaveError`): Symfony `violations` → ключи полей; иначе `globalError`.
Вверху формы при ошибках:
- `alert alert-danger` с `globalMessage`;
- список полей с ошибками (дублирует подсказки у полей, удобно для длинных форм).
При появлении ошибок — smooth scroll к форме и к первому `[data-field-key]`.
Успех: `Modal` «Изменения внесены», таймер 2 с, затем refetch (edit) или переход на edit (create).
Удаление: `window.confirm` (единственный системный диалог); ошибка удаления — через `parseSaveError` / `globalError`, не alert.
### `pages/content/index.jsx`
Фабрика `bind(resourceKey)`:
```javascript
ListPage: () => <ContentListPage config={config} hooks={hooks} />
EditPage: () => <ContentEditPage config={config} hooks={hooks} isCreate={false} />
CreatePage: () => <ContentEditPage config={config} hooks={hooks} isCreate />
```
Экспортирует именованные компоненты для `App.jsx`: `NewsListPage`, `NewsEditPage`, `NewsCreatePage`, …
## Слой API (`apiContent.js`)
Для каждого ключа `CONTENT_RESOURCES` вызывается `injectResource(resourceKey)`:
| Endpoint name | Hook | Метод |
| --- | --- | --- |
| `get{Resource}List` | `useGetNewsListQuery`, … | GET list |
| `get{Resource}Item` | `useGetNewsItemQuery`, … | GET one |
| `create{Resource}` | `useCreateNewsMutation`, … | POST |
| `update{Resource}` | `useUpdateNewsMutation`, … | PUT |
| `delete{Resource}` | `useDeleteNewsMutation`, … | DELETE |
Имя ресурса в PascalCase: `news` → `News`, `medical-center` → `MedicalCenter`, `site-services` → `SiteServices`.
Публичный объект:
```javascript
export const contentHooks = {
news: { useListQuery, useItemQuery, useCreateMutation, useUpdateMutation, useDeleteMutation },
// promo, disease, medical-center, article, site-services
}
```
`refetchOnMountOrArgChange: true`, `keepUnusedDataFor: 0` на list — как у акций.
## Переиспользуемые компоненты (не контент-специфичные)
| Компонент | Роль в CRUD |
| --- | --- |
| `EditElementForm` | Карточка: заголовок, children, Сохранить / Отмена / Удалить |
| `LoadingComponent` | Загрузка list/detail |
| `ErrorComponent` | Ошибка запроса |
| `NotFindElement` | Запись не найдена (edit) |
| `TextEditor` | HTML-поля `anons`, `content` |
| `Modal` | Успешное сохранение |
| `useOutsideClick` | Закрытие раскрытой строки в таблице |
## Навигация
Пункты в `Sidebar.jsx` и `Navbar.jsx` (порядок может отличаться):
| label | `to` |
| --- | --- |
| Новости | `/news` |
| Промо (контент) | `/site-promo` |
| Заболевания | `/disease` |
| Медцентры | `/medical-center` |
| Статьи | `/article` |
| Услуги сайта | `/site-services` |
Все маршруты — дочерние к `MainPage` + `ProtectedRoute` (нужен token в `localStorage`).
## Поля по ресурсам (кратко)
Помимо `baseContentFields`, в конфиге заданы доп. поля:
| Ресурс | Особенности списка | Доп. поля (типы) |
| --- | --- | --- |
| news | + region | text: shortName, linkElPrice, timer, timerBg; json: formOrder, linkServices, linkStaff, photos |
| promo | + region | text: shortName, period, timer, timerBg; json: clinics, linkServices, linkStaff, photos |
| disease | без region в таблице | text + checkbox hidePicture; json: tags, staffList, linkFaq, … |
| medical-center | без region | много text + json (doctors, services, plusList, …) |
| article | `listUsesMeta` + `listUsesLimit` | text previewPicture; json doctors, services |
| site-services | без region | много text + json (faq, quiz, photos, …) |
Полный список — в `src/config/contentResources.js`.
## `parseSaveError`
Файл: `src/utils/parseSaveError.js`.
Обрабатывает:
- клиентский throw с `fieldKey` (JSON);
- RTK Query `err.data` — массив Symfony violations или объект с `violations`;
- строковый JSON в `data`;
- десериализацию Symfony (`propertyPath`, `message`);
- уникальные constraint (duplicate key) → привязка к `name` / `alias` по тексту.
Возвращает `{ fieldErrors, globalMessage }`. Если есть полевые ошибки, `globalMessage` обычно `null`.
## Как добавить седьмой ресурс
1. Backend: architecture + resource-ветка ([инструкция](/apps/backend-content-crud#как-добавить-новый-ресурс-по-образцу-news)).
2. В `CONTENT_RESOURCES` добавить объект с `slug`, `basePath`, `listColumns`, `fields`.
3. Ключ объекта автоматически подхватится в `apiContent.js` (`injectResource`) и в `index.jsx` (`bind`).
4. В `App.jsx` — три `Route` (list, create, edit/:id).
5. В `Sidebar` / `Navbar` — ссылка `to: '/your-slug'`.
Отдельные JSX-страницы для ресурса **не нужны**.
## Локальный запуск
```bash
make local-up
```
| Сервис | URL |
| --- | --- |
| Админка | http://localhost:3211/login |
| Backend API | http://localhost:8081 |
Логин: `local.backend@example.test` / `local-password` (`ROLE_ADMIN`).
`apps/adminPanel/.env.local`:
```env
VITE_API_BASE_URL=http://localhost:8081
```
Backend должен быть с поднятым content CRUD (`issues/27` или feature-ветки).
## Проверка
1. Войти под админом.
2. Для каждого из 6 пунктов меню: список, поиск, пагинация, create, edit, delete.
3. Сохранить с пустым `name` — красная подсветка, **без** alert.
4. Невалидный JSON в textarea — ошибка на поле.
5. Успешное сохранение — модалка, без alert.
6. Сравнить с backend: те же поля уходят в PUT/POST, что в [serializer groups](/apps/backend-content-crud).
## Сравнение с `/promotions`
| | Контент (`issues/27-future`) | Акции (`/promotions`) |
| --- | --- | --- |
| Страницы | 2 общие + конфиг | 3 отдельных файла |
| API | 1 `apiContent.js` | 1 `apiStock.js` |
| Поля формы | `ContentField` по типу | JSX вручную в `EditStockPage` |
| Ошибки save | `parseSaveError` + красные блоки | `text-danger` + `window.alert` |
| Успех | `Modal` | `Modal` |
| Когда выбирать | Много однотипных сущностей | Один сложный домен с датами и картинкой |