417 lines
20 KiB
Markdown
417 lines
20 KiB
Markdown
# 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` |
|
||
| Когда выбирать | Много однотипных сущностей | Один сложный домен с датами и картинкой |
|