4 Commits

Author SHA1 Message Date
Valery Petrov 052d843dbd issues/27: add formatApiError util and unit tests 2026-06-04 13:19:02 +03:00
Valery Petrov cfd8a4d403 issues/27: CI Jest coverage on test and stage tags 2026-06-04 12:51:57 +03:00
sova-ci c797ad7e2f fix: git-flow prod/test/stage (revert mistaken dev branch)
adminpanel-ci-cd / parse-tag (push) Successful in 28s
adminpanel-ci-cd / test (push) Failing after 13m53s
adminpanel-ci-cd / build-and-push (push) Has been skipped
adminpanel-ci-cd / deploy-gitops (push) Has been skipped
2026-06-03 17:14:31 +03:00
sova-ci bcb4bd16d3 ci: tag-only pipeline; env test|dev|prod 2026-06-03 17:11:47 +03:00
27 changed files with 99 additions and 1235 deletions
+38 -36
View File
@@ -1,17 +1,8 @@
name: adminpanel-ci-cd
# Pre-deploy: Jest + coverage on test|stage tags. Prod — без автотестов.
on:
workflow_dispatch:
inputs:
branch:
description: 'Ветка для прогона тестов'
required: true
default: test
type: choice
options:
- prod
- test
- stage
push:
tags:
- 'adminpanel-v*'
@@ -22,23 +13,7 @@ env:
IMAGE_DEPLOY: git.sova.local/sova/adminpanel
jobs:
test:
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/adminpanel-v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }}
- uses: actions/setup-node@v4
with:
node-version: '24'
- name: Install dependencies
run: |
if [ -f package-lock.json ]; then npm ci; else npm install; fi
- run: npm run build
parse-tag:
if: startsWith(github.ref, 'refs/tags/adminpanel-v')
runs-on: ubuntu-latest
outputs:
full_tag: ${{ steps.meta.outputs.full_tag }}
@@ -53,9 +28,42 @@ jobs:
echo "env=$(echo "$TAG" | sed -E 's/adminpanel-v([0-9.]+)-([a-z]+)/\2/')" >> "$GITHUB_OUTPUT"
echo "version=$(echo "$TAG" | sed -E 's/adminpanel-v([0-9.]+).*/\1/')" >> "$GITHUB_OUTPUT"
test:
needs: [parse-tag]
if: needs.parse-tag.outputs.env != 'prod'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
- name: Install dependencies
run: |
if [ -f package-lock.json ]; then npm ci; else npm install; fi
- name: Lint
run: npm run lint
- name: Unit tests + coverage
run: npm run test:ci
- name: Build
run: npm run build
- name: Upload coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: adminpanel-coverage-${{ needs.parse-tag.outputs.full_tag }}
path: coverage/
retention-days: 14
test-prod-skip:
needs: [parse-tag]
if: needs.parse-tag.outputs.env == 'prod'
runs-on: ubuntu-latest
steps:
- run: echo "Prod tag — automated tests skipped."
build-and-push:
needs: [test, parse-tag]
if: startsWith(github.ref, 'refs/tags/adminpanel-v')
needs: [parse-tag, test, test-prod-skip]
if: always() && (needs.test.result == 'success' || needs.test.result == 'skipped') && (needs.test-prod-skip.result == 'success' || needs.test-prod-skip.result == 'skipped')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -74,7 +82,6 @@ jobs:
deploy-gitops:
needs: [build-and-push, parse-tag]
if: startsWith(github.ref, 'refs/tags/adminpanel-v')
runs-on: ubuntu-latest
steps:
- name: Bump image tag in sova-deploy
@@ -110,13 +117,8 @@ jobs:
git add "apps/adminpanel/values-${ENV}.yaml"
git diff --cached --quiet && { echo "No changes"; exit 0; }
git commit -m "chore(adminpanel): bump ${ENV} to ${TAG}"
if git push origin "${ENV}"; then
echo "Push OK on attempt ${attempt}"
exit 0
fi
echo "Push failed, retry ${attempt}/${MAX_RETRIES}..."
if git push origin "${ENV}"; then exit 0; fi
git reset --hard HEAD~1
sleep $((attempt * 2))
done
echo "Failed to push after ${MAX_RETRIES} attempts"
exit 1
+1
View File
@@ -5,6 +5,7 @@
<link rel="icon" type="image/png" href="/src/assets/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin panel</title>
<script src="/env.js"></script>
</head>
<body>
<div id="root"></div>
+1
View File
@@ -10,6 +10,7 @@
"lint-fix": "eslint . --fix",
"preview": "vite preview",
"test": "jest --runInBand",
"test:ci": "jest --runInBand --coverage --coverageReporters=text-summary --coverageReporters=lcov",
"test-watch": "jest --watch",
"test-clear-cache": "jest --clearCache"
},
+3
View File
@@ -0,0 +1,3 @@
window.__ENV__ = window.__ENV__ || {
API_BASE_URL: 'http://localhost:8081',
};
+1 -39
View File
@@ -21,26 +21,6 @@ import { EditStockPage } from './pages/EditStockPage';
import { AddStockPage } from './pages/AddStockPage';
import { InfoclinicListPage } from './pages/InfoclinicListPage';
import { LostDoctorsPage } from './pages/LostDoctorsPage';
import {
NewsListPage,
NewsEditPage,
NewsCreatePage,
SitePromoListPage,
SitePromoEditPage,
SitePromoCreatePage,
DiseaseListPage,
DiseaseEditPage,
DiseaseCreatePage,
MedicalCenterListPage,
MedicalCenterEditPage,
MedicalCenterCreatePage,
ArticleListPage,
ArticleEditPage,
ArticleCreatePage,
SiteServicesListPage,
SiteServicesEditPage,
SiteServicesCreatePage,
} from './pages/content';
function App() {
return (
@@ -70,25 +50,7 @@ function App() {
<Route path="prices" element={<PricesListPage/>} />
<Route path="promotions" element={<StocksListPage/>} />
<Route path="promotions/edit/:id" element={<EditStockPage/>} />
<Route path="promotions/create" element={<AddStockPage/>} />
<Route path="news" element={<NewsListPage />} />
<Route path="news/edit/:id" element={<NewsEditPage />} />
<Route path="news/create" element={<NewsCreatePage />} />
<Route path="site-promo" element={<SitePromoListPage />} />
<Route path="site-promo/edit/:id" element={<SitePromoEditPage />} />
<Route path="site-promo/create" element={<SitePromoCreatePage />} />
<Route path="disease" element={<DiseaseListPage />} />
<Route path="disease/edit/:id" element={<DiseaseEditPage />} />
<Route path="disease/create" element={<DiseaseCreatePage />} />
<Route path="medical-center" element={<MedicalCenterListPage />} />
<Route path="medical-center/edit/:id" element={<MedicalCenterEditPage />} />
<Route path="medical-center/create" element={<MedicalCenterCreatePage />} />
<Route path="article" element={<ArticleListPage />} />
<Route path="article/edit/:id" element={<ArticleEditPage />} />
<Route path="article/create" element={<ArticleCreatePage />} />
<Route path="site-services" element={<SiteServicesListPage />} />
<Route path="site-services/edit/:id" element={<SiteServicesEditPage />} />
<Route path="site-services/create" element={<SiteServicesCreatePage />} />
<Route path="promotions/create" element={<AddStockPage/>} />
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
-117
View File
@@ -1,117 +0,0 @@
import { API, authHeader } from './apiSlice'
import { CONTENT_RESOURCES } from '../config/contentResources'
const buildListQuery = (basePath, { usesLimit = false } = {}) =>
({ search = '', page = 1, perPage = 20 } = {}) => {
let queryString = `?page=${page}`
if (usesLimit) {
queryString += `&limit=${perPage}`
} else {
queryString += `&perPage=${perPage}`
}
if (search) {
queryString += `&search=${encodeURIComponent(search)}`
}
return {
url: `${basePath}/list${queryString}`,
}
}
const injectResource = (resourceKey) => {
const { basePath, listUsesLimit } = CONTENT_RESOURCES[resourceKey]
const cap = resourceKey
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
return API.injectEndpoints({
endpoints: (build) => ({
[`get${cap}List`]: build.query({
query: buildListQuery(basePath, { usesLimit: listUsesLimit }),
refetchOnMountOrArgChange: true,
keepUnusedDataFor: 0,
}),
[`get${cap}Item`]: build.query({
query: (id) => ({
url: `${basePath}/${id}`,
}),
}),
[`create${cap}`]: build.mutation({
query: (data) => ({
url: `${basePath}/create`,
method: 'POST',
headers: authHeader(),
body: JSON.stringify(data),
}),
}),
[`update${cap}`]: build.mutation({
query: ({ id, data }) => ({
url: `${basePath}/${id}`,
method: 'PUT',
headers: authHeader(),
body: JSON.stringify(data),
}),
}),
[`delete${cap}`]: build.mutation({
query: (id) => ({
url: `${basePath}/${id}`,
method: 'DELETE',
headers: authHeader(),
}),
}),
}),
overrideExisting: false,
})
}
const newsApi = injectResource('news')
const promoApi = injectResource('promo')
const diseaseApi = injectResource('disease')
const medicalCenterApi = injectResource('medical-center')
const articleApi = injectResource('article')
const siteServicesApi = injectResource('site-services')
export const contentHooks = {
news: {
useListQuery: newsApi.useGetNewsListQuery,
useItemQuery: newsApi.useGetNewsItemQuery,
useCreateMutation: newsApi.useCreateNewsMutation,
useUpdateMutation: newsApi.useUpdateNewsMutation,
useDeleteMutation: newsApi.useDeleteNewsMutation,
},
promo: {
useListQuery: promoApi.useGetPromoListQuery,
useItemQuery: promoApi.useGetPromoItemQuery,
useCreateMutation: promoApi.useCreatePromoMutation,
useUpdateMutation: promoApi.useUpdatePromoMutation,
useDeleteMutation: promoApi.useDeletePromoMutation,
},
disease: {
useListQuery: diseaseApi.useGetDiseaseListQuery,
useItemQuery: diseaseApi.useGetDiseaseItemQuery,
useCreateMutation: diseaseApi.useCreateDiseaseMutation,
useUpdateMutation: diseaseApi.useUpdateDiseaseMutation,
useDeleteMutation: diseaseApi.useDeleteDiseaseMutation,
},
'medical-center': {
useListQuery: medicalCenterApi.useGetMedicalCenterListQuery,
useItemQuery: medicalCenterApi.useGetMedicalCenterItemQuery,
useCreateMutation: medicalCenterApi.useCreateMedicalCenterMutation,
useUpdateMutation: medicalCenterApi.useUpdateMedicalCenterMutation,
useDeleteMutation: medicalCenterApi.useDeleteMedicalCenterMutation,
},
article: {
useListQuery: articleApi.useGetArticleListQuery,
useItemQuery: articleApi.useGetArticleItemQuery,
useCreateMutation: articleApi.useCreateArticleMutation,
useUpdateMutation: articleApi.useUpdateArticleMutation,
useDeleteMutation: articleApi.useDeleteArticleMutation,
},
'site-services': {
useListQuery: siteServicesApi.useGetSiteServicesListQuery,
useItemQuery: siteServicesApi.useGetSiteServicesItemQuery,
useCreateMutation: siteServicesApi.useCreateSiteServicesMutation,
useUpdateMutation: siteServicesApi.useUpdateSiteServicesMutation,
useDeleteMutation: siteServicesApi.useDeleteSiteServicesMutation,
},
}
+1 -2
View File
@@ -1,5 +1,4 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { API_BASE_URL } from '@/config/api'
export const authHeader = () => {
const token = localStorage.getItem('token')
@@ -9,7 +8,7 @@ export const authHeader = () => {
export const API = createApi({
reducerPath: 'API',
baseQuery: fetchBaseQuery({
baseUrl: API_BASE_URL,
baseUrl: 'https://api.sovamed.ru',
credentials: 'include',
}),
endpoints: (builder) => ({
+1 -2
View File
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react';
import { apiUrl } from '@/config/api';
import { CertEditor } from '../Editors/CertEditor'
const isValidImage = (file) => {
@@ -16,7 +15,7 @@ export function CertificatesForm({ initCertificates, onChange }) {
const [certificates, setCertificates] = useState([]);
useEffect(() => {
const certificatesWithPictureUrl = initCertificates.map((init) => ({ ...init, picture: apiUrl(`/uploads/${init.picture}`)}))
const certificatesWithPictureUrl = initCertificates.map((init) => ({ ...init, picture: `https://api.sovamed.ru/uploads/${init.picture}`}))
setInitialCertificates([...certificatesWithPictureUrl])
setCertificates([...certificatesWithPictureUrl])
}, [initCertificates])
+1 -2
View File
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react';
import { apiUrl } from '@/config/api';
import { CertEditor } from '../Editors/CertEditor'
const isValidImage = (file) => {
@@ -18,7 +17,7 @@ export function PortfolioForm({ initPortfolios, onChange }) {
useEffect(() => {
// console.log(initPortfolios)
const portfolioWithPictureUrl = initPortfolios.map((init) => {
if ( init.picture ) return ({ ...init, picture: apiUrl(`/uploads/${init.picture}`)})
if ( init.picture ) return ({ ...init, picture: `https://api.sovamed.ru/uploads/${init.picture}`})
return { ...init }
})
setInitialPorfolios([...portfolioWithPictureUrl])
+1 -2
View File
@@ -1,6 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { apiUrl } from '@/config/api';
import { CertEditor } from '../Editors/CertEditor'
const isValidImage = (file) => {
@@ -23,7 +22,7 @@ export function StocksForm({ initStocks, onChange }) {
// console.log(initStocks[0]?.startDate)
// console.log(typeof initStocks[0]?.startDate)
const portfolioWithPictureUrl = initStocks.map((init) => {
if ( init.picture ) return ({ ...init, picture: apiUrl(`/uploads/${init.picture}`)})
if ( init.picture ) return ({ ...init, picture: `https://api.sovamed.ru/uploads/${init.picture}`})
return { ...init }
}).map(init => {
const dateStart = new Date(init.startDate);
-6
View File
@@ -15,12 +15,6 @@ export const Navbar = () => {
{ to: '/promotions',icon: 'fas fa-percent', label: 'Акции' },
{ to: '/departments',icon: 'fas fa-stethoscope', label: 'Отделения' },
{ to: '/filials',icon: 'fas fa-building', label: 'Филиалы' },
{ to: '/news', icon: 'fas fa-newspaper', label: 'Новости' },
{ to: '/site-promo', icon: 'fas fa-bullhorn', label: 'Промо (контент)' },
{ to: '/disease', icon: 'fas fa-heartbeat', label: 'Заболевания' },
{ to: '/medical-center', icon: 'fas fa-hospital', label: 'Медцентры' },
{ to: '/article', icon: 'fas fa-file-alt', label: 'Статьи' },
{ to: '/site-services', icon: 'fas fa-concierge-bell', label: 'Услуги сайта' },
];
const [open, setOpen] = useState(false);
const toggleRef = useRef(null);
-6
View File
@@ -10,12 +10,6 @@ export const Sidebar = () => {
{ to: '/promotions',icon: 'fas fa-percent', label: 'Акции' },
{ to: '/departments',icon: 'fas fa-stethoscope', label: 'Отделения' },
{ to: '/filials',icon: 'fas fa-building', label: 'Филиалы' },
{ to: '/news', icon: 'fas fa-newspaper', label: 'Новости' },
{ to: '/site-promo', icon: 'fas fa-bullhorn', label: 'Промо (контент)' },
{ to: '/disease', icon: 'fas fa-heartbeat', label: 'Заболевания' },
{ to: '/medical-center', icon: 'fas fa-hospital', label: 'Медцентры' },
{ to: '/article', icon: 'fas fa-file-alt', label: 'Статьи' },
{ to: '/site-services', icon: 'fas fa-concierge-bell', label: 'Услуги сайта' },
];
return (
-7
View File
@@ -1,7 +0,0 @@
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.sovamed.ru'
export const apiUrl = (path) => {
const base = API_BASE_URL.replace(/\/$/, '')
const suffix = path.startsWith('/') ? path : `/${path}`
return `${base}${suffix}`
}
-223
View File
@@ -1,223 +0,0 @@
const baseContentFields = [
{ key: 'name', label: 'Название', type: 'text' },
{ key: 'active', label: 'Активно', type: 'checkbox' },
{ key: 'regionId', label: 'Регион', type: 'region' },
{ key: 'alias', label: 'Alias', type: 'text' },
{ key: 'anons', label: 'Анонс', type: 'html' },
{ key: 'content', label: 'Контент', type: 'html' },
]
const json = (key, label) => ({ key, label: `${label} (JSON)`, type: 'json' })
const text = (key, label) => ({ key, label, type: 'text' })
export const CONTENT_RESOURCES = {
news: {
slug: 'news',
basePath: '/news',
title: 'Новости',
titleSingle: 'новость',
icon: 'fas fa-newspaper',
listColumns: [
{ key: 'id', label: 'ID' },
{ key: 'name', label: 'Название' },
{ key: 'alias', label: 'Alias' },
{ key: 'active', label: 'Активно', format: 'bool' },
{ key: 'regionId', label: 'Регион' },
],
fields: [
...baseContentFields,
text('shortName', 'Короткое название'),
text('linkElPrice', 'Ссылка на прайс'),
text('timer', 'Таймер'),
text('timerBg', 'Фон таймера'),
json('formOrder', 'formOrder'),
json('linkServices', 'linkServices'),
json('linkStaff', 'linkStaff'),
json('photos', 'photos'),
],
},
promo: {
slug: 'site-promo',
basePath: '/promo',
title: 'Промо (контент)',
titleSingle: 'промо',
icon: 'fas fa-bullhorn',
listColumns: [
{ key: 'id', label: 'ID' },
{ key: 'name', label: 'Название' },
{ key: 'alias', label: 'Alias' },
{ key: 'active', label: 'Активно', format: 'bool' },
{ key: 'regionId', label: 'Регион' },
],
fields: [
...baseContentFields,
text('shortName', 'Короткое название'),
text('period', 'Период'),
text('timer', 'Таймер'),
text('timerBg', 'Фон таймера'),
json('clinics', 'clinics'),
json('linkServices', 'linkServices'),
json('linkStaff', 'linkStaff'),
json('photos', 'photos'),
],
},
disease: {
slug: 'disease',
basePath: '/disease',
title: 'Заболевания',
titleSingle: 'заболевание',
icon: 'fas fa-heartbeat',
listColumns: [
{ key: 'id', label: 'ID' },
{ key: 'name', label: 'Название' },
{ key: 'alias', label: 'Alias' },
{ key: 'active', label: 'Активно', format: 'bool' },
],
fields: [
...baseContentFields,
text('previewPicture', 'previewPicture'),
{ key: 'hidePicture', label: 'hidePicture', type: 'checkbox' },
text('readTime', 'readTime'),
text('diseasesName', 'diseasesName'),
text('diseasesOtherName', 'diseasesOtherName'),
text('symptom', 'symptom'),
text('staff', 'staff'),
text('bibliography', 'bibliography'),
json('tagsImportant', 'tagsImportant'),
json('tags', 'tags'),
json('linkServices', 'linkServices'),
json('staffList', 'staffList'),
json('staffPost', 'staffPost'),
json('staffPostExclude', 'staffPostExclude'),
json('linkFaq', 'linkFaq'),
json('staffCheck', 'staffCheck'),
],
},
'medical-center': {
slug: 'medical-center',
basePath: '/medical-center',
title: 'Медцентры',
titleSingle: 'медцентр',
icon: 'fas fa-hospital',
listColumns: [
{ key: 'id', label: 'ID' },
{ key: 'name', label: 'Название' },
{ key: 'alias', label: 'Alias' },
{ key: 'active', label: 'Активно', format: 'bool' },
],
fields: [
...baseContentFields,
text('mainLinkStaff', 'mainLinkStaff'),
text('plusText', 'plusText'),
text('plusTitle', 'plusTitle'),
text('processText', 'processText'),
text('processTitle', 'processTitle'),
text('servicesTitle', 'servicesTitle'),
text('trainingText', 'trainingText'),
text('trainingTextTitle', 'trainingTextTitle'),
text('whyText', 'whyText'),
text('whyTitle', 'whyTitle'),
{ key: 'hidePicture', label: 'hidePicture', type: 'number' },
json('kodUslug', 'kodUslug'),
json('doctors', 'doctors'),
json('services', 'services'),
json('articles', 'articles'),
json('txtUp', 'txtUp'),
json('contraindications', 'contraindications'),
json('indications', 'indications'),
json('linkSale', 'linkSale'),
json('plusList', 'plusList'),
json('servicesList', 'servicesList'),
json('servicesPhotos', 'servicesPhotos'),
json('sortStaff', 'sortStaff'),
],
},
article: {
slug: 'article',
basePath: '/article',
title: 'Статьи',
titleSingle: 'статью',
icon: 'fas fa-file-alt',
listUsesMeta: true,
listUsesLimit: true,
listColumns: [
{ key: 'id', label: 'ID' },
{ key: 'name', label: 'Название' },
{ key: 'alias', label: 'Alias' },
{ key: 'active', label: 'Активно', format: 'bool' },
],
fields: [
...baseContentFields,
text('previewPicture', 'previewPicture'),
json('doctors', 'doctors'),
json('services', 'services'),
],
},
'site-services': {
slug: 'site-services',
basePath: '/site-services',
title: 'Услуги сайта',
titleSingle: 'услугу',
icon: 'fas fa-concierge-bell',
listColumns: [
{ key: 'id', label: 'ID' },
{ key: 'name', label: 'Название' },
{ key: 'alias', label: 'Alias' },
{ key: 'active', label: 'Активно', format: 'bool' },
],
fields: [
...baseContentFields,
text('previewImg', 'previewImg'),
text('partPrice', 'partPrice'),
text('pokazaniya', 'pokazaniya'),
text('preparation', 'preparation'),
text('protivopokazaniya', 'protivopokazaniya'),
text('bannerImg', 'bannerImg'),
text('bannerImgM', 'bannerImgM'),
text('bannerImgUrl', 'bannerImgUrl'),
text('downloadFile', 'downloadFile'),
text('fullWidthBanner', 'fullWidthBanner'),
text('kodUslug', 'kodUslug'),
text('linkPrice', 'linkPrice'),
text('photosTitle', 'photosTitle'),
text('contraindicationsList', 'contraindicationsList'),
text('customBlockText', 'customBlockText'),
text('customBlockText2', 'customBlockText2'),
text('customBlockTitle', 'customBlockTitle'),
text('customBlockTitle2', 'customBlockTitle2'),
text('indicationsList', 'indicationsList'),
text('plusList', 'plusList'),
text('plusText', 'plusText'),
text('plusTitle', 'plusTitle'),
text('prepareTitle', 'prepareTitle'),
text('processText', 'processText'),
text('processTitle', 'processTitle'),
text('servicesList', 'servicesList'),
text('servicesTitle', 'servicesTitle'),
text('textUp', 'textUp'),
text('trainingText', 'trainingText'),
text('whyText', 'whyText'),
text('whyTitle', 'whyTitle'),
{ key: 'hidePicture', label: 'hidePicture', type: 'number' },
json('linkVideoreviews', 'linkVideoreviews'),
json('faq', 'faq'),
json('hideSignBtn', 'hideSignBtn'),
json('quiz', 'quiz'),
json('tags', 'tags'),
json('tagsImportant', 'tagsImportant'),
json('clinics', 'clinics'),
json('staffUp', 'staffUp'),
json('advantages', 'advantages'),
json('saleId', 'saleId'),
json('sortStaff', 'sortStaff'),
json('linkArticlesServices', 'linkArticlesServices'),
json('servicesPhotos', 'servicesPhotos'),
json('linkFaq', 'linkFaq'),
json('linkServices', 'linkServices'),
json('linkStaff', 'linkStaff'),
json('photos', 'photos'),
],
},
}
export const CONTENT_RESOURCE_KEYS = Object.keys(CONTENT_RESOURCES)
+1 -3
View File
@@ -2,8 +2,6 @@ import { useState, useEffect } from 'react';
import axios from 'axios';
import { useSelector } from 'react-redux';
import { apiUrl } from '@/config/api';
import { useGetFilialsQuery } from '../api/apiFilial';
import { useGetDepartmentsQuery } from '../api/apiDepartment';
import { selectRegions } from '../store/slice/regionSlice';
@@ -33,7 +31,7 @@ export function useLostDoctors() {
Promise.all(
data.data.map(item => {
const fetchString = fetchParams.filter((param => Boolean(item[param]))).map(param => `${param}=${item[param]}`).join('&');
return axios.get(apiUrl(`/idoctor/list?${fetchString}`), {
return axios.get(`https://api.sovamed.ru/idoctor/list?${fetchString}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
})
.then(res => {
+1 -3
View File
@@ -2,8 +2,6 @@ import { useState, useEffect } from 'react';
import axios from 'axios';
import { useSelector } from 'react-redux';
import { apiUrl } from '@/config/api';
import { useGetSpecialistsQuery, useGetSpecialistQuery } from '../api/apiSpecialist';
import { useGetKodopersQuery } from '../api/apiKodoper';
import { useGetFilialsQuery } from '../api/apiFilial';
@@ -29,7 +27,7 @@ export function useSpecialist(id) {
Promise.all(
specialist.kodoper.map(code =>
axios.get(apiUrl(`/pricelist/list?search=${code}`), {
axios.get(`https://api.sovamed.ru/pricelist/list?search=${code}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
})
.then(res => {
+1 -3
View File
@@ -2,8 +2,6 @@ import { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { apiUrl } from '@/config/api';
import { useGetFilialsQuery, useUpdateFilialMutation, useUploadFilialPictureMutation } from '../api/apiFilial';
import { selectUtils } from '../store/slice/utilsSlice';
import { selectRegions } from '../store/slice/regionSlice';
@@ -71,7 +69,7 @@ export const EditFilialPage = () => {
email: filial.email,
fid: filial.fid,
origin: filial.origin,
picture: apiUrl(filial.pictureLink),
picture: `https://api.sovamed.ru${filial.pictureLink}`,
policy: filial.policy,
}
setForm({... filialData})
+1 -3
View File
@@ -2,8 +2,6 @@ import {useEffect, useState, useMemo, useRef} from 'react'
import {useParams, useNavigate, useLocation } from 'react-router-dom'
import DatePicker from "react-datepicker";
import { apiUrl } from '@/config/api';
import { useUpdateSpecialistMutation, useDeleteSpecialistMutation, useUploadSpecialistPictureMutation } from '../api/apiSpecialist'
import { useCreateLocationMutation, useUpdateLocationMutation, useDeleteLocationMutation } from '/src/api/apiLocation.js'
import {
@@ -388,7 +386,7 @@ export const EditSpecialistPage = () => {
}
updateField('category', formatCategory(specialist.category));
updateField('previewPicture', apiUrl(specialist.pictureLink));
updateField('previewPicture', `https://api.sovamed.ru${specialist.pictureLink}`);
const formattedDate = specialist.experience
? `${specialist.experience}-01-01`
+1 -3
View File
@@ -1,8 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { apiUrl } from '@/config/api';
import {
useGetStockQuery,
useUpdateStockMutation,
@@ -87,7 +85,7 @@ export function EditStockPage() {
name: stock.name ?? '',
startDate: datetimeLocalStart,
endDate: datetimeLocalEnd,
picture: stock.picture ? apiUrl(`/uploads/${stock.picture}`) : '',
picture: stock.picture ? `https://api.sovamed.ru/uploads/${stock.picture}` : '',
}));
setAnons( stock.anons )
-370
View File
@@ -1,370 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useSelector } from 'react-redux'
import { selectRegions } from '../../store/slice/regionSlice'
import { TextEditor } from '../../components/Editors/TextEditor'
import { LoadingComponent } from '../../components/Placeholders/LoadingComponent'
import { ErrorComponent } from '../../components/Placeholders/ErrorComponent'
import { NotFindElement } from '../../components/Placeholders/NotFindElement'
import { EditElementForm } from '../../components/Forms/EditElementForm'
import Modal from '../../components/Modals/Modal'
import { parseSaveError } from '../../utils/parseSaveError'
const SUCCESS_MODAL_MS = 2000
const emptyFormFromConfig = (fields) => {
const form = {}
fields.forEach((field) => {
if (field.type === 'checkbox') {
form[field.key] = false
} else if (field.type === 'number' || field.type === 'region') {
form[field.key] = ''
} else {
form[field.key] = ''
}
})
return form
}
const itemToForm = (item, fields) => {
const form = {}
fields.forEach((field) => {
const value = item[field.key]
if (field.type === 'json') {
form[field.key] = value == null ? '' : JSON.stringify(value, null, 2)
} else if (field.type === 'checkbox') {
form[field.key] = Boolean(value)
} else if (field.type === 'region' || field.type === 'number') {
form[field.key] = value ?? ''
} else {
form[field.key] = value ?? ''
}
})
return form
}
const formToPayload = (form, fields) => {
const data = {}
fields.forEach((field) => {
const raw = form[field.key]
if (field.type === 'json') {
if (!raw || !String(raw).trim()) {
data[field.key] = null
return
}
try {
data[field.key] = JSON.parse(raw)
} catch {
const error = new Error('Невалидный JSON — проверьте синтаксис')
error.fieldKey = field.key
throw error
}
return
}
if (field.type === 'checkbox') {
data[field.key] = Boolean(raw)
return
}
if (field.type === 'region' || field.type === 'number') {
data[field.key] = raw === '' ? null : Number(raw)
return
}
data[field.key] = raw === '' ? null : raw
})
return data
}
const FieldHint = ({ message }) =>
message ? <span className="content-field-error-msg" role="alert">{message}</span> : null
const fieldWrapperClass = (hasError, extra = '') =>
['form-group', extra, hasError ? 'content-field--has-error' : ''].filter(Boolean).join(' ')
const ContentField = ({ field, form, updateField, regions, fieldErrors }) => {
const value = form[field.key]
const errorMessage = fieldErrors[field.key]
const hasError = Boolean(errorMessage)
const labelClass = hasError ? 'content-field-error-label' : undefined
const controlClass = `form-control${hasError ? ' is-invalid' : ''}`
if (field.type === 'checkbox') {
return (
<div className={fieldWrapperClass(hasError, 'form-check')} data-field-key={field.key}>
<input
type="checkbox"
className={`form-check-input${hasError ? ' is-invalid' : ''}`}
id={field.key}
checked={Boolean(value)}
onChange={(e) => updateField(field.key, e.target.checked)}
/>
<label className={`form-check-label${hasError ? ' content-field-error-label' : ''}`} htmlFor={field.key}>
{field.label}
</label>
<FieldHint message={errorMessage} />
</div>
)
}
if (field.type === 'region') {
const selectValue = value === '' || value == null ? '' : String(value)
return (
<div className={fieldWrapperClass(hasError)} data-field-key={field.key}>
<label className={labelClass}>{field.label}</label>
<select
className={controlClass}
value={selectValue}
onChange={(e) => updateField(field.key, e.target.value)}
>
<option value=""></option>
{Object.entries(regions).map(([id, name]) => (
<option key={id} value={id}>
{name}
</option>
))}
</select>
<FieldHint message={errorMessage} />
</div>
)
}
if (field.type === 'html') {
return (
<div className={fieldWrapperClass(hasError)} data-field-key={field.key}>
<label className={labelClass}>{field.label}</label>
<TextEditor content={value} setContent={(html) => updateField(field.key, html)} />
<FieldHint message={errorMessage} />
</div>
)
}
if (field.type === 'json') {
return (
<div className={fieldWrapperClass(hasError)} data-field-key={field.key}>
<label className={labelClass}>{field.label}</label>
<textarea
className={`${controlClass} font-monospace`}
rows={4}
value={value}
onChange={(e) => updateField(field.key, e.target.value)}
/>
<FieldHint message={errorMessage} />
</div>
)
}
return (
<div className={fieldWrapperClass(hasError)} data-field-key={field.key}>
<label className={labelClass}>{field.label}</label>
<input
type={field.type === 'number' ? 'number' : 'text'}
className={controlClass}
value={value}
onChange={(e) => updateField(field.key, e.target.value)}
/>
<FieldHint message={errorMessage} />
</div>
)
}
export const ContentEditPage = ({ config, hooks, isCreate = false }) => {
const { id } = useParams()
const navigate = useNavigate()
const regions = useSelector(selectRegions)
const navigateBack = () => navigate(`/${config.slug}`)
const formTopRef = useRef(null)
const successTimerRef = useRef(null)
const skip = isCreate || !id
const { data: item, isLoading, error, refetch } = hooks.useItemQuery(id, { skip })
const [createItem] = hooks.useCreateMutation()
const [updateItem] = hooks.useUpdateMutation()
const [deleteItem] = hooks.useDeleteMutation()
const [form, setForm] = useState(() => emptyFormFromConfig(config.fields))
const [fieldErrors, setFieldErrors] = useState({})
const [globalError, setGlobalError] = useState(null)
const [isModalSuccess, setModalSuccess] = useState(false)
useEffect(() => {
return () => {
if (successTimerRef.current) {
window.clearTimeout(successTimerRef.current)
}
}
}, [])
useEffect(() => {
if (isCreate || !item) {
return
}
setForm(itemToForm(item, config.fields))
}, [item, isCreate, config.fields])
const showSuccessModal = (onClose) => {
setModalSuccess(true)
if (successTimerRef.current) {
window.clearTimeout(successTimerRef.current)
}
successTimerRef.current = window.setTimeout(() => {
setModalSuccess(false)
onClose?.()
}, SUCCESS_MODAL_MS)
}
useEffect(() => {
const keys = Object.keys(fieldErrors)
if (!keys.length && !globalError) {
return
}
formTopRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
const firstKey = keys[0]
if (firstKey) {
document
.querySelector(`[data-field-key="${firstKey}"]`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, [fieldErrors, globalError])
const updateField = (key, value) => {
setForm((prev) => ({ ...prev, [key]: value }))
setFieldErrors((prev) => {
if (!prev[key]) {
return prev
}
const next = { ...prev }
delete next[key]
return next
})
setGlobalError(null)
}
const validateClient = () => {
const next = {}
const has = (key) => config.fields.some((f) => f.key === key)
if (has('name') && !String(form.name ?? '').trim()) {
next.name = 'Название не может быть пустым'
}
if (has('alias') && !String(form.alias ?? '').trim()) {
next.alias = 'Alias не может быть пустым'
}
if (has('regionId') && (form.regionId === '' || form.regionId == null)) {
next.regionId = 'Укажите регион'
}
return next
}
const handleSave = async () => {
setFieldErrors({})
setGlobalError(null)
const clientErrors = validateClient()
if (Object.keys(clientErrors).length) {
setFieldErrors(clientErrors)
return
}
let data
try {
data = formToPayload(form, config.fields)
} catch (err) {
const key = err.fieldKey
if (key) {
setFieldErrors({ [key]: err.message || 'Ошибка в поле' })
} else {
setGlobalError(err.message || 'Ошибка в форме')
}
return
}
try {
if (isCreate) {
const created = await createItem(data).unwrap()
showSuccessModal(() => navigate(`/${config.slug}/edit/${created.id}`))
return
}
await updateItem({ id: Number(id), data }).unwrap()
showSuccessModal(() => refetch())
} catch (err) {
const { fieldErrors: nextFieldErrors, globalMessage } = parseSaveError(err)
setFieldErrors(nextFieldErrors)
if (globalMessage) {
setGlobalError(globalMessage)
} else if (!Object.keys(nextFieldErrors).length) {
setGlobalError('Не удалось сохранить запись')
} else {
setGlobalError(null)
}
console.error('Ошибка сохранения контента:', err, nextFieldErrors)
}
}
const handleDelete = async () => {
if (!window.confirm('Удалить запись?')) {
return
}
try {
await deleteItem(Number(id)).unwrap()
navigateBack()
} catch (err) {
const { fieldErrors: nextFieldErrors, globalMessage } = parseSaveError(err)
setFieldErrors(nextFieldErrors)
setGlobalError(globalMessage || 'Не удалось удалить запись')
console.error(err)
}
}
const fieldsWithErrors = config.fields.filter((field) => fieldErrors[field.key])
if (!isCreate && isLoading) {
return <LoadingComponent />
}
if (!isCreate && error) {
return <ErrorComponent />
}
if (!isCreate && !isLoading && !item) {
return <NotFindElement />
}
return (
<EditElementForm
navigateBack={navigateBack}
header={isCreate ? `Добавление: ${config.titleSingle}` : `Редактирование: ${config.titleSingle}`}
handleSave={handleSave}
handleDelete={isCreate ? null : handleDelete}
isAddSpecialist={isCreate}
>
<div ref={formTopRef} />
{globalError ? (
<div className="alert alert-danger" role="alert">
{globalError}
</div>
) : null}
{fieldsWithErrors.length > 0 ? (
<div className="alert alert-danger" role="alert">
<strong>Исправьте поля:</strong>
<ul className="mb-0 mt-2">
{fieldsWithErrors.map((field) => (
<li key={field.key}>
{field.label}: {fieldErrors[field.key]}
</li>
))}
</ul>
</div>
) : null}
{config.fields.map((field) => (
<ContentField
key={field.key}
field={field}
form={form}
updateField={updateField}
regions={regions}
fieldErrors={fieldErrors}
/>
))}
<Modal isOpen={isModalSuccess} title="Изменения внесены" hasButtons={false}>
<p className="mb-1">Изменения успешно внесены.</p>
</Modal>
</EditElementForm>
)
}
-210
View File
@@ -1,210 +0,0 @@
import { Fragment, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useSelector } from 'react-redux'
import { selectRegions } from '../../store/slice/regionSlice'
import { useOutsideClick } from '../../hooks/useOutsideClick'
import { LoadingComponent } from '../../components/Placeholders/LoadingComponent'
import { ErrorComponent } from '../../components/Placeholders/ErrorComponent'
const normalizePagination = (response) => {
if (response.pagination) {
return response.pagination
}
if (response.meta) {
const page = response.meta.page ?? 1
const totalPages = response.meta.totalPages ?? 1
return {
total_pages: totalPages,
current_page: page,
has_previous_page: page > 1,
has_next_page: page < totalPages,
}
}
return {
total_pages: 1,
current_page: 1,
has_previous_page: false,
has_next_page: false,
}
}
const formatCell = (item, column, regions) => {
const value = item[column.key]
if (column.format === 'bool') {
return value ? 'Да' : 'Нет'
}
if (column.key === 'regionId' && regions[value]) {
return regions[value]
}
return value ?? ''
}
export const ContentListPage = ({ config, hooks }) => {
const [searchValue, setSearchValue] = useState('')
const [currentPage, setCurrentPage] = useState(1)
const [expandedId, setExpandedId] = useState(null)
const navigate = useNavigate()
const regions = useSelector(selectRegions)
const tableRef = useRef(null)
useOutsideClick(tableRef, () => setExpandedId(null))
const { data: response = {}, isFetching, error: queryError } = hooks.useListQuery({
search: searchValue,
page: currentPage,
perPage: 20,
})
const items = response.data ?? []
const pagination = normalizePagination(response)
const renderPagination = () => {
const total = pagination.total_pages || 1
const current = pagination.current_page || 1
const pages = new Set([1, total])
for (let page = current - 2; page <= current + 2; page += 1) {
if (page > 1 && page < total) {
pages.add(page)
}
}
const sorted = Array.from(pages).sort((a, b) => a - b)
const elements = []
let last = 0
sorted.forEach((page) => {
if (last && page - last > 1) {
elements.push(
<li key={`dots-${last}`} className="page-item disabled">
<span className="page-link"></span>
</li>,
)
}
elements.push(
<li key={page} className={`page-item ${page === current ? 'active' : ''}`}>
<button
type="button"
className="page-link"
onClick={() => page !== current && setCurrentPage(page)}
>
{page}
</button>
</li>,
)
last = page
})
return (
<nav>
<ul className="pagination justify-content-center">
<li className={`page-item ${!pagination.has_previous_page ? 'disabled' : ''}`}>
<button
type="button"
className="page-link"
onClick={() => pagination.has_previous_page && setCurrentPage(current - 1)}
>
&laquo;
</button>
</li>
{elements}
<li className={`page-item ${!pagination.has_next_page ? 'disabled' : ''}`}>
<button
type="button"
className="page-link"
onClick={() => pagination.has_next_page && setCurrentPage(current + 1)}
>
&raquo;
</button>
</li>
</ul>
</nav>
)
}
return (
<div className="container-fluid">
<h1 className="h3 mb-4 text-gray-800">{config.title}</h1>
<div
className="d-flex justify-content-between mb-3"
style={{ marginRight: '0.1rem', marginLeft: '0.1rem' }}
>
<div className="form-group align-self-end mr-3">
<input
type="button"
className="btn btn-outline-primary"
value="Добавить"
onClick={(e) => {
e.stopPropagation()
navigate(`/${config.slug}/create`)
}}
/>
</div>
<div className="form-group flex-grow-1">
<label>Поиск</label>
<input
type="text"
className="form-control"
value={searchValue}
onChange={(e) => {
setSearchValue(e.target.value)
setCurrentPage(1)
}}
/>
</div>
</div>
{isFetching ? (
<LoadingComponent />
) : queryError ? (
<ErrorComponent />
) : (
<>
<div className="table-responsive" ref={tableRef}>
<table className="table table-hover table-bordered">
<thead>
<tr>
{config.listColumns.map((col) => (
<th key={col.key}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{items.map((item) => (
<Fragment key={item.id}>
<tr
className={`cursor-pointer${expandedId === item.id ? ' table-success' : ''}`}
onClick={() => {
setExpandedId(expandedId === item.id ? null : item.id)
}}
>
{config.listColumns.map((col) => (
<td key={col.key}>{formatCell(item, col, regions)}</td>
))}
</tr>
{expandedId === item.id && (
<tr className="table-success">
<td colSpan={config.listColumns.length}>
<input
type="button"
className="btn btn-outline-primary"
value="Редактировать"
onClick={(e) => {
e.stopPropagation()
navigate(`/${config.slug}/edit/${item.id}`)
}}
/>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
{renderPagination()}
</>
)}
</div>
)
}
-46
View File
@@ -1,46 +0,0 @@
import { CONTENT_RESOURCES } from '../../config/contentResources'
import { contentHooks } from '../../api/apiContent'
import { ContentListPage } from './ContentListPage'
import { ContentEditPage } from './ContentEditPage'
const bind = (resourceKey) => {
const config = CONTENT_RESOURCES[resourceKey]
const hooks = contentHooks[resourceKey]
return {
ListPage: () => <ContentListPage config={config} hooks={hooks} />,
EditPage: () => <ContentEditPage config={config} hooks={hooks} isCreate={false} />,
CreatePage: () => <ContentEditPage config={config} hooks={hooks} isCreate />,
}
}
const news = bind('news')
const promo = bind('promo')
const disease = bind('disease')
const medicalCenter = bind('medical-center')
const article = bind('article')
const siteServices = bind('site-services')
export const NewsListPage = news.ListPage
export const NewsEditPage = news.EditPage
export const NewsCreatePage = news.CreatePage
export const SitePromoListPage = promo.ListPage
export const SitePromoEditPage = promo.EditPage
export const SitePromoCreatePage = promo.CreatePage
export const DiseaseListPage = disease.ListPage
export const DiseaseEditPage = disease.EditPage
export const DiseaseCreatePage = disease.CreatePage
export const MedicalCenterListPage = medicalCenter.ListPage
export const MedicalCenterEditPage = medicalCenter.EditPage
export const MedicalCenterCreatePage = medicalCenter.CreatePage
export const ArticleListPage = article.ListPage
export const ArticleEditPage = article.EditPage
export const ArticleCreatePage = article.CreatePage
export const SiteServicesListPage = siteServices.ListPage
export const SiteServicesEditPage = siteServices.EditPage
export const SiteServicesCreatePage = siteServices.CreatePage
-1
View File
@@ -7,7 +7,6 @@ import { API } from '../api/apiSlice';
import '../api/apiDepartment'
import '../api/apiFilial'
import '../api/apiSpecialist'
import '../api/apiContent'
export const store = configureStore({
reducer: {
-28
View File
@@ -147,31 +147,3 @@ input[type="number"]:focus {
flex-direction: column;
}
}
// Ошибки валидации в формах контента (SB Admin перебивает .text-danger на label)
.content-field--has-error {
margin-bottom: 1rem;
padding: 0.75rem;
border: 1px solid #e74a3b;
border-radius: 0.35rem;
background-color: #fff5f5;
}
.content-field--has-error > label,
.content-field--has-error .content-field-error-label {
color: #e74a3b !important;
font-weight: 700;
}
.content-field--has-error .form-control,
.content-field--has-error .form-check-input {
border-color: #e74a3b !important;
}
.content-field-error-msg {
display: block;
margin-top: 0.35rem;
color: #e74a3b !important;
font-size: 0.875rem;
font-weight: 600;
}
+19
View File
@@ -0,0 +1,19 @@
import { formatApiError } from '../formatApiError';
describe('formatApiError', () => {
it('returns string errors as-is', () => {
expect(formatApiError('bad')).toBe('bad');
});
it('reads RTK-style data.message', () => {
expect(formatApiError({ data: { message: 'Validation failed' } })).toBe('Validation failed');
});
it('reads axios-style response.data.error', () => {
expect(formatApiError({ response: { data: { error: 'Unauthorized' } } })).toBe('Unauthorized');
});
it('falls back to error.message', () => {
expect(formatApiError({ message: 'Network' })).toBe('Network');
});
});
+27
View File
@@ -0,0 +1,27 @@
/**
* Normalize API error payload for UI (RTK Query / axios).
* @param {unknown} error
* @returns {string}
*/
export function formatApiError(error) {
if (!error) {
return 'Unknown error';
}
if (typeof error === 'string') {
return error;
}
const data = error?.data ?? error?.response?.data;
if (typeof data === 'string') {
return data;
}
if (data?.message) {
return String(data.message);
}
if (data?.error) {
return String(data.error);
}
if (error?.message) {
return String(error.message);
}
return 'Request failed';
}
-123
View File
@@ -1,123 +0,0 @@
const snakeToCamel = (key) =>
key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
const normalizePropertyPath = (path) => {
if (!path) {
return ''
}
const last = String(path).replace(/^\[|\]$/g, '').split('.').pop()
return snakeToCamel(last)
}
const addViolation = (fieldErrors, path, message) => {
const key = normalizePropertyPath(path)
if (!key || !message) {
return
}
if (!fieldErrors[key]) {
fieldErrors[key] = message
}
}
const extractFieldFromMessage = (message) => {
const text = String(message)
const patterns = [
/"(\w+)" attribute/,
/attribute "\w+" of type "\w+" expects [\w\\]+, "(\w+)" given/,
/at property path "([^"]+)"/,
/for property "(\w+)"/,
]
for (const pattern of patterns) {
const match = text.match(pattern)
if (match?.[1]) {
return normalizePropertyPath(match[1])
}
}
return null
}
/**
* Разбирает ошибку сохранения (клиент, RTK Query, Symfony validation / denormalize).
* @returns {{ fieldErrors: Record<string, string>, globalMessage: string | null }}
*/
export const parseSaveError = (err) => {
const fieldErrors = {}
const fieldKey = err?.fieldKey
if (fieldKey) {
fieldErrors[fieldKey] = err?.message || 'Ошибка поля'
return { fieldErrors, globalMessage: null }
}
let data = err?.data
if (data == null && err?.error && typeof err.error === 'object') {
data = err.error.data ?? err.error
}
if (typeof data === 'string') {
try {
data = JSON.parse(data)
} catch {
const fieldFromText = extractFieldFromMessage(data)
if (fieldFromText) {
fieldErrors[fieldFromText] = data
return { fieldErrors, globalMessage: null }
}
return { fieldErrors, globalMessage: data }
}
}
const collectViolations = (violations) => {
if (!Array.isArray(violations)) {
return
}
violations.forEach((violation) => {
addViolation(
fieldErrors,
violation.propertyPath ?? violation.property_path,
violation.message ?? violation.title,
)
})
}
if (Array.isArray(data)) {
collectViolations(data)
return {
fieldErrors,
globalMessage: Object.keys(fieldErrors).length ? null : 'Ошибка сохранения',
}
}
if (data && typeof data === 'object') {
collectViolations(data.violations ?? data['@violations'])
if (data.error) {
const message = String(data.error)
const fieldFromText = extractFieldFromMessage(message)
if (fieldFromText) {
const detail = message.replace(/^Ошибка десериализации:\s*/u, '').trim()
fieldErrors[fieldFromText] = detail
}
return {
fieldErrors,
globalMessage: Object.keys(fieldErrors).length ? null : message,
}
}
if (data.message && !data.error) {
return { fieldErrors, globalMessage: String(data.message) }
}
}
if (err instanceof Error && err.status == null) {
return { fieldErrors, globalMessage: err.message || 'Ошибка сохранения' }
}
return {
fieldErrors,
globalMessage:
(typeof err?.data === 'string' ? err.data : null) ||
err?.error ||
err?.message ||
'Ошибка сохранения',
}
}