feat: отметки «@» в чате заявки и письма через поток Power Automate
В поле ввода чата «@» открывает подсказку с участниками заявки (инициатор и все из маршрута). Отметка — обычный текст «@ФИО», без штатного упоминания SharePoint, чтобы не уходило его собственное письмо. После отправки сообщения портал вызывает поток FLOW_CHAT_NOTIFY_URL: отмеченным — «Вас упомянули», инициатору — «Новое сообщение по вашей заявке». О своих сообщениях писем нет; для проверки это включается свойством веб-части «Чат: уведомлять о своих сообщениях» (по умолчанию выключено) — тогда же можно отметить себя. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,56 @@
|
||||
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, initiatorEmail: '' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
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;
|
||||
requestCode: string;
|
||||
requestUrl: string;
|
||||
requestTitle: string;
|
||||
/** Режим проверки (свойство веб-части chatSelfNotify): письма и о своих сообщениях. */
|
||||
selfNotify: boolean;
|
||||
}
|
||||
|
||||
const sameEmail = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase();
|
||||
|
||||
/**
|
||||
* Собрать тело запроса к потоку или 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: input.messageText,
|
||||
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,8 @@ 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 { approvalServiceFor } from '../approval/approvalServiceFactory';
|
||||
|
||||
/**
|
||||
* Чат по заявке ПОВЕРХ штатных комментариев к строке списка.
|
||||
@@ -76,3 +78,42 @@ 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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициатор — автор строки списка данных: заявку создаёт он сам (подмена
|
||||
* личности на автора записи не влияет, см. RequestChat). Участники — строки
|
||||
* маршрута; если список согласования не прочитался, чат работает и так —
|
||||
* просто отметить можно только инициатора.
|
||||
*/
|
||||
export async function getChatRequestInfo(
|
||||
formKey: RequestTypeId,
|
||||
itemId: number,
|
||||
listId: string | undefined
|
||||
): Promise<IChatRequestInfo> {
|
||||
const [item, matrix] = 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(() => [])
|
||||
]);
|
||||
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 }))]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(formKey, itemId, listId)
|
||||
.then((loaded) => {
|
||||
if (alive) setInfo(loaded);
|
||||
})
|
||||
.catch((e) => console.warn('[Чат] Не удалось загрузить участников заявки', e));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [formKey, itemId, listId]);
|
||||
|
||||
// В режиме проверки себя тоже можно отметить — отсекаем «себя» по пустому адресу.
|
||||
const participants = React.useMemo(
|
||||
() => (info ? buildParticipants(info.people, selfNotify ? '' : currentUserEmail) : []),
|
||||
[info, selfNotify, currentUserEmail]
|
||||
);
|
||||
|
||||
// Панель заявки не размонтируется при переходе на ДРУГУЮ заявку (меняются
|
||||
// только пропсы), поэтому прежнюю переписку убираем явно — иначе на экране
|
||||
// на миг оставались бы чужие сообщения.
|
||||
@@ -86,6 +123,28 @@ export function useRequestChat(
|
||||
};
|
||||
}, [formKey, itemId, listId, currentUserEmail, reloadSeq]);
|
||||
|
||||
// Письмо о сообщении — после записи комментария и без ожидания: сбой потока
|
||||
// не должен выглядеть как неудачная отправка (см. notifyChatMessage).
|
||||
const notifyAboutMessage = React.useCallback(
|
||||
(text: string): void => {
|
||||
if (!info) return;
|
||||
const body = buildChatNotifyBody({
|
||||
initiatorEmail: info.initiator.email,
|
||||
participants,
|
||||
authorName: currentUserName,
|
||||
authorEmail: currentUserEmail,
|
||||
messageText: text,
|
||||
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 +163,7 @@ export function useRequestChat(
|
||||
await addChatMessage(formKey, itemId, listId, trimmed, files);
|
||||
setError(undefined);
|
||||
reload();
|
||||
notifyAboutMessage(trimmed);
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(`Не удалось отправить сообщение: ${friendlyErrorMessage(e)}`);
|
||||
@@ -112,8 +172,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 существует и в
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user