Files
docs/apps/admin-panel-content-crud.md

417 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` |
| Когда выбирать | Много однотипных сущностей | Один сложный домен с датами и картинкой |