import * as React from 'react'; import * as ReactDom from 'react-dom'; import { Version } from '@microsoft/sp-core-library'; import { type IPropertyPaneConfiguration, PropertyPaneDropdown, PropertyPaneTextField, PropertyPaneToggle } from '@microsoft/sp-property-pane'; import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base'; import { IReadonlyTheme } from '@microsoft/sp-component-base'; import { initMonitoring, spfxTokenProvider } from '@tnsu/monitoring-web'; import * as strings from 'RequestPortalWebPartStrings'; import RequestPortal from './components/RequestPortal'; import { IRequestPortalProps } from './components/IRequestPortalProps'; import { initSP } from './shared/pnp/pnpjs'; import { installErrorLogging } from './shared/diagnostics'; import { setImpersonatedIdentity } from './shared/impersonation'; import { APPROVAL_API_BASE_URL_LOCAL, APPROVAL_API_BASE_URL_PROD, EDO_API_BASE_URL_LOCAL, EDO_API_BASE_URL_PROD, EDO_API_BASE_URL_STAGE, setApprovalApiBaseUrl, setEdoApiBaseUrl, setNotificationSettings, setChatSelfNotify } from './shared/config'; import { initListPartitions } from './shared/approval'; import { ErrorOverlay } from './shared/ui'; import { PropertyPaneDebugUser } from './propertyPane/PropertyPaneDebugUser'; export interface IRequestPortalWebPartProps { /** * Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто. * Одно поле на оба случая: e-mail, введённый напрямую, хранится как есть; * ФИО, найденное поиском по имени (см. propertyPane/DebugUserField), после * резолва заменяется собой же на найденный e-mail — отдельное поле под * «сырой» ввод не нужно, DebugUserField ведёт его в своём локальном состоянии. */ debugUserQuery: string; debugUserDisplayName: string; /** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */ approvalApiBaseUrl: string; /** * С каким ЭДО работаем при отправке документов на подписание — локальным или * стейджинговым. У веб-частей, добавленных до появления свойства, оно пустое — это стейдж. */ edoApiBaseUrl: string; /** * Уведомления согласующим (поток Power Automate). По умолчанию включены: * у веб-частей, добавленных до появления свойства, оно undefined — это тоже «вкл». */ notificationsEnabled?: boolean; /** Непустой адрес — письма только если активный шаг назначен на него (обкатка). Пусто — всем. */ notifyOnlyTo?: string; /** * Для проверки чата: письма и о СВОИХ сообщениях, и можно отметить себя через «@». * По умолчанию выключено (undefined — тоже «выкл»). */ chatSelfNotify?: boolean; } // Мониторинг — один на страницу: экземпляров веб-части на странице может быть несколько. let monitoringStarted = false; export default class RequestPortalWebPart extends BaseClientSideWebPart { private _isDarkTheme: boolean = false; private _environmentMessage: string = ''; public render(): void { // Именно здесь, а не в onInit: свойство меняется в панели без перезапуска // веб-части, и render вызывается на каждое такое изменение. setImpersonatedIdentity(this.properties.debugUserQuery, this.properties.debugUserDisplayName); this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD; setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl); this.properties.edoApiBaseUrl = this.properties.edoApiBaseUrl || EDO_API_BASE_URL_PROD; setEdoApiBaseUrl(this.properties.edoApiBaseUrl); this._applyNotificationSettings(); const element: React.ReactElement = React.createElement( ErrorOverlay, undefined, React.createElement(RequestPortal, { // key на подменённом e-mail: смена подмены в property pane должна // пересобрать раздел заново — иначе счётчики в боковом меню и открытые // таблицы остались бы от прежнего пользователя (их эффекты завязаны на // монтирование/context, а не на identity подмены). key: this.properties.debugUserQuery || '__self__', isDarkTheme: this._isDarkTheme, environmentMessage: this._environmentMessage, hasTeamsContext: !!this.context.sdks.microsoftTeams, userDisplayName: this.context.pageContext.user.displayName, context: this.context }) ); ReactDom.render(element, this.domElement); } protected onInit(): Promise { // Логи, ошибки, console.error и запись экрана в мониторинг (monitoring.tnsu.kz). Токен — молча через // AAD веб-части (разрешение Logs.Write в package-solution.json); при отладке в workbench окружение — development. if (!monitoringStarted) { monitoringStarted = true; initMonitoring({ service: 'requestPortal', getAccessToken: spfxTokenProvider(this.context.aadTokenProviderFactory) }); } installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения initSP(this.context); // единый клиент PnPjs для записи в списки // Адрес approval-route должен быть выставлен ДО загрузки партиций — они // берутся именно оттуда. В render() он ставится снова (свойство меняется в // панели без перезапуска), но render случается позже onInit. this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD; setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl); this.properties.edoApiBaseUrl = this.properties.edoApiBaseUrl || EDO_API_BASE_URL_PROD; setEdoApiBaseUrl(this.properties.edoApiBaseUrl); this._applyNotificationSettings(); // Партиции грузим здесь, а не по требованию из форм: имена списков нужны // формам синхронно (см. shared/listTitles.ts), а формы — лениво подгружаемые // чанки, монтируются заведомо позже onInit. initListPartitions не бросает: // при недоступном approval-route портал работает по конвенции имён. // // НЕ ждём эту загрузку здесь (не Promise.all, а просто вызов) — onInit // блокирует ПЕРВЫЙ render() всего портала (SPFx не рисует веб-часть, пока // его промис не разрешится), а два новых AAD-запроса к внешнему api.tnsu.kz // не обязаны попадать в этот путь: раньше onInit резолвился почти мгновенно // (только _getEnvironmentMessage, без сети), и весь портал — включая // «Мои заявки» и «Новая заявка», которым партиции вообще не нужны сразу — // ждал открытия форм. Партиции при этом гарантированно успевают раньше, // чем смонтируется первая форма (ленивый чанк грузится дольше). // eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error void initListPartitions(this.context); return this._getEnvironmentMessage().then(message => { this._environmentMessage = message; }); } private _applyNotificationSettings(): void { setNotificationSettings(this.properties.notificationsEnabled !== false, this.properties.notifyOnlyTo || ''); setChatSelfNotify(this.properties.chatSelfNotify === true); } private _getEnvironmentMessage(): Promise { if (!!this.context.sdks.microsoftTeams) { // running in Teams, office.com or Outlook return this.context.sdks.microsoftTeams.teamsJs.app.getContext() .then(context => { let environmentMessage: string; switch (context.app.host.name) { case 'Office': // running in Office environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOffice : strings.AppOfficeEnvironment; break; case 'Outlook': // running in Outlook environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOutlook : strings.AppOutlookEnvironment; break; case 'Teams': // running in Teams case 'TeamsModern': environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentTeams : strings.AppTeamsTabEnvironment; break; default: environmentMessage = strings.UnknownEnvironment; } return environmentMessage; }); } return Promise.resolve(this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentSharePoint : strings.AppSharePointEnvironment); } protected onThemeChanged(currentTheme: IReadonlyTheme | undefined): void { if (!currentTheme) { return; } this._isDarkTheme = !!currentTheme.isInverted; const { semanticColors } = currentTheme; if (semanticColors) { this.domElement.style.setProperty('--bodyText', semanticColors.bodyText || null); this.domElement.style.setProperty('--link', semanticColors.link || null); this.domElement.style.setProperty('--linkHovered', semanticColors.linkHovered || null); } } protected onDispose(): void { ReactDom.unmountComponentAtNode(this.domElement); } protected get dataVersion(): Version { return Version.parse('1.0'); } protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration { return { pages: [ { header: { description: strings.PropertyPaneDescription }, groups: [ { groupName: strings.BasicGroupName, groupFields: [ PropertyPaneDebugUser('debugUserQuery', { label: strings.DebugUserFieldLabel, description: strings.DebugUserFieldDescription, context: this.context, initialQuery: this.properties.debugUserQuery, onResolved: (query, email, displayName) => { // Сохраняем ТОЛЬКО e-mail (введённый напрямую или найденный по // ФИО) — свойство одно и то же используется как e-mail для // подмены (см. render() выше), так что писать в него сырое ФИО // нельзя. Пустой query — поле очистили, подмену выключаем. // А вот «email пуст, но query не пуст» — ФИО ещё не нашлось // (недописано/не существует): ничего не трогаем, иначе неудачный // промежуточный поиск молча выключил бы уже работающую подмену. if (email) { this.properties.debugUserQuery = email; this.properties.debugUserDisplayName = displayName; this.render(); } else if (!query) { this.properties.debugUserQuery = ''; this.properties.debugUserDisplayName = ''; this.render(); } } }) ] }, { groupName: 'Approval Route API', groupFields: [ PropertyPaneDropdown('approvalApiBaseUrl', { label: 'Api Base Url', selectedKey: this.properties.approvalApiBaseUrl, options: [ { key: APPROVAL_API_BASE_URL_PROD, text: `Прод (${APPROVAL_API_BASE_URL_PROD})` }, { key: APPROVAL_API_BASE_URL_LOCAL, text: `Локально (${APPROVAL_API_BASE_URL_LOCAL})` } ] }) ] }, { groupName: 'ЭДО (подписание)', groupFields: [ PropertyPaneDropdown('edoApiBaseUrl', { label: 'С каким ЭДО работать', selectedKey: this.properties.edoApiBaseUrl || EDO_API_BASE_URL_PROD, options: [ { key: EDO_API_BASE_URL_PROD, text: `Прод (${EDO_API_BASE_URL_PROD})` }, { key: EDO_API_BASE_URL_STAGE, text: `Стейдж (${EDO_API_BASE_URL_STAGE})` }, { key: EDO_API_BASE_URL_LOCAL, text: `Локально (${EDO_API_BASE_URL_LOCAL})` } ] }) ] }, { groupName: 'Уведомления', groupFields: [ PropertyPaneToggle('notificationsEnabled', { label: 'Уведомления согласующим', onText: 'Включены', offText: 'Выключены', checked: this.properties.notificationsEnabled !== false }), PropertyPaneTextField('notifyOnlyTo', { label: 'Слать только на адрес', description: 'Для обкатки: письмо уходит, только если шаг назначен на этот e-mail. Пусто — всем.', placeholder: 'user@tnsu.kz', disabled: this.properties.notificationsEnabled === false }), PropertyPaneToggle('chatSelfNotify', { label: 'Чат: уведомлять о своих сообщениях (для теста)', onText: 'Да — и можно отметить себя через «@»', offText: 'Нет', checked: this.properties.chatSelfNotify === true }) ] } ] } ] }; } }