From 7ca829e7e9f36c9af9c9bb9404e6302deb7da0ac Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 22 Mar 2026 14:33:40 +0000 Subject: [PATCH] Extract alert schedule state owner --- .../v6/internal/subsystems/alerts.md | 8 + .../subsystems/frontend-primitives.md | 7 + .../__tests__/useAlertScheduleState.test.tsx | 82 ++++ .../src/features/alerts/tabs/ScheduleTab.tsx | 289 ++------------ .../features/alerts/useAlertScheduleState.ts | 369 ++++++++++++++++++ .../pages/__tests__/Alerts.helpers.test.ts | 13 + .../frontendResourceTypeBoundaries.test.ts | 13 + 7 files changed, 532 insertions(+), 249 deletions(-) create mode 100644 frontend-modern/src/features/alerts/__tests__/useAlertScheduleState.test.tsx create mode 100644 frontend-modern/src/features/alerts/useAlertScheduleState.ts diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 6059611185..2895c0491b 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -102,6 +102,14 @@ The alerts schedule surface now also routes quiet-hour suppress-category card styling through `frontend-modern/src/utils/alertSchedulePresentation.ts` instead of leaving that selectable-card presentation inline in `frontend-modern/src/pages/Alerts.tsx`. +That schedule surface now also follows the same shell/runtime split as the +other feature tabs: `frontend-modern/src/features/alerts/tabs/ScheduleTab.tsx` +stays the render shell, while +`frontend-modern/src/features/alerts/useAlertScheduleState.ts` owns schedule +reset behavior, quiet-hours day/category toggles, cooldown/grouping/escalation +update policy, and the canonical defaults handoff. Future schedule control-flow +work should extend that hook instead of rebuilding those mutations inline in +the tab shell. Incident-event filter chip and filter-action styling now routes through `frontend-modern/src/utils/alertIncidentPresentation.ts` for both diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 07da76924e..99fac03da7 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -226,6 +226,13 @@ must both compose acknowledge/restore behavior rather than keeping duplicate API and notification logic inline in `useAlertOverviewState.ts` or `frontend-modern/src/components/Alerts/RecentAlertsPanel.tsx`. +The same feature-owner rule now applies to the alert scheduling surface: +`frontend-modern/src/features/alerts/tabs/ScheduleTab.tsx` must remain the +schedule render shell, while +`frontend-modern/src/features/alerts/useAlertScheduleState.ts` owns schedule +reset/update policy and canonical default application. The tab should not +re-accumulate quiet-hours, cooldown, grouping, or escalation mutation logic +inline. The updates settings surface now follows the same presentation-owner rule. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` stays the diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertScheduleState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertScheduleState.test.tsx new file mode 100644 index 0000000000..2e40919902 --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/useAlertScheduleState.test.tsx @@ -0,0 +1,82 @@ +import { renderHook } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { describe, expect, it } from 'vitest'; + +import { useAlertScheduleState } from '../useAlertScheduleState'; +import { + createDefaultCooldown, + createDefaultEscalation, + createDefaultGrouping, + createDefaultQuietHours, + createDefaultResolveNotifications, +} from '../helpers'; + +describe('useAlertScheduleState', () => { + it('owns schedule reset and update control flow outside the tab shell', () => { + const [hasUnsavedChanges, setHasUnsavedChanges] = createSignal(false); + const [quietHours, setQuietHours] = createSignal(createDefaultQuietHours()); + const [cooldown, setCooldown] = createSignal(createDefaultCooldown()); + const [grouping, setGrouping] = createSignal(createDefaultGrouping()); + const [notifyOnResolve, setNotifyOnResolve] = createSignal(false); + const [escalation, setEscalation] = createSignal(createDefaultEscalation()); + + const { result } = renderHook(() => + useAlertScheduleState({ + setHasUnsavedChanges, + quietHours, + setQuietHours, + cooldown, + setCooldown, + grouping, + setGrouping, + notifyOnResolve, + setNotifyOnResolve, + escalation, + setEscalation, + }), + ); + + result.setQuietHoursEnabled(true); + result.setQuietHoursStart('21:30'); + result.toggleQuietDay('saturday'); + result.setQuietSuppressCategory('storage', true); + result.setCooldownEnabled(true); + result.setCooldownMinutes('45'); + result.setCooldownMaxAlerts('5'); + result.setGroupingEnabled(true); + result.setGroupingWindow('8'); + result.setGroupingByNode(true); + result.setNotifyOnResolveEnabled(true); + result.setEscalationEnabled(true); + result.addEscalationLevel(); + result.setEscalationAfter(0, '30'); + result.setEscalationNotify(0, 'webhook'); + + expect(hasUnsavedChanges()).toBe(true); + expect(quietHours()).toMatchObject({ + enabled: true, + start: '21:30', + suppress: expect.objectContaining({ storage: true }), + days: expect.objectContaining({ saturday: true }), + }); + expect(result.weekendsOnly()).toBe(false); + expect(cooldown()).toMatchObject({ enabled: true, minutes: 45, maxAlerts: 5 }); + expect(grouping()).toMatchObject({ enabled: true, window: 8, byNode: true }); + expect(notifyOnResolve()).toBe(true); + expect(escalation()).toMatchObject({ + enabled: true, + levels: [expect.objectContaining({ after: 30, notify: 'webhook' })], + }); + + result.removeEscalationLevel(0); + expect(escalation().levels).toHaveLength(0); + + result.resetToDefaults(); + + expect(quietHours()).toEqual(createDefaultQuietHours()); + expect(cooldown()).toEqual(createDefaultCooldown()); + expect(grouping()).toEqual(createDefaultGrouping()); + expect(notifyOnResolve()).toBe(createDefaultResolveNotifications()); + expect(escalation()).toEqual(createDefaultEscalation()); + }); +}); diff --git a/frontend-modern/src/features/alerts/tabs/ScheduleTab.tsx b/frontend-modern/src/features/alerts/tabs/ScheduleTab.tsx index 83b9dea732..4c6a2c1942 100644 --- a/frontend-modern/src/features/alerts/tabs/ScheduleTab.tsx +++ b/frontend-modern/src/features/alerts/tabs/ScheduleTab.tsx @@ -68,22 +68,17 @@ import { } from '@/utils/alertSchedulePresentation'; import { - createDefaultCooldown, - createDefaultEscalation, - createDefaultGrouping, - createDefaultQuietHours, - createDefaultResolveNotifications, - fallbackMaxAlertsPerHour, -} from '../helpers'; + ALERT_SCHEDULE_DAYS, + ALERT_SCHEDULE_TIMEZONES, + useAlertScheduleState, +} from '../useAlertScheduleState'; import type { CooldownConfig, EscalationConfig, - EscalationLevel, EscalationNotifyTarget, GroupingConfig, QuietHoursConfig, } from '../types'; -import { fallbackCooldownMinutes } from '../types'; export interface ScheduleTabProps { setHasUnsavedChanges: (value: boolean) => void; @@ -99,99 +94,9 @@ export interface ScheduleTabProps { setEscalation: (value: EscalationConfig) => void; } -const timezones = [ - 'UTC', - 'Africa/Cairo', - 'Africa/Johannesburg', - 'Africa/Lagos', - 'Africa/Nairobi', - 'America/Anchorage', - 'America/Argentina/Buenos_Aires', - 'America/Bogota', - 'America/Caracas', - 'America/Chicago', - 'America/Denver', - 'America/Halifax', - 'America/Lima', - 'America/Los_Angeles', - 'America/Mexico_City', - 'America/New_York', - 'America/Phoenix', - 'America/Santiago', - 'America/Sao_Paulo', - 'America/St_Johns', - 'America/Toronto', - 'America/Vancouver', - 'Asia/Bangkok', - 'Asia/Dhaka', - 'Asia/Dubai', - 'Asia/Hong_Kong', - 'Asia/Jakarta', - 'Asia/Jerusalem', - 'Asia/Karachi', - 'Asia/Kolkata', - 'Asia/Kuala_Lumpur', - 'Asia/Manila', - 'Asia/Riyadh', - 'Asia/Seoul', - 'Asia/Shanghai', - 'Asia/Singapore', - 'Asia/Taipei', - 'Asia/Tehran', - 'Asia/Tokyo', - 'Australia/Adelaide', - 'Australia/Brisbane', - 'Australia/Melbourne', - 'Australia/Perth', - 'Australia/Sydney', - 'Europe/Amsterdam', - 'Europe/Athens', - 'Europe/Berlin', - 'Europe/Brussels', - 'Europe/Budapest', - 'Europe/Copenhagen', - 'Europe/Dublin', - 'Europe/Helsinki', - 'Europe/Istanbul', - 'Europe/Lisbon', - 'Europe/London', - 'Europe/Madrid', - 'Europe/Moscow', - 'Europe/Oslo', - 'Europe/Paris', - 'Europe/Prague', - 'Europe/Rome', - 'Europe/Stockholm', - 'Europe/Vienna', - 'Europe/Warsaw', - 'Europe/Zurich', - 'Pacific/Auckland', - 'Pacific/Fiji', - 'Pacific/Guam', - 'Pacific/Honolulu', -]; - -const days = [ - { id: 'monday', label: 'M', fullLabel: 'Monday' }, - { id: 'tuesday', label: 'T', fullLabel: 'Tuesday' }, - { id: 'wednesday', label: 'W', fullLabel: 'Wednesday' }, - { id: 'thursday', label: 'T', fullLabel: 'Thursday' }, - { id: 'friday', label: 'F', fullLabel: 'Friday' }, - { id: 'saturday', label: 'S', fullLabel: 'Saturday' }, - { id: 'sunday', label: 'S', fullLabel: 'Sunday' }, -]; - export function ScheduleTab(props: ScheduleTabProps) { const quietHourSuppressOptions = getAlertConfigQuietHourSuppressOptions(); - - const resetToDefaults = () => { - props.setQuietHours(createDefaultQuietHours()); - props.setCooldown(createDefaultCooldown()); - props.setGrouping(createDefaultGrouping()); - props.setNotifyOnResolve(createDefaultResolveNotifications()); - props.setEscalation(createDefaultEscalation()); - props.setHasUnsavedChanges(true); - }; + const scheduleState = useAlertScheduleState(props); return (
@@ -204,7 +109,7 @@ export function ScheduleTab(props: ScheduleTabProps) {