Чат заявки: отметки «@» и оповещения через поток (#26)
Tests / frontend-test (push) Successful in 2m1s

## Что сделано

**Чат заявки: отметки и оповещения**
- В поле ввода «@» открывает подсказку с участниками заявки: инициатор, согласующие и исполнители из маршрута, а также те, кому открыты «Все заявки» этого типа (список из request-type-access).
- Отметка — обычный текст «@ФИО», без штатного упоминания SharePoint, чтобы не уходило его письмо.
- После отправки сообщения портал вызывает поток Power Automate (`FLOW_CHAT_NOTIFY_URL`): отмеченным — «Вас упомянули», инициатору — «Новое сообщение по вашей заявке». Сбой потока не мешает отправке.
- В письмо добавляется строка «Прикреплены файлы: …», чтобы сообщение из одного файла не приходило пустым.
- Свойство веб-части «Чат: уведомлять о своих сообщениях (для теста)», по умолчанию выключено: письма и о своих сообщениях, можно отметить себя.
- Когда пишет сам инициатор с отметками, в `initiatorEmail` уходит первый отмеченный — поток шлёт инициатору всегда, если его нет среди отмеченных, и иначе он получил бы письмо о своём же сообщении.

**Чат включён для:** Обходной лист, заявка на подбор, заявка на приём, заявка в отдел 1С.

**Исправление:** редактор маршрута может удалять исполнителей в Обходном листе, Командировке и заявке в 1С (не передавалось `canRemoveRow`).

**Прочее:** убраны 4 предупреждения линтера (`no-new-null`, `no-script-url`).

## Не проверено на тенанте
- Ответ потока 200 через AadHttpClient и доставка писем.
- Права согласующих на комментарии и на запись в библиотеку «Файлы чата».
- Папки в «Файлы чата» наследуют права библиотеки, а не закрыты на заявку — для подбора и приёма это важно решить до выкладки.
- Если поток вставляет `messageText` в HTML-письмо, перенос строки перед «Прикреплены файлы» может не отобразиться.

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/26
This commit was merged in pull request #26.
This commit is contained in:
2026-10-14 10:05:45 +00:00
parent 96f53da020
commit 5d2abaf430
27 changed files with 754 additions and 46 deletions
@@ -24,7 +24,8 @@ import {
EDO_API_BASE_URL_STAGE,
setApprovalApiBaseUrl,
setEdoApiBaseUrl,
setNotificationSettings
setNotificationSettings,
setChatSelfNotify
} from './shared/config';
import { initListPartitions } from './shared/approval';
import { ErrorOverlay } from './shared/ui';
@@ -54,6 +55,11 @@ export interface IRequestPortalWebPartProps {
notificationsEnabled?: boolean;
/** Непустой адрес — письма только если активный шаг назначен на него (обкатка). Пусто — всем. */
notifyOnlyTo?: string;
/**
* Для проверки чата: письма и о СВОИХ сообщениях, и можно отметить себя через «@».
* По умолчанию выключено (undefined — тоже «выкл»).
*/
chatSelfNotify?: boolean;
}
export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> {
@@ -126,6 +132,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
private _applyNotificationSettings(): void {
setNotificationSettings(this.properties.notificationsEnabled !== false, this.properties.notifyOnlyTo || '');
setChatSelfNotify(this.properties.chatSelfNotify === true);
}
private _getEnvironmentMessage(): Promise<string> {
@@ -259,6 +266,12 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
description: 'Для обкатки: письмо уходит, только если шаг назначен на этот e-mail. Пусто — всем.',
placeholder: 'user@tnsu.kz',
disabled: this.properties.notificationsEnabled === false
}),
PropertyPaneToggle('chatSelfNotify', {
label: 'Чат: уведомлять о своих сообщениях (для теста)',
onText: 'Да — и можно отметить себя через «@»',
offText: 'Нет',
checked: this.properties.chatSelfNotify === true
})
]
}
@@ -53,7 +53,7 @@ const ApproveFeature: React.FC<IApproveFeatureProps> = ({ context, formKey, item
{/* Доступно всем, кто может открыть заявку: автору, всем согласующим
(и уже решившим, и ещё ждущим своей очереди) и исполнителям —
в отличие от панели решения, которая видна только текущему шагу.
Пока включено только для «Обходного листа» — см. isChatEnabledFor. */}
Включено не для всех типов заявок — см. isChatEnabledFor. */}
{isChatEnabledFor(formKey) && (
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
)}
@@ -73,7 +73,7 @@ const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, li
{/* Обсуждение — то же самое, что видят согласующие (комментарии к строке
заявки). Здесь оно особенно нужно: заявку правят как раз после
возврата на доработку, и уточнить причину логично не уходя с формы.
Пока включено только для «Обходного листа» — см. isChatEnabledFor. */}
Включено не для всех типов заявок — см. isChatEnabledFor. */}
{isChatEnabledFor(formKey) && (
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
)}
@@ -33,6 +33,7 @@ function asDay(value: unknown): string {
}
/** Разобрать сохранённое значение; всё, что не прошло проверку, — как «не задано». */
// eslint-disable-next-line @rushstack/no-new-null -- так отдаёт localStorage.getItem, если ключа нет
export function parseStoredRefinement(raw: string | null): IStoredRefinement | undefined {
if (!raw) return undefined;
let parsed: { filters?: Record<string, unknown>; sort?: Record<string, unknown> };
@@ -27,8 +27,10 @@ import {
IApproverToWrite,
IUseMatrixEditor,
unassignedStepMessage,
resolveWriteListId
resolveWriteListId,
composeRemoveRowRule
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
@@ -244,7 +246,11 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
<ObligationSection {...sectionProps} />
</FormModeProvider>
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
<ApprovalMatrix
editor={matrix}
search={(q) => approval.searchUsers(q)}
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/>
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
@@ -253,6 +259,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
title="Исполнение (после согласования)"
roleLabel="Исполнитель"
emptyText="Исполнители не заданы по маршруту."
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/>
)}
@@ -342,6 +342,7 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
title="Исполнение (после согласования)"
roleLabel="Исполнитель"
emptyText="Исполнители не заданы по маршруту."
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/>
)}
@@ -31,7 +31,8 @@ import {
isEditableStatus,
IApproverToWrite,
unassignedStepMessage,
executorsToWrite
executorsToWrite,
composeRemoveRowRule
} from '../../shared/approval';
import {
uploadAttachments,
@@ -444,6 +445,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix
editor={matrixEditor}
search={(q) => approval.searchUsers(q)}
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
// Пустой маршрут — штатная ситуация: для части видов работ согласование
// не нужно, заявка сразу уходит в Planner. Сбой загрузки маршрута
// показывается отдельно (matrix.error) и блокирует отправку.
@@ -457,6 +459,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
title="Исполнение"
roleLabel="Исполнитель"
emptyText="Исполнители не назначены."
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
/>
)}
@@ -34,7 +34,7 @@ export {
getRequestTypeAccessEmails,
resolveRoute
} from './approvalRoute.service';
export { hasRequestTypeAccess, accessibleAllRequestsTypes } from './requestTypeAccess';
export { hasRequestTypeAccess, accessibleAllRequestsTypes, emailsForType } from './requestTypeAccess';
export { useAccessibleAllRequestsFormKeys } from './useAccessibleAllRequestsFormKeys';
export {
initListPartitions,
@@ -59,7 +59,7 @@ export { approvalServiceFor } from './approvalServiceFactory';
export { ApproverService } from './approverDecision.service';
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
export { notifyActiveStep } from './notify.service';
export { notifyActiveStep, postToFlow } from './notify.service';
export type { INotifyTarget } from './notify.service';
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
export type { IUseMatrixEditor } from './useMatrixEditor';
@@ -12,14 +12,15 @@ export interface INotifyTarget {
approvalListName: string; // ApproveListName (список согласования)
}
// POST в поток оповещения (Entra-триггер) через AadHttpClient — обычный fetch → 401,
// AadHttpClient даёт токен Flow Service (и снимает вопрос CORS).
async function postNotify(context: WebPartContext, body: Record<string, unknown>): Promise<void> {
if (!FLOW_NOTIFY_URL) {
// POST в поток Power Automate (Entra-триггер) через AadHttpClient — обычный fetch → 401,
// AadHttpClient даёт токен Flow Service (и снимает вопрос CORS). Общий для всех
// потоков оповещения портала (согласование, чат заявки — shared/chat/chatNotify.service.ts).
export async function postToFlow(context: WebPartContext, flowUrl: string, body: object): Promise<void> {
if (!flowUrl) {
return;
}
const client = await context.aadHttpClientFactory.getClient(FLOW_RESOURCE);
const res = await client.post(FLOW_NOTIFY_URL, AadHttpClient.configurations.v1, {
const res = await client.post(flowUrl, AadHttpClient.configurations.v1, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
@@ -68,7 +69,7 @@ async function notifyRow(
if (!(await isNotifyAllowed(approval, row.approverId))) {
return;
}
await postNotify(context, {
await postToFlow(context, FLOW_NOTIFY_URL, {
RequestTypeId: target.requestTypeId,
RowID: row.id,
RequestID: requestId,
@@ -9,7 +9,12 @@ import { effectiveUserEmail } from '../impersonation';
// кешируется, чтобы временный сбой сети не «залип» на весь сеанс.
const cache = new Map<number, Promise<string[]>>();
function emailsForType(context: WebPartContext, requestTypeId: number): Promise<string[]> {
/**
* Кому выдан доступ видеть ВСЕ заявки этого типа (раздел «Все заявки»). Кешируется
* на сеанс. Нужен не только для проверки доступа: эти люди могут писать в чат
* любой заявки типа, значит, их можно и отмечать в нём (см. shared/chat).
*/
export function emailsForType(context: WebPartContext, requestTypeId: number): Promise<string[]> {
let pending = cache.get(requestTypeId);
if (!pending) {
pending = getRequestTypeAccessEmails(context, requestTypeId).catch((e) => {
@@ -23,6 +23,7 @@ describe('linkify', () => {
});
it('не-http схемы ссылкой не становятся', () => {
// eslint-disable-next-line no-script-url -- проверяем как раз то, что такая строка ссылкой не станет
expect(linkify('javascript:alert(1)')).toEqual([{ text: 'javascript:alert(1)' }]);
});
@@ -1,14 +1,16 @@
import { isChatEnabledFor } from './chatEnabled';
describe('isChatEnabledFor', () => {
it('включён для «Обходного листа»', () => {
it('включён для Обходного листа, подбора, приёма и заявки в 1С', () => {
expect(isChatEnabledFor('clearance-form')).toBe(true);
expect(isChatEnabledFor('recruiting-selection')).toBe(true);
expect(isChatEnabledFor('recruiting-hire')).toBe(true);
expect(isChatEnabledFor('it-1c-ticket')).toBe(true);
});
it('выключен для остальных типов заявок', () => {
expect(isChatEnabledFor('hr-vacation')).toBe(false);
expect(isChatEnabledFor('it-1c-ticket')).toBe(false);
expect(isChatEnabledFor('recruiting-hire')).toBe(false);
expect(isChatEnabledFor('recruiting-referral')).toBe(false);
});
it('незнакомый ключ — тоже выключен, а не бросает', () => {
@@ -3,9 +3,9 @@ import { RequestTypeId } from '../../models/types';
/**
* Типы заявок, для которых показывается чат (shared/chat).
*
* Пилот: только «Обходной лист» (forms/Clearance, FORM_ID = 'clearance-form').
* Остальным формам чат пока не нужен — решение сузить область до одного типа,
* а не включать сразу на все девять.
* Включаем по одному типу, а не сразу на все: «Обходной лист» — первый пилот,
* с 2026-10-14 добавлены «Заявка на подбор», «Заявка на приём» и «Заявка в
* отдел 1С».
*
* Ключ — строковый литерал, а не импорт FORM_ID из forms/Clearance/model:
* shared/* не должен зависеть от forms/* (зависимость идёт в обратную сторону
@@ -13,7 +13,12 @@ import { RequestTypeId } from '../../models/types';
* так что литерал здесь не менее надёжен, чем в LIST_TITLE_BASE
* (shared/listTitles.ts), где тем же приёмом перечислены все типы заявок.
*/
const CHAT_ENABLED_FORM_IDS: ReadonlySet<RequestTypeId> = new Set<RequestTypeId>(['clearance-form']);
const CHAT_ENABLED_FORM_IDS: ReadonlySet<RequestTypeId> = new Set<RequestTypeId>([
'clearance-form',
'recruiting-selection',
'recruiting-hire',
'it-1c-ticket'
]);
/** Показывать ли чат по заявке для этого типа. Проверяется ДО монтирования
* RequestChat — иначе хук useRequestChat опрашивал бы SharePoint (загрузка +
@@ -0,0 +1,70 @@
import {
buildParticipants,
extractMentionedEmails,
filterParticipants,
findMentionQuery,
insertMention
} from './chatMentions';
const ivanov = { name: 'Иван Иванов', email: 'ivanov@tnsu.kz' };
const petrova = { name: 'Анна Петрова', email: 'petrova@tnsu.kz' };
describe('buildParticipants', () => {
it('убирает повторы, пустые адреса и самого пишущего', () => {
const result = buildParticipants(
[ivanov, { name: 'Иван Иванов', email: 'IVANOV@tnsu.kz' }, { name: 'Без почты', email: '' }, undefined, petrova],
'petrova@tnsu.kz'
);
expect(result).toEqual([ivanov]);
});
it('без ФИО показывает почту', () => {
expect(buildParticipants([{ email: 'x@tnsu.kz' }], '')).toEqual([{ name: 'x@tnsu.kz', email: 'x@tnsu.kz' }]);
});
});
describe('findMentionQuery', () => {
it('находит отметку в начале и после пробела', () => {
expect(findMentionQuery('@Ив', 3)).toEqual({ start: 0, query: 'Ив' });
expect(findMentionQuery('Привет @Иван Ив', 15)).toEqual({ start: 7, query: 'Иван Ив' });
});
it('не считает отметкой адрес почты и перенос строки', () => {
expect(findMentionQuery('mail@tnsu', 9)).toBeUndefined();
expect(findMentionQuery('@Иван\nдалее', 11)).toBeUndefined();
expect(findMentionQuery('без отметки', 5)).toBeUndefined();
});
});
describe('filterParticipants', () => {
it('ищет по началу слов ФИО и по почте', () => {
expect(filterParticipants([ivanov, petrova], 'пет')).toEqual([petrova]);
expect(filterParticipants([ivanov, petrova], 'иван ив')).toEqual([ivanov]);
expect(filterParticipants([ivanov, petrova], 'petrova@')).toEqual([petrova]);
expect(filterParticipants([ivanov, petrova], '')).toEqual([ivanov, petrova]);
});
});
describe('insertMention', () => {
it('заменяет набранное на ФИО и ставит курсор за пробелом', () => {
const text = 'Привет @Ив, глянь';
const result = insertMention(text, { start: 7, query: 'Ив' }, 10, ivanov);
expect(result.text).toBe('Привет @Иван Иванов , глянь');
expect(result.caret).toBe('Привет @Иван Иванов '.length);
});
});
describe('extractMentionedEmails', () => {
it('берёт отметки из текста без повторов', () => {
expect(extractMentionedEmails('@Иван Иванов и @Анна Петрова, @Иван Иванов', [ivanov, petrova])).toEqual([
'ivanov@tnsu.kz',
'petrova@tnsu.kz'
]);
});
it('стёртая отметка не считается, короткое ФИО не цепляется за длинное', () => {
const ivan = { name: 'Иван', email: 'ivan@tnsu.kz' };
expect(extractMentionedEmails('@Иван Иванов, привет', [ivan, ivanov])).toEqual(['ivanov@tnsu.kz']);
expect(extractMentionedEmails('Иван Иванов, привет', [ivanov])).toEqual([]);
});
});
@@ -0,0 +1,116 @@
/**
* Отметки участников заявки в чате через «@».
*
* Отметка — ОБЫЧНЫЙ текст «@ФИО» в сообщении, а не штатное упоминание
* SharePoint (параметр mentions у комментария): штатное упоминание SharePoint
* сопровождает своим письмом, а письма по чату шлёт наш поток (chatNotify.ts) —
* с номером заявки и ссылкой на портал, а не на строку списка. Два письма об
* одном сообщении — лишнее.
*
* Модуль чистый (без React и PnPjs) — покрыт тестами.
*/
/** Участник заявки, которого можно отметить в чате. */
export interface IChatParticipant {
name: string;
email: string;
}
/** Активная отметка, которую пользователь сейчас набирает: «@Ива|». */
export interface IMentionQuery {
/** Позиция символа «@» в тексте. */
start: number;
/** Набранное после «@» до курсора. */
query: string;
}
/**
* Предел длины набираемой отметки: ФИО с отчеством укладывается, а «@» в
* середине обычного текста (адрес почты, «@всем, смотрите…») не держит
* подсказку открытой до конца абзаца.
*/
const MAX_QUERY_LENGTH = 40;
const sameEmail = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase();
/**
* Список тех, кого можно отметить: инициатор и все из маршрута согласования
* (согласующие и исполнители), без повторов и без самого пишущего — отмечать
* себя незачем. Порядок — как в маршруте, инициатор первым.
*/
export function buildParticipants(
people: Array<Partial<IChatParticipant> | undefined>,
currentUserEmail: string
): IChatParticipant[] {
const result: IChatParticipant[] = [];
for (const p of people) {
const email = (p?.email || '').trim();
if (!email) continue;
if (currentUserEmail && sameEmail(email, currentUserEmail)) continue;
if (result.some((r) => sameEmail(r.email, email))) continue;
result.push({ name: (p?.name || '').trim() || email, email });
}
return result;
}
/**
* Набирается ли сейчас отметка: «@» в начале текста или после пробела/переноса,
* и между ним и курсором нет переноса строки. Пробелы внутри допустимы — ФИО
* из нескольких слов.
*/
export function findMentionQuery(text: string, caret: number): IMentionQuery | undefined {
const before = text.slice(0, caret);
const start = before.lastIndexOf('@');
if (start < 0) return undefined;
if (start > 0 && !/\s/.test(before[start - 1])) return undefined;
const query = before.slice(start + 1);
if (query.length > MAX_QUERY_LENGTH || /[\r\n@]/.test(query)) return undefined;
return { start, query };
}
/** Участники, подходящие под набранное: каждое слово запроса — начало слова в ФИО или часть почты. */
export function filterParticipants(participants: IChatParticipant[], query: string): IChatParticipant[] {
const words = query.toLowerCase().split(/\s+/).filter((w) => !!w);
if (words.length === 0) return participants;
return participants.filter((p) => {
const nameWords = p.name.toLowerCase().split(/\s+/);
const email = p.email.toLowerCase();
return words.every((w) => nameWords.some((n) => n.startsWith(w)) || email.includes(w));
});
}
/** Заменить набранное «@запрос» на «@ФИО » и вернуть новый текст и позицию курсора. */
export function insertMention(
text: string,
mention: IMentionQuery,
caret: number,
participant: IChatParticipant
): { text: string; caret: number } {
const inserted = `@${participant.name} `;
const head = text.slice(0, mention.start);
// Пробел сразу за курсором уже есть — второй не нужен.
const tail = text.slice(caret).replace(/^ /, '');
return { text: head + inserted + tail, caret: head.length + inserted.length };
}
/**
* Кого в итоге отметили — по тексту, а не по истории выбора в подсказке:
* отметку могли стереть или вписать руками целиком. Длинные ФИО проверяются
* первыми, чтобы «@Иван Иванов» не засчитал заодно участника «Иван».
*/
export function extractMentionedEmails(text: string, participants: IChatParticipant[]): string[] {
// Найденную отметку затираем пробелами той же длины — позиции остальных не
// сдвигаются, и результат идёт в порядке появления в тексте.
let rest = text;
const found: Array<{ email: string; at: number }> = [];
const byLength = [...participants].sort((a, b) => b.name.length - a.name.length);
for (const p of byLength) {
const tag = `@${p.name}`;
const at = rest.indexOf(tag);
if (at < 0) continue;
rest = rest.split(tag).join(' '.repeat(tag.length));
if (!found.some((f) => sameEmail(f.email, p.email))) found.push({ email: p.email, at });
}
return found.sort((a, b) => a.at - b.at).map((f) => f.email);
}
@@ -0,0 +1,17 @@
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { FLOW_CHAT_NOTIFY_URL } from '../config';
import { postToFlow } from '../approval/notify.service';
import { IChatNotifyBody } from './chatNotify';
/**
* Отправить оповещение о сообщении в чате. Некритично: сообщение уже записано,
* и сбой письма не должен выглядеть как сбой отправки — ошибку только пишем в
* консоль, как и прочие оповещения портала (approval/notify.service.ts).
*/
export async function notifyChatMessage(context: WebPartContext, body: IChatNotifyBody): Promise<void> {
try {
await postToFlow(context, FLOW_CHAT_NOTIFY_URL, body);
} catch (e) {
console.warn('[Чат] Оповещение о сообщении не отправлено', e);
}
}
@@ -0,0 +1,65 @@
import { buildChatNotifyBody, IChatNotifyInput } from './chatNotify';
const initiator = { name: 'Инициатор Заявки', email: 'init@tnsu.kz' };
const approver = { name: 'Анна Петрова', email: 'petrova@tnsu.kz' };
const base: IChatNotifyInput = {
initiatorEmail: initiator.email,
participants: [initiator, approver],
authorName: 'Анна Петрова',
authorEmail: approver.email,
messageText: 'Текст',
requestCode: '13-2608-42',
requestUrl: 'https://portal/x',
requestTitle: 'Обходной лист',
selfNotify: false
};
describe('buildChatNotifyBody', () => {
it('пишет согласующий без отметок — письмо инициатору, отмеченных нет', () => {
expect(buildChatNotifyBody(base)).toEqual({
initiatorEmail: 'init@tnsu.kz',
mentionedEmails: [],
authorName: 'Анна Петрова',
messageText: 'Текст',
RequestUniqueCode: '13-2608-42',
requestUrl: 'https://portal/x',
requestTitle: 'Обходной лист'
});
});
it('инициатор пишет без отметок — поток не вызываем', () => {
expect(buildChatNotifyBody({ ...base, authorEmail: 'INIT@tnsu.kz' })).toBeUndefined();
});
it('инициатор отметил согласующего — инициатор подменён отмеченным, чтобы не писать самому себе', () => {
const body = buildChatNotifyBody({ ...base, authorEmail: initiator.email, messageText: '@Анна Петрова глянь' });
expect(body?.initiatorEmail).toBe('petrova@tnsu.kz');
expect(body?.mentionedEmails).toEqual(['petrova@tnsu.kz']);
});
it('отметка самого себя без режима проверки не считается', () => {
const body = buildChatNotifyBody({ ...base, messageText: '@Анна Петрова @Инициатор Заявки' });
expect(body?.mentionedEmails).toEqual(['init@tnsu.kz']);
});
it('в режиме проверки письма и о своих сообщениях, и себе по отметке', () => {
const own = buildChatNotifyBody({ ...base, authorEmail: initiator.email, selfNotify: true });
expect(own?.initiatorEmail).toBe('init@tnsu.kz');
const self = buildChatNotifyBody({ ...base, messageText: '@Анна Петрова', selfNotify: true });
expect(self?.mentionedEmails).toEqual(['petrova@tnsu.kz']);
});
it('файлы — строкой в тексте письма, и при пустом сообщении тоже', () => {
expect(buildChatNotifyBody({ ...base, fileNames: ['акт.pdf', 'скан.jpg'] })?.messageText).toBe(
'Текст\n\nПрикреплены файлы: акт.pdf, скан.jpg'
);
expect(buildChatNotifyBody({ ...base, messageText: '', fileNames: ['акт.pdf'] })?.messageText).toBe(
'Прикреплены файлы: акт.pdf'
);
});
it('нет ни инициатора, ни отметок — поток не вызываем', () => {
expect(buildChatNotifyBody({ ...base, initiatorEmail: '' })).toBeUndefined();
});
});
@@ -0,0 +1,83 @@
import { IChatParticipant, extractMentionedEmails } from './chatMentions';
/**
* Тело запроса к потоку оповещения о сообщении в чате (FLOW_CHAT_NOTIFY_URL).
* Контракт потока: все поля — строки, кроме mentionedEmails; никого не
* отметили — пустой массив, а не null и не пропущенное поле.
*/
export interface IChatNotifyBody {
initiatorEmail: string;
mentionedEmails: string[];
authorName: string;
messageText: string;
RequestUniqueCode: string;
requestUrl: string;
requestTitle: string;
}
export interface IChatNotifyInput {
/** Инициатор заявки (автор строки списка данных). */
initiatorEmail: string;
/** Кого можно было отметить — по ним ищутся «@ФИО» в тексте. */
participants: IChatParticipant[];
authorName: string;
authorEmail: string;
messageText: string;
/** Имена приложенных файлов, как их выбрал пользователь (без складского префикса). */
fileNames?: string[];
requestCode: string;
requestUrl: string;
requestTitle: string;
/** Режим проверки (свойство веб-части chatSelfNotify): письма и о своих сообщениях. */
selfNotify: boolean;
}
const sameEmail = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase();
/**
* Текст для письма: сообщение и строка о файлах. Без неё сообщение из одного
* файла пришло бы письмом с пустым текстом. Сами файлы в письмо не кладём —
* они в библиотеке, открываются по ссылке на заявку.
*/
function letterText(text: string, fileNames: string[]): string {
if (fileNames.length === 0) return text;
const files = `Прикреплены файлы: ${fileNames.join(', ')}`;
return text ? `${text}\n\n${files}` : files;
}
/**
* Собрать тело запроса к потоку или undefined, если писать некому.
*
* Поток шлёт инициатору письмо ВСЕГДА, когда его нет среди отмеченных. Значит,
* когда пишет сам инициатор (а режим проверки выключен), письмо «новое сообщение
* по вашей заявке» ушло бы ему же. Отключить его в контракте потока нечем,
* поэтому:
* — никого не отметил → поток не вызываем вовсе;
* — кого-то отметил → в initiatorEmail кладём первого отмеченного: по
* контракту отмеченный инициатор получает только «Вас упомянули», и
* лишнего письма никому не уходит.
*/
export function buildChatNotifyBody(input: IChatNotifyInput): IChatNotifyBody | undefined {
const mentioned = extractMentionedEmails(input.messageText, input.participants).filter(
(email) => input.selfNotify || !sameEmail(email, input.authorEmail)
);
let initiatorEmail = input.initiatorEmail;
const authorIsInitiator =
!!initiatorEmail && !!input.authorEmail && sameEmail(initiatorEmail, input.authorEmail);
if (authorIsInitiator && !input.selfNotify) {
if (mentioned.length === 0) return undefined;
initiatorEmail = mentioned[0];
}
if (!initiatorEmail && mentioned.length === 0) return undefined;
return {
initiatorEmail,
mentionedEmails: mentioned,
authorName: input.authorName,
messageText: letterText(input.messageText, input.fileNames || []),
RequestUniqueCode: input.requestCode,
requestUrl: input.requestUrl,
requestTitle: input.requestTitle
};
}
@@ -3,7 +3,19 @@ export { RequestChat } from './ui/RequestChat';
export { isChatEnabledFor } from './chatEnabled';
export { useRequestChat } from './useRequestChat';
export type { IRequestChat } from './useRequestChat';
export { getChatMessages, addChatMessage } from './requestChat.service';
export { getChatMessages, addChatMessage, getChatRequestInfo } from './requestChat.service';
export type { IChatRequestInfo } from './requestChat.service';
export {
buildParticipants,
findMentionQuery,
filterParticipants,
insertMention,
extractMentionedEmails
} from './chatMentions';
export type { IChatParticipant, IMentionQuery } from './chatMentions';
export { buildChatNotifyBody } from './chatNotify';
export type { IChatNotifyBody } from './chatNotify';
export { notifyChatMessage } from './chatNotify.service';
export {
uploadChatFiles,
chatFilesFolderUrl,
@@ -6,6 +6,12 @@ import { RequestTypeId } from '../../models/types';
import { IChatMessage } from './models/IChatMessage';
import { buildCommentText, hasAttachments, toChatMessages } from './chatMessages';
import { chatFilesFolderUrl, uploadChatFiles } from './chatFiles.service';
import { IChatParticipant } from './chatMentions';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { approvalServiceFor } from '../approval/approvalServiceFactory';
import { getRequestTypeIdByPageUrl } from '../approval/approvalRoute.service';
import { emailsForType } from '../approval/requestTypeAccess';
import { getEmployeeOrganizations } from '../dictionary';
/**
* Чат по заявке ПОВЕРХ штатных комментариев к строке списка.
@@ -76,3 +82,68 @@ export async function addChatMessage(
const storedNames = await uploadChatFiles(formKey, itemId, listId, files);
await commentsOf(formKey, itemId, listId).add(buildCommentText(text, storedNames));
}
/** Сведения о заявке, нужные чату помимо переписки: кого отмечать и что писать в письме. */
export interface IChatRequestInfo {
code: string;
title: string;
initiator: IChatParticipant;
/**
* Инициатор, все из маршрута (согласующие и исполнители) и те, кому открыты
* «Все заявки» этого типа, — как есть, повторы убирает buildParticipants.
*/
people: IChatParticipant[];
}
/**
* Люди с доступом ко «Всем заявкам» этого типа (админка approval-route): они
* открывают любую заявку типа и пишут в её чат, значит, их можно и отметить.
* ФИО — из кадрового справочника, не нашлось — показываем почту. Любой сбой —
* просто без них: отметки участников маршрута от этого не страдают.
*/
async function allRequestsViewers(context: WebPartContext, formKey: RequestTypeId): Promise<IChatParticipant[]> {
try {
const typeId = await getRequestTypeIdByPageUrl(context, formKey);
const emails = await emailsForType(context, typeId);
return await Promise.all(
emails.map(async (email) => {
const records = await getEmployeeOrganizations(context, email).catch(() => []);
return { name: records[0]?.fio || email, email };
})
);
} catch {
return [];
}
}
/**
* Инициатор — автор строки списка данных: заявку создаёт он сам (подмена
* личности на автора записи не влияет, см. RequestChat). Участники — строки
* маршрута; если список согласования не прочитался, чат работает и так —
* просто отметить можно только инициатора.
*/
export async function getChatRequestInfo(
context: WebPartContext,
formKey: RequestTypeId,
itemId: number,
listId: string | undefined
): Promise<IChatRequestInfo> {
const [item, matrix, viewers] = await Promise.all([
resolveDataList(formKey, listId)
.items.getById(itemId)
.select('Title', 'RequestUniqueCode', 'Author/Title', 'Author/EMail')
.expand('Author')() as Promise<Record<string, unknown>>,
approvalServiceFor(formKey, listId)
.getMatrixForRequest(itemId)
.catch(() => []),
allRequestsViewers(context, formKey)
]);
const author = item.Author as { Title?: string; EMail?: string } | undefined;
const initiator = { name: author?.Title || '', email: author?.EMail || '' };
return {
code: (item.RequestUniqueCode as string) || '',
title: (item.Title as string) || '',
initiator,
people: [initiator, ...matrix.map((r) => ({ name: r.who, email: r.email })), ...viewers]
};
}
@@ -2,22 +2,33 @@ import * as React from 'react';
import styles from './RequestChat.module.scss';
import { Button, FileDropzone, Icon } from '../../ui';
import { MAX_FILES_PER_MESSAGE, MAX_FILE_SIZE_BYTES } from '../chatFiles.service';
import { filterParticipants, findMentionQuery, IChatParticipant, insertMention } from '../chatMentions';
interface IChatComposerProps {
sending: boolean;
/** Кого можно отметить через «@» (см. chatMentions.ts). */
participants: IChatParticipant[];
/** Возвращает true, если сообщение записано — только тогда очищаем поле. */
onSend: (text: string, files: File[]) => Promise<boolean>;
}
const MAX_FILE_SIZE_MB = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024));
/** Сколько вариантов показывать в подсказке «@» — дальше пусть уточняют набором. */
const MAX_SUGGESTIONS = 8;
/**
* Поле ввода сообщения и выбор файлов. Здесь НЕ ui-kit TextArea, а собственный
* textarea: чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш
* не пробрасывает.
*/
export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend }) => {
export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, participants, onSend }) => {
const [text, setText] = React.useState<string>('');
const [caret, setCaret] = React.useState<number>(0);
const [highlighted, setHighlighted] = React.useState<number>(0);
// Esc закрывает подсказку до следующего набора — иначе её не убрать, не стерев «@».
const [mentionDismissed, setMentionDismissed] = React.useState<boolean>(false);
const inputRef = React.useRef<HTMLTextAreaElement>(null);
const [files, setFiles] = React.useState<File[]>([]);
const [filesOpen, setFilesOpen] = React.useState<boolean>(false);
@@ -25,6 +36,30 @@ export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend })
// текст отправку не блокирует.
const canSend = (text.trim().length > 0 || files.length > 0) && !sending;
const mention = mentionDismissed ? undefined : findMentionQuery(text, caret);
const suggestions = mention ? filterParticipants(participants, mention.query).slice(0, MAX_SUGGESTIONS) : [];
const suggestOpen = suggestions.length > 0;
const active = Math.min(highlighted, suggestions.length - 1);
const pickMention = (participant: IChatParticipant): void => {
if (!mention) return;
const next = insertMention(text, mention, caret, participant);
setText(next.text);
setCaret(next.caret);
setHighlighted(0);
// Курсор ставим за вставленным ФИО — после перерисовки, когда текст уже в поле.
window.setTimeout(() => {
const input = inputRef.current;
if (!input) return;
input.focus();
input.setSelectionRange(next.caret, next.caret);
}, 0);
};
const trackCaret = (e: React.SyntheticEvent<HTMLTextAreaElement>): void => {
setCaret(e.currentTarget.selectionStart || 0);
};
const send = (): void => {
if (!canSend) return;
onSend(text, files)
@@ -38,7 +73,26 @@ export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend })
};
// Enter отправляет, Shift+Enter переносит строку — привычное поведение чата.
// Пока открыта подсказка «@», стрелки ходят по ней, а Enter/Tab выбирают.
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
if (suggestOpen) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const step = e.key === 'ArrowDown' ? 1 : -1;
setHighlighted((active + step + suggestions.length) % suggestions.length);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
pickMention(suggestions[active]);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setMentionDismissed(true);
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
@@ -57,16 +111,49 @@ export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend })
)}
<div className={styles.composer}>
<textarea
className={styles.input}
value={text}
rows={2}
disabled={sending}
placeholder="Сообщение… (Enter — отправить, Shift+Enter — новая строка)"
aria-label="Сообщение в обсуждение заявки"
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
/>
<div className={styles.inputWrap}>
<textarea
ref={inputRef}
className={styles.input}
value={text}
rows={2}
disabled={sending}
placeholder="Сообщение… (@ — отметить участника, Enter — отправить, Shift+Enter — новая строка)"
aria-label="Сообщение в обсуждение заявки"
aria-autocomplete="list"
aria-expanded={suggestOpen}
onChange={(e) => {
setText(e.target.value);
setCaret(e.target.selectionStart || 0);
setHighlighted(0);
setMentionDismissed(false);
}}
onSelect={trackCaret}
onClick={trackCaret}
onKeyDown={handleKeyDown}
/>
{suggestOpen && (
<ul className={styles.mentions} role="listbox" aria-label="Участники заявки">
{suggestions.map((p, i) => (
<li
key={p.email}
role="option"
aria-selected={i === active}
className={`${styles.mentionItem} ${i === active ? styles.mentionActive : ''}`}
// mousedown, а не click: иначе поле теряет фокус раньше, чем выбор сработает.
onMouseDown={(e) => {
e.preventDefault();
pickMention(p);
}}
onMouseEnter={() => setHighlighted(i)}
>
<span className={styles.mentionName}>{p.name}</span>
<span className={styles.mentionEmail}>{p.email}</span>
</li>
))}
</ul>
)}
</div>
<div className={styles.composerActions}>
{/* Пока файлы выбраны, панель не сворачиваем: иначе прикреплённое
пропадает с глаз, и легко отправить не то, что собирался. */}
@@ -167,6 +167,59 @@
flex-shrink: 0;
}
.inputWrap {
position: relative;
flex: 1 1 auto;
min-width: 0;
display: flex;
}
// Подсказка «@» — НАД полем: панель заявки прокручивается, и под полем список
// уходил бы за нижний край.
.mentions {
position: absolute;
left: 0;
right: 0;
bottom: calc(100% + 4px);
z-index: 10;
list-style: none;
margin: 0;
padding: 4px;
max-height: 240px;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-md;
box-shadow: $sh-1;
}
.mentionItem {
display: flex;
align-items: baseline;
gap: 8px;
padding: 6px 10px;
border-radius: $r-sm;
cursor: pointer;
}
.mentionActive {
background: $navy-100;
}
.mentionName {
font-size: $fs-sm;
font-weight: 600;
color: $navy-800;
}
.mentionEmail {
font-size: $fs-caption;
color: $gray-500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.input {
flex: 1 1 auto;
min-width: 0;
@@ -27,11 +27,7 @@ interface IRequestChatProps {
* Сообщения — штатные комментарии к строке списка (см. requestChat.service.ts).
*/
export const RequestChat: React.FC<IRequestChatProps> = ({ context, formKey, itemId, listId }) => {
// Автора комментария SharePoint подписывает РЕАЛЬНОЙ учёткой — отладочная
// подмена личности (shared/impersonation.ts) права доступа и автора записи не
// меняет. Поэтому «свои» сообщения ищем по настоящему пользователю.
const currentUserEmail = context.pageContext.user.email || '';
const chat = useRequestChat(formKey, itemId, listId, currentUserEmail);
const chat = useRequestChat(context, formKey, itemId, listId);
const impersonated = getImpersonatedEmail();
return (
@@ -50,7 +46,7 @@ export const RequestChat: React.FC<IRequestChatProps> = ({ context, formKey, ite
{chat.error && <Alert variant="err">{chat.error}</Alert>}
<ChatMessageList messages={chat.messages} loading={chat.loading} />
<ChatComposer sending={chat.sending} onSend={chat.send} />
<ChatComposer sending={chat.sending} participants={chat.participants} onSend={chat.send} />
{/* Подмена личности не распространяется на комментарии — предупреждаем,
иначе при отладке непонятно, почему сообщение подписано не тем. */}
@@ -1,9 +1,15 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { friendlyErrorMessage } from '../diagnostics';
import { getChatSelfNotify, PORTAL_PAGE_URL } from '../config';
import { requestLinkOnPage } from '../routing';
import { RequestTypeId } from '../../models/types';
import { IChatMessage } from './models/IChatMessage';
import { addChatMessage, getChatMessages } from './requestChat.service';
import { addChatMessage, getChatMessages, getChatRequestInfo, IChatRequestInfo } from './requestChat.service';
import { validateChatFiles } from './chatFiles.service';
import { buildParticipants, IChatParticipant } from './chatMentions';
import { buildChatNotifyBody } from './chatNotify';
import { notifyChatMessage } from './chatNotify.service';
/**
* Как часто перечитываем переписку, пока заявка открыта. Чат — переписка живая,
@@ -18,6 +24,8 @@ export interface IRequestChat {
loading: boolean;
sending: boolean;
error?: string;
/** Кого можно отметить через «@». Пусто, пока сведения о заявке не загрузились. */
participants: IChatParticipant[];
/**
* Отправить сообщение с файлами (файлы необязательны). Пустое сообщение —
* без текста и без файлов — игнорируется. Возвращает true, если сообщение
@@ -34,11 +42,17 @@ export interface IRequestChat {
* Логика вынесена из компонента, чтобы RequestChat остался разметкой.
*/
export function useRequestChat(
context: WebPartContext,
formKey: RequestTypeId,
itemId: number,
listId: string | undefined,
currentUserEmail: string
listId: string | undefined
): IRequestChat {
// Автора комментария SharePoint подписывает РЕАЛЬНОЙ учёткой — отладочная
// подмена личности (shared/impersonation.ts) права доступа и автора записи не
// меняет. Поэтому «свои» сообщения ищем по настоящему пользователю.
const currentUserEmail = context.pageContext.user.email || '';
const currentUserName = context.pageContext.user.displayName || currentUserEmail;
const selfNotify = getChatSelfNotify();
const [messages, setMessages] = React.useState<IChatMessage[]>([]);
const [loading, setLoading] = React.useState<boolean>(true);
const [sending, setSending] = React.useState<boolean>(false);
@@ -48,6 +62,29 @@ export function useRequestChat(
const [reloadSeq, setReloadSeq] = React.useState<number>(0);
const reload = React.useCallback((): void => setReloadSeq((n) => n + 1), []);
// Сведения о заявке (инициатор, маршрут, код) — один раз на заявку, без
// автообновления: маршрут за время открытой панели почти не меняется. Не
// загрузились — чат работает, только без отметок и без писем.
const [info, setInfo] = React.useState<IChatRequestInfo | undefined>(undefined);
React.useEffect(() => {
let alive = true;
setInfo(undefined);
getChatRequestInfo(context, formKey, itemId, listId)
.then((loaded) => {
if (alive) setInfo(loaded);
})
.catch((e) => console.warn('[Чат] Не удалось загрузить участников заявки', e));
return () => {
alive = false;
};
}, [context, formKey, itemId, listId]);
// В режиме проверки себя тоже можно отметить — отсекаем «себя» по пустому адресу.
const participants = React.useMemo(
() => (info ? buildParticipants(info.people, selfNotify ? '' : currentUserEmail) : []),
[info, selfNotify, currentUserEmail]
);
// Панель заявки не размонтируется при переходе на ДРУГУЮ заявку (меняются
// только пропсы), поэтому прежнюю переписку убираем явно — иначе на экране
// на миг оставались бы чужие сообщения.
@@ -86,6 +123,29 @@ export function useRequestChat(
};
}, [formKey, itemId, listId, currentUserEmail, reloadSeq]);
// Письмо о сообщении — после записи комментария и без ожидания: сбой потока
// не должен выглядеть как неудачная отправка (см. notifyChatMessage).
const notifyAboutMessage = React.useCallback(
(text: string, fileNames: string[]): void => {
if (!info) return;
const body = buildChatNotifyBody({
initiatorEmail: info.initiator.email,
participants,
authorName: currentUserName,
authorEmail: currentUserEmail,
messageText: text,
fileNames,
requestCode: info.code,
requestUrl: requestLinkOnPage(PORTAL_PAGE_URL, formKey, itemId, listId),
requestTitle: info.title || info.code,
selfNotify
});
// eslint-disable-next-line no-void -- письмо не ждём, ошибки notifyChatMessage глотает сам
if (body) void notifyChatMessage(context, body);
},
[info, participants, currentUserName, currentUserEmail, formKey, itemId, listId, selfNotify, context]
);
const send = React.useCallback(
async (text: string, files: File[]): Promise<boolean> => {
const trimmed = text.trim();
@@ -104,6 +164,10 @@ export function useRequestChat(
await addChatMessage(formKey, itemId, listId, trimmed, files);
setError(undefined);
reload();
notifyAboutMessage(
trimmed,
files.map((f) => f.name)
);
return true;
} catch (e) {
setError(`Не удалось отправить сообщение: ${friendlyErrorMessage(e)}`);
@@ -112,8 +176,8 @@ export function useRequestChat(
setSending(false);
}
},
[formKey, itemId, listId, reload]
[formKey, itemId, listId, reload, notifyAboutMessage]
);
return { messages, loading, sending, error, send, reload };
return { messages, loading, sending, error, participants, send, reload };
}
@@ -157,6 +157,30 @@ export const FLOW_RESOURCE = 'https://service.flow.microsoft.com/';
export const FLOW_NOTIFY_URL =
'https://eab3d2815f2beb42bf2e7ec1731708.92.environment.api.powerplatform.com:443/powerautomate/automations/direct/cu/07/workflows/b21516edf8a14287a65a39e4116dfe4f/triggers/manual/paths/invoke?api-version=1';
/**
* Power Automate: поток оповещения о новом сообщении в чате заявки (shared/chat).
* Тот же механизм вызова, что у FLOW_NOTIFY_URL (Entra-триггер, AadHttpClient с
* ресурсом FLOW_RESOURCE), но тело другое — получателей поток НЕ ищет сам, их
* передаёт портал (см. shared/chat/chatNotify.ts):
* отмеченным через «@» — «Вас упомянули в чате по заявке №…»,
* инициатору (если его не отметили) — «Новое сообщение в чате по вашей заявке №…».
*/
export const FLOW_CHAT_NOTIFY_URL =
'https://eab3d2815f2beb42bf2e7ec1731708.92.environment.api.powerplatform.com:443/powerautomate/automations/direct/cu/07/workflows/325d735d32de4e6989f4f06fc347e833/triggers/manual/paths/invoke?api-version=1';
// Чат заявки, режим проверки (свойство веб-части chatSelfNotify): письма уходят и
// о СВОИХ сообщениях, и себя можно отметить через «@». По умолчанию выключен —
// писать самому себе в обычной работе незачем.
let _chatSelfNotify = false;
export function setChatSelfNotify(enabled: boolean): void {
_chatSelfNotify = enabled;
}
export function getChatSelfNotify(): boolean {
return _chatSelfNotify;
}
/**
* Microsoft Dataverse (Power Platform) — рабочая среда «По умолчанию»
* (org95ca17d6.crm4.dynamics.com). NB: таблица cree9_demandline существует и в
@@ -91,6 +91,7 @@ export function parseFieldChanges(raw: unknown): IFieldChange[] {
}
/** Журнал → JSON для колонки (null — журнал пуст, колонку очищаем). */
// eslint-disable-next-line @rushstack/no-new-null -- null очищает колонку SharePoint, undefined поле бы не тронул
export function serializeFieldChanges(changes: IFieldChange[]): string | null {
return changes.length > 0 ? JSON.stringify(changes) : null;
}
+11 -1
View File
@@ -535,6 +535,16 @@ export function routeUrl(route: IRoute): string {
* другого способа узнать её путь у клиентского кода нет.
*/
export function requestLinkUrl(formKey: string, itemId: number, listId?: string): string {
return requestLinkOnPage(`${window.location.origin}${window.location.pathname}`, formKey, itemId, listId);
}
/**
* То же, что requestLinkUrl, но на ЗАДАННОЙ странице портала, а не на текущей.
* Нужна там, где текущий адрес может оказаться не страницей портала: внутри
* Teams это служебная страница вкладки, и ссылка из неё в письме не откроется
* (см. PORTAL_PAGE_URL в config.ts).
*/
export function requestLinkOnPage(pageUrl: string, formKey: string, itemId: number, listId?: string): string {
const params = new URLSearchParams();
params.set('rpmode', 'approve');
params.set('rptype', formKey);
@@ -542,7 +552,7 @@ export function requestLinkUrl(formKey: string, itemId: number, listId?: string)
if (listId) {
params.set('rplist', listId);
}
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
return `${pageUrl}?${params.toString()}`;
}
/**