Refactor LayerApplicabilityDialog and RulesCard components to utilize useKeyedList for managing lists, improving state handling and performance. Update ScheduleGrid to enhance key assignment for mapped elements, ensuring better React reconciliation. Modify ManualInboxDialog to use unique keys for parts in sample name processing, and improve ToastProvider and ThemeProvider by memoizing context values to prevent unnecessary re-renders.
ci / build-backend (push) Successful in 1m15s
ci / build-frontend (push) Failing after 15s
ci / tests (push) Skipped
ci / sonar (push) Skipped

This commit is contained in:
Leonid Pershin
2026-07-26 23:54:30 +03:00
parent 940b622c03
commit 8699d8ee00
8 changed files with 148 additions and 90 deletions
@@ -14,6 +14,7 @@ import {
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { useKeyedList } from '@/shared/lib/keyed-list'
import { updateLayer } from '../api'
import { isEmpty, toIsoDate } from '../lib/applicability'
@@ -37,21 +38,17 @@ export function LayerApplicabilityDialog({
const { t } = useTranslation()
const [name, setName] = useState(layer.name)
const [weekdays, setWeekdays] = useState<number[]>(layer.applicability?.weekdays ?? [])
const [dateRanges, setDateRanges] = useState<DateRange[]>(layer.applicability?.dateRanges ?? [])
const [annualRanges, setAnnualRanges] = useState<AnnualRange[]>(
layer.applicability?.annualRanges ?? [],
)
const [specificDates, setSpecificDates] = useState<string[]>(
layer.applicability?.specificDates ?? [],
)
const dateRanges = useKeyedList<DateRange>(layer.applicability?.dateRanges ?? [])
const annualRanges = useKeyedList<AnnualRange>(layer.applicability?.annualRanges ?? [])
const specificDates = useKeyedList<string>(layer.applicability?.specificDates ?? [])
const save = useMutation({
mutationFn: () => {
const applicability: LayerApplicability = {
weekdays: weekdays.length > 0 ? [...weekdays].sort((a, b) => a - b) : null,
dateRanges: dateRanges.length > 0 ? dateRanges : null,
annualRanges: annualRanges.length > 0 ? annualRanges : null,
specificDates: specificDates.length > 0 ? specificDates : null,
dateRanges: dateRanges.values.length > 0 ? dateRanges.values : null,
annualRanges: annualRanges.values.length > 0 ? annualRanges.values : null,
specificDates: specificDates.values.length > 0 ? specificDates.values : null,
}
return updateLayer(layer.id, {
name: name.trim() || layer.name,
@@ -112,36 +109,26 @@ export function LayerApplicabilityDialog({
<Section
title={t('admin.channels.applicabilityDateRanges')}
onAdd={() => setDateRanges((c) => [...c, { from: today, to: today }])}
empty={dateRanges.length === 0}
onAdd={() => dateRanges.add({ from: today, to: today })}
empty={dateRanges.rows.length === 0}
>
{dateRanges.map((range, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
{dateRanges.rows.map(({ key, value: range }) => (
<li key={key} className="flex flex-wrap items-end gap-2">
<Input
type="date"
className="w-40"
value={range.from}
onChange={(e) =>
setDateRanges((c) =>
c.map((r, i) => (i === index ? { ...r, from: e.target.value } : r)),
)
dateRanges.patch(key, (r) => ({ ...r, from: e.target.value }))
}
/>
<Input
type="date"
className="w-40"
value={range.to}
onChange={(e) =>
setDateRanges((c) =>
c.map((r, i) => (i === index ? { ...r, to: e.target.value } : r)),
)
}
onChange={(e) => dateRanges.patch(key, (r) => ({ ...r, to: e.target.value }))}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setDateRanges((c) => c.filter((_, i) => i !== index))}
>
<Button size="sm" variant="ghost" onClick={() => dateRanges.remove(key)}>
<Trash2 className="h-4 w-4" />
</Button>
</li>
@@ -150,33 +137,23 @@ export function LayerApplicabilityDialog({
<Section
title={t('admin.channels.applicabilityAnnual')}
onAdd={() =>
setAnnualRanges((c) => [...c, { fromMonth: 12, fromDay: 20, toMonth: 1, toDay: 8 }])
}
empty={annualRanges.length === 0}
onAdd={() => annualRanges.add({ fromMonth: 12, fromDay: 20, toMonth: 1, toDay: 8 })}
empty={annualRanges.rows.length === 0}
>
{annualRanges.map((range, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
{annualRanges.rows.map(({ key, value: range }) => (
<li key={key} className="flex flex-wrap items-end gap-2">
<MonthDay
value={range}
prefix="from"
onChange={(part) =>
setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r)))
}
onChange={(part) => annualRanges.patch(key, (r) => ({ ...r, ...part }))}
/>
<span className="pb-2 text-muted-foreground"></span>
<MonthDay
value={range}
prefix="to"
onChange={(part) =>
setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r)))
}
onChange={(part) => annualRanges.patch(key, (r) => ({ ...r, ...part }))}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setAnnualRanges((c) => c.filter((_, i) => i !== index))}
>
<Button size="sm" variant="ghost" onClick={() => annualRanges.remove(key)}>
<Trash2 className="h-4 w-4" />
</Button>
</li>
@@ -185,24 +162,18 @@ export function LayerApplicabilityDialog({
<Section
title={t('admin.channels.applicabilityDates')}
onAdd={() => setSpecificDates((c) => [...c, today])}
empty={specificDates.length === 0}
onAdd={() => specificDates.add(today)}
empty={specificDates.rows.length === 0}
>
{specificDates.map((date, index) => (
<li key={index} className="flex items-end gap-2">
{specificDates.rows.map(({ key, value: date }) => (
<li key={key} className="flex items-end gap-2">
<Input
type="date"
className="w-40"
value={date}
onChange={(e) =>
setSpecificDates((c) => c.map((d, i) => (i === index ? e.target.value : d)))
}
onChange={(e) => specificDates.patch(key, () => e.target.value)}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setSpecificDates((c) => c.filter((_, i) => i !== index))}
>
<Button size="sm" variant="ghost" onClick={() => specificDates.remove(key)}>
<Trash2 className="h-4 w-4" />
</Button>
</li>
@@ -17,6 +17,16 @@ import { CollapsibleCard } from './CollapsibleCard'
const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'PG-13' }
/**
* Окно со стабильным ключом. Индекс в качестве key не годится: строки удаляются из середины, и React
* сопоставил бы уцелевшие узлы не с теми окнами — фокус и внутреннее состояние полей переехали бы
* в соседнюю строку. Ключ живёт только на клиенте и в API не уезжает.
*/
type WindowRow = { key: string; window: AudienceWindow }
const toRows = (windows: AudienceWindow[]): WindowRow[] =>
windows.map((window) => ({ key: crypto.randomUUID(), window }))
/**
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
@@ -34,8 +44,8 @@ export function RulesCard({
onError: (error: unknown) => void
}) {
const { t } = useTranslation()
const [windows, setWindows] = useState<AudienceWindow[]>(
() => template.rules?.maxAudienceByTime ?? [],
const [windows, setWindows] = useState<WindowRow[]>(() =>
toRows(template.rules?.maxAudienceByTime ?? []),
)
const [limitOn, setLimitOn] = useState(() => template.rules?.maxRepeatsInWindow != null)
const [windowDays, setWindowDays] = useState(
@@ -47,7 +57,7 @@ export function RulesCard({
const [fallbackCap, setFallbackCap] = useState(() => template.rules?.maxFallbackSharePercent ?? 0)
useEffect(() => {
setWindows(template.rules?.maxAudienceByTime ?? [])
setWindows(toRows(template.rules?.maxAudienceByTime ?? []))
setLimitOn(template.rules?.maxRepeatsInWindow != null)
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
@@ -59,7 +69,7 @@ export function RulesCard({
const save = useMutation({
mutationFn: () => {
const rules: PlanningRules = {
maxAudienceByTime: windows.length > 0 ? windows : null,
maxAudienceByTime: windows.length > 0 ? windows.map((row) => row.window) : null,
maxRepeatsInWindow: limitOn ? { windowDays, max } : null,
// Ноль означает «не проверять»: отдельного выключателя на каждый порог не нужно.
maxBreakMinutesPerHour: breakCap > 0 ? breakCap : null,
@@ -77,8 +87,12 @@ export function RulesCard({
onError,
})
const patchWindow = (index: number, part: Partial<AudienceWindow>) =>
setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w)))
const patchWindow = (key: string, part: Partial<AudienceWindow>) =>
setWindows((current) =>
current.map((row) =>
row.key === key ? { ...row, window: { ...row.window, ...part } } : row,
),
)
return (
<CollapsibleCard title={t('admin.channels.rules')} bare={bare}>
@@ -93,7 +107,7 @@ export function RulesCard({
<Button
size="sm"
variant="ghost"
onClick={() => setWindows((c) => [...c, EMPTY_WINDOW])}
onClick={() => setWindows((c) => [...c, ...toRows([EMPTY_WINDOW])])}
>
<Plus className="h-4 w-4" />
</Button>
@@ -103,15 +117,15 @@ export function RulesCard({
<p className="text-xs text-muted-foreground">{t('admin.channels.noAudienceWindows')}</p>
) : (
<ul className="flex flex-col gap-2">
{windows.map((window, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
{windows.map(({ key, window }) => (
<li key={key} className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.from')}</Label>
<Input
type="time"
className="w-28"
value={window.from.slice(0, 5)}
onChange={(e) => patchWindow(index, { from: `${e.target.value}:00` })}
onChange={(e) => patchWindow(key, { from: `${e.target.value}:00` })}
/>
</div>
<div className="flex flex-col gap-1.5">
@@ -120,7 +134,7 @@ export function RulesCard({
type="time"
className="w-28"
value={window.to.slice(0, 5)}
onChange={(e) => patchWindow(index, { to: `${e.target.value}:00` })}
onChange={(e) => patchWindow(key, { to: `${e.target.value}:00` })}
/>
</div>
<div className="flex flex-col gap-1.5">
@@ -129,7 +143,7 @@ export function RulesCard({
className="h-9 rounded-md border border-border bg-transparent px-2"
value={window.maxAudience}
onChange={(e) =>
patchWindow(index, { maxAudience: e.target.value as ShowAudience })
patchWindow(key, { maxAudience: e.target.value as ShowAudience })
}
>
{SHOW_AUDIENCES.map((value) => (
@@ -142,7 +156,7 @@ export function RulesCard({
<Button
size="sm"
variant="ghost"
onClick={() => setWindows((c) => c.filter((_, i) => i !== index))}
onClick={() => setWindows((c) => c.filter((row) => row.key !== key))}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -174,9 +174,9 @@ export function ScheduleGrid({
<div className="grid grid-cols-[56px_repeat(7,1fr)]">
<div>
{hours.map((hour, index) => (
{hours.map((hour) => (
<div
key={index}
key={hour}
className="border-b border-border/40 px-2 text-[11px] text-muted-foreground"
style={{ height: HOUR_HEIGHT }}
>
@@ -197,9 +197,9 @@ export function ScheduleGrid({
onDragOver={(e) => dragged && e.preventDefault()}
onDrop={(e) => drop(e, weekday)}
>
{hours.map((_, index) => (
{hours.map((hour, index) => (
<button
key={index}
key={hour}
type="button"
title={t('admin.channels.addSlotHere')}
className="group absolute inset-x-0 border-b border-border/40 hover:bg-muted/30"
@@ -244,8 +244,12 @@ export function ScheduleGrid({
{slot.weekday === null && <Repeat className="h-3 w-3 shrink-0 opacity-60" />}
</span>
<span className="block truncate">{slot.title}</span>
{/* Ручка растягивания — исключительно мышиная: role="presentation" на элементе
с обработчиком противоречив (роль говорит «меня нет», а элемент реагирует),
поэтому прячем её от вспомогательных технологий. Длительность слота
правится с клавиатуры в инспекторе — доступный путь есть. */}
<span
role="presentation"
aria-hidden="true"
title={t('admin.channels.resizeSlot')}
className="absolute inset-x-0 bottom-0 h-1.5 cursor-ns-resize hover:bg-primary/40"
onMouseDown={(e) =>