fix: count suffixed renames, roll back failed theme saves, keep reset dialog open on error

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-20 19:58:08 +02:00
1 parent f8424dcb23
commit 1a44b87cdc
13 files changed
+65 -22

No files matched your search

+10 -7
View File
@@ -2,7 +2,7 @@ import type { Prisma } from "@prisma/client";
import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { DEFAULT_CATEGORIES, type DefaultCategoryNode } from "../seedData/defaultCategories";
import { availableModelName, reorganizeManagedPrints } from "./printService";
import { availableModelName, reorganizeManagedPrints, sanitizePathSegment } from "./printService";
export const CATEGORY_KINDS = ["category", "folder"] as const;
export type CategoryKind = (typeof CATEGORY_KINDS)[number];
@@ -188,24 +188,27 @@ async function planCategoryReset(db: Prisma.TransactionClient, userId: string):
const modelsIn = new Map<string, number>();
for (const p of prints) modelsIn.set(p.categoryId!, (modelsIn.get(p.categoryId!) ?? 0) + 1);
// A moved model is renamed when its name is already taken where it lands, by a model there or one moved earlier.
// A moved model is renamed as availableModelName will at reset: suffixed past names taken where it lands, by a model
// there or one moved earlier.
let renamed = 0;
if (moves.length) {
const names = [...new Set(moves.map((m) => m.name.trim().toLowerCase()))];
const targets = [...new Set(moves.map((m) => m.target).filter((t): t is string => t !== null))];
const staying = await db.print.findMany({
where: {
userId,
nameNormalized: { in: names },
OR: [{ categoryId: { in: targets } }, ...(moves.some((m) => !m.target) ? [{ categoryId: null }] : [])],
},
select: { categoryId: true, nameNormalized: true },
});
const taken = new Set(staying.map((p) => `${p.categoryId}\u0000${p.nameNormalized}`));
for (const m of moves) {
const key = `${m.target}\u0000${m.name.trim().toLowerCase()}`;
if (taken.has(key)) renamed++;
else taken.add(key);
const base = sanitizePathSegment(m.name, "Model");
let candidate = base;
for (let suffix = 2; taken.has(`${m.target}\u0000${candidate.trim().toLowerCase()}`); suffix++) {
candidate = `${base} (${suffix})`;
}
taken.add(`${m.target}\u0000${candidate.trim().toLowerCase()}`);
if (candidate !== m.name) renamed++;
}
}
+6 -2
View File
@@ -111,15 +111,18 @@ describe("POST /api/categories/reset-defaults", () => {
const extra = await create(`Extra ${stamp}`, child.id);
const nested = await create(`Nested ${stamp}`, extra);
const fromNested = await createPrint(`Clash ${stamp}`, nested);
// Lands after the first one has taken its name.
const suffixed = await createPrint(`Clash ${stamp} (2)`, nested);
await prisma.print.update({ where: { id: suffixed.id }, data: { createdAt: new Date(Date.now() + 1000) } });
const planned = await preview();
expect(planned.added).toEqual([]);
expect(planned.restored).toEqual([]);
expect(planned.removed).toEqual([
{ path: [firstRoot.name, firstChild.name, `Extra ${stamp}`], models: 0 },
{ path: [firstRoot.name, firstChild.name, `Extra ${stamp}`, `Nested ${stamp}`], models: 1 },
{ path: [firstRoot.name, firstChild.name, `Extra ${stamp}`, `Nested ${stamp}`], models: 2 },
]);
expect(planned).toMatchObject({ models_moved: 1, models_uncategorized: 0, models_renamed: 1 });
expect(planned).toMatchObject({ models_moved: 2, models_uncategorized: 0, models_renamed: 2 });
expect((await reset()).status).toBe(200);
const after = await list();
@@ -131,5 +134,6 @@ describe("POST /api/categories/reset-defaults", () => {
expect(moved.categoryId).toBe(child.id);
expect(moved.categorySource).toBe("MANUAL");
expect(moved.name).toBe(`Clash ${stamp} (2)`);
expect((await prisma.print.findUniqueOrThrow({ where: { id: suffixed.id } })).name).toBe(`Clash ${stamp} (2) (2)`);
});
});
+22 -5
View File
@@ -43,6 +43,7 @@ import AiPage from "./pages/AiPage";
import { healthApi, type HealthInfo } from "./api/health";
import { authApi, type AuthUser } from "./api/auth";
import { settingsApi, type PreviewMode } from "./api/settings";
import { UnauthorizedError } from "./api/client";
import { clearToken, clearUser, readToken, readUser, storeToken, storeUser } from "./utils/auth";
import { type AppSettings, type MakerWorldSettings, loadSettings, saveSettings } from "./utils/settings";
import { useResolvedTheme } from "./hooks/useResolvedTheme";
@@ -63,7 +64,7 @@ type AppShellProps = {
previewMode: PreviewMode;
setPreviewMode: (mode: PreviewMode) => void;
themeSelection: ThemeSelection;
onThemeChange: (theme: ThemeSelection) => void;
onThemeChange: (theme: ThemeSelection) => Promise<boolean>;
resolvedTheme: ResolvedTheme;
muiTheme: ReturnType<typeof buildTheme>;
onUnauthorized: () => void;
@@ -356,10 +357,6 @@ export default function App() {
active = false;
};
}, [token]);
const handleThemeChange = React.useCallback((selected: ThemeSelection) => {
setThemeSelection(selected);
void settingsApi.updateTheme({ theme: selected }).catch(() => {});
}, []);
React.useEffect(() => {
saveSettings(settings);
}, [settings]);
@@ -386,6 +383,26 @@ export default function App() {
setShowExpiredToast(true);
}, []);
// Picks can overlap; only the latest one's failure puts the theme back.
const latestThemeSave = React.useRef(0);
/** Resolves false when the save failed and the theme went back. */
const handleThemeChange = React.useCallback(
async (selected: ThemeSelection): Promise<boolean> => {
const previous = themeSelection;
const save = ++latestThemeSave.current;
setThemeSelection(selected);
try {
await settingsApi.updateTheme({ theme: selected });
return true;
} catch (err) {
if (save === latestThemeSave.current) setThemeSelection(previous);
if (err instanceof UnauthorizedError) handleUnauthorized();
return false;
}
},
[themeSelection, handleUnauthorized],
);
const handleLogout = () => {
// Fire-and-forget: local logout proceeds immediately.
void authApi.logout();
+1 -1
View File
@@ -41,7 +41,7 @@ type AppLayoutProps = {
makerworldCookie: string;
onUpdateMakerWorld: (patch: Partial<MakerWorldSettings>) => void;
user: AuthUser | null;
onThemeChange: (theme: ThemeSelection) => void;
onThemeChange: (theme: ThemeSelection) => Promise<boolean>;
children: React.ReactNode;
};
@@ -43,7 +43,7 @@ const MAX_SEGMENTED_LANGUAGES = 4;
type Props = {
theme: ThemeSelection;
onThemeChange: (theme: ThemeSelection) => void;
onThemeChange: (theme: ThemeSelection) => Promise<boolean>;
onUnauthorized?: () => void;
};
@@ -69,11 +69,18 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
themeSaves.current = request.catch(() => undefined);
return request;
};
const [themeError, setThemeError] = React.useState<string | null>(null);
const [cardPreviewError, setCardPreviewError] = React.useState<string | null>(null);
const [hoverSlideshow, setHoverSlideshow] = useHoverSlideshowEnabled();
const [hoverSlideshowError, setHoverSlideshowError] = React.useState<string | null>(null);
const confirm = useConfirm();
const changeTheme = async (next: ThemeSelection) => {
setThemeError(null);
// A lapsed session also signs out, where the theme lives.
if (!(await onThemeChange(next))) setThemeError(t("appearance.themeFailed"));
};
const changeCardPreview = async (next: CardPreview) => {
setCardPreviewError(null);
try {
@@ -162,7 +169,7 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
});
if (!confirmed) return;
// Only what differs is saved; each keeps its own error message if its save fails.
if (theme !== DEFAULT_THEME) onThemeChange(DEFAULT_THEME);
if (theme !== DEFAULT_THEME) void changeTheme(DEFAULT_THEME);
// A new accent puts the render colour back to following it.
if (!accentAtDefault) void changeAccent(DEFAULT_ACCENT);
if (cardPreview !== null && cardPreview !== DEFAULT_CARD_PREVIEW) void changeCardPreview(DEFAULT_CARD_PREVIEW);
@@ -217,9 +224,14 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
<Segmented
label={t("userMenu.theme")}
value={theme}
onChange={onThemeChange}
onChange={(next) => void changeTheme(next)}
options={THEMES.map((value) => ({ value, label: t(`appearance.themes.${value}`) }))}
/>
{themeError && (
<Typography variant="caption" color="error" sx={{ display: "block", mt: 1 }}>
{themeError}
</Typography>
)}
</div>
<div>
<SectionLabel>{t("appearance.accent")}</SectionLabel>
@@ -24,7 +24,7 @@ type Props = {
open: ConfigurationTab | null;
onClose: () => void;
theme: ThemeSelection;
onThemeChange: (theme: ThemeSelection) => void;
onThemeChange: (theme: ThemeSelection) => Promise<boolean>;
isAdmin: boolean;
makerworldCookie: string;
onUpdateMakerWorld: (patch: Partial<MakerWorldSettings>) => void;
+1 -1
View File
@@ -29,7 +29,7 @@ type Props = {
onUnauthorized?: () => void;
user: AuthUser | null;
theme: ThemeSelection;
onThemeChange: (theme: ThemeSelection) => void;
onThemeChange: (theme: ThemeSelection) => Promise<boolean>;
onOpenProfile: () => void;
onLogout: () => void;
};
+1
View File
@@ -1578,6 +1578,7 @@
"off": "Off",
"accent": "Accent color",
"accentFailed": "Couldn't save the accent color.",
"themeFailed": "Couldn't save the theme.",
"accents": {
"green": "Thingport green",
"blue": "Thingiverse blue",
+1
View File
@@ -1578,6 +1578,7 @@
"off": "Desactivado",
"accent": "Color de acento",
"accentFailed": "No se pudo guardar el color de acento.",
"themeFailed": "No se pudo guardar el tema.",
"accents": {
"green": "Verde Thingport",
"blue": "Azul Thingiverse",
+1
View File
@@ -1591,6 +1591,7 @@
"off": "Désactivé",
"accent": "Couleur d’accent",
"accentFailed": "Impossible d’enregistrer la couleur d’accent.",
"themeFailed": "Impossible d’enregistrer le thème.",
"accents": {
"green": "Vert Thingport",
"blue": "Bleu Thingiverse",
+1
View File
@@ -1644,6 +1644,7 @@
"off": "Išjungta",
"accent": "Akcento spalva",
"accentFailed": "Nepavyko išsaugoti akcento spalvos.",
"themeFailed": "Nepavyko išsaugoti temos.",
"accents": {
"green": "Thingport žalia",
"blue": "Thingiverse mėlyna",
@@ -25,7 +25,7 @@ const fadeIn = keyframes`
type Props = {
open: boolean;
onClose: () => void;
/** Resets; reports its own failure. */
/** Resets; reports its own failure, then rejects to keep the dialog open. */
onConfirm: () => Promise<void>;
onUnauthorized?: () => void;
};
@@ -129,6 +129,8 @@ export default function CategoryResetDialog({ open, onClose, onConfirm, onUnauth
try {
await onConfirm();
onClose();
} catch {
// onConfirm has reported it; staying open keeps the preview for a retry.
} finally {
setResetting(false);
}
+2 -1
View File
@@ -223,7 +223,7 @@ export default function ModelsPage({
}
};
// Models in removed categories move, so the list reloads too.
// Models in removed categories move, so the list reloads too. Rethrows so the reset dialog stays open to retry.
const resetCategories = async () => {
try {
const next = await categoriesApi.resetDefaults();
@@ -232,6 +232,7 @@ export default function ModelsPage({
refreshLibrary();
} catch (err) {
handleError(err, t("models:errors.resetCategoriesFailed"));
throw err;
}
};