diff --git a/docs/PORTING-GUIDE.md b/docs/PORTING-GUIDE.md new file mode 100644 index 0000000..9b6bb02 --- /dev/null +++ b/docs/PORTING-GUIDE.md @@ -0,0 +1,54 @@ +# Как переносится раздел из stroy-bot-* в монорепо + +Образец — главная страница: `src/webparts/stroyBot/pages/hub/` + `app/project/`. + +## Главное правило +**Поведение 1:1 со старым web part.** Те же запросы (URL, `$select`, `$filter`, `$expand`, `$top`, `$orderby`), те же поля, те же правила доступа, **те же тексты** (дословно), те же валидации. Известные баги переносятся как есть. Любое сознательное отличие записывается в `docs/MIGRATION-CHECKLIST.md` в блок «Сознательные отличия». + +Меняется только слой интерфейса: HTML-строки и `innerHTML` → React-компоненты на `@tnsu/ui-kit`. + +## Структура раздела +``` +pages/<раздел>/ + <Раздел>Page.tsx — страница: собирает компоненты, обрабатывает loading / error / empty + <Раздел>Page.module.scss + texts.ts — ВСЕ пользовательские тексты раздела (дословно из старого кода) + <раздел>Types.ts — типы данных + <раздел>Repository.ts — запросы: функции-конструкторы URL + функции загрузки/записи + <раздел>Repository.test.ts — тесты URL и сценариев на fakeHttpClient + <раздел>Rules.ts — чистые функции (права, валидация, дерево, форматирование) + <раздел>Rules.test.ts + use<Что-то>.ts — хуки состояния (загрузка, сохранение) + <Компонент>.tsx — под-компоненты, один компонент — один файл +``` + +## Правила кода (стайл-гайд проекта) +- TypeScript строго, **без `any`**. Ответы сервера описываются интерфейсами. +- Компонент ≤ ~300 строк, функция ≤ ~50 строк. Больше — разбивать. +- Логика — в `*Rules.ts` / `*Repository.ts` / хуках, не в JSX. +- Имена списков — `LISTS` из `config/lists.ts`; таблицы Dataverse — `DV_TABLES` из `config/dataverse.ts`; адреса — из `useAppServices().settings`. +- Внутренние имена полей SharePoint / колонок Dataverse — константами в `*Repository.ts` раздела (схемы в разных старых репо отличаются — не унифицировать без проверки). +- Комментарии — «почему», а не «что». Без мёртвого кода. +- `null` в своих типах не использовать (линтер) — `undefined`. Внешние ответы с `null` приводить через `?? undefined`. +- Стили: SCSS-модули, `@import '../../../../styles/design-tokens';`, токены вместо цветов. Без magic numbers без комментария. +- Форматирование — `.prettierrc` (одинарные кавычки, 120 символов, без висячих запятых). + +## Данные +- Клиенты: `const { sharePoint, dataverse, tnsuApi, settings, page } = useAppServices();` +- SharePoint: `shared/sharepoint/spRest.ts` (`listItemsUrl`, `getJson`, `fetchCurrentUserId`). Запись — `sharePoint.fetch(url, { method: 'POST', headers, body })` с **теми же заголовками**, что в старом коде (verbose `__metadata` / `X-HTTP-Method: MERGE` / `IF-MATCH` — как было). +- Dataverse: `shared/dataverse/dataverseRest.ts` (`dataverseUrl`, `dataverseGet/Post/Patch/Delete`). +- Корпоративный API: `tnsuApi.fetch(`${settings.apiBaseUrl}/...`)`. +- Текущий пользователь: `page.userId` (legacyPageContext.userId) или `fetchCurrentUserId` — как в старом коде раздела. +- Маршрут: `useRouter()` → `route.projectId`, `route.objectId`, `route.reportId`, `route.copyFromId`; переход — `navigate({ page, projectId, ... })`; ссылка — ``. +- Ссылки старых web parts на другие страницы (`SendReport.aspx?...`, `worksPageUrl`) → `navigate` / `RouteLink` на раздел приложения. + +## UI +- ui-kit: `Button`, `IconButton`, `Dialog`, `confirmModal`, `Alert`, `Badge`, `Spinner`, `TextInput`, `TextArea`, `Select`, `Checkbox`, `Tabs`, `DataTable`, `FormCard`, `FormField`, `FormGrid`, `FormActions`, `FormHeader`, `Icon` (имена — `ICON_NAMES` в ките). +- `alert()` / `confirm()` → `Alert` / `confirmModal` (тексты те же). +- Чего нет в ките (дерево, тосты, контекстное меню, хлебные крошки) — делать в `pages/<раздел>/` своего раздела. Существующие файлы в `shared/` не менять; `EmptyState` и `useOutsideClose` — переиспользовать. +- Всегда явные состояния: загрузка (`Spinner`), ошибка (`Alert variant="err"`), пусто (`EmptyState`). + +## Тесты +- `shared/testing/fakeHttpClient.ts` — заглушка: проверяем точные URL и тела запросов против старого кода, сценарии успеха и ошибок. +- Чистые функции правил — отдельными тестами. +- Запуск: `npx heft test --clean`. Должно быть 0 ошибок линтера и все тесты зелёные. diff --git a/src/webparts/stroyBot/shared/dataverse/dataverseRest.ts b/src/webparts/stroyBot/shared/dataverse/dataverseRest.ts new file mode 100644 index 0000000..48b1539 --- /dev/null +++ b/src/webparts/stroyBot/shared/dataverse/dataverseRest.ts @@ -0,0 +1,62 @@ +import { ensureOk, IHttpClient, IHttpRequest } from '../../services/http/IHttpClient'; +import { DATAVERSE_API_PATH } from '../../config/dataverse'; + +// Общие помощники для Dataverse Web API. Заголовки — те же, что в старых web parts +// (stroy-bot-works / stroy-bot-report): OData 4.0, JSON. + +export const DATAVERSE_HEADERS: Record = { + Accept: 'application/json', + 'Content-Type': 'application/json; charset=utf-8', + 'OData-Version': '4.0', + 'OData-MaxVersion': '4.0' +}; + +/** `{dataverseUrl}/api/data/v9.2/{path}` */ +export function dataverseUrl(baseUrl: string, path: string): string { + return `${baseUrl}${DATAVERSE_API_PATH}${path}`; +} + +/** Ответ Dataverse на запрос коллекции. */ +export interface IDataverseCollection { + value: T[]; + '@odata.nextLink'?: string; +} + +async function send(client: IHttpClient, url: string, request: IHttpRequest): Promise { + const response = await ensureOk( + await client.fetch(url, { ...request, headers: { ...DATAVERSE_HEADERS, ...request.headers } }) + ); + // 204 No Content (PATCH/DELETE без Prefer: return=representation) — тела нет. + if (response.status === 204) { + return undefined; + } + const text = await response.text(); + return text ? JSON.parse(text) : undefined; +} + +export async function dataverseGet(client: IHttpClient, url: string): Promise { + return (await send(client, url, { method: 'GET' })) as T; +} + +/** POST. С `Prefer: return=representation` Dataverse возвращает созданную запись. */ +export async function dataversePost( + client: IHttpClient, + url: string, + body: unknown, + headers: Record = {} +): Promise { + return (await send(client, url, { method: 'POST', body: JSON.stringify(body), headers })) as T | undefined; +} + +export async function dataversePatch( + client: IHttpClient, + url: string, + body: unknown, + headers: Record = {} +): Promise { + await send(client, url, { method: 'PATCH', body: JSON.stringify(body), headers }); +} + +export async function dataverseDelete(client: IHttpClient, url: string): Promise { + await send(client, url, { method: 'DELETE' }); +}