From 8699d8ee006906a30615cf7f5e46302e96d8e0d0 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 26 Jul 2026 23:54:30 +0300 Subject: [PATCH] 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. --- .../components/LayerApplicabilityDialog.tsx | 83 ++++++------------- .../admin/channels/components/RulesCard.tsx | 40 ++++++--- .../channels/components/ScheduleGrid.tsx | 14 ++-- .../admin/media/ManualInboxDialog.tsx | 20 +++-- frontend/src/shared/lib/keyed-list.ts | 40 +++++++++ frontend/src/shared/ui/toast-store.tsx | 15 +++- frontend/src/shared/ui/toaster.tsx | 7 +- frontend/src/theme/ThemeProvider.tsx | 19 ++++- 8 files changed, 148 insertions(+), 90 deletions(-) create mode 100644 frontend/src/shared/lib/keyed-list.ts diff --git a/frontend/src/features/admin/channels/components/LayerApplicabilityDialog.tsx b/frontend/src/features/admin/channels/components/LayerApplicabilityDialog.tsx index 337dfed..c2df1a9 100644 --- a/frontend/src/features/admin/channels/components/LayerApplicabilityDialog.tsx +++ b/frontend/src/features/admin/channels/components/LayerApplicabilityDialog.tsx @@ -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(layer.applicability?.weekdays ?? []) - const [dateRanges, setDateRanges] = useState(layer.applicability?.dateRanges ?? []) - const [annualRanges, setAnnualRanges] = useState( - layer.applicability?.annualRanges ?? [], - ) - const [specificDates, setSpecificDates] = useState( - layer.applicability?.specificDates ?? [], - ) + const dateRanges = useKeyedList(layer.applicability?.dateRanges ?? []) + const annualRanges = useKeyedList(layer.applicability?.annualRanges ?? []) + const specificDates = useKeyedList(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({
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) => ( -
  • + {dateRanges.rows.map(({ key, value: range }) => ( +
  • - setDateRanges((c) => - c.map((r, i) => (i === index ? { ...r, from: e.target.value } : r)), - ) + dateRanges.patch(key, (r) => ({ ...r, from: e.target.value })) } /> - 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 }))} /> -
  • @@ -150,33 +137,23 @@ export function LayerApplicabilityDialog({
    - 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) => ( -
  • + {annualRanges.rows.map(({ key, value: range }) => ( +
  • - setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r))) - } + onChange={(part) => annualRanges.patch(key, (r) => ({ ...r, ...part }))} /> - setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r))) - } + onChange={(part) => annualRanges.patch(key, (r) => ({ ...r, ...part }))} /> -
  • @@ -185,24 +162,18 @@ export function LayerApplicabilityDialog({
    setSpecificDates((c) => [...c, today])} - empty={specificDates.length === 0} + onAdd={() => specificDates.add(today)} + empty={specificDates.rows.length === 0} > - {specificDates.map((date, index) => ( -
  • + {specificDates.rows.map(({ key, value: date }) => ( +
  • - setSpecificDates((c) => c.map((d, i) => (i === index ? e.target.value : d))) - } + onChange={(e) => specificDates.patch(key, () => e.target.value)} /> -
  • diff --git a/frontend/src/features/admin/channels/components/RulesCard.tsx b/frontend/src/features/admin/channels/components/RulesCard.tsx index 6be588c..71c579a 100644 --- a/frontend/src/features/admin/channels/components/RulesCard.tsx +++ b/frontend/src/features/admin/channels/components/RulesCard.tsx @@ -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( - () => template.rules?.maxAudienceByTime ?? [], + const [windows, setWindows] = useState(() => + 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) => - setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w))) + const patchWindow = (key: string, part: Partial) => + setWindows((current) => + current.map((row) => + row.key === key ? { ...row, window: { ...row.window, ...part } } : row, + ), + ) return ( @@ -93,7 +107,7 @@ export function RulesCard({ @@ -103,15 +117,15 @@ export function RulesCard({

    {t('admin.channels.noAudienceWindows')}

    ) : (
      - {windows.map((window, index) => ( -
    • + {windows.map(({ key, window }) => ( +
    • patchWindow(index, { from: `${e.target.value}:00` })} + onChange={(e) => patchWindow(key, { from: `${e.target.value}:00` })} />
      @@ -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` })} />
      @@ -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({ diff --git a/frontend/src/features/admin/channels/components/ScheduleGrid.tsx b/frontend/src/features/admin/channels/components/ScheduleGrid.tsx index fd46ece..eaa81c7 100644 --- a/frontend/src/features/admin/channels/components/ScheduleGrid.tsx +++ b/frontend/src/features/admin/channels/components/ScheduleGrid.tsx @@ -174,9 +174,9 @@ export function ScheduleGrid({
      - {hours.map((hour, index) => ( + {hours.map((hour) => (
      @@ -197,9 +197,9 @@ export function ScheduleGrid({ onDragOver={(e) => dragged && e.preventDefault()} onDrop={(e) => drop(e, weekday)} > - {hours.map((_, index) => ( + {hours.map((hour, index) => ( -
      + ) } diff --git a/frontend/src/theme/ThemeProvider.tsx b/frontend/src/theme/ThemeProvider.tsx index 563d4b3..18d1ffa 100644 --- a/frontend/src/theme/ThemeProvider.tsx +++ b/frontend/src/theme/ThemeProvider.tsx @@ -1,4 +1,12 @@ -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react' type Theme = 'light' | 'dark' | 'system' @@ -36,12 +44,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) { return () => media.removeEventListener('change', onChange) }, [theme]) - const setTheme = (next: Theme) => { + const setTheme = useCallback((next: Theme) => { localStorage.setItem(STORAGE_KEY, next) setThemeState(next) - } + }, []) - return {children} + // Литерал в value пересоздавался бы на каждый рендер и перерисовывал всех потребителей темы. + const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]) + + return {children} } export function useTheme(): ThemeContextValue {