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,25 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "619a5eba-d596-47e5-a95a-9b9682ed7ec3",
"alias": "StroyBotWebPart",
"componentType": "WebPart",
// The "*" signifies that the version should be taken from the package.json
"version": "*",
"manifestVersion": 2,
"requiresCustomScript": false,
"supportedHosts": ["SharePointWebPart", "SharePointFullPage"],
"supportsThemeVariants": true,
"preconfiguredEntries": [
{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Advanced
"group": { "default": "Advanced" },
"title": { "default": "StroyBot" },
"description": { "default": "StroyBot: проекты, объекты, работы и отчёты" },
"officeFabricIconFontName": "ConstructionCone",
"properties": {}
}
]
}
+58
View File
@@ -0,0 +1,58 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import { IPropertyPaneConfiguration, PropertyPaneTextField } from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { SPComponentLoader } from '@microsoft/sp-loader';
import { App } from './app/App';
import { createAppServices, IConnectionSettings } from './services/AppServices';
import { FONTS_CSS_URL } from './config/constants';
import { PROPERTY_PANE_TEXTS } from './app/texts';
export interface IStroyBotWebPartProps extends IConnectionSettings {}
export default class StroyBotWebPart extends BaseClientSideWebPart<IStroyBotWebPartProps> {
protected async onInit(): Promise<void> {
// Inter и Lora — шрифты дизайн-системы. Раньше их грузили report-list и assigner,
// остальные web parts полагались на шрифты, установленные у пользователя.
SPComponentLoader.loadCss(FONTS_CSS_URL);
await super.onInit();
}
public render(): void {
const services = createAppServices(this.context, this.properties);
ReactDom.render(React.createElement(App, { services }), this.domElement);
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
const texts = PROPERTY_PANE_TEXTS;
return {
pages: [
{
header: { description: texts.header },
groups: [
{
groupName: texts.connectionGroup,
groupFields: [
PropertyPaneTextField('siteUrl', { label: texts.siteUrl, placeholder: texts.siteUrlPlaceholder }),
PropertyPaneTextField('dataverseUrl', {
label: texts.dataverseUrl,
placeholder: texts.dataverseUrlPlaceholder
}),
PropertyPaneTextField('apiBaseUrl', { label: texts.apiBaseUrl, placeholder: texts.apiBaseUrlPlaceholder })
]
}
]
}
]
};
}
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from 'react';
import '@tnsu/ui-kit/styles.css';
import { ConfirmHost, IconSprite } from '@tnsu/ui-kit';
import { IAppServices } from '../services/AppServices';
import { AppServicesContext } from './AppServicesContext';
import { RouterProvider } from './routing/RouterContext';
import { AppShell } from './AppShell';
interface Props {
services: IAppServices;
}
/** Корень приложения: стили и общие хосты ui-kit (спрайт иконок, модалка подтверждения) монтируются один раз. */
export const App: React.FC<Props> = ({ services }) => (
<AppServicesContext.Provider value={services}>
<IconSprite />
<ConfirmHost />
<RouterProvider>
<AppShell />
</RouterProvider>
</AppServicesContext.Provider>
);
@@ -0,0 +1,14 @@
import * as React from 'react';
import { IAppServices } from '../services/AppServices';
// Клиенты к SharePoint / Dataverse / API нужны почти каждой странице —
// поэтому они в контексте, а не передаются пропсами через всё дерево.
export const AppServicesContext = React.createContext<IAppServices | undefined>(undefined);
export function useAppServices(): IAppServices {
const services = React.useContext(AppServicesContext);
if (!services) {
throw new Error('useAppServices: компонент отрисован вне AppServicesContext.Provider');
}
return services;
}
@@ -0,0 +1,7 @@
@import '../../../styles/design-tokens';
.content {
width: 100%;
font-family: $font-sans;
color: $gray-900;
}
+42
View File
@@ -0,0 +1,42 @@
import * as React from 'react';
import { AppLayout, Sidebar, SidebarToggle } from '@tnsu/ui-kit';
import { useRouter } from './routing/RouterContext';
import { PageId } from './routing/routes';
import { buildSidebarItems, navPageFor } from './navigation';
import { PageOutlet } from './PageOutlet';
import { APP_TEXTS } from './texts';
import styles from './AppShell.module.scss';
/** Каркас приложения: меню разделов слева, текущий раздел справа. */
export const AppShell: React.FC = () => {
const { route, navigate } = useRouter();
const [isCollapsed, setIsCollapsed] = React.useState<boolean>(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState<boolean>(false);
const openSection = (id: string): void => {
setIsMobileMenuOpen(false);
navigate({ page: id as PageId, projectId: route.projectId });
};
return (
<AppLayout
sidebar={
<Sidebar
items={buildSidebarItems(route.projectId)}
activeId={navPageFor(route.page)}
onChange={openSection}
brand={APP_TEXTS.brand}
collapsed={isCollapsed}
onToggleCollapsed={() => setIsCollapsed((prev) => !prev)}
mobileOpen={isMobileMenuOpen}
onMobileClose={() => setIsMobileMenuOpen(false)}
/>
}
mobileToggle={<SidebarToggle onClick={() => setIsMobileMenuOpen(true)} />}
>
<div className={styles.content}>
<PageOutlet />
</div>
</AppLayout>
);
};
+13
View File
@@ -0,0 +1,13 @@
import * as React from 'react';
import { useRouter } from './routing/RouterContext';
import { SectionPlaceholder } from './SectionPlaceholder';
/** Показывает страницу текущего раздела. Разделы подключаются сюда по мере переноса. */
export const PageOutlet: React.FC = () => {
const { route } = useRouter();
switch (route.page) {
default:
return <SectionPlaceholder />;
}
};
@@ -0,0 +1,8 @@
import * as React from 'react';
import { EmptyState } from '../shared/components/EmptyState';
import { APP_TEXTS } from './texts';
/** Временная заглушка для разделов, которые ещё не перенесены. Удаляется вместе с последним разделом. */
export const SectionPlaceholder: React.FC = () => (
<EmptyState icon="refresh" title={APP_TEXTS.sectionInProgress} text={APP_TEXTS.sectionInProgressHint} />
);
@@ -0,0 +1,34 @@
import { buildSidebarItems, navPageFor } from './navigation';
describe('buildSidebarItems', () => {
it('без проекта в меню только главная', () => {
expect(buildSidebarItems(undefined).map((item) => item.id)).toEqual(['hub']);
});
it('с проектом — все разделы', () => {
expect(buildSidebarItems('5').map((item) => item.id)).toEqual([
'hub',
'objects',
'reports',
'report',
'import',
'resources',
'binder'
]);
});
it('недоступные разделы скрываются, главная остаётся всегда', () => {
const onlyReports = (page: string): boolean => page === 'reports';
expect(buildSidebarItems('5', onlyReports).map((item) => item.id)).toEqual(['hub', 'reports']);
});
});
describe('navPageFor', () => {
it('работы подсвечивают пункт «Объекты»', () => {
expect(navPageFor('works')).toEqual('objects');
});
it('остальные разделы подсвечивают сами себя', () => {
expect(navPageFor('reports')).toEqual('reports');
});
});
+39
View File
@@ -0,0 +1,39 @@
import { IconName, ISidebarItem } from '@tnsu/ui-kit';
import { PageId } from './routing/routes';
export interface INavItem {
page: PageId;
title: string;
icon: IconName;
/** Раздел работает только внутри проекта (нужен projectId). */
needsProject: boolean;
}
// Названия разделов — как на карточках stroy-bot-main.
export const NAV_ITEMS: INavItem[] = [
{ page: 'hub', title: 'Проект', icon: 'building', needsProject: false },
{ page: 'objects', title: 'Объекты', icon: 'folder', needsProject: true },
{ page: 'reports', title: 'Отчёты по проекту', icon: 'clipboard', needsProject: true },
{ page: 'report', title: 'Отправить отчёт', icon: 'send', needsProject: true },
{ page: 'import', title: 'Загрузить работы из сметы', icon: 'download', needsProject: true },
{ page: 'resources', title: 'Ресурсы проекта', icon: 'people', needsProject: true },
{ page: 'binder', title: 'Привязка к справочнику', icon: 'swap', needsProject: true }
];
/** Пункт меню, который подсвечивается для раздела. Работы — подраздел объектов, форма отчёта — свой пункт. */
export function navPageFor(page: PageId): PageId {
return page === 'works' ? 'objects' : page;
}
/**
* Пункты меню для текущего состояния. Без проекта — только главная.
* isAvailable — правила доступа к разделу (статус проекта, права); по умолчанию всё доступно.
*/
export function buildSidebarItems(
projectId: string | undefined,
isAvailable: (page: PageId) => boolean = () => true
): ISidebarItem[] {
return NAV_ITEMS.filter((item) => !item.needsProject || (projectId !== undefined && isAvailable(item.page))).map(
(item) => ({ id: item.page, title: item.title, icon: item.icon })
);
}
@@ -0,0 +1,43 @@
import * as React from 'react';
import { IRoute } from './routes';
import { useRouter } from './RouterContext';
interface Props {
to: IRoute;
className?: string;
/** Неактивная ссылка: рендерится без href и не реагирует на клик. */
disabled?: boolean;
children: React.ReactNode;
}
/**
* Ссылка на раздел приложения. Это настоящий <a href>, поэтому работают Ctrl+клик,
* средняя кнопка и «открыть в новой вкладке» (как было в stroy-bot-main),
* а обычный клик переключает раздел без перезагрузки страницы.
*/
export const RouteLink: React.FC<Props> = ({ to, className, disabled, children }) => {
const { navigate, buildHref } = useRouter();
if (disabled) {
return (
<a className={className} aria-disabled="true">
{children}
</a>
);
}
const onClick = (event: React.MouseEvent<HTMLAnchorElement>): void => {
const opensNewTab = event.ctrlKey || event.metaKey || event.shiftKey || event.button !== 0;
if (opensNewTab) {
return;
}
event.preventDefault();
navigate(to);
};
return (
<a className={className} href={buildHref(to)} onClick={onClick}>
{children}
</a>
);
};
@@ -0,0 +1,49 @@
import * as React from 'react';
import { buildSearch, IRoute, parseRoute } from './routes';
interface IRouterValue {
route: IRoute;
/** Перейти в раздел: меняет адрес (history.pushState) и перерисовывает приложение. */
navigate: (route: IRoute) => void;
/** Полный адрес раздела — для настоящих ссылок (Ctrl+клик, «открыть в новой вкладке»). */
buildHref: (route: IRoute) => string;
}
const RouterContext = React.createContext<IRouterValue | undefined>(undefined);
function readCurrentRoute(): IRoute {
return parseRoute(window.location.search);
}
function buildHref(route: IRoute): string {
return `${window.location.pathname}${buildSearch(route, window.location.search)}`;
}
export const RouterProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [route, setRoute] = React.useState<IRoute>(readCurrentRoute);
// Кнопки «назад/вперёд» браузера меняют адрес без нашего участия — перечитываем маршрут.
React.useEffect(() => {
const onPopState = (): void => setRoute(readCurrentRoute());
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
const navigate = React.useCallback((next: IRoute): void => {
window.history.pushState(undefined, '', buildHref(next));
setRoute(next);
window.scrollTo(0, 0);
}, []);
const value = React.useMemo<IRouterValue>(() => ({ route, navigate, buildHref }), [route, navigate]);
return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
};
export function useRouter(): IRouterValue {
const router = React.useContext(RouterContext);
if (!router) {
throw new Error('useRouter: компонент отрисован вне RouterProvider');
}
return router;
}
@@ -0,0 +1,20 @@
import { parseRoute, buildSearch } from './routes';
describe('parseRoute', () => {
it('пустой адрес - главная', () => {
expect(parseRoute('')).toEqual({ page: 'hub' });
});
it('адрес с разделом works, проектом 3 и объектом 41', () => {
expect(parseRoute('?page=works&projectId=3&objectId=41')).toEqual({
page: 'works',
projectId: '3',
objectId: '41'
});
});
it('опечатка в разделе (objectz) — должна быть главная, а projectId сохраниться', () => {
expect(parseRoute('?page=objectz&projectId=3')).toEqual({ page: 'hub', projectId: '3' });
});
it('адрес только с projectId=7', () => {
expect(parseRoute('?projectId=7')).toEqual({ page: 'hub', projectId: '7' });
});
});
@@ -0,0 +1,70 @@
// Маршруты приложения. Раздел и его параметры живут в query-строке страницы
// SharePoint (?page=objects&projectId=5), поэтому ссылку на любой раздел можно
// скопировать, открыть в новой вкладке, а «назад» в браузере работает как раньше.
export const PAGE_IDS = ['hub', 'objects', 'works', 'reports', 'report', 'import', 'resources', 'binder'] as const;
export type PageId = (typeof PAGE_IDS)[number];
/** Параметры маршрута. Все id — строки как в URL; разбор в числа делают страницы. */
export interface IRoute {
page: PageId;
projectId?: string;
/** Работы: id элемента списка «Объекты». */
objectId?: string;
/** Форма отчёта: открыть существующий отчёт (GUID Dataverse). */
reportId?: string;
/** Форма отчёта: создать на основе существующего (GUID Dataverse). */
copyFromId?: string;
}
export const ROUTE_PARAMS = {
page: 'page',
projectId: 'projectId',
objectId: 'objectId',
reportId: 'reportId',
copyFromId: 'copyFromId'
} as const;
const ROUTE_VALUE_KEYS: Array<Exclude<keyof IRoute, 'page'>> = ['projectId', 'objectId', 'reportId', 'copyFromId'];
function isPageId(value: string | undefined): value is PageId {
return value !== undefined && (PAGE_IDS as readonly string[]).indexOf(value) !== -1;
}
export function parseRoute(search: string): IRoute {
const params = new URLSearchParams(search);
const page = params.get(ROUTE_PARAMS.page) ?? undefined;
const route: IRoute = { page: isPageId(page) ? page : 'hub' };
ROUTE_VALUE_KEYS.forEach((key) => {
const value = params.get(ROUTE_PARAMS[key]);
if (value) {
route[key] = value;
}
});
return route;
}
/**
* Собирает query-строку для маршрута. Посторонние параметры страницы SharePoint
* (например, режим редактирования) сохраняются, параметры прошлого маршрута — нет.
*/
export function buildSearch(route: IRoute, currentSearch: string): string {
const params = new URLSearchParams(currentSearch);
params.delete(ROUTE_PARAMS.page);
ROUTE_VALUE_KEYS.forEach((key) => params.delete(ROUTE_PARAMS[key]));
if (route.page !== 'hub') {
params.set(ROUTE_PARAMS.page, route.page);
}
ROUTE_VALUE_KEYS.forEach((key) => {
const value = route[key];
if (value) {
params.set(ROUTE_PARAMS[key], value);
}
});
const query = params.toString();
return query ? `?${query}` : '';
}
+18
View File
@@ -0,0 +1,18 @@
// Тексты оболочки приложения. Тексты разделов лежат рядом с разделами (pages/*/texts.ts).
export const APP_TEXTS = {
brand: 'StroyBot',
sectionInProgress: 'Раздел переносится из старого web part',
sectionInProgressHint: 'Пока пользуйтесь старой страницей раздела.'
} as const;
export const PROPERTY_PANE_TEXTS = {
header: 'Настройки StroyBot',
connectionGroup: 'Подключение',
siteUrl: 'URL сайта SharePoint со списками (пусто — по умолчанию)',
siteUrlPlaceholder: 'https://tnsukz.sharepoint.com/sites/stroy',
dataverseUrl: 'URL Dataverse (пусто — по умолчанию)',
dataverseUrlPlaceholder: 'https://tansu.crm5.dynamics.com',
apiBaseUrl: 'Базовый URL API (пусто — по умолчанию)',
apiBaseUrlPlaceholder: 'https://api.tnsu.kz'
} as const;
+23
View File
@@ -0,0 +1,23 @@
// Адреса окружения StroyBot. Раньше каждое значение было захардкожено
// в нескольких репозиториях stroy-bot-* — теперь единственное место.
/** Сайт SharePoint со списками StroyBot (Проекты, Объекты, Доступы и т.д.). */
export const SITE_URL = 'https://tnsukz.sharepoint.com/sites/stroy';
/** Окружение Dataverse (таблицы cree9_*). */
export const DATAVERSE_URL = 'https://tansu.crm5.dynamics.com';
/** Корпоративный API (справочник проектов, сметы, Excel-выгрузки). */
export const TNSU_API_BASE_URL = 'https://api.tnsu.kz';
/** AAD-приложение корпоративного API — ресурс для AadHttpClient. */
export const TNSU_API_RESOURCE_ID = '9232d9e2-9807-4753-808f-07250e5474b0';
/** Шрифты дизайн-системы (Inter — текст, Lora — заголовки). Тот же URL, что в report-list и assigner. */
export const FONTS_CSS_URL =
'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lora:wght@500;600;700&display=swap';
/** Ключи sessionStorage. Значение совпадает со stroy-bot-main — сохранённый выбор не теряется. */
export const SESSION_KEYS = {
selectedProject: 'stroybot_selected_project'
} as const;
+15
View File
@@ -0,0 +1,15 @@
// Таблицы Dataverse StroyBot (Web API, множественное число — как в URL).
export const DATAVERSE_API_PATH = '/api/data/v9.2/';
export const DV_TABLES = {
/** Работы (иерархия, привязка к объекту SharePoint через cree9_objectid). */
works: 'cree9_tables',
/** Отчёты. */
reports: 'cree9_reports',
/** Строки отчётов: работы, персонал, техника. */
reportData: 'cree9_report_datas'
} as const;
/** Логическое имя (единственное число) таблицы работ — для EntityDefinitions. */
export const DV_WORKS_LOGICAL_NAME = 'cree9_table';
+23
View File
@@ -0,0 +1,23 @@
// Названия списков и библиотек SharePoint (getbytitle).
// Внутренние имена полей описываются рядом с репозиторием, который с ними работает.
export const LISTS = {
projects: 'Проекты',
objects: 'Объекты',
access: 'Доступы',
companies: 'Компании',
companiesByProject: 'Компании по проекту',
professions: 'Профессии',
professionsByProject: 'Профессии по проекту',
shifts: 'Смены',
technics: 'Техника',
units: 'Единицы измерения'
} as const;
/** Библиотека с фотографиями отчётов: папка `{projectId}_{reportNumber}`. */
export const REPORT_PHOTO_LIBRARY = 'DocLib';
/** Библиотека с архивом проектов: папка `Архив/{название проекта}`. */
export const ARCHIVE_FOLDER_PATH = 'Shared Documents/Архив';
export type ListTitle = (typeof LISTS)[keyof typeof LISTS];
@@ -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
});
}
};
}
@@ -0,0 +1,39 @@
@import '../../../../styles/design-tokens';
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
padding: $sp-8 $sp-4;
text-align: center;
color: $gray-500;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: $r-full;
background: $navy-50;
color: $navy-700;
}
.title {
margin: 0;
font-family: $font-sans;
font-size: $fs-body;
font-weight: 600;
color: $navy-900;
}
.text {
margin: 0;
font-size: $fs-sm;
}
.action {
margin-top: $sp-2;
}
@@ -0,0 +1,23 @@
import * as React from 'react';
import { Icon, IconName } from '@tnsu/ui-kit';
import styles from './EmptyState.module.scss';
interface Props {
icon?: IconName;
title: string;
text?: string;
/** Кнопка действия под текстом (например, «+ Добавить объект»). */
action?: React.ReactNode;
}
// В ui-kit пока нет EmptyState — живёт здесь, позже переедет в кит.
export const EmptyState: React.FC<Props> = ({ icon = 'info', title, text, action }) => (
<div className={styles.emptyState}>
<span className={styles.icon}>
<Icon name={icon} size="lg" />
</span>
<p className={styles.title}>{title}</p>
{text && <p className={styles.text}>{text}</p>}
{action && <div className={styles.action}>{action}</div>}
</div>
);