чистка кода
This commit is contained in:
+13
-2
@@ -71,8 +71,19 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
||||
[blocks, withParallel]
|
||||
);
|
||||
// Индекс последнего заблокированного блока — все блоки ПОСЛЕ него движимы,
|
||||
// все блоки НА НЁМ И РАНЬШЕ — нет (см. комментарий к компоненту).
|
||||
const lastLockedBlockIndex = lockedBlock.reduce((acc, locked, i) => (locked ? i : acc), -1);
|
||||
// все блоки НА НЁМ И РАНЬШЕ — нет (см. комментарий к компоненту). Считаем
|
||||
// именно НЕПРЕРЫВНЫЙ префикс заблокированных блоков с начала, а не индекс
|
||||
// последнего true по всему списку: реальные данные не всегда гарантируют
|
||||
// строгую непрерывность (например, более ранняя ручная правка тем же
|
||||
// инструментом могла оставить решённый шаг ПОСЛЕ ещё не решённого) — а
|
||||
// reduce по всему массиву в этом случае блокирует стрелки вообще у всех
|
||||
// блоков до самого дальнего заблокированного, даже у тех, что реально
|
||||
// редактируемы.
|
||||
let lastLockedBlockIndex = -1;
|
||||
for (let i = 0; i < lockedBlock.length; i++) {
|
||||
if (!lockedBlock[i]) break;
|
||||
lastLockedBlockIndex = i;
|
||||
}
|
||||
const firstMovableRowIndex = blocks[lastLockedBlockIndex + 1]?.start ?? -1;
|
||||
|
||||
// Фаза уже целиком решена (например, все согласующие одобрили, и заявка ушла
|
||||
|
||||
@@ -42,15 +42,20 @@ interface IFormState {
|
||||
|
||||
const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listId: '' };
|
||||
|
||||
// siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда
|
||||
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
|
||||
// внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде.
|
||||
/** Ячейка «Сайт SharePoint» — подтягивает заголовок сайта по url, хранящемуся на бэкенде. */
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- siteUrl зеркалит RequestType.siteUrl (nullable JSON-контракт бэкенда)
|
||||
const SiteUrlCell: React.FC<{ client: SharePointListsClient; siteUrl: string | null }> = ({ client, siteUrl }) => {
|
||||
const { title } = useSharePointSiteTitle(client, siteUrl);
|
||||
const { title } = useSharePointSiteTitle(client, siteUrl ?? undefined);
|
||||
return <>{siteUrl ? formatSiteLabel(title, siteUrl) : texts.common.empty}</>;
|
||||
};
|
||||
|
||||
/** Ячейка «Список SharePoint» — на бэкенде хранится id списка (устойчив к переименованию), тут подтягиваем Title для читаемости. */
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- siteUrl/listId зеркалят RequestType (nullable JSON-контракт бэкенда)
|
||||
const ListIdCell: React.FC<{ client: SharePointListsClient; siteUrl: string | null; listId: string | null }> = ({ client, siteUrl, listId }) => {
|
||||
const { title } = useSharePointListTitle(client, siteUrl, listId);
|
||||
const { title } = useSharePointListTitle(client, siteUrl ?? undefined, listId ?? undefined);
|
||||
return <>{listId ? (title ?? listId) : texts.common.empty}</>;
|
||||
};
|
||||
|
||||
@@ -104,7 +109,11 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
name: form.name.trim(),
|
||||
pageUrl: form.pageUrl.trim(),
|
||||
section: form.section.trim(),
|
||||
// null, а не undefined — контракт CreateRequestTypeInput зеркалит
|
||||
// nullable-поля бэкенда (см. models/requestTypes.ts).
|
||||
// eslint-disable-next-line @rushstack/no-new-null
|
||||
siteUrl: siteUrl || null,
|
||||
// eslint-disable-next-line @rushstack/no-new-null
|
||||
listId: listId || null
|
||||
};
|
||||
const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
|
||||
|
||||
@@ -99,7 +99,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
||||
|
||||
const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId));
|
||||
const hasListId = Boolean(requestType?.listId);
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listId);
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
|
||||
const fieldNameOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||
|
||||
const resolved = useResolveRoute(api);
|
||||
@@ -272,7 +272,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
||||
key: 'executor',
|
||||
header: texts.resolve.columnExecutor,
|
||||
render: r => {
|
||||
const employee = findEmployeeByEmail(employees.items, r.executorEmail);
|
||||
const employee = findEmployeeByEmail(employees.items, r.executorEmail ?? undefined);
|
||||
return (
|
||||
<div>
|
||||
<PrimaryCell>{employee ? employee.fio : (r.executorEmail ?? texts.common.empty)}</PrimaryCell>
|
||||
|
||||
@@ -113,7 +113,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
|
||||
const submit = useAsyncSubmit();
|
||||
const hasListId = Boolean(requestType?.listId);
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listId);
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
|
||||
|
||||
const fieldNameOptions = React.useMemo(() => {
|
||||
const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||
|
||||
@@ -10,8 +10,8 @@ export interface SharePointFieldsState {
|
||||
/** Заголовки столбцов списка (site_url + list_name типа заявки) — источник значений для field_name шага. */
|
||||
export function useSharePointFields(
|
||||
client: SharePointListsClient | undefined,
|
||||
siteUrl: string | undefined | null,
|
||||
listTitle: string | undefined | null
|
||||
siteUrl: string | undefined,
|
||||
listTitle: string | undefined
|
||||
): SharePointFieldsState {
|
||||
const [items, setItems] = React.useState<SharePointListField[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
@@ -2,22 +2,22 @@ import * as React from 'react';
|
||||
import { SharePointListsClient } from '../services/sharePointLists';
|
||||
|
||||
export interface SharePointListTitleState {
|
||||
title: string | null;
|
||||
title: string | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/** Заголовок списка SharePoint по (site_url, list_id) — для отображения «Название (id)» вместо голого GUID. */
|
||||
export function useSharePointListTitle(
|
||||
client: SharePointListsClient | undefined,
|
||||
siteUrl: string | undefined | null,
|
||||
listId: string | undefined | null
|
||||
siteUrl: string | undefined,
|
||||
listId: string | undefined
|
||||
): SharePointListTitleState {
|
||||
const [title, setTitle] = React.useState<string | null>(null);
|
||||
const [title, setTitle] = React.useState<string | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!client || !siteUrl?.trim() || !listId?.trim()) {
|
||||
setTitle(null);
|
||||
setTitle(undefined);
|
||||
setIsLoading(false);
|
||||
return undefined;
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function useSharePointListTitle(
|
||||
|
||||
client.getListTitle(siteUrl.trim(), listId.trim())
|
||||
.then(result => { if (!cancelled) setTitle(result); })
|
||||
.catch(() => { if (!cancelled) setTitle(null); })
|
||||
.catch(() => { if (!cancelled) setTitle(undefined); })
|
||||
.finally(() => { if (!cancelled) setIsLoading(false); });
|
||||
|
||||
return () => { cancelled = true; };
|
||||
|
||||
@@ -2,21 +2,21 @@ import * as React from 'react';
|
||||
import { SharePointListsClient } from '../services/sharePointLists';
|
||||
|
||||
export interface SharePointSiteTitleState {
|
||||
title: string | null;
|
||||
title: string | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/** Заголовок сайта SharePoint по его url — для отображения «Название — url» вместо голого url. */
|
||||
export function useSharePointSiteTitle(
|
||||
client: SharePointListsClient | undefined,
|
||||
siteUrl: string | undefined | null
|
||||
siteUrl: string | undefined
|
||||
): SharePointSiteTitleState {
|
||||
const [title, setTitle] = React.useState<string | null>(null);
|
||||
const [title, setTitle] = React.useState<string | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!client || !siteUrl?.trim()) {
|
||||
setTitle(null);
|
||||
setTitle(undefined);
|
||||
setIsLoading(false);
|
||||
return undefined;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function useSharePointSiteTitle(
|
||||
|
||||
client.getSiteTitle(siteUrl.trim())
|
||||
.then(result => { if (!cancelled) setTitle(result); })
|
||||
.catch(() => { if (!cancelled) setTitle(null); })
|
||||
.catch(() => { if (!cancelled) setTitle(undefined); })
|
||||
.finally(() => { if (!cancelled) setIsLoading(false); });
|
||||
|
||||
return () => { cancelled = true; };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Справочники api.tnsu.kz (компании, отделы, проекты) в виде, удобном для UI.
|
||||
// Приведение из «сырых» PascalCase-ответов портала — в services/portalDictionary.ts.
|
||||
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт портала api.tnsu.kz */
|
||||
|
||||
export interface Company {
|
||||
id: string;
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface ManagedRequestPage {
|
||||
export interface ManagedApprovalRow {
|
||||
id: number;
|
||||
ticketId: number;
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- lookup-поле SharePoint, у ghost-строки (см. handleAddApprover) значения ещё нет
|
||||
approverId: number | null;
|
||||
approverName: string;
|
||||
approverEmail: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// DTO эндпоинта api/request-types (Application/RequestTypes/*.cs).
|
||||
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда */
|
||||
|
||||
export interface RequestType {
|
||||
id: number;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// DTO эндпоинта api/template-routes/resolve
|
||||
// (Application/TemplateRoutes/ResolveTemplateRouteDto.cs).
|
||||
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда */
|
||||
|
||||
import { RouteStepStatus } from './enums';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// DTO эндпоинта api/template-routes/{routeId}/steps
|
||||
// (Application/TemplateRouteSteps/*.cs).
|
||||
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда (C# nullable-типы сериализуются в null, не в отсутствие поля) */
|
||||
|
||||
import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus } from './enums';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// DTO эндпоинта api/template-routes (Application/TemplateRoutes/*.cs).
|
||||
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда */
|
||||
|
||||
export interface TemplateRoute {
|
||||
id: number;
|
||||
|
||||
@@ -54,10 +54,13 @@ const PARAM_KEYS = [
|
||||
'aamtype', 'aamreq'
|
||||
];
|
||||
|
||||
// raw — результат URLSearchParams.get() (DOM API, возвращает string | null).
|
||||
// eslint-disable-next-line @rushstack/no-new-null
|
||||
function parseTab(raw: string | null): ScreenKey {
|
||||
return raw && (TABS as string[]).indexOf(raw) !== -1 ? (raw as ScreenKey) : 'dashboard';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- raw — результат URLSearchParams.get() (DOM API)
|
||||
function parseFieldValues(raw: string | null): ResolveFieldValue[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
|
||||
@@ -18,7 +18,7 @@ export class ApiClientError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||
export type QueryParams = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
export interface IHttpClient {
|
||||
get<T>(path: string, query?: QueryParams): Promise<T>;
|
||||
@@ -34,7 +34,7 @@ function buildUrl(baseUrl: string, path: string, query?: QueryParams): string {
|
||||
if (query) {
|
||||
Object.keys(query).forEach(key => {
|
||||
const value = query[key];
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (value !== undefined && value !== '') {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @rushstack/no-new-null -- поля "сырых" IPortal*-интерфейсов 1:1 зеркалят nullable JSON-контракт портала api.tnsu.kz */
|
||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||
import { Company, Department, Employee, Project } from '../models';
|
||||
import { ApiClientError } from './httpClient';
|
||||
|
||||
@@ -51,7 +51,7 @@ export function isNumericFieldType(typeAsString: string): boolean {
|
||||
}
|
||||
|
||||
/** «Название — url», либо просто url, пока название ещё не подтянулось/не нашлось. */
|
||||
export function formatSiteLabel(title: string | null | undefined, url: string): string {
|
||||
export function formatSiteLabel(title: string | undefined, url: string): string {
|
||||
return title ? `${title} — ${url}` : url;
|
||||
}
|
||||
|
||||
@@ -79,8 +79,8 @@ export class SharePointListsClient {
|
||||
// Заголовок сайта по site_url и заголовок списка по (site_url, list_id) бэкенд
|
||||
// не хранит (только сами url) — кэшируем на клиенте, чтобы не дёргать сайт
|
||||
// заново на каждый рендер строки таблицы или переоткрытие формы с тем же значением.
|
||||
private readonly siteTitleCache = new Map<string, string | null>();
|
||||
private readonly listTitleCache = new Map<string, string | null>();
|
||||
private readonly siteTitleCache = new Map<string, string | undefined>();
|
||||
private readonly listTitleCache = new Map<string, string | undefined>();
|
||||
|
||||
constructor(private readonly context: WebPartContext) {}
|
||||
|
||||
@@ -90,33 +90,31 @@ export class SharePointListsClient {
|
||||
}
|
||||
|
||||
/** Заголовок сайта по его url — для отображения рядом с url там, где хранится только он. */
|
||||
public async getSiteTitle(siteUrl: string): Promise<string | null> {
|
||||
const cached = this.siteTitleCache.get(siteUrl);
|
||||
if (cached !== undefined) return cached;
|
||||
public async getSiteTitle(siteUrl: string): Promise<string | undefined> {
|
||||
if (this.siteTitleCache.has(siteUrl)) return this.siteTitleCache.get(siteUrl);
|
||||
|
||||
try {
|
||||
const web = await spfi(siteUrl).using(SPFx(this.context)).web.select('Title')() as { Title: string };
|
||||
this.siteTitleCache.set(siteUrl, web.Title);
|
||||
return web.Title;
|
||||
} catch {
|
||||
this.siteTitleCache.set(siteUrl, null);
|
||||
return null;
|
||||
this.siteTitleCache.set(siteUrl, undefined);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Заголовок списка по (site_url, list_id — server-relative url списка) — для отображения там, где хранится только он. */
|
||||
public async getListTitle(siteUrl: string, listId: string): Promise<string | null> {
|
||||
public async getListTitle(siteUrl: string, listId: string): Promise<string | undefined> {
|
||||
const key = `${siteUrl}|${listId}`;
|
||||
const cached = this.listTitleCache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
if (this.listTitleCache.has(key)) return this.listTitleCache.get(key);
|
||||
|
||||
try {
|
||||
const list = await clientFor(this.context, siteUrl).web.getList(listId).select('Title')() as { Title: string };
|
||||
this.listTitleCache.set(key, list.Title);
|
||||
return list.Title;
|
||||
} catch {
|
||||
this.listTitleCache.set(key, null);
|
||||
return null;
|
||||
this.listTitleCache.set(key, undefined);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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