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 ===
|
||||
|
||||
@@ -323,7 +323,7 @@ i18n
|
||||
'schema.redisProjection.hint': 'CEO plan E2 (tiered perf). По умолчанию выключено — read-api идёт в PG read replica. Включай когда dict упирается в RPS limit (целевые 10k+ RPS read через Redis hot cache). Projection-writer материализует updates per locale в Redis. Read-api routing появится отдельным feature — пока flag готовит почву.',
|
||||
'schema.events.intro': 'Превью JSON событий, которые будут опубликованы в Kafka после save справочника. Данные подставлены на основе типов полей schema. Полезно интеграторам (Альтум, Геопортал) для понимания shape\'а до live-выкатки.',
|
||||
'schema.events.topic': 'Topic',
|
||||
'schema.events.disclaimer': 'Это client-side preview. Реальные события содержат actual data + traceId/spanId из request scope. Schema events ordinis-events-api/src/main/java/.../events/ — единственный source of truth.',
|
||||
'schema.events.disclaimer': 'Это client-side preview. Реальные события содержат actual data + traceId/spanId из request scope. Backend events module — единственный source of truth.',
|
||||
'schema.name': 'Имя',
|
||||
'schema.nameHint': 'snake_case, начинается с буквы (например my_dictionary)',
|
||||
'schema.nameInvalid': 'Только snake_case: a-z, 0-9, _; начинается с буквы',
|
||||
@@ -793,7 +793,7 @@ i18n
|
||||
'schema.redisProjection.hint': 'CEO plan E2 (tiered perf). Off by default — read-api hits PG read replica. Turn on when this dict approaches RPS limit (target 10k+ RPS read through Redis hot cache). The projection writer materializes per-locale updates to Redis. Read-api routing will arrive in a separate feature; the flag prepares the ground.',
|
||||
'schema.events.intro': 'Preview of JSON events that will be published to Kafka after the dictionary is saved. Data is filled in based on schema field types. Useful for integrators (Altum, Geoportal) to understand the shape before going live.',
|
||||
'schema.events.topic': 'Topic',
|
||||
'schema.events.disclaimer': 'This is a client-side preview. Real events contain actual data + traceId/spanId from request scope. The events ordinis-events-api/src/main/java/.../events/ is the single source of truth.',
|
||||
'schema.events.disclaimer': 'This is a client-side preview. Real events contain actual data + traceId/spanId from request scope. The backend events module is the single source of truth.',
|
||||
'schema.name': 'Name',
|
||||
'schema.nameHint': 'snake_case, starts with letter (e.g. my_dictionary)',
|
||||
'schema.nameInvalid': 'Only snake_case: a-z, 0-9, _; must start with letter',
|
||||
|
||||
@@ -385,9 +385,18 @@ function DictionaryDetail() {
|
||||
const rawRecordQuery = useRecordRaw(name, editingKey)
|
||||
|
||||
const handleSubmit = (req: CreateRecordRequest) => {
|
||||
// Fill validFrom at submit (review #5): defaultValues в RHF берётся
|
||||
// ТОЛЬКО при mount. Если user открыл modal в 12:00 и submit в 12:05 —
|
||||
// `validFrom` в req это значение из 12:00. Backend check `validFrom >
|
||||
// existingFrom` может fail если existing запись свежее. Submit-time
|
||||
// refresh гарантирует что validFrom не устарел между mount и submit.
|
||||
const submitReq: CreateRecordRequest = {
|
||||
...req,
|
||||
validFrom: req.validFrom && req.validFrom.length > 0 ? req.validFrom : nowIsoLocal(),
|
||||
}
|
||||
if (edit.kind === 'create') {
|
||||
createMut.mutate(
|
||||
{ payload: req, idempotencyKey: recordIdempotencyKey },
|
||||
{ payload: submitReq, idempotencyKey: recordIdempotencyKey },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEdit({ kind: 'closed' })
|
||||
@@ -401,7 +410,7 @@ function DictionaryDetail() {
|
||||
updateMut.mutate(
|
||||
{
|
||||
businessKey: edit.record.businessKey,
|
||||
payload: req,
|
||||
payload: submitReq,
|
||||
idempotencyKey: recordIdempotencyKey,
|
||||
},
|
||||
{
|
||||
@@ -436,6 +445,10 @@ function DictionaryDetail() {
|
||||
(code === 'x_references_blocked_by_dependents' ||
|
||||
code === 'x_references_cascade_required')
|
||||
) {
|
||||
// Reset closeMut.error до открытия cascade dialog'а — иначе stale
|
||||
// 409 mesg может проскочить в открытом cascade UI пока user
|
||||
// ещё не сделал retry (review #18).
|
||||
closeMut.reset()
|
||||
setEdit({ kind: 'closed' })
|
||||
setCloseReason('')
|
||||
setCascadeKey(targetKey)
|
||||
@@ -914,11 +927,11 @@ function DictionaryDetail() {
|
||||
businessKey: rawRecordQuery.data.businessKey,
|
||||
data: rawRecordQuery.data.data,
|
||||
// Edit в bitemporal model = «создаём новую версию с этого момента,
|
||||
// старая закрывается». Дефолтим validFrom = now (а не historical
|
||||
// validFrom существующей записи): иначе юзер выберет момент
|
||||
// ДО любой версии и backend не найдёт что закрывать.
|
||||
// старая закрывается». validFrom оставляем пустым — handleSubmit
|
||||
// подставит nowIsoLocal() at submit-time (review #5: иначе stale
|
||||
// если user долго держит modal открытым).
|
||||
// validTo пустой = бессрочно (или до следующей правки).
|
||||
validFrom: nowIsoLocal(),
|
||||
validFrom: '',
|
||||
validTo: '',
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
|
||||
Reference in New Issue
Block a user