20 KiB
adminPanel: CRUD контентных сущностей
Подробное описание UI для backend CRUD контента. Общие соглашения админки (layout, RTK, переиспользуемые компоненты): adminPanel: обзор.
Задача (#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 — 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().
Общая схема
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)
{ 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)
- Клиент (
validateClient): если вconfig.fieldsестьname/alias/regionId— проверка на пустое; результат вfieldErrors, безwindow.alert. - JSON в
formToPayload: catch →fieldErrors[fieldKey]илиglobalError. - API (
parseSaveError): Symfonyviolations→ ключи полей; иначе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):
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.
Публичный объект:
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.
Как добавить седьмой ресурс
- Backend: architecture + resource-ветка (инструкция).
- В
CONTENT_RESOURCESдобавить объект сslug,basePath,listColumns,fields. - Ключ объекта автоматически подхватится в
apiContent.js(injectResource) и вindex.jsx(bind). - В
App.jsx— триRoute(list, create, edit/:id). - В
Sidebar/Navbar— ссылкаto: '/your-slug'.
Отдельные JSX-страницы для ресурса не нужны.
Локальный запуск
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:
VITE_API_BASE_URL=http://localhost:8081
Backend должен быть с поднятым content CRUD (issues/27 или feature-ветки).
Проверка
- Войти под админом.
- Для каждого из 6 пунктов меню: список, поиск, пагинация, create, edit, delete.
- Сохранить с пустым
name— красная подсветка, без alert. - Невалидный JSON в textarea — ошибка на поле.
- Успешное сохранение — модалка, без alert.
- Сравнить с backend: те же поля уходят в PUT/POST, что в serializer groups.
Сравнение с /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 |
| Когда выбирать | Много однотипных сущностей | Один сложный домен с датами и картинкой |