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:
@@ -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
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user