Управление заявками (#7)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/7
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { Employee } from '../models';
|
||||
import { findEmployeeByEmail, toEmployeeOption } from './employee';
|
||||
|
||||
const employees: Employee[] = [
|
||||
{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик middle' },
|
||||
{ fio: 'Соколов Давид', email: 'sokolov.david@tnsu.kz', position: 'Руководитель отдела' }
|
||||
];
|
||||
|
||||
describe('findEmployeeByEmail', () => {
|
||||
it('находит сотрудника по точному совпадению email', () => {
|
||||
expect(findEmployeeByEmail(employees, 'gorbenko.pavel@tnsu.kz')?.fio).toBe('Горбенко Павел');
|
||||
});
|
||||
|
||||
it('сравнение регистронезависимое', () => {
|
||||
expect(findEmployeeByEmail(employees, 'GORBENKO.PAVEL@TNSU.KZ')?.fio).toBe('Горбенко Павел');
|
||||
});
|
||||
|
||||
it('возвращает undefined, если email не задан', () => {
|
||||
expect(findEmployeeByEmail(employees, undefined)).toBeUndefined();
|
||||
expect(findEmployeeByEmail(employees, '')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('возвращает undefined, если сотрудник с таким email не найден', () => {
|
||||
expect(findEmployeeByEmail(employees, 'nobody@tnsu.kz')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toEmployeeOption', () => {
|
||||
it('строит опцию для SearchableSelect из сотрудника', () => {
|
||||
expect(toEmployeeOption(employees[0])).toEqual({
|
||||
value: 'gorbenko.pavel@tnsu.kz',
|
||||
label: 'Горбенко Павел',
|
||||
sublabel: 'gorbenko.pavel@tnsu.kz'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ export function toEmployeeOption(employee: Employee): ISearchableOption {
|
||||
return { value: employee.email, label: employee.fio, sublabel: employee.email };
|
||||
}
|
||||
|
||||
export function findEmployeeByEmail(employees: Employee[], email: string | null): Employee | undefined {
|
||||
export function findEmployeeByEmail(employees: Employee[], email: string | undefined): Employee | undefined {
|
||||
if (!email) return undefined;
|
||||
const target = email.toLowerCase();
|
||||
return employees.find(e => e.email.toLowerCase() === target);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describeError, reportError } from './errors';
|
||||
|
||||
describe('describeError', () => {
|
||||
it('вытаскивает читаемый текст из сырого odata.error-ответа SharePoint', () => {
|
||||
const raw =
|
||||
'Error making HttpClient request in queryable [500] :: ' +
|
||||
'{"odata.error":{"code":"-1, Microsoft.SharePoint.SPException","message":{"lang":"ru-RU","value":"Не удалось сохранить."}}}';
|
||||
expect(describeError(new Error(raw))).toBe('Не удалось сохранить.');
|
||||
});
|
||||
|
||||
it('раскрывает \\uXXXX-escape внутри value в кириллицу (JSON.parse делает это сам)', () => {
|
||||
const raw = '{"odata.error":{"message":{"lang":"ru-RU","value":"\\u041f\\u0440\\u0438\\u0432\\u0435\\u0442"}}}';
|
||||
expect(describeError(new Error(raw))).toBe('Привет');
|
||||
});
|
||||
|
||||
it('возвращает исходный текст, если в сообщении нет JSON', () => {
|
||||
expect(describeError(new Error('Просто текст ошибки'))).toBe('Просто текст ошибки');
|
||||
});
|
||||
|
||||
it('возвращает исходный текст, если JSON есть, но не парсится (обрезан)', () => {
|
||||
const raw = 'Error :: {"odata.error":{"message":';
|
||||
expect(describeError(new Error(raw))).toBe(raw);
|
||||
});
|
||||
|
||||
it('возвращает исходный текст, если JSON валиден, но без odata.error.message.value', () => {
|
||||
const raw = '{"foo":"bar"}';
|
||||
expect(describeError(new Error(raw))).toBe(raw);
|
||||
});
|
||||
|
||||
it('работает не только с Error, но и с произвольным выброшенным значением', () => {
|
||||
expect(describeError('строка вместо Error')).toBe('строка вместо Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportError', () => {
|
||||
it('логирует ошибку целиком в консоль и возвращает читаемый текст', () => {
|
||||
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const error = new Error('{"odata.error":{"message":{"value":"Сбой"}}}');
|
||||
|
||||
const result = reportError(error, 'someContext');
|
||||
|
||||
expect(result).toBe('Сбой');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy.mock.calls[0][0]).toContain('someContext');
|
||||
expect(spy.mock.calls[0][1]).toBe(error);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// PnPjs (getById(...).update(...) и т.п.) при ошибке SharePoint кладёт в
|
||||
// Error.message не только код HTTP, но и ВЕСЬ сырой текст ответа сервера —
|
||||
// это JSON вида {"odata.error":{"code":"...","message":{"lang":"ru-RU","value":"..."}}}.
|
||||
// Если просто вывести e.message пользователю, он увидит фигурные скобки,
|
||||
// "odata.error" и escape-последовательности \uXXXX прямо как текст (это НЕ
|
||||
// баг кодировки — JSON.parse их корректно раскрывает в кириллицу, но e.message
|
||||
// это просто СТРОКА, а не распарсенный объект, вот они и остаются как есть).
|
||||
|
||||
interface ISharePointErrorEnvelope {
|
||||
'odata.error'?: { message?: { value?: string } };
|
||||
}
|
||||
|
||||
/** Читаемый текст ошибки — вытаскивает odata.error.message.value, если он есть. */
|
||||
export function describeError(e: unknown): string {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
const jsonStart = message.indexOf('{');
|
||||
if (jsonStart === -1) return message;
|
||||
try {
|
||||
const parsed = JSON.parse(message.slice(jsonStart)) as ISharePointErrorEnvelope;
|
||||
return parsed['odata.error']?.message?.value || message;
|
||||
} catch {
|
||||
// Не JSON (или обрезан) — отдаём как есть, лучше сырой текст, чем ничего.
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/** Полная ошибка — в консоль (для диагностики), пользователю возвращается только читаемый текст. */
|
||||
export function reportError(e: unknown, context: string): string {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[ApprovalAdmin] ${context}`, e);
|
||||
return describeError(e);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { IOrderedItem, blockIndexOf, blockRanges, markParallel, swapBlocks, withOrdersFrom } from './orderBlocks';
|
||||
|
||||
interface TestRow extends IOrderedItem {
|
||||
id: number;
|
||||
}
|
||||
|
||||
function row(id: number, order: number, parallelWithPrev?: boolean): TestRow {
|
||||
return { id, order, parallelWithPrev };
|
||||
}
|
||||
|
||||
describe('blockRanges', () => {
|
||||
it('кладёт каждую непараллельную строку в отдельный блок', () => {
|
||||
const rows = [row(1, 1), row(2, 2), row(3, 3)];
|
||||
expect(blockRanges(rows)).toEqual([
|
||||
{ start: 0, end: 0 },
|
||||
{ start: 1, end: 1 },
|
||||
{ start: 2, end: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('объединяет подряд идущие parallelWithPrev-строки в один блок', () => {
|
||||
const rows = [row(1, 1), row(2, 2), row(3, 2, true), row(4, 3)];
|
||||
expect(blockRanges(rows)).toEqual([
|
||||
{ start: 0, end: 0 },
|
||||
{ start: 1, end: 2 },
|
||||
{ start: 3, end: 3 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('игнорирует parallelWithPrev у самой первой строки — блок ей всё равно свой', () => {
|
||||
const rows = [row(1, 1, true)];
|
||||
expect(blockRanges(rows)).toEqual([{ start: 0, end: 0 }]);
|
||||
});
|
||||
|
||||
it('для пустого списка строк возвращает пустой список блоков', () => {
|
||||
expect(blockRanges([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blockIndexOf', () => {
|
||||
const ranges = [
|
||||
{ start: 0, end: 0 },
|
||||
{ start: 1, end: 2 },
|
||||
{ start: 3, end: 3 }
|
||||
];
|
||||
|
||||
it('находит блок для строки в его середине/на границах', () => {
|
||||
expect(blockIndexOf(ranges, 0)).toBe(0);
|
||||
expect(blockIndexOf(ranges, 1)).toBe(1);
|
||||
expect(blockIndexOf(ranges, 2)).toBe(1);
|
||||
expect(blockIndexOf(ranges, 3)).toBe(2);
|
||||
});
|
||||
|
||||
it('возвращает -1, если индекс строки ни в один диапазон не попадает', () => {
|
||||
expect(blockIndexOf(ranges, 99)).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('swapBlocks', () => {
|
||||
it('меняет местами два блока, не трогая порядок строк внутри них', () => {
|
||||
const rows = ['a', 'b1', 'b2', 'c'];
|
||||
const a = { start: 0, end: 0 };
|
||||
const b = { start: 1, end: 2 };
|
||||
expect(swapBlocks(rows, a, b)).toEqual(['b1', 'b2', 'a', 'c']);
|
||||
});
|
||||
|
||||
it('не трогает строки за пределами переставляемых блоков', () => {
|
||||
const rows = ['head', 'a', 'b', 'tail'];
|
||||
const a = { start: 1, end: 1 };
|
||||
const b = { start: 2, end: 2 };
|
||||
expect(swapBlocks(rows, a, b)).toEqual(['head', 'b', 'a', 'tail']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withOrdersFrom', () => {
|
||||
it('нумерует непараллельные строки последовательно начиная с floor+1', () => {
|
||||
const rows = [row(1, 5), row(2, 5), row(3, 5)];
|
||||
const result = withOrdersFrom(rows, 0);
|
||||
expect(result.map(r => r.order)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('строкам с parallelWithPrev даёт тот же order, что и предыдущей строке', () => {
|
||||
const rows = [row(1, 1), row(2, 1, true), row(3, 1)];
|
||||
const result = withOrdersFrom(rows, 0);
|
||||
expect(result.map(r => r.order)).toEqual([1, 1, 2]);
|
||||
expect(result.map(r => r.parallelWithPrev)).toEqual([false, true, false]);
|
||||
});
|
||||
|
||||
it('первая строка никогда не параллельна, даже если parallelWithPrev выставлен явно', () => {
|
||||
const rows = [row(1, 1, true), row(2, 1)];
|
||||
const result = withOrdersFrom(rows, 0);
|
||||
expect(result[0].parallelWithPrev).toBe(false);
|
||||
expect(result[0].order).toBe(1);
|
||||
});
|
||||
|
||||
it('floor сдвигает нумерацию — строки после заблокированного шага не могут получить order <= floor', () => {
|
||||
const rows = [row(1, 1), row(2, 2)];
|
||||
const result = withOrdersFrom(rows, 3);
|
||||
expect(result.map(r => r.order)).toEqual([4, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markParallel', () => {
|
||||
it('помечает строку как параллельную, только если её order совпадает с order предыдущей', () => {
|
||||
const rows = [row(1, 1), row(2, 1), row(3, 2), row(4, 2)];
|
||||
const result = markParallel(rows);
|
||||
expect(result.map(r => r.parallelWithPrev)).toEqual([false, true, false, true]);
|
||||
});
|
||||
|
||||
it('первая строка списка никогда не параллельна', () => {
|
||||
const rows = [row(1, 1)];
|
||||
expect(markParallel(rows)[0].parallelWithPrev).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
// Общая механика «шагов, которые могут идти параллельно» — 1:1 повторяет
|
||||
// request-portal/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts
|
||||
// (blockRanges/swapBlocks/withOrders/markParallel), только обобщена под
|
||||
// generic-тип с полями order/parallelWithPrev, чтобы работать и с
|
||||
// ManagedApprovalRow здесь, и не тащить зависимость на request-portal.
|
||||
//
|
||||
// Параллельность кодируется ЧЕРЕЗ РАВНЫЙ order у соседних строк — отдельного
|
||||
// флага в SharePoint нет (см. FIELDS в requestManageService.ts: там только
|
||||
// StepOrder). parallelWithPrev — это производное поле, вычисляемое на клиенте
|
||||
// из совпадения order с предыдущей строкой (см. markParallel), а не то, что
|
||||
// хранится в списке.
|
||||
export interface IOrderedItem {
|
||||
order: number;
|
||||
parallelWithPrev?: boolean;
|
||||
}
|
||||
|
||||
export interface IBlockRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Разбивает строки (уже отсортированные по order) на шаги — блоки с общим order. */
|
||||
export function blockRanges<T extends IOrderedItem>(rows: T[]): IBlockRange[] {
|
||||
const ranges: IBlockRange[] = [];
|
||||
rows.forEach((r, i) => {
|
||||
if (i === 0 || !r.parallelWithPrev) ranges.push({ start: i, end: i });
|
||||
else ranges[ranges.length - 1].end = i;
|
||||
});
|
||||
return ranges;
|
||||
}
|
||||
|
||||
export function blockIndexOf(ranges: IBlockRange[], row: number): number {
|
||||
for (let i = 0; i < ranges.length; i += 1) {
|
||||
if (row >= ranges[i].start && row <= ranges[i].end) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Меняет местами два блока (диапазона индексов), не трогая порядок строк внутри них. */
|
||||
export function swapBlocks<T>(rows: T[], a: IBlockRange, b: IBlockRange): T[] {
|
||||
return [
|
||||
...rows.slice(0, a.start),
|
||||
...rows.slice(b.start, b.end + 1),
|
||||
...rows.slice(a.start, a.end + 1),
|
||||
...rows.slice(b.end + 1)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Пересчитывает order начиная с floor+1: у параллельных строк (parallelWithPrev)
|
||||
* order общий с предыдущей, у остальных — на единицу больше. Вызывается после
|
||||
* КАЖДОГО перемещения/переключения параллельности, иначе order разъедется с тем,
|
||||
* что показано на экране. floor — order последней ЗАБЛОКИРОВАННОЙ (уже решённой)
|
||||
* строки фазы; строки после неё не могут получить order <= floor — это и есть
|
||||
* гарантия «нельзя поставить согласующего выше того, кто уже одобрил».
|
||||
*/
|
||||
export function withOrdersFrom<T extends IOrderedItem>(rows: T[], floor: number): T[] {
|
||||
let order = floor;
|
||||
return rows.map((r, i) => {
|
||||
const parallel = i > 0 && !!r.parallelWithPrev;
|
||||
if (!parallel) order += 1;
|
||||
return { ...r, order, parallelWithPrev: parallel };
|
||||
});
|
||||
}
|
||||
|
||||
/** Заполняет parallelWithPrev по факту совпадения order с предыдущей строкой (строки уже отсортированы по order). */
|
||||
export function markParallel<T extends IOrderedItem>(rows: T[]): T[] {
|
||||
return rows.map((r, i) => ({ ...r, parallelWithPrev: i > 0 && r.order === rows[i - 1].order }));
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ComparisonOperator, Employee, HeadType, ProjectRole, RouteStepStatus, TemplateRouteStep } from '../models';
|
||||
import { describeStepCondition, describeStepExecutor, detectExecutorKind } from './stepExecutor';
|
||||
|
||||
function step(overrides: Partial<TemplateRouteStep>): TemplateRouteStep {
|
||||
return {
|
||||
id: 1,
|
||||
orderNo: 1,
|
||||
executorEmail: null,
|
||||
position: null,
|
||||
projectRole: null,
|
||||
headType: null,
|
||||
status: RouteStepStatus.Approval,
|
||||
routeId: 1,
|
||||
allowReassignToOtherApprovers: false,
|
||||
fieldName: null,
|
||||
operation: null,
|
||||
value: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('detectExecutorKind', () => {
|
||||
it('email имеет приоритет над остальными способами', () => {
|
||||
expect(detectExecutorKind({ executorEmail: 'a@tnsu.kz', position: 'Директор' })).toBe('email');
|
||||
});
|
||||
|
||||
it('при отсутствии email — position', () => {
|
||||
expect(detectExecutorKind({ position: 'Директор' })).toBe('position');
|
||||
});
|
||||
|
||||
it('при отсутствии email/position — projectRole, если он не None', () => {
|
||||
expect(detectExecutorKind({ projectRole: ProjectRole.Planner })).toBe('projectRole');
|
||||
});
|
||||
|
||||
it('projectRole=None не считается заданным способом', () => {
|
||||
expect(detectExecutorKind({ projectRole: ProjectRole.None })).toBe('headType');
|
||||
});
|
||||
|
||||
it('если ничего не задано — headType (последний вариант по умолчанию)', () => {
|
||||
expect(detectExecutorKind({})).toBe('headType');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeStepExecutor', () => {
|
||||
const employees: Employee[] = [{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик' }];
|
||||
|
||||
it('для email найденного сотрудника показывает ФИО и email отдельной строкой', () => {
|
||||
const result = describeStepExecutor(step({ executorEmail: 'gorbenko.pavel@tnsu.kz' }), employees);
|
||||
expect(result).toEqual({ primary: 'Горбенко Павел', secondary: 'gorbenko.pavel@tnsu.kz' });
|
||||
});
|
||||
|
||||
it('для email, которого нет в справочнике, показывает сам email без secondary', () => {
|
||||
const result = describeStepExecutor(step({ executorEmail: 'unknown@tnsu.kz' }), employees);
|
||||
expect(result).toEqual({ primary: 'unknown@tnsu.kz' });
|
||||
});
|
||||
|
||||
it('для шага по должности показывает должность', () => {
|
||||
const result = describeStepExecutor(step({ position: 'Главный бухгалтер' }), employees);
|
||||
expect(result.primary).toBe('Главный бухгалтер');
|
||||
});
|
||||
|
||||
it('для шага по проектной роли показывает подпись роли', () => {
|
||||
const result = describeStepExecutor(step({ projectRole: ProjectRole.Planner }), employees);
|
||||
expect(result.primary).toEqual(expect.stringContaining('Роль:'));
|
||||
});
|
||||
|
||||
it('для шага по типу руководителя показывает подпись типа', () => {
|
||||
const result = describeStepExecutor(step({ headType: HeadType.Chief }), employees);
|
||||
expect(result.primary).toEqual(expect.stringContaining('Руководитель:'));
|
||||
});
|
||||
|
||||
it('если у шага вообще не задан исполнитель — пустое значение', () => {
|
||||
const result = describeStepExecutor(step({}), employees);
|
||||
expect(result.secondary).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeStepCondition', () => {
|
||||
it('undefined, если условие не задано целиком', () => {
|
||||
expect(describeStepCondition(step({}))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('undefined, если operation === None', () => {
|
||||
expect(describeStepCondition(step({ fieldName: 'Amount', operation: ComparisonOperator.None, value: '100' }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('собирает "поле оператор значение", если условие задано полностью', () => {
|
||||
const result = describeStepCondition(step({ fieldName: 'Amount', operation: ComparisonOperator.GreaterThan, value: '100' }));
|
||||
expect(result).toBe('Amount > 100');
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,14 @@ import { comparisonOperatorSymbols, headTypeLabels, projectRoleLabels, texts } f
|
||||
import { findEmployeeByEmail } from './employee';
|
||||
|
||||
/** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */
|
||||
/* eslint-disable @rushstack/no-new-null -- форма зеркалит TemplateRouteStep (nullable JSON-контракт бэкенда) */
|
||||
export function detectExecutorKind(step: {
|
||||
executorEmail?: string | null;
|
||||
position?: string | null;
|
||||
projectRole?: ProjectRole | null;
|
||||
headType?: HeadType | null;
|
||||
}): StepExecutorKind {
|
||||
/* eslint-enable @rushstack/no-new-null */
|
||||
if (step.executorEmail) return 'email';
|
||||
if (step.position) return 'position';
|
||||
if (step.projectRole && step.projectRole !== ProjectRole.None) return 'projectRole';
|
||||
@@ -24,7 +26,7 @@ export interface StepExecutorDisplay {
|
||||
/** Колонка «Исполнитель» в таблице шагов шаблона маршрута. */
|
||||
export function describeStepExecutor(step: TemplateRouteStep, employees: Employee[]): StepExecutorDisplay {
|
||||
if (step.executorEmail) {
|
||||
const employee = findEmployeeByEmail(employees, step.executorEmail);
|
||||
const employee = findEmployeeByEmail(employees, step.executorEmail ?? undefined);
|
||||
return employee ? { primary: employee.fio, secondary: employee.email } : { primary: step.executorEmail };
|
||||
}
|
||||
if (step.position) return { primary: step.position };
|
||||
|
||||
Reference in New Issue
Block a user