mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(web): resolve feature install status sync and mutual exclusivity (#214)
This commit is contained in:
@@ -97,6 +97,12 @@ export function FeatureInstallPrompt({
|
|||||||
installBundle(bundle.id);
|
installBundle(bundle.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Defensive guard: if the bundle is already installed (status may have
|
||||||
|
// been refreshed after mount), never render the install prompt.
|
||||||
|
if (bundle.status === "installed") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-center px-4">
|
<div className="flex flex-col items-center justify-center h-full gap-4 text-center px-4">
|
||||||
|
|||||||
@@ -178,7 +178,13 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
|||||||
startTimes: {},
|
startTimes: {},
|
||||||
|
|
||||||
fetch: async () => {
|
fetch: async () => {
|
||||||
if (get().loaded && !get().loadError) return;
|
if (get().loaded && !get().loadError) {
|
||||||
|
// Already loaded successfully before. Refresh in the background
|
||||||
|
// so navigating between tool pages picks up status changes
|
||||||
|
// (e.g. a bundle installed from the settings page).
|
||||||
|
refreshBundles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features");
|
const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features");
|
||||||
set({ bundles: data.bundles, loaded: true, loadError: false });
|
set({ bundles: data.bundles, loaded: true, loadError: false });
|
||||||
@@ -251,13 +257,23 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const installing = { ...get().installing };
|
const installing = { ...get().installing };
|
||||||
delete installing[bundleId];
|
delete installing[bundleId];
|
||||||
set({
|
|
||||||
installing,
|
const message = err instanceof Error ? err.message : "Failed to start installation";
|
||||||
errors: {
|
const isAlreadyInstalled = /already installed/i.test(message);
|
||||||
...get().errors,
|
|
||||||
[bundleId]: err instanceof Error ? err.message : "Failed to start installation",
|
if (isAlreadyInstalled) {
|
||||||
},
|
// 409 "already installed": clear the error and refresh status
|
||||||
});
|
// so the UI transitions to the installed state silently.
|
||||||
|
const errors = { ...get().errors };
|
||||||
|
delete errors[bundleId];
|
||||||
|
set({ installing, errors });
|
||||||
|
await refreshBundles();
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
installing,
|
||||||
|
errors: { ...get().errors, [bundleId]: message },
|
||||||
|
});
|
||||||
|
}
|
||||||
resolveCompletion(bundleId);
|
resolveCompletion(bundleId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -123,12 +123,15 @@ describe("useFeaturesStore", () => {
|
|||||||
expect(useFeaturesStore.getState().loadError).toBe(true);
|
expect(useFeaturesStore.getState().loadError).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips fetch if already loaded without error", async () => {
|
it("does a background refresh if already loaded without error", async () => {
|
||||||
useFeaturesStore.setState({ loaded: true, loadError: false });
|
const bundles = [makeBundleState({ id: "bundle-a" })];
|
||||||
|
useFeaturesStore.setState({ loaded: true, loadError: false, bundles });
|
||||||
|
apiGetMock.mockResolvedValueOnce({ bundles });
|
||||||
|
|
||||||
await useFeaturesStore.getState().fetch();
|
await useFeaturesStore.getState().fetch();
|
||||||
|
|
||||||
expect(apiGetMock).not.toHaveBeenCalled();
|
// Should call apiGet for a background refresh (not short-circuit)
|
||||||
|
expect(apiGetMock).toHaveBeenCalledWith("/v1/features");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retries fetch if previously loaded with error", async () => {
|
it("retries fetch if previously loaded with error", async () => {
|
||||||
|
|||||||
@@ -2010,10 +2010,12 @@ describe("useFeaturesStore", () => {
|
|||||||
expect(s.loaded).toBe(true);
|
expect(s.loaded).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetch skips when already loaded without error", async () => {
|
it("fetch triggers background refresh when already loaded without error", async () => {
|
||||||
|
const bundles = [{ id: "ocr", name: "OCR", status: "installed" }];
|
||||||
|
mockApiGet.mockResolvedValueOnce({ bundles });
|
||||||
useFeaturesStore.setState({ loaded: true, loadError: false });
|
useFeaturesStore.setState({ loaded: true, loadError: false });
|
||||||
await useFeaturesStore.getState().fetch();
|
await useFeaturesStore.getState().fetch();
|
||||||
expect(mockApiGet).not.toHaveBeenCalled();
|
expect(mockApiGet).toHaveBeenCalledWith("/v1/features");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetch sets loaded=true and loadError=true on error", async () => {
|
it("fetch sets loaded=true and loadError=true on error", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user