mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
feat(editor): move admin settings onto TanStack Query (#7437)
# Description of Changes
## The problem
`useAdminSettings` backs all 18 admin config sections. Each section
fetched its own copy of its settings block, held it in hand-rolled
loading/saving state, and refetched manually after every save.
Three consequences:
- **Duplicate fetching.** Four AI tabs all read the `aiEngine` block.
Nothing was shared, so each open refetched it.
- **Duplicated wiring.** All 18 sections carried the same effect to
trigger the fetch, each one depending on a `fetchSettings` callback that
would have refetched on every render had it ever become unstable.
- **Console noise.** The hook made 11 `console.*` calls, four of them
`JSON.stringify(settings, null, 2)` on **every fetch and every save** —
admin configuration serialised into the console of every admin session.
Every save also ended with a hand-written `await fetchSettings()`.
Forget it in a new section and its pending badges silently go stale.
## The fix
The hook uses TanStack Query, keyed on `sectionName`, so sections
reading the same block share one fetch and one cache entry.
The fetch gate moved into the hook. Sections used to write:
```ts
const { settings, fetchSettings } = useAdminSettings({ sectionName: "legal" });
useEffect(() => {
if (loginEnabled) fetchSettings();
}, [loginEnabled, fetchSettings]);
```
and now write:
```ts
const { settings } = useAdminSettings({
sectionName: "legal",
enabled: loginEnabled,
});
```
Saving is a mutation that invalidates the section on success, so the
refetch is structural rather than something each section remembers.
The delta computation and the save transformer are unchanged — that is
domain logic, not fetching. `settings` is still an editable draft seeded
from the server response, so forms behave exactly as before.
## Why it is better
Measured against the previous implementation across identical scenarios.
`commits` counts committed renders.
| Scenario | Before | After |
|---|---|---|
| Open one section | 2 commits, 1 request | 2 commits, 1 request |
| Browse the four AI tabs | 8 commits, 4 requests | **5 commits, 1
request** |
| Edit and save | 4 commits, 2 requests | 4 commits, 2 requests |
Committed renders are equal or better everywhere; browsing the AI tabs
costs a quarter of the requests.
The diff reads +449 / −303, but that includes a test file for a hook
that had no tests:
| | Added | Removed | Net |
|---|---|---|---|
| Production code (21 files) | 154 | 303 | **−149** |
| Tests (1 file) | 295 | 0 | +295 |
The 18 section files account for −133 of that: each drops an effect, a
destructure and usually an import, and gains one `enabled:` line. The
hook itself goes from 234 to 180 lines. `console.*` calls go from 11 to
0.
## Caching
Settings inherit the client's 30s stale window rather than refetching on
every mount, which is where the request saving comes from.
Nothing inside a cached block is server-observed — the only live reads
in these sections, `/api/v1/ai/health` and the tessdata language list,
are separate calls outside this query. A block therefore only changes
when another admin writes it.
Two things bound the staleness:
- Sections already held a single snapshot for as long as the modal
stayed open, with no refetch on focus. 30s is shorter than that window,
not longer.
- `computeDelta` only emits fields whose draft differs from the baseline
it was seeded from, so a stale baseline cannot produce a collateral
write. The only race is two admins editing the same field, which is
unchanged. Saving invalidates, so acting refreshes to current values.
The blocks where a stale read would matter most — `security`, `premium`,
`database` — are set once at deployment and effectively never edited
concurrently. The block with the most cache reuse, `aiEngine`, is the
least consequential.
**Convention:** config blocks cache; observed state does not. A section
that displays live server state inside its settings block should
override `staleTime` locally.
## Testing
14 tests, covering the shared fetch, cache reuse across tab reopens, key
separation between blocks, the `enabled` gate, delta-only saves, the
empty-delta short circuit, post-save invalidation, pending-value
display, and draft reseeding.
Each was checked by breaking the implementation and confirming the suite
fails: per-consumer query keys, sending the whole draft instead of the
delta, dropping the post-save invalidate, reporting loaded while
disabled, skipping the empty-delta short circuit, and reverting the
stale window to zero.
`task frontend:check` green. Two unrelated tests fail on this branch —
`workbenchSession.test.ts` and `notificationActions.test.tsx` — and fail
identically on `main`.
## Follow-ups
The sections that fetch through services rather than this hook — Teams,
TeamDetails, People, roughly 2,600 lines — are unchanged. Between them
they share two reads (`getTeams` and `getUsers`, both used by all three)
and carry ten distinct write operations, with no test coverage today.
---
## Primer: mutations
`useQuery` is for reads. It caches, dedupes, and re-renders when data
arrives. `useMutation` is for writes, where none of that applies — a
write happens once, when the user asks.
```ts
const save = useMutation({
mutationFn: (body) => putAdminSection("legal", body),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});
save.mutate(body); // fire and forget
await save.mutateAsync(body); // or await it
save.isPending; // disable the button
save.error; // show the failure
```
`isPending` and `error` replace the `useState` flag and
`try/catch/finally` you would otherwise write around every save.
After a write the cache holds stale data. Two ways to fix it:
| | What it does | Use when |
|---|---|---|
| `invalidateQueries` | Marks the data stale so it refetches | The
server may transform, queue or reject part of what you sent |
| `setQueryData` | Writes your value into the cache, no request | The
response tells you exactly what the server now holds |
**Invalidate by default. Use `setQueryData` only when the response is
authoritative.**
This hook has to invalidate: the server can queue a settings change
rather than applying it, returning it in a `_pending` block that the
form renders as a badge. Writing the local draft into the cache would
show a queued change as applied.
Most mutations are not like that. A "rename a team" write, where the
response is the new team, is a `setQueryData` case.
One gotcha: `mutate` does not throw, `mutateAsync` does. An awaited
`mutateAsync` without a `try/catch` is an unhandled rejection.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
export async function fetchAdminSection<T>(sectionName: string): Promise<T> {
|
||||
const response = await apiClient.get<T>(
|
||||
`/api/v1/admin/settings/section/${sectionName}`,
|
||||
);
|
||||
return (response.data ?? {}) as T;
|
||||
}
|
||||
|
||||
export async function putAdminSection(
|
||||
sectionName: string,
|
||||
delta: unknown,
|
||||
): Promise<void> {
|
||||
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<string, unknown>,
|
||||
): Promise<void> {
|
||||
await apiClient.put("/api/v1/admin/settings", { settings });
|
||||
}
|
||||
@@ -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 }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
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 }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
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<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setSettings({ appName: "Renamed" });
|
||||
});
|
||||
let done: Promise<void>;
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
interface UseAdminSettingsOptions<T> {
|
||||
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<T> {
|
||||
* Returns an object with sectionData and optionally deltaSettings.
|
||||
*/
|
||||
saveTransformer?: (settings: T) => {
|
||||
sectionData: any;
|
||||
deltaSettings?: Record<string, any>;
|
||||
sectionData: SettingsBlock;
|
||||
deltaSettings?: SettingsBlock;
|
||||
};
|
||||
}
|
||||
|
||||
interface UseAdminSettingsReturn<T> {
|
||||
settings: T;
|
||||
rawSettings: any;
|
||||
rawSettings: (T & SettingsWithPending<T>) | null;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
setSettings: (settings: T) => void;
|
||||
fetchSettings: () => Promise<void>;
|
||||
saveSettings: () => Promise<void>;
|
||||
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<T = any>(
|
||||
export function useAdminSettings<T>(
|
||||
options: UseAdminSettingsOptions<T>,
|
||||
): UseAdminSettingsReturn<T> {
|
||||
const { sectionName, fetchTransformer, saveTransformer } = options;
|
||||
const {
|
||||
sectionName,
|
||||
enabled = true,
|
||||
fetchTransformer,
|
||||
saveTransformer,
|
||||
} = options;
|
||||
|
||||
const [settings, setSettings] = useState<T>({} as T);
|
||||
const [rawSettings, setRawSettings] = useState<any>(null);
|
||||
const [originalSettings, setOriginalSettings] = useState<T>({} 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<T & SettingsWithPending<T>> =>
|
||||
fetchTransformerRef.current
|
||||
? (fetchTransformerRef.current() as Promise<T & SettingsWithPending<T>>)
|
||||
: fetchAdminSection<T & SettingsWithPending<T>>(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<T>(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<string, any> = {};
|
||||
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<T = any>(
|
||||
* 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
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Editor query keys: ["editor", <resource>, ...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,
|
||||
|
||||
+1
-7
@@ -86,11 +86,11 @@ export default function AdminAdvancedSection() {
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<AdvancedSettingsData>({
|
||||
sectionName: "advanced",
|
||||
enabled: loginEnabled,
|
||||
fetchTransformer: async (): Promise<
|
||||
AdvancedSettingsData & { _pending?: Record<string, unknown> }
|
||||
> => {
|
||||
@@ -207,12 +207,6 @@ export default function AdminAdvancedSection() {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loginEnabled) {
|
||||
fetchSettings();
|
||||
}
|
||||
}, [loginEnabled]);
|
||||
|
||||
const [tessdataLanguages, setTessdataLanguages] = useState<string[]>([]);
|
||||
const [remoteTessdataLanguages, setRemoteTessdataLanguages] = useState<
|
||||
string[]
|
||||
|
||||
+1
-6
@@ -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<AiEngineSettingsData>({
|
||||
@@ -83,10 +82,6 @@ export default function AdminAiDocumentsSection() {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
|
||||
+1
-6
@@ -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<AiEngineSettingsData>({
|
||||
@@ -82,10 +81,6 @@ export default function AdminAiGeneralSection() {
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
|
||||
+1
-6
@@ -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<AiEngineSettingsData>({
|
||||
@@ -53,10 +52,6 @@ export default function AdminAiLimitsSection() {
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
|
||||
+1
-6
@@ -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<AiEngineSettingsData>({
|
||||
@@ -84,10 +83,6 @@ export default function AdminAiModelsSection() {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
|
||||
+3
-9
@@ -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<ConnectionsSettingsData>({
|
||||
sectionName: "connections",
|
||||
enabled: loginEnabled,
|
||||
fetchTransformer: async (): Promise<
|
||||
ConnectionsSettingsData & { _pending?: Record<string, unknown> }
|
||||
> => {
|
||||
@@ -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,
|
||||
|
||||
+1
-7
@@ -85,11 +85,11 @@ export default function AdminDatabaseSection() {
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<DatabaseSettingsData>({
|
||||
sectionName: "database",
|
||||
enabled: loginEnabled,
|
||||
fetchTransformer: async (): Promise<
|
||||
DatabaseSettingsData & { _pending?: Record<string, unknown> }
|
||||
> => {
|
||||
@@ -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(() => {
|
||||
|
||||
+3
-10
@@ -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<EndpointsSettingsData>({
|
||||
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<UISettingsData>({
|
||||
sectionName: "ui",
|
||||
enabled: loginEnabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loginEnabled) {
|
||||
fetchSettings();
|
||||
fetchUiSettings();
|
||||
}
|
||||
}, [loginEnabled, fetchSettings, fetchUiSettings]);
|
||||
|
||||
const {
|
||||
isDirty: isEndpointsDirty,
|
||||
resetToSnapshot: resetEndpointsSnapshot,
|
||||
|
||||
+2
-8
@@ -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<FeaturesSettingsData>({
|
||||
sectionName: "features",
|
||||
enabled: loginEnabled,
|
||||
fetchTransformer: async (): Promise<
|
||||
FeaturesSettingsData & { _pending?: Record<string, unknown> }
|
||||
> => {
|
||||
@@ -103,12 +103,6 @@ export default function AdminFeaturesSection() {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loginEnabled) {
|
||||
fetchSettings();
|
||||
}
|
||||
}, [loginEnabled]);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
|
||||
+4
-8
@@ -48,20 +48,16 @@ export default function AdminFolderAccessSection() {
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<FolderAccessSettingsData>({ sectionName: "policies" });
|
||||
} = useAdminSettings<FolderAccessSettingsData>({
|
||||
sectionName: "policies",
|
||||
enabled: loginEnabled,
|
||||
});
|
||||
|
||||
const [newRoot, setNewRoot] = useState("");
|
||||
const [impliedRoots, setImpliedRoots] = useState<ImpliedFolderRoot[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loginEnabled) {
|
||||
fetchSettings();
|
||||
}
|
||||
}, [loginEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loginEnabled) return;
|
||||
apiClient
|
||||
|
||||
+1
-8
@@ -112,11 +112,11 @@ export default function AdminGeneralSection() {
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<GeneralSettingsData>({
|
||||
sectionName: "general",
|
||||
enabled: loginEnabled,
|
||||
fetchTransformer: async (): Promise<
|
||||
GeneralSettingsData & { _pending?: Record<string, unknown> }
|
||||
> => {
|
||||
@@ -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;
|
||||
|
||||
+2
-8
@@ -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<LegalSettingsData>({
|
||||
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,
|
||||
|
||||
+3
-8
@@ -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<T> {
|
||||
_pending?: Partial<T>;
|
||||
@@ -57,7 +57,6 @@ export default function AdminMailSection() {
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<MailSettingsData>({
|
||||
@@ -78,10 +77,6 @@ export default function AdminMailSection() {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
|
||||
+1
-6
@@ -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<McpSettingsData>({
|
||||
@@ -106,10 +105,6 @@ export default function AdminMcpSection() {
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
|
||||
+2
-8
@@ -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<PremiumSettingsData>({
|
||||
sectionName: "premium",
|
||||
enabled: loginEnabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loginEnabled) {
|
||||
fetchSettings();
|
||||
}
|
||||
}, [loginEnabled]);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
|
||||
+2
-8
@@ -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<PrivacySettingsData>({
|
||||
sectionName: "privacy",
|
||||
enabled: loginEnabled,
|
||||
fetchTransformer: async (): Promise<
|
||||
PrivacySettingsData & { _pending?: Record<string, unknown> }
|
||||
> => {
|
||||
@@ -90,12 +90,6 @@ export default function AdminPrivacySection() {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loginEnabled) {
|
||||
fetchSettings();
|
||||
}
|
||||
}, [loginEnabled, fetchSettings]);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
|
||||
+2
-8
@@ -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<SecuritySettingsData>({
|
||||
sectionName: "security",
|
||||
enabled: loginEnabled,
|
||||
fetchTransformer: async (): Promise<
|
||||
SecuritySettingsData & { _pending?: Record<string, unknown> }
|
||||
> => {
|
||||
@@ -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;
|
||||
|
||||
|
||||
+2
-8
@@ -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<StorageSharingSettingsData>({
|
||||
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());
|
||||
|
||||
Reference in New Issue
Block a user