fix(admin-ui): backlog sweep — 7 review items
This commit is contained in:
@@ -36,8 +36,28 @@ export const setUnauthorizedHandler = (h: Unauthorized401Handler | null): void =
|
||||
unauthorizedHandler = h
|
||||
}
|
||||
|
||||
/**
|
||||
* baseURL построение:
|
||||
* - Если VITE_API_BASE задан и заканчивается на /api/v1 (или содержит api/) —
|
||||
* используем как есть.
|
||||
* - Если VITE_API_BASE задан без api-prefix (e.g. https://api.example.com) —
|
||||
* автоматически добавляем `/api/v1` суффикс.
|
||||
* - Default: `/api/v1` (relative — admin-ui nginx proxies to writer/read-api).
|
||||
*
|
||||
* Раньше ambiguous — если кто-то задал `VITE_API_BASE=https://api.example.com`
|
||||
* без `/v1`, все запросы становились `/dictionaries` относительно корня
|
||||
* (правильно `/api/v1/dictionaries`). Теперь fall-through к expected layout.
|
||||
*/
|
||||
const buildBaseURL = (): string => {
|
||||
const env = import.meta.env.VITE_API_BASE
|
||||
if (!env) return '/api/v1'
|
||||
const trimmed = env.replace(/\/+$/, '')
|
||||
if (/\/api(\/|$)/i.test(trimmed)) return trimmed
|
||||
return `${trimmed}/api/v1`
|
||||
}
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE ?? '/api/v1',
|
||||
baseURL: buildBaseURL(),
|
||||
timeout: 10_000,
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseContentDispositionFilename } from './mutations'
|
||||
|
||||
describe('parseContentDispositionFilename', () => {
|
||||
it('returns null for undefined / empty', () => {
|
||||
expect(parseContentDispositionFilename(undefined)).toBeNull()
|
||||
expect(parseContentDispositionFilename('')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses RFC 2183 (legacy) quoted filename', () => {
|
||||
expect(
|
||||
parseContentDispositionFilename('attachment; filename="report.csv"'),
|
||||
).toBe('report.csv')
|
||||
})
|
||||
|
||||
it('parses RFC 2183 unquoted filename', () => {
|
||||
expect(
|
||||
parseContentDispositionFilename('attachment; filename=report.csv'),
|
||||
).toBe('report.csv')
|
||||
})
|
||||
|
||||
it('parses RFC 5987 UTF-8 encoded filename', () => {
|
||||
const result = parseContentDispositionFilename(
|
||||
"attachment; filename*=UTF-8''%D0%9E%D1%82%D1%87%D1%91%D1%82.csv",
|
||||
)
|
||||
expect(result).toBe('Отчёт.csv')
|
||||
})
|
||||
|
||||
it('prefers RFC 5987 when both forms present', () => {
|
||||
const result = parseContentDispositionFilename(
|
||||
"attachment; filename=fallback.csv; filename*=UTF-8''%D0%9F%D1%80%D0%B0%D0%B9%D1%81.csv",
|
||||
)
|
||||
expect(result).toBe('Прайс.csv')
|
||||
})
|
||||
|
||||
it('falls back gracefully for ISO-8859-1 (decode may not produce correct chars)', () => {
|
||||
// ISO-8859-1 single-byte encoding не decodes via decodeURIComponent —
|
||||
// browser TextDecoder с label='iso-8859-1' нужен. Сейчас fallback к raw
|
||||
// value (без decode). Acceptable degradation — backend нужно использовать
|
||||
// UTF-8 для non-ASCII filenames (RFC 5987 recommends).
|
||||
const result = parseContentDispositionFilename(
|
||||
"attachment; filename*=ISO-8859-1''r%E9sum%E9.csv",
|
||||
)
|
||||
// Result either decoded or raw — main thing: not null, no throw
|
||||
expect(result).not.toBeNull()
|
||||
expect(typeof result).toBe('string')
|
||||
})
|
||||
|
||||
it('returns raw value if decodeURIComponent fails', () => {
|
||||
// Malformed % encoding — decode throws, fallback к raw value
|
||||
const result = parseContentDispositionFilename(
|
||||
"attachment; filename*=UTF-8''broken%ZZ.csv",
|
||||
)
|
||||
// Не throws — graceful fallback
|
||||
expect(result).toBe('broken%ZZ.csv')
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,40 @@ import {
|
||||
type WebhookTestPingResult,
|
||||
} from './client'
|
||||
|
||||
/**
|
||||
* Parse `Content-Disposition` filename (RFC 5987 + legacy):
|
||||
* - `filename*=UTF-8''Cyrillic%20Name.csv` (RFC 5987 — для не-ASCII names)
|
||||
* - `filename="legacy.csv"` (RFC 2183 — ASCII-only)
|
||||
*
|
||||
* Возвращает первое match'нувшее имя или null. RFC 5987 priority — стандарт
|
||||
* требует UA prefer the encoded form when both present.
|
||||
*/
|
||||
export const parseContentDispositionFilename = (
|
||||
cd: string | undefined,
|
||||
): string | null => {
|
||||
if (!cd) return null
|
||||
// RFC 5987: filename*=charset''url-encoded-value
|
||||
const rfc5987 = cd.match(/filename\*=(?:([^']*)'[^']*')?([^;]+)/i)
|
||||
if (rfc5987) {
|
||||
const charset = rfc5987[1] || 'UTF-8'
|
||||
const raw = rfc5987[2].trim().replace(/^"(.*)"$/, '$1')
|
||||
try {
|
||||
// decodeURIComponent works для UTF-8 (и compatible сюда же ISO-8859-1).
|
||||
// Если backend present extended-charset — fallback на raw value.
|
||||
if (charset.toUpperCase() === 'UTF-8' || charset.toUpperCase() === 'ISO-8859-1') {
|
||||
return decodeURIComponent(raw)
|
||||
}
|
||||
return raw
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
// RFC 2183: filename="..." or filename=raw
|
||||
const legacy = cd.match(/filename=("([^"]+)"|([^;]+))/i)
|
||||
if (legacy) return (legacy[2] ?? legacy[3])?.trim() ?? null
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate UUID v4 (или fallback). Раньше вызывался **per mutate** в каждой
|
||||
* useCreateRecord/useUpdateRecord — двойной submit формы (Enter + click) давал
|
||||
@@ -186,7 +220,11 @@ export const useCascadeCloseRecord = (dictionaryName: string) => {
|
||||
const { data } = await apiClient.post<CascadeCloseResult>(
|
||||
`/dictionaries/${dictionaryName}/records/${encodeURIComponent(params.businessKey)}/cascade-close`,
|
||||
null,
|
||||
{ params: queryParams },
|
||||
// Cascade tx до 30s на backend (см. 503 x_references_cascade_timeout).
|
||||
// Дефолт apiClient timeout=10s слишком агрессивен — обрезает запрос
|
||||
// ДО того как backend сам timeout'ает с понятной ошибкой. Per-request
|
||||
// override на 60s гарантирует что user видит backend response.
|
||||
{ params: queryParams, timeout: 60_000 },
|
||||
)
|
||||
return data
|
||||
},
|
||||
@@ -208,9 +246,12 @@ export const useBulkCloseRecords = (dictionaryName: string) => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (req: BulkCloseRequest): Promise<BulkCloseResponse> => {
|
||||
// Bulk close — per-record транзакция; на 1000+ keys запрос идёт >10s.
|
||||
// Дефолт apiClient timeout слишком жёсткий → 60s safety.
|
||||
const { data } = await apiClient.post<BulkCloseResponse>(
|
||||
`/dictionaries/${dictionaryName}/records/bulk-close`,
|
||||
req,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
return data
|
||||
},
|
||||
@@ -233,13 +274,13 @@ export const useBulkExportRecords = (dictionaryName: string) => {
|
||||
const response = await apiClient.post(
|
||||
`/dictionaries/${dictionaryName}/records/export-selected.csv`,
|
||||
{ businessKeys },
|
||||
{ responseType: 'blob' },
|
||||
// 60s timeout: 10k+ записей export может идти >10s.
|
||||
{ responseType: 'blob', timeout: 60_000 },
|
||||
)
|
||||
// Извлекаем filename из Content-Disposition если есть
|
||||
const cd = response.headers['content-disposition'] as string | undefined
|
||||
const match = cd?.match(/filename="?([^";]+)"?/)
|
||||
const filename = match?.[1]
|
||||
?? `${dictionaryName}-selected-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
const filename =
|
||||
parseContentDispositionFilename(cd) ??
|
||||
`${dictionaryName}-selected-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
|
||||
// Trigger browser download через blob URL + temporary <a download>
|
||||
const url = window.URL.createObjectURL(response.data as Blob)
|
||||
|
||||
@@ -371,11 +371,20 @@ export const useCascadePreview = (dict: string, businessKey: string | undefined)
|
||||
/**
|
||||
* Free-form ILIKE search across all dictionaries (active records only).
|
||||
* Min query length = 3 (backend требует для использования trigram index).
|
||||
*
|
||||
* Min-length guard: throw в queryFn — защищает от prefetch (router loader)
|
||||
* вызовов с q="ab" → backend 400. queryFn enforces invariant; useQuery
|
||||
* `enabled` gating опциональный для UI shortcut, но не source of truth.
|
||||
*/
|
||||
export const SEARCH_MIN_LEN = 3
|
||||
|
||||
export const searchQuery = (q: string, size = 100, perDict = 10) =>
|
||||
queryOptions({
|
||||
queryKey: ['search', q, size, perDict] as const,
|
||||
queryFn: async (): Promise<SearchResponse> => {
|
||||
if (q.length < SEARCH_MIN_LEN) {
|
||||
throw new Error(`search query too short (min ${SEARCH_MIN_LEN} chars)`)
|
||||
}
|
||||
const { data } = await apiClient.get<SearchResponse>('/search', {
|
||||
params: { q, size, perDict },
|
||||
})
|
||||
@@ -387,7 +396,7 @@ export const searchQuery = (q: string, size = 100, perDict = 10) =>
|
||||
export const useSearch = (q: string | undefined, size = 100, perDict = 10) =>
|
||||
useQuery({
|
||||
...searchQuery(q ?? '', size, perDict),
|
||||
enabled: Boolean(q && q.length >= 3),
|
||||
enabled: Boolean(q && q.length >= SEARCH_MIN_LEN),
|
||||
})
|
||||
|
||||
// === Approval Workflow v2 ===
|
||||
|
||||
Reference in New Issue
Block a user