fix(files): клики внутри popover не открывают строку, ключи и aria-label (#43)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 13:47:21 +04:00
parent fdf9358b40
commit fc5cc16b3d
2 changed files with 30 additions and 7 deletions

View File

@ -1,5 +1,5 @@
import { Popover, Spin, Tag } from 'antd';
import { useState } from 'react';
import { useRef, useState } from 'react';
import { getDocument } from '../../services/api/documents';
import type { IssueOut, Severity } from '../../types/api';
import { severityLabel } from '../../utils/labels';
@ -27,6 +27,16 @@ function DocumentIssues({ state }: { state: IssuesState | null }) {
);
}
/** Ключи для строк, которые могут повторяться: к тексту добавляется порядковый номер повтора (индекс массива в key линтер запрещает). */
function uniqueKeys(list: string[]): { key: string; text: string }[] {
const seen = new Map<string, number>();
return list.map((text) => {
const n = (seen.get(text) ?? 0) + 1;
seen.set(text, n);
return { key: `${text}-${n}`, text };
});
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={{ marginBottom: 8 }}>
@ -39,11 +49,15 @@ function Section({ title, children }: { title: string; children: React.ReactNode
/** Значок с числом предупреждений под плашкой статуса; по клику — подробности (файл, обработка, документ). */
export function FileWarningsPopover({ row, badge }: { row: DocumentRow; badge: { count: number; level: Severity } | null }) {
const [issues, setIssues] = useState<IssuesState | null>(null);
// номер текущего открытия: ответ от прошлого открытия или после закрытия игнорируется
const requestNo = useRef(0);
if (!badge) return null;
// предупреждения документа грузим один раз за открытие: при закрытии сбрасываем, повторное открытие запрашивает заново
const onOpenChange = (open: boolean) => {
requestNo.current += 1;
const mine = requestNo.current;
if (!open) {
setIssues(null);
return;
@ -51,12 +65,16 @@ export function FileWarningsPopover({ row, badge }: { row: DocumentRow; badge: {
if (!row.documentId) return;
setIssues({ status: 'loading' });
getDocument(row.documentId)
.then((d) => setIssues({ status: 'done', issues: d.issues }))
.catch(() => setIssues({ status: 'error' }));
.then((d) => mine === requestNo.current && setIssues({ status: 'done', issues: d.issues }))
.catch(() => mine === requestNo.current && setIssues({ status: 'error' }));
};
const content = (
<div style={{ maxWidth: 420, maxHeight: 320, overflowY: 'auto' }}>
<div
// клики внутри поповера всплывают по дереву React до onClick строки таблицы и открывали бы документ
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
style={{ maxWidth: 420, maxHeight: 320, overflowY: 'auto' }}>
{row.fileWarnings.length > 0 && (
<Section title='Предупреждения файла'>
{row.fileWarnings.map((w, i) => (
@ -69,10 +87,10 @@ export function FileWarningsPopover({ row, badge }: { row: DocumentRow; badge: {
)}
{row.mappingWarnings.length > 0 && (
<Section title='Предупреждения обработки'>
{row.mappingWarnings.map((w) => (
<div key={w}>
{uniqueKeys(row.mappingWarnings).map((w) => (
<div key={w.key}>
<Tag color={tagColor('info')}>{severityLabel('info')}</Tag>
{w}
{w.text}
</div>
))}
</Section>
@ -89,6 +107,7 @@ export function FileWarningsPopover({ row, badge }: { row: DocumentRow; badge: {
<Popover trigger='click' title='Предупреждения' content={content} onOpenChange={onOpenChange}>
<button
type='button'
aria-label={`Предупреждения: ${badge.count}`}
onClick={(e) => e.stopPropagation()}
style={{
cursor: 'pointer',

View File

@ -170,6 +170,10 @@ describe('rowBadge', () => {
it('error документа без файла', () => {
expect(rowBadge(row([], { error: 1, info: 0 }))).toMatchObject({ level: 'error' });
});
it('файл без документов: считаются предупреждения файла', () => {
const r = buildDocumentRows([file({ status: 'ERROR', job: job({ warnings: [jsonFailed] }) })])[0];
expect(rowBadge(r)).toEqual({ count: 1, level: 'error' });
});
it('нули — null', () => {
expect(rowBadge(row([], { error: 0, info: 0 }))).toBeNull();
});