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,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}` : '';
}