diff --git a/frontend/editor/src/core/api/adminSettings.ts b/frontend/editor/src/core/api/adminSettings.ts new file mode 100644 index 0000000000..d2f01a6ebd --- /dev/null +++ b/frontend/editor/src/core/api/adminSettings.ts @@ -0,0 +1,22 @@ +import apiClient from "@app/services/apiClient"; + +export async function fetchAdminSection(sectionName: string): Promise { + const response = await apiClient.get( + `/api/v1/admin/settings/section/${sectionName}`, + ); + return (response.data ?? {}) as T; +} + +export async function putAdminSection( + sectionName: string, + delta: unknown, +): Promise { + await apiClient.put(`/api/v1/admin/settings/section/${sectionName}`, delta); +} + +/** Flat dotted-path settings, for sections that write outside their own block. */ +export async function putAdminSettings( + settings: Record, +): Promise { + await apiClient.put("/api/v1/admin/settings", { settings }); +} diff --git a/frontend/editor/src/core/hooks/useAdminSettings.test.tsx b/frontend/editor/src/core/hooks/useAdminSettings.test.tsx new file mode 100644 index 0000000000..cf8e17ed3f --- /dev/null +++ b/frontend/editor/src/core/hooks/useAdminSettings.test.tsx @@ -0,0 +1,313 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useAdminSettings } from "@app/hooks/useAdminSettings"; +import { qk } from "@app/query/keys"; +import { + fetchAdminSection, + putAdminSection, + putAdminSettings, +} from "@app/api/adminSettings"; + +vi.mock("@app/api/adminSettings", () => ({ + fetchAdminSection: vi.fn(), + putAdminSection: vi.fn(), + putAdminSettings: vi.fn(), +})); + +const mockFetch = vi.mocked(fetchAdminSection); +const mockPutSection = vi.mocked(putAdminSection); +const mockPutSettings = vi.mocked(putAdminSettings); + +function makeWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +describe("useAdminSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue({ appName: "Stirling" }); + mockPutSection.mockResolvedValue(undefined); + mockPutSettings.mockResolvedValue(undefined); + }); + + it("loads the section and seeds the editable draft", async () => { + const { result } = renderHook( + () => useAdminSettings({ sectionName: "general" }), + { wrapper: makeWrapper() }, + ); + + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.settings).toEqual({ appName: "Stirling" }); + expect(mockFetch).toHaveBeenCalledWith("general"); + }); + + it("shares one fetch between sections reading the same block", async () => { + const { result } = renderHook( + () => ({ + a: useAdminSettings({ sectionName: "aiEngine" }), + b: useAdminSettings({ sectionName: "aiEngine" }), + c: useAdminSettings({ sectionName: "aiEngine" }), + }), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => expect(result.current.a.loading).toBe(false)); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("serves a reopened tab from cache within the stale window", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 30_000 } }, + }); + const shared = ({ children }: { children: ReactNode }) => ( + {children} + ); + + for (let i = 0; i < 4; i++) { + const tab = renderHook( + () => useAdminSettings({ sectionName: "aiEngine" }), + { wrapper: shared }, + ); + await waitFor(() => expect(tab.result.current.loading).toBe(false)); + tab.unmount(); + } + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("keeps sections with different blocks apart", async () => { + const { result } = renderHook( + () => ({ + a: useAdminSettings({ sectionName: "general" }), + b: useAdminSettings({ sectionName: "security" }), + }), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => expect(result.current.a.loading).toBe(false)); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledWith("general"); + expect(mockFetch).toHaveBeenCalledWith("security"); + }); + + it("does not fetch while disabled, and reports itself unloaded", async () => { + const { result } = renderHook( + () => useAdminSettings({ sectionName: "general", enabled: false }), + { + wrapper: makeWrapper(), + }, + ); + + expect(mockFetch).not.toHaveBeenCalled(); + // Sections gate their render on this; false would show an empty form. + expect(result.current.loading).toBe(true); + }); + + it("fetches when the gate opens", async () => { + const { result, rerender } = renderHook( + ({ on }: { on: boolean }) => + useAdminSettings({ sectionName: "general", enabled: on }), + { wrapper: makeWrapper(), initialProps: { on: false } }, + ); + + expect(mockFetch).not.toHaveBeenCalled(); + rerender({ on: true }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("sends only changed fields", async () => { + mockFetch.mockResolvedValue({ appName: "Stirling", theme: "dark" }); + const { result } = renderHook( + () => + useAdminSettings<{ appName: string; theme: string }>({ + sectionName: "general", + }), + { wrapper: makeWrapper() }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.setSettings({ appName: "Renamed", theme: "dark" }); + }); + await act(async () => { + await result.current.saveSettings(); + }); + + expect(mockPutSection).toHaveBeenCalledWith("general", { + appName: "Renamed", + }); + }); + + it("skips the request when nothing changed", async () => { + const { result } = renderHook( + () => useAdminSettings({ sectionName: "general" }), + { wrapper: makeWrapper() }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.saveSettings(); + }); + + expect(mockPutSection).not.toHaveBeenCalled(); + }); + + it("refetches after a save so the _pending block is current", async () => { + mockFetch.mockResolvedValue({ appName: "Stirling" }); + const { result } = renderHook( + () => useAdminSettings<{ appName: string }>({ sectionName: "general" }), + { + wrapper: makeWrapper(), + }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(mockFetch).toHaveBeenCalledTimes(1); + + mockFetch.mockResolvedValue({ + appName: "Stirling", + _pending: { appName: "Renamed" }, + }); + act(() => { + result.current.setSettings({ appName: "Renamed" }); + }); + await act(async () => { + await result.current.saveSettings(); + }); + + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(result.current.hasPendingChanges()).toBe(true)); + }); + + it("surfaces pending values in the draft and flags the field", async () => { + mockFetch.mockResolvedValue({ + appName: "Stirling", + _pending: { appName: "Queued" }, + }); + const { result } = renderHook( + () => useAdminSettings<{ appName: string }>({ sectionName: "general" }), + { + wrapper: makeWrapper(), + }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + // The draft shows the queued value, not the active one. + expect(result.current.settings.appName).toBe("Queued"); + expect(result.current.isFieldPending("appName")).toBe(true); + }); + + it("resets the draft when a fetch delivers new values", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const { result } = renderHook( + () => useAdminSettings<{ appName: string }>({ sectionName: "general" }), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.setSettings({ appName: "Half-typed" }); + }); + expect(result.current.settings.appName).toBe("Half-typed"); + + mockFetch.mockResolvedValue({ appName: "From server" }); + await act(async () => { + await client.invalidateQueries({ queryKey: qk.adminSection("general") }); + }); + + // A fetch is authoritative over the draft. + await waitFor(() => + expect(result.current.settings.appName).toBe("From server"), + ); + }); + + it("does not clobber an in-progress edit on re-render", async () => { + const { result, rerender } = renderHook( + () => useAdminSettings<{ appName: string }>({ sectionName: "general" }), + { + wrapper: makeWrapper(), + }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.setSettings({ appName: "Half-typed" }); + }); + rerender(); + rerender(); + + expect(result.current.settings.appName).toBe("Half-typed"); + }); + + it("routes transformer output to both endpoints", async () => { + mockFetch.mockResolvedValue({ a: 1, b: 2 }); + const { result } = renderHook( + () => + useAdminSettings<{ a: number; b: number }>({ + sectionName: "general", + saveTransformer: (s) => ({ + sectionData: { a: s.a }, + deltaSettings: { "some.flat.path": s.b }, + }), + }), + { wrapper: makeWrapper() }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.setSettings({ a: 9, b: 8 }); + }); + await act(async () => { + await result.current.saveSettings(); + }); + + expect(mockPutSection).toHaveBeenCalledWith("general", { a: 9 }); + expect(mockPutSettings).toHaveBeenCalledWith({ "some.flat.path": 8 }); + }); + + it("reports saving while the save is in flight", async () => { + const { result } = renderHook( + () => useAdminSettings<{ appName: string }>({ sectionName: "general" }), + { + wrapper: makeWrapper(), + }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let release: () => void = () => {}; + mockPutSection.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }), + ); + + act(() => { + result.current.setSettings({ appName: "Renamed" }); + }); + let done: Promise; + act(() => { + done = result.current.saveSettings(); + }); + await waitFor(() => expect(result.current.saving).toBe(true)); + + await act(async () => { + release(); + await done; + }); + await waitFor(() => expect(result.current.saving).toBe(false)); + }); +}); diff --git a/frontend/editor/src/core/hooks/useAdminSettings.ts b/frontend/editor/src/core/hooks/useAdminSettings.ts index 1cc495a5da..4e6cf95d26 100644 --- a/frontend/editor/src/core/hooks/useAdminSettings.ts +++ b/frontend/editor/src/core/hooks/useAdminSettings.ts @@ -1,13 +1,24 @@ -import { useState, useCallback } from "react"; -import apiClient from "@app/services/apiClient"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + fetchAdminSection, + putAdminSection, + putAdminSettings, +} from "@app/api/adminSettings"; +import { qk } from "@app/query/keys"; import { mergePendingSettings, isFieldPending, hasPendingChanges, + type SettingsWithPending, } from "@app/utils/settingsPendingHelper"; +/** A settings block, which is an object of unknown-shaped fields. */ +type SettingsBlock = Record; + interface UseAdminSettingsOptions { sectionName: string; + enabled?: boolean; /** * Optional transformer to combine data from multiple endpoints. * If not provided, uses the section response directly. @@ -18,201 +29,121 @@ interface UseAdminSettingsOptions { * Returns an object with sectionData and optionally deltaSettings. */ saveTransformer?: (settings: T) => { - sectionData: any; - deltaSettings?: Record; + sectionData: SettingsBlock; + deltaSettings?: SettingsBlock; }; } interface UseAdminSettingsReturn { settings: T; - rawSettings: any; + rawSettings: (T & SettingsWithPending) | null; loading: boolean; saving: boolean; setSettings: (settings: T) => void; - fetchSettings: () => Promise; saveSettings: () => Promise; isFieldPending: (fieldPath: string) => boolean; hasPendingChanges: () => boolean; } /** - * Hook for managing admin settings with automatic pending changes support. - * Includes delta detection to only send changed fields. - * - * @example - * const { settings, setSettings, saveSettings, isFieldPending } = useAdminSettings({ - * sectionName: 'legal' - * }); + * One config section: the server value, an editable draft over it, and a save + * that sends only what changed. Sections sharing a sectionName share the fetch. */ -export function useAdminSettings( +export function useAdminSettings( options: UseAdminSettingsOptions, ): UseAdminSettingsReturn { - const { sectionName, fetchTransformer, saveTransformer } = options; + const { + sectionName, + enabled = true, + fetchTransformer, + saveTransformer, + } = options; - const [settings, setSettings] = useState({} as T); - const [rawSettings, setRawSettings] = useState(null); - const [originalSettings, setOriginalSettings] = useState({} as T); // Track original active values - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); + const queryClient = useQueryClient(); + const queryKey = qk.adminSection(sectionName); - const fetchSettings = useCallback(async () => { - try { - setLoading(true); + // Inline closures at the call sites, so their identity changes every render. + const fetchTransformerRef = useRef(fetchTransformer); + fetchTransformerRef.current = fetchTransformer; + const saveTransformerRef = useRef(saveTransformer); + saveTransformerRef.current = saveTransformer; - let rawData: any; + const { + data: rawSettings, + isPending, + isFetching, + } = useQuery({ + queryKey, + queryFn: (): Promise> => + fetchTransformerRef.current + ? (fetchTransformerRef.current() as Promise>) + : fetchAdminSection>(sectionName), + enabled, + // Inherits the client's 30s window. Not CONFIG_STALE_TIME: these are + // editable, and a save invalidates. Override it for live server state. + }); - if (fetchTransformer) { - // Use custom fetch logic for complex sections - rawData = await fetchTransformer(); - } else { - // Simple single-endpoint fetch - const response = await apiClient.get( - `/api/v1/admin/settings/section/${sectionName}`, - ); - rawData = response.data || {}; - } + // Pending changes folded in: what the form shows, and the delta baseline. + const baseline = useMemo( + () => (rawSettings ? (mergePendingSettings(rawSettings) as T) : ({} as T)), + [rawSettings], + ); - console.log( - `[useAdminSettings:${sectionName}] Raw response:`, - JSON.stringify(rawData, null, 2), - ); + // Adjusted during render, not in an effect: React re-runs the component + // before committing, so reseeding costs no extra render. + const [draft, setDraft] = useState(baseline); + const seededFrom = useRef(rawSettings); + if (rawSettings !== undefined && seededFrom.current !== rawSettings) { + seededFrom.current = rawSettings; + setDraft(baseline); + } - // Store raw settings (includes _pending if present) - setRawSettings(rawData); + const save = useMutation({ + mutationFn: async () => { + const delta = computeDelta(baseline, draft); + if (Object.keys(delta).length === 0) return; - // Merge pending changes into settings for display - const mergedSettings = mergePendingSettings(rawData); - console.log( - `[useAdminSettings:${sectionName}] Merged settings:`, - JSON.stringify(mergedSettings, null, 2), - ); - - // Store merged settings as original for delta comparison - // This ensures we compare against what the user SAW (with pending), not raw active values - setOriginalSettings(mergedSettings as T); - console.log( - `[useAdminSettings:${sectionName}] Original settings (for comparison):`, - JSON.stringify(mergedSettings, null, 2), - ); - - setSettings(mergedSettings as T); - } catch (error) { - console.error( - `[useAdminSettings:${sectionName}] Failed to fetch:`, - error, - ); - throw error; - } finally { - setLoading(false); - } - }, [sectionName]); - - const saveSettings = async () => { - try { - setSaving(true); - - // Compute delta: only include fields that changed from original - const delta = computeDelta(originalSettings, settings); - console.log( - `[useAdminSettings:${sectionName}] Delta (changed fields):`, - JSON.stringify(delta, null, 2), - ); - - if (Object.keys(delta).length === 0) { - console.log( - `[useAdminSettings:${sectionName}] No changes detected, skipping save`, - ); + const transform = saveTransformerRef.current; + if (!transform) { + await putAdminSection(sectionName, delta); return; } - if (saveTransformer) { - // Use custom save logic for complex sections - const { sectionData, deltaSettings } = saveTransformer(settings); + const { sectionData, deltaSettings } = transform(draft); + const { sectionData: originalSectionData, deltaSettings: originalDelta } = + transform(baseline); - // Get original sectionData using same transformer for fair comparison - const { sectionData: originalSectionData } = - saveTransformer(originalSettings); - - // Save section data (with delta applied) - compare transformed vs transformed - const sectionDelta = computeDelta(originalSectionData, sectionData); - if (Object.keys(sectionDelta).length > 0) { - await apiClient.put( - `/api/v1/admin/settings/section/${sectionName}`, - sectionDelta, - ); - } - - // Save delta settings if provided (filter to only changed values) - if (deltaSettings && Object.keys(deltaSettings).length > 0) { - // Build deltaSettings from original using same transformer to get correct structure - const { deltaSettings: originalDeltaSettings } = - saveTransformer(originalSettings); - - console.log( - `[useAdminSettings:${sectionName}] Comparing deltaSettings:`, - { - original: originalDeltaSettings, - current: deltaSettings, - }, - ); - - // Compare current vs original deltaSettings (both have same backend paths) - const changedDeltaSettings: Record = {}; - for (const [key, value] of Object.entries(deltaSettings)) { - const originalValue = originalDeltaSettings?.[key]; - - // Only include if value actually changed - if (JSON.stringify(value) !== JSON.stringify(originalValue)) { - changedDeltaSettings[key] = value; - console.log( - `[useAdminSettings:${sectionName}] Delta field changed: ${key}`, - { - original: originalValue, - new: value, - }, - ); - } - } - - if (Object.keys(changedDeltaSettings).length > 0) { - console.log( - `[useAdminSettings:${sectionName}] Sending delta settings:`, - changedDeltaSettings, - ); - await apiClient.put("/api/v1/admin/settings", { - settings: changedDeltaSettings, - }); - } else { - console.log( - `[useAdminSettings:${sectionName}] No delta settings changed, skipping`, - ); - } - } - } else { - // Simple single-endpoint save with delta - await apiClient.put( - `/api/v1/admin/settings/section/${sectionName}`, - delta, - ); + const sectionDelta = computeDelta(originalSectionData, sectionData); + if (Object.keys(sectionDelta).length > 0) { + await putAdminSection(sectionName, sectionDelta); } - // Refetch to get updated _pending block - await fetchSettings(); - } catch (error) { - console.error(`[useAdminSettings:${sectionName}] Failed to save:`, error); - throw error; - } finally { - setSaving(false); - } - }; + if (deltaSettings && Object.keys(deltaSettings).length > 0) { + const changed: SettingsBlock = {}; + for (const [key, value] of Object.entries(deltaSettings)) { + if (JSON.stringify(value) !== JSON.stringify(originalDelta?.[key])) { + changed[key] = value; + } + } + if (Object.keys(changed).length > 0) await putAdminSettings(changed); + } + }, + // Refetch rather than trust the draft: the response carries the _pending + // block the badges render from. + onSuccess: () => queryClient.invalidateQueries({ queryKey }), + }); + + const saveSettings = useCallback(async () => { + await save.mutateAsync(); + }, [save]); return { - settings, - rawSettings, - loading, - saving, - setSettings, - fetchSettings, + settings: draft, + rawSettings: rawSettings ?? null, + // True while disabled too: nothing has loaded. + loading: isPending || isFetching, + saving: save.isPending, + setSettings: setDraft, saveSettings, isFieldPending: (fieldPath: string) => isFieldPending(rawSettings, fieldPath), @@ -224,30 +155,25 @@ export function useAdminSettings( * Compute delta between original and current settings. * Returns only fields that have changed. */ -function computeDelta(original: any, current: any): any { - const delta: any = {}; +function computeDelta(original: unknown, current: unknown): SettingsBlock { + const delta: SettingsBlock = {}; + if (!isPlainObject(current)) return delta; + const before: SettingsBlock = isPlainObject(original) ? original : {}; - for (const key in current) { - if (!Object.prototype.hasOwnProperty.call(current, key)) continue; - - const originalValue = original[key]; + for (const key of Object.keys(current)) { + const originalValue = before[key]; const currentValue = current[key]; - // Handle nested objects if (isPlainObject(currentValue) && isPlainObject(originalValue)) { const nestedDelta = computeDelta(originalValue, currentValue); if (Object.keys(nestedDelta).length > 0) { delta[key] = nestedDelta; } - } - // Handle arrays - else if (Array.isArray(currentValue) && Array.isArray(originalValue)) { + } else if (Array.isArray(currentValue) && Array.isArray(originalValue)) { if (JSON.stringify(currentValue) !== JSON.stringify(originalValue)) { delta[key] = currentValue; } - } - // Handle primitives - else if (currentValue !== originalValue) { + } else if (currentValue !== originalValue) { delta[key] = currentValue; } } @@ -258,7 +184,7 @@ function computeDelta(original: any, current: any): any { /** * Check if value is a plain object (not array, not null, not Date, etc.) */ -function isPlainObject(value: any): boolean { +function isPlainObject(value: unknown): value is SettingsBlock { return ( value !== null && typeof value === "object" && value.constructor === Object ); diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts index 3e44395de0..cae39e74f2 100644 --- a/frontend/editor/src/core/query/keys.ts +++ b/frontend/editor/src/core/query/keys.ts @@ -1,5 +1,7 @@ /** Editor query keys: ["editor", , ...params]. */ export const qk = { + adminSection: (sectionName: string) => + ["editor", "adminSection", sectionName] as const, /** The admin directory payload: a different endpoint and shape to qk.users(). */ adminUsers: () => ["editor", "adminUsers"] as const, appConfig: () => ["editor", "appConfig"] as const, diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAdvancedSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAdvancedSection.tsx index 9c81199ed5..f1831c5b48 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAdvancedSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAdvancedSection.tsx @@ -86,11 +86,11 @@ export default function AdminAdvancedSection() { setSettings, loading, saving, - fetchSettings, saveSettings, isFieldPending, } = useAdminSettings({ sectionName: "advanced", + enabled: loginEnabled, fetchTransformer: async (): Promise< AdvancedSettingsData & { _pending?: Record } > => { @@ -207,12 +207,6 @@ export default function AdminAdvancedSection() { }, }); - useEffect(() => { - if (loginEnabled) { - fetchSettings(); - } - }, [loginEnabled]); - const [tessdataLanguages, setTessdataLanguages] = useState([]); const [remoteTessdataLanguages, setRemoteTessdataLanguages] = useState< string[] diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiDocumentsSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiDocumentsSection.tsx index 55c5867288..eee54a9520 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiDocumentsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiDocumentsSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { NumberInput, @@ -44,7 +44,6 @@ export default function AdminAiDocumentsSection() { setSettings, loading, saving, - fetchSettings, saveSettings, isFieldPending, } = useAdminSettings({ @@ -83,10 +82,6 @@ export default function AdminAiDocumentsSection() { }, }); - useEffect(() => { - fetchSettings(); - }, []); - const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty( settings, loading, diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.tsx index db835740c0..f28803fcc5 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { TextInput, @@ -45,7 +45,6 @@ export default function AdminAiGeneralSection() { setSettings, loading, saving, - fetchSettings, saveSettings, isFieldPending, } = useAdminSettings({ @@ -82,10 +81,6 @@ export default function AdminAiGeneralSection() { }), }); - useEffect(() => { - fetchSettings(); - }, []); - const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty( settings, loading, diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiLimitsSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiLimitsSection.tsx index bfb1c31f28..72de03ade0 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiLimitsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiLimitsSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { NumberInput, Stack, Paper, Text, Loader, Group } from "@mantine/core"; import { alert } from "@app/components/toast"; @@ -25,7 +25,6 @@ export default function AdminAiLimitsSection() { setSettings, loading, saving, - fetchSettings, saveSettings, isFieldPending, } = useAdminSettings({ @@ -53,10 +52,6 @@ export default function AdminAiLimitsSection() { }), }); - useEffect(() => { - fetchSettings(); - }, []); - const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty( settings, loading, diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiModelsSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiModelsSection.tsx index c7161f4e6e..acba1c9571 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiModelsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiModelsSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { TextInput, @@ -45,7 +45,6 @@ export default function AdminAiModelsSection() { setSettings, loading, saving, - fetchSettings, saveSettings, isFieldPending, } = useAdminSettings({ @@ -84,10 +83,6 @@ export default function AdminAiModelsSection() { }, }); - useEffect(() => { - fetchSettings(); - }, []); - const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty( settings, loading, diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx index b993889e52..faae178f9e 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { @@ -157,6 +157,7 @@ export default function AdminConnectionsSection() { const adminSettings = useAdminSettings({ sectionName: "connections", + enabled: loginEnabled, fetchTransformer: async (): Promise< ConnectionsSettingsData & { _pending?: Record } > => { @@ -397,14 +398,7 @@ export default function AdminConnectionsSection() { }, }); - const { settings, setSettings, loading, fetchSettings, isFieldPending } = - adminSettings; - - useEffect(() => { - if (loginEnabled) { - fetchSettings(); - } - }, [loginEnabled, fetchSettings]); + const { settings, setSettings, loading, isFieldPending } = adminSettings; const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty( settings, diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminDatabaseSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminDatabaseSection.tsx index 6b34e79fa6..3d013c6bd4 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminDatabaseSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminDatabaseSection.tsx @@ -85,11 +85,11 @@ export default function AdminDatabaseSection() { setSettings, loading, saving, - fetchSettings, saveSettings, isFieldPending, } = useAdminSettings({ sectionName: "database", + enabled: loginEnabled, fetchTransformer: async (): Promise< DatabaseSettingsData & { _pending?: Record } > => { @@ -140,12 +140,6 @@ export default function AdminDatabaseSection() { }, }); - useEffect(() => { - if (loginEnabled) { - fetchSettings(); - } - }, [loginEnabled, fetchSettings]); - const datasourceType = (settings?.type || "").toLowerCase(); const isCustomDatabase = settings?.enableCustomDatabase === true; const isEmbeddedH2 = useMemo(() => { diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminEndpointsSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminEndpointsSection.tsx index 05fb7e11d4..b4df553a93 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminEndpointsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminEndpointsSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Stack, @@ -45,11 +45,11 @@ export default function AdminEndpointsSection() { setSettings, loading, saving, - fetchSettings, saveSettings, isFieldPending, } = useAdminSettings({ sectionName: "endpoints", + enabled: loginEnabled, }); const { @@ -57,20 +57,13 @@ export default function AdminEndpointsSection() { setSettings: setUiSettings, loading: uiLoading, saving: uiSaving, - fetchSettings: fetchUiSettings, saveSettings: saveUiSettings, isFieldPending: isUiFieldPending, } = useAdminSettings({ sectionName: "ui", + enabled: loginEnabled, }); - useEffect(() => { - if (loginEnabled) { - fetchSettings(); - fetchUiSettings(); - } - }, [loginEnabled, fetchSettings, fetchUiSettings]); - const { isDirty: isEndpointsDirty, resetToSnapshot: resetEndpointsSnapshot, diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFeaturesSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFeaturesSection.tsx index 961c6f6ce4..de6801f330 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFeaturesSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFeaturesSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { @@ -49,11 +49,11 @@ export default function AdminFeaturesSection() { setSettings, loading, saving, - fetchSettings, saveSettings, isFieldPending, } = useAdminSettings({ sectionName: "features", + enabled: loginEnabled, fetchTransformer: async (): Promise< FeaturesSettingsData & { _pending?: Record } > => { @@ -103,12 +103,6 @@ export default function AdminFeaturesSection() { }, }); - useEffect(() => { - if (loginEnabled) { - fetchSettings(); - } - }, [loginEnabled]); - const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty( settings, loading, diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.tsx index fad2782162..742bebbbb1 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.tsx @@ -48,20 +48,16 @@ export default function AdminFolderAccessSection() { setSettings, loading, saving, - fetchSettings, saveSettings, isFieldPending, - } = useAdminSettings({ sectionName: "policies" }); + } = useAdminSettings({ + sectionName: "policies", + enabled: loginEnabled, + }); const [newRoot, setNewRoot] = useState(""); const [impliedRoots, setImpliedRoots] = useState([]); - useEffect(() => { - if (loginEnabled) { - fetchSettings(); - } - }, [loginEnabled]); - useEffect(() => { if (!loginEnabled) return; apiClient diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx index 3fdd597175..caa19e8d25 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx @@ -112,11 +112,11 @@ export default function AdminGeneralSection() { setSettings, loading, saving, - fetchSettings, saveSettings, isFieldPending, } = useAdminSettings({ sectionName: "general", + enabled: loginEnabled, fetchTransformer: async (): Promise< GeneralSettingsData & { _pending?: Record } > => { @@ -331,13 +331,6 @@ export default function AdminGeneralSection() { ); }, [selectedLanguages, languageOptions]); - useEffect(() => { - // Only fetch real settings if login is enabled - if (loginEnabled) { - fetchSettings(); - } - }, [loginEnabled, fetchSettings]); - // Sync local preference with server setting on initial load useEffect(() => { if (loading || !loginEnabled || !settings.ui?.logoStyle) return; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminLegalSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminLegalSection.tsx index 4d22a8d304..4598e4a3d0 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminLegalSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminLegalSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { TextInput, @@ -51,11 +51,11 @@ export default function AdminLegalSection() { setSettings, loading, saving, - fetchSettings, saveSettings, isFieldPending, } = useAdminSettings({ sectionName: "legal", + enabled: loginEnabled, // The flat legal URL fields save through the section endpoint as before; the nested // loginAgreement object is flattened to dotted keys sent via the global settings endpoint // (updateSettingsTransactional), which merges into the existing node. Saving a partial @@ -75,12 +75,6 @@ export default function AdminLegalSection() { }, }); - useEffect(() => { - if (loginEnabled) { - fetchSettings(); - } - }, [loginEnabled]); - const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty( settings, loading, diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx index c62125be3f..663d5c9dbb 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { @@ -23,7 +23,7 @@ import EditableSecretField from "@app/components/shared/EditableSecretField"; import apiClient from "@app/services/apiClient"; import { useLoginRequired } from "@app/hooks/useLoginRequired"; -interface MailSettingsData { +type MailSettingsData = { enabled?: boolean; enableInvites?: boolean; inviteLinkExpiryHours?: number; @@ -32,7 +32,7 @@ interface MailSettingsData { username?: string; password?: string; from?: string; -} +}; interface ApiResponseWithPending { _pending?: Partial; @@ -57,7 +57,6 @@ export default function AdminMailSection() { setSettings, loading, saving, - fetchSettings, saveSettings, isFieldPending, } = useAdminSettings({ @@ -78,10 +77,6 @@ export default function AdminMailSection() { }, }); - useEffect(() => { - fetchSettings(); - }, []); - const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty( settings, loading, diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMcpSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMcpSection.tsx index 64976c296a..5567b136b5 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMcpSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMcpSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { TextInput, @@ -73,7 +73,6 @@ export default function AdminMcpSection() { setSettings, loading, saving, - fetchSettings, saveSettings, isFieldPending, } = useAdminSettings({ @@ -106,10 +105,6 @@ export default function AdminMcpSection() { }), }); - useEffect(() => { - fetchSettings(); - }, []); - const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty( settings, loading, diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPremiumSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPremiumSection.tsx index d65abbf6f8..2389b4bc66 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPremiumSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPremiumSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback } from "react"; import { Trans, useTranslation } from "react-i18next"; import { TextInput, @@ -43,19 +43,13 @@ export default function AdminPremiumSection() { setSettings, loading, saving, - fetchSettings, saveSettings, isFieldPending, } = useAdminSettings({ sectionName: "premium", + enabled: loginEnabled, }); - useEffect(() => { - if (loginEnabled) { - fetchSettings(); - } - }, [loginEnabled]); - const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty( settings, loading, diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPrivacySection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPrivacySection.tsx index 37d6b7da1d..14f44074db 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPrivacySection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPrivacySection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Switch, Stack, Paper, Text, Loader, Group } from "@mantine/core"; import { alert } from "@app/components/toast"; @@ -34,11 +34,11 @@ export default function AdminPrivacySection() { setSettings, loading, saving, - fetchSettings, saveSettings, isFieldPending, } = useAdminSettings({ sectionName: "privacy", + enabled: loginEnabled, fetchTransformer: async (): Promise< PrivacySettingsData & { _pending?: Record } > => { @@ -90,12 +90,6 @@ export default function AdminPrivacySection() { }, }); - useEffect(() => { - if (loginEnabled) { - fetchSettings(); - } - }, [loginEnabled, fetchSettings]); - const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty( settings, loading, diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx index 448ebe3fc1..fc46685c6c 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { @@ -81,11 +81,11 @@ export default function AdminSecuritySection() { setSettings, loading, saving, - fetchSettings, saveSettings, isFieldPending, } = useAdminSettings({ sectionName: "security", + enabled: loginEnabled, fetchTransformer: async (): Promise< SecuritySettingsData & { _pending?: Record } > => { @@ -216,12 +216,6 @@ export default function AdminSecuritySection() { loading, ); - useEffect(() => { - if (loginEnabled) { - fetchSettings(); - } - }, [loginEnabled, fetchSettings]); - // Override loading state when login is disabled const actualLoading = loginEnabled ? loading : false; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx index dfaecac1fb..70b25e4813 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Anchor, @@ -57,11 +57,11 @@ export default function AdminStorageSharingSection() { setSettings, loading, saving, - fetchSettings, saveSettings, isFieldPending, } = useAdminSettings({ sectionName: "storage", + enabled: loginEnabled, fetchTransformer: async () => { const [storageResponse, systemResponse, mailResponse] = await Promise.all( [ @@ -96,12 +96,6 @@ export default function AdminStorageSharingSection() { }), }); - useEffect(() => { - if (loginEnabled) { - fetchSettings(); - } - }, [loginEnabled]); - const storageEnabled = settings.enabled ?? false; const sharingEnabled = storageEnabled && (settings.sharing?.enabled ?? false); const frontendUrlConfigured = Boolean(settings.system?.frontendUrl?.trim());