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