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

20 KiB
Raw Permalink Blame History

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: ветки и MRissues/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: truelimit вместо 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)

  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):

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: newsNews, medical-centerMedicalCenter, site-servicesSiteServices.

Публичный объект:

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-ветка (инструкция).
  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-страницы для ресурса не нужны.

Локальный запуск

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-ветки).

Проверка

  1. Войти под админом.
  2. Для каждого из 6 пунктов меню: список, поиск, пагинация, create, edit, delete.
  3. Сохранить с пустым name — красная подсветка, без alert.
  4. Невалидный JSON в textarea — ошибка на поле.
  5. Успешное сохранение — модалка, без alert.
  6. Сравнить с 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
Когда выбирать Много однотипных сущностей Один сложный домен с датами и картинкой