From 0e8496bffcba7b330151e72fd55227bdd2fb7008 Mon Sep 17 00:00:00 2001 From: Patrick Bacon-Blaber Date: Sun, 15 Feb 2026 09:32:42 -0500 Subject: [PATCH] fix: preserve changes across config editor tabs when saving --- .../ConfigEditorScreen.test.tsx | 47 +++++++++++++++++++ .../ConfigEditorScreen/ConfigEditorScreen.tsx | 27 +++++++++-- .../components/GeneralTab.test.tsx | 1 + .../components/GeneralTab.tsx | 30 ++++++++++-- .../components/LinksTab.test.tsx | 4 +- .../components/LinksTab.tsx | 11 +++-- 6 files changed, 108 insertions(+), 12 deletions(-) diff --git a/src/screens/ConfigEditorScreen/ConfigEditorScreen.test.tsx b/src/screens/ConfigEditorScreen/ConfigEditorScreen.test.tsx index d0f2a31..6fd9193 100644 --- a/src/screens/ConfigEditorScreen/ConfigEditorScreen.test.tsx +++ b/src/screens/ConfigEditorScreen/ConfigEditorScreen.test.tsx @@ -2,6 +2,7 @@ import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ConfigEditor } from './ConfigEditorScreen.tsx'; import { mockConfig } from '../../test/fixtures.ts'; +import type { AppConfig } from '../../types/config.ts'; describe('ConfigEditorScreen', () => { const defaultProps = { @@ -83,4 +84,50 @@ describe('ConfigEditorScreen', () => { expect(screen.getByText('Invalid base64 string.')).toBeInTheDocument(); }); + + it('preserves link changes when saving from the General tab', async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render(); + + // Switch to Links tab and add a new section + await user.click(screen.getByRole('button', { name: 'Links' })); + await user.click(screen.getByRole('button', { name: 'Add Section' })); + + // Fill in the new section name + const sectionInputs = screen.getAllByPlaceholderText('Section name'); + await user.type(sectionInputs[0], 'New Section'); + + // Switch back to General tab and save + await user.click(screen.getByRole('button', { name: 'General' })); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(onSave).toHaveBeenCalledTimes(1); + const savedConfig = onSave.mock.calls[0][0] as AppConfig; + // Should have 3 modules: the new one + the 2 original ones + expect(savedConfig.modules).toHaveLength(3); + expect(savedConfig.modules[0].title).toBe('New Section'); + }); + + it('preserves general changes when saving from the Links tab', async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render(); + + // Edit placeholder on General tab + const placeholderInput = screen.getByPlaceholderText('Filter links...'); + await user.clear(placeholderInput); + await user.type(placeholderInput, 'Search...'); + + // Switch to Links tab and save + await user.click(screen.getByRole('button', { name: 'Links' })); + const saveButtons = screen.getAllByRole('button', { name: 'Save' }); + await user.click(saveButtons[0]); + + expect(onSave).toHaveBeenCalledTimes(1); + const savedConfig = onSave.mock.calls[0][0] as AppConfig; + expect(savedConfig.search?.placeholder).toBe('Search...'); + // Original modules should still be there + expect(savedConfig.modules).toHaveLength(2); + }); }); diff --git a/src/screens/ConfigEditorScreen/ConfigEditorScreen.tsx b/src/screens/ConfigEditorScreen/ConfigEditorScreen.tsx index 3526d2b..f7fcaf1 100644 --- a/src/screens/ConfigEditorScreen/ConfigEditorScreen.tsx +++ b/src/screens/ConfigEditorScreen/ConfigEditorScreen.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import type { AppConfig, BackgroundConfig } from '../../types/config.ts'; import { GeneralTab } from './components/GeneralTab.tsx'; import { LinksTab } from './components/LinksTab.tsx'; @@ -49,12 +49,25 @@ interface ConfigEditorProps { export function ConfigEditor({ config, onSave, onClose, onPreview }: ConfigEditorProps) { const [tab, setTab] = useState('general'); + const [draftConfig, setDraftConfig] = useState(() => ({ + ...config, + modules: config.modules.map((m) => ({ ...m, links: m.links.map((l) => ({ ...l })) })), + })); const [activePanel, setActivePanel] = useState('none'); const [exportString, setExportString] = useState(''); const [copied, setCopied] = useState(false); const [importString, setImportString] = useState(''); const [importError, setImportError] = useState(''); + const handleConfigChange = useCallback((updatedConfig: AppConfig) => { + setDraftConfig(updatedConfig); + }, []); + + const handleSave = useCallback((configToSave: AppConfig) => { + onSave(configToSave); + onClose(); + }, [onSave, onClose]); + const handleExport = () => { if (activePanel === 'export') { setActivePanel('none'); @@ -166,15 +179,21 @@ export function ConfigEditor({ config, onSave, onClose, onPreview }: ConfigEdito {tab === 'general' && ( )} {tab === 'links' && ( - + )} {activePanel !== 'none' && ( diff --git a/src/screens/ConfigEditorScreen/components/GeneralTab.test.tsx b/src/screens/ConfigEditorScreen/components/GeneralTab.test.tsx index c800e42..30785ca 100644 --- a/src/screens/ConfigEditorScreen/components/GeneralTab.test.tsx +++ b/src/screens/ConfigEditorScreen/components/GeneralTab.test.tsx @@ -10,6 +10,7 @@ describe('GeneralTab', () => { onSave: vi.fn(), onClose: vi.fn(), onPreview: vi.fn(), + onConfigChange: vi.fn(), }; beforeEach(() => { diff --git a/src/screens/ConfigEditorScreen/components/GeneralTab.tsx b/src/screens/ConfigEditorScreen/components/GeneralTab.tsx index b3ce124..d86ffd9 100644 --- a/src/screens/ConfigEditorScreen/components/GeneralTab.tsx +++ b/src/screens/ConfigEditorScreen/components/GeneralTab.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import type { AppConfig, BackgroundConfig, GradientDirection } from '../../../types/config.ts'; const DIRECTION_ARROWS: { value: GradientDirection; arrow: string }[] = [ @@ -13,9 +13,10 @@ interface GeneralTabProps { onSave: (config: AppConfig) => void; onClose: () => void; onPreview: (background: BackgroundConfig) => void; + onConfigChange: (config: AppConfig) => void; } -export function GeneralTab({ config, onSave, onClose, onPreview }: GeneralTabProps) { +export function GeneralTab({ config, onSave, onClose, onPreview, onConfigChange }: GeneralTabProps) { const [imageUrl, setImageUrl] = useState(config.background?.imageUrl ?? ''); const [appliedImageUrl, setAppliedImageUrl] = useState(config.background?.imageUrl ?? ''); const [opacity, setOpacity] = useState(config.background?.opacity ?? 0.5); @@ -27,6 +28,30 @@ export function GeneralTab({ config, onSave, onClose, onPreview }: GeneralTabPro const hasImage = appliedImageUrl.trim() !== ''; + // Push general-tab changes to the shared draft so they persist across tab switches + useEffect(() => { + const updated: AppConfig = { + ...config, + background: { + ...config.background, + imageUrl: appliedImageUrl || undefined, + opacity, + color, + gradient: { + enabled: gradientEnabled, + color2: gradientColor2, + direction: gradientDirection, + }, + }, + search: { + ...config.search, + enabled: config.search?.enabled ?? false, + placeholder: placeholder || undefined, + }, + }; + onConfigChange(updated); + }, [appliedImageUrl, opacity, color, gradientEnabled, gradientColor2, gradientDirection, placeholder]);// eslint-disable-line react-hooks/exhaustive-deps + const buildGradient = (updates: { enabled?: boolean; color2?: string; direction?: GradientDirection } = {}) => ({ enabled: updates.enabled ?? gradientEnabled, color2: updates.color2 ?? gradientColor2, @@ -79,7 +104,6 @@ export function GeneralTab({ config, onSave, onClose, onPreview }: GeneralTabPro placeholder: placeholder || undefined, }, }); - onClose(); }; return ( diff --git a/src/screens/ConfigEditorScreen/components/LinksTab.test.tsx b/src/screens/ConfigEditorScreen/components/LinksTab.test.tsx index 7ad897b..a07d091 100644 --- a/src/screens/ConfigEditorScreen/components/LinksTab.test.tsx +++ b/src/screens/ConfigEditorScreen/components/LinksTab.test.tsx @@ -8,6 +8,7 @@ describe('LinksTab', () => { config: mockConfig, onSave: vi.fn(), onClose: vi.fn(), + onConfigChange: vi.fn(), }; beforeEach(() => { @@ -122,7 +123,7 @@ describe('LinksTab', () => { expect(screen.queryByText('Clear All Links', { selector: 'h2' })).not.toBeInTheDocument(); }); - it('renders a top Save button that saves and closes', async () => { + it('renders a top Save button that calls onSave', async () => { const user = userEvent.setup(); render(); @@ -133,6 +134,5 @@ describe('LinksTab', () => { await user.click(saveButtons[0]); expect(defaultProps.onSave).toHaveBeenCalledTimes(1); - expect(defaultProps.onClose).toHaveBeenCalledTimes(1); }); }); diff --git a/src/screens/ConfigEditorScreen/components/LinksTab.tsx b/src/screens/ConfigEditorScreen/components/LinksTab.tsx index fe4e674..fe1adce 100644 --- a/src/screens/ConfigEditorScreen/components/LinksTab.tsx +++ b/src/screens/ConfigEditorScreen/components/LinksTab.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import type { AppConfig, ModuleConfig } from '../../../types/config.ts'; const MAX_SECTION_NAME = 50; @@ -15,15 +15,21 @@ interface LinksTabProps { config: AppConfig; onSave: (config: AppConfig) => void; onClose: () => void; + onConfigChange: (config: AppConfig) => void; } -export function LinksTab({ config, onSave, onClose }: LinksTabProps) { +export function LinksTab({ config, onSave, onClose, onConfigChange }: LinksTabProps) { const [modules, setModules] = useState( () => config.modules.map((m) => ({ ...m, links: m.links.map((l) => ({ ...l })) })), ); const [submitted, setSubmitted] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false); + // Push link changes to the shared draft so they persist across tab switches + useEffect(() => { + onConfigChange({ ...config, modules }); + }, [modules]); // eslint-disable-line react-hooks/exhaustive-deps + // --- Section handlers --- const addSection = () => { @@ -164,7 +170,6 @@ export function LinksTab({ config, onSave, onClose }: LinksTabProps) { })); onSave({ ...config, modules: cleaned }); - onClose(); }; const totalLinks = modules.reduce((sum, m) => sum + m.links.length, 0);