Merge pull request #3568 from bluewave-labs/feat/status-page-themes-backend
feat(status-page): add theme and themeMode fields (backend)
This commit is contained in:
6 files changed
+167
-3
No files matched your search
@@ -3425,11 +3425,13 @@
|
||||
"StatusPageTheme": {
|
||||
"type": "string",
|
||||
"enum": ["refined", "modern", "bold", "editorial"],
|
||||
"default": "refined",
|
||||
"description": "Visual theme for the public status page. Defaults to 'refined'."
|
||||
},
|
||||
"StatusPageThemeMode": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "light", "dark"],
|
||||
"default": "auto",
|
||||
"description": "Forces a light/dark mode on the public page. 'auto' follows the visitor's OS preference. Defaults to 'auto'."
|
||||
},
|
||||
"SuccessResponse": {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, type StatusPage } from "@/types/statusPage.js";
|
||||
|
||||
/**
|
||||
* When STATUS_PAGE_THEMES_ENABLED is false, the controller drops incoming
|
||||
* theme fields before persisting so an admin client can't accidentally store
|
||||
* a value the public page won't honour. Whatever a caller sends here for
|
||||
* `theme` / `themeMode` is silently ignored — the rest of the body is
|
||||
* preserved verbatim.
|
||||
*
|
||||
* The strip is silent (not a 400) because the kill switch is an ops-side
|
||||
* concern: API clients should not have to know whether the operator has
|
||||
* disabled themes today, only that their saved value won't take effect.
|
||||
* If we later decide we'd rather fail loudly, change this to throw a
|
||||
* 400 AppError; downstream consumers already handle it.
|
||||
*/
|
||||
export const withoutThemeFields = (body: Record<string, unknown>): Record<string, unknown> => {
|
||||
const rest = { ...body };
|
||||
delete rest.theme;
|
||||
delete rest.themeMode;
|
||||
return rest;
|
||||
};
|
||||
|
||||
/**
|
||||
* When STATUS_PAGE_THEMES_ENABLED is false, callers reading a status page
|
||||
* should see the canonical default theme / mode regardless of what's stored
|
||||
* in Mongo. Whatever the user previously chose stays in the DB so flipping
|
||||
* the flag back on resurfaces their selection.
|
||||
*/
|
||||
export const applyDefaultTheme = (statusPage: StatusPage): StatusPage => ({
|
||||
...statusPage,
|
||||
theme: DEFAULT_STATUS_PAGE_THEME,
|
||||
themeMode: DEFAULT_STATUS_PAGE_THEME_MODE,
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Schema, model, type Types } from "mongoose";
|
||||
import type { StatusPage, StatusPageLogoDocument } from "@/types/statusPage.js";
|
||||
import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/types/statusPage.js";
|
||||
import {
|
||||
DEFAULT_STATUS_PAGE_THEME,
|
||||
DEFAULT_STATUS_PAGE_THEME_MODE,
|
||||
StatusPageThemeModes,
|
||||
StatusPageThemes,
|
||||
StatusPageTypes,
|
||||
} from "@/types/statusPage.js";
|
||||
|
||||
type StatusPageDocumentBase = Omit<
|
||||
StatusPage,
|
||||
@@ -111,12 +117,12 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
|
||||
theme: {
|
||||
type: String,
|
||||
enum: StatusPageThemes,
|
||||
default: "refined",
|
||||
default: DEFAULT_STATUS_PAGE_THEME,
|
||||
},
|
||||
themeMode: {
|
||||
type: String,
|
||||
enum: StatusPageThemeModes,
|
||||
default: "auto",
|
||||
default: DEFAULT_STATUS_PAGE_THEME_MODE,
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { StatusPage } from "../../src/types/index.ts";
|
||||
|
||||
export const makeStatusPage = (overrides?: Partial<StatusPage>): StatusPage =>
|
||||
({
|
||||
id: "sp-1",
|
||||
teamId: "team-1",
|
||||
userId: "user-1",
|
||||
url: "my-status-page",
|
||||
companyName: "Test Co",
|
||||
monitors: ["mon-1"],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
}) as StatusPage;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import { applyDefaultTheme, withoutThemeFields } from "../../../src/controllers/statusPageThemeFlag.ts";
|
||||
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/types/statusPage.ts";
|
||||
import { makeStatusPage } from "../../helpers/makeStatusPage.ts";
|
||||
|
||||
describe("statusPageThemeFlag", () => {
|
||||
describe("withoutThemeFields", () => {
|
||||
it("removes theme and themeMode from a body that has them", () => {
|
||||
const body = { companyName: "Co", theme: "modern", themeMode: "dark" };
|
||||
const result = withoutThemeFields(body);
|
||||
expect(result).toEqual({ companyName: "Co" });
|
||||
});
|
||||
|
||||
it("is a no-op when neither field is present", () => {
|
||||
const body = { companyName: "Co", url: "page" };
|
||||
const result = withoutThemeFields(body);
|
||||
expect(result).toEqual({ companyName: "Co", url: "page" });
|
||||
});
|
||||
|
||||
it("removes only the theme fields when one is present", () => {
|
||||
const body = { companyName: "Co", theme: "bold" };
|
||||
const result = withoutThemeFields(body);
|
||||
expect(result).toEqual({ companyName: "Co" });
|
||||
});
|
||||
|
||||
it("does not mutate the input body", () => {
|
||||
const body = { companyName: "Co", theme: "modern", themeMode: "dark" };
|
||||
withoutThemeFields(body);
|
||||
expect(body).toEqual({ companyName: "Co", theme: "modern", themeMode: "dark" });
|
||||
});
|
||||
|
||||
it("preserves all other fields verbatim", () => {
|
||||
const body = {
|
||||
companyName: "Co",
|
||||
monitors: ["m-1", "m-2"],
|
||||
isPublished: true,
|
||||
showCharts: false,
|
||||
color: "#13715B",
|
||||
theme: "editorial",
|
||||
themeMode: "light",
|
||||
};
|
||||
const result = withoutThemeFields(body);
|
||||
expect(result).toEqual({
|
||||
companyName: "Co",
|
||||
monitors: ["m-1", "m-2"],
|
||||
isPublished: true,
|
||||
showCharts: false,
|
||||
color: "#13715B",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDefaultTheme", () => {
|
||||
it("overwrites theme and themeMode with the canonical defaults", () => {
|
||||
const sp = makeStatusPage({ theme: "modern", themeMode: "dark" });
|
||||
const result = applyDefaultTheme(sp);
|
||||
expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME);
|
||||
expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE);
|
||||
});
|
||||
|
||||
it("leaves all other fields untouched", () => {
|
||||
const sp = makeStatusPage({
|
||||
companyName: "Test Co",
|
||||
url: "my-page",
|
||||
monitors: ["mon-1", "mon-2"],
|
||||
isPublished: true,
|
||||
color: "#13715B",
|
||||
});
|
||||
const result = applyDefaultTheme(sp);
|
||||
expect(result.companyName).toBe("Test Co");
|
||||
expect(result.url).toBe("my-page");
|
||||
expect(result.monitors).toEqual(["mon-1", "mon-2"]);
|
||||
expect(result.isPublished).toBe(true);
|
||||
expect(result.color).toBe("#13715B");
|
||||
});
|
||||
|
||||
it("does not mutate the input status page", () => {
|
||||
const sp = makeStatusPage({ theme: "modern", themeMode: "dark" });
|
||||
applyDefaultTheme(sp);
|
||||
expect(sp.theme).toBe("modern");
|
||||
expect(sp.themeMode).toBe("dark");
|
||||
});
|
||||
|
||||
it("works on a status page that has no theme set yet", () => {
|
||||
const sp = makeStatusPage({ theme: undefined, themeMode: undefined });
|
||||
const result = applyDefaultTheme(sp);
|
||||
expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME);
|
||||
expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ const makeEnv = (overrides?: Partial<ValidatedEnv>): ValidatedEnv =>
|
||||
CLIENT_HOST: "http://localhost:5173",
|
||||
DB_CONNECTION_STRING: "mongodb://localhost:27017/test_db",
|
||||
DB_TYPE: "mongodb",
|
||||
STATUS_PAGE_THEMES_ENABLED: false,
|
||||
...overrides,
|
||||
}) as ValidatedEnv;
|
||||
|
||||
@@ -81,6 +82,7 @@ describe("SettingsService", () => {
|
||||
clientHost: "http://localhost:5173",
|
||||
dbConnectionString: "mongodb://localhost:27017/test_db",
|
||||
dbType: "mongodb",
|
||||
statusPageThemesEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +105,22 @@ describe("SettingsService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── areStatusPageThemesEnabled ──────────────────────────────────────────
|
||||
|
||||
describe("areStatusPageThemesEnabled", () => {
|
||||
it("returns false when STATUS_PAGE_THEMES_ENABLED env is false", () => {
|
||||
const { service } = createService({ STATUS_PAGE_THEMES_ENABLED: false });
|
||||
|
||||
expect(service.areStatusPageThemesEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when STATUS_PAGE_THEMES_ENABLED env is true", () => {
|
||||
const { service } = createService({ STATUS_PAGE_THEMES_ENABLED: true });
|
||||
|
||||
expect(service.areStatusPageThemesEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── setRepository ───────────────────────────────────────────────────────
|
||||
|
||||
describe("setRepository", () => {
|
||||
|
||||
Reference in new issue
Block a user