feat: add StroyBot app shell with URL routing

One web part replaces the separate stroy-bot-* web parts. The section and
its parameters live in the page query string (?page=objects&projectId=5),
so links can be copied, opened in a new tab and the browser Back button works.

- shared config: site/Dataverse/API addresses, list and table names
- HTTP layer behind IHttpClient so repositories can be unit-tested
- ui-kit AppLayout + Sidebar, placeholder for sections not ported yet
- migration checklist with every feature of the old web parts
- design tokens mirrored from ui-kit instead of 8 copies

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-15 16:42:37 +05:00
parent 5418da3bdc
commit b868e8012d
37 changed files with 1135 additions and 274 deletions
@@ -0,0 +1,70 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { IHttpClient } from './http/IHttpClient';
import { createAadClient, createSharePointClient } from './http/spfxClients';
import { DATAVERSE_URL, SITE_URL, TNSU_API_BASE_URL, TNSU_API_RESOURCE_ID } from '../config/constants';
/** Настройки подключения из property pane (пустое значение — берётся значение по умолчанию). */
export interface IConnectionSettings {
siteUrl?: string;
dataverseUrl?: string;
apiBaseUrl?: string;
}
export interface IResolvedSettings {
siteUrl: string;
dataverseUrl: string;
apiBaseUrl: string;
}
/** Сведения о текущей странице и пользователе из контекста SPFx. */
export interface IPageInfo {
/** Абсолютный URL сайта, на котором стоит web part. */
webAbsoluteUrl: string;
/** Серверный относительный URL сайта (`/sites/stroy`) — нужен для путей к папкам. */
webServerRelativeUrl: string;
/** Id пользователя в SharePoint этого сайта (legacyPageContext.userId). */
userId: number | undefined;
userDisplayName: string;
}
/** Всё, что нужно страницам для работы с данными. Создаётся в web part. */
export interface IAppServices {
sharePoint: IHttpClient;
dataverse: IHttpClient;
tnsuApi: IHttpClient;
settings: IResolvedSettings;
page: IPageInfo;
}
function withoutTrailingSlash(url: string): string {
return url.replace(/\/+$/, '');
}
export function resolveSettings(settings: IConnectionSettings): IResolvedSettings {
return {
siteUrl: withoutTrailingSlash(settings.siteUrl || SITE_URL),
dataverseUrl: withoutTrailingSlash(settings.dataverseUrl || DATAVERSE_URL),
apiBaseUrl: withoutTrailingSlash(settings.apiBaseUrl || TNSU_API_BASE_URL)
};
}
function readPageInfo(context: WebPartContext): IPageInfo {
const legacyUserId: unknown = context.pageContext.legacyPageContext?.userId;
return {
webAbsoluteUrl: context.pageContext.web.absoluteUrl,
webServerRelativeUrl: context.pageContext.web.serverRelativeUrl,
userId: typeof legacyUserId === 'number' ? legacyUserId : undefined,
userDisplayName: context.pageContext.user.displayName
};
}
export function createAppServices(context: WebPartContext, settings: IConnectionSettings): IAppServices {
const resolved = resolveSettings(settings);
return {
sharePoint: createSharePointClient(context),
dataverse: createAadClient(context, resolved.dataverseUrl),
tnsuApi: createAadClient(context, TNSU_API_RESOURCE_ID),
settings: resolved,
page: readPageInfo(context)
};
}
@@ -0,0 +1,51 @@
// Минимальный контракт HTTP-клиента. Репозитории зависят только от него,
// а не от SPHttpClient/AadHttpClient напрямую — поэтому в тестах их можно
// проверить заглушкой и убедиться, что URL, фильтры и тела запросов 1:1
// совпадают со старыми web parts.
export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE';
export interface IHttpRequest {
method?: HttpMethod;
headers?: Record<string, string>;
body?: string | ArrayBuffer;
}
export interface IHttpResponse {
ok: boolean;
status: number;
statusText: string;
json(): Promise<unknown>;
text(): Promise<string>;
blob(): Promise<Blob>;
}
export interface IHttpClient {
fetch(url: string, request?: IHttpRequest): Promise<IHttpResponse>;
}
/** Ошибка HTTP-ответа: сохраняет статус, чтобы страницы показывали те же тексты, что и раньше. */
export class HttpError extends Error {
public readonly status: number;
public readonly statusText: string;
constructor(status: number, statusText: string, details?: string) {
super(details ? `${status} ${statusText}: ${details}` : `${status} ${statusText}`);
this.status = status;
this.statusText = statusText;
}
}
/** Бросает HttpError, если ответ не успешный. Текст ответа добавляется в сообщение. */
export async function ensureOk(response: IHttpResponse): Promise<IHttpResponse> {
if (response.ok) {
return response;
}
let details = '';
try {
details = await response.text();
} catch {
// Тело ошибки может быть недоступно — статуса достаточно.
}
throw new HttpError(response.status, response.statusText, details);
}
@@ -0,0 +1,43 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { SPHttpClient, AadHttpClient } from '@microsoft/sp-http';
import { IHttpClient, IHttpRequest, IHttpResponse } from './IHttpClient';
// Адаптеры SPFx-клиентов к IHttpClient. Вся работа с авторизацией — здесь,
// остальной код о SPFx не знает.
export function createSharePointClient(context: WebPartContext): IHttpClient {
return {
fetch: (url: string, request: IHttpRequest = {}): Promise<IHttpResponse> =>
context.spHttpClient.fetch(url, SPHttpClient.configurations.v1, {
method: request.method ?? 'GET',
headers: request.headers,
body: request.body
})
};
}
/**
* Клиент к ресурсу под AAD (Dataverse или корпоративный API).
* AadHttpClient создаётся лениво и один раз — получение клиента запрашивает токен.
*/
export function createAadClient(context: WebPartContext, resource: string): IHttpClient {
let clientPromise: Promise<AadHttpClient> | undefined;
const getClient = (): Promise<AadHttpClient> => {
if (!clientPromise) {
clientPromise = context.aadHttpClientFactory.getClient(resource);
}
return clientPromise;
};
return {
fetch: async (url: string, request: IHttpRequest = {}): Promise<IHttpResponse> => {
const client = await getClient();
return client.fetch(url, AadHttpClient.configurations.v1, {
method: request.method ?? 'GET',
headers: request.headers,
body: request.body
});
}
};
}