From 2148ac96a8eb55179cd6d7b4ad492f21bc556dc3 Mon Sep 17 00:00:00 2001 From: Tahmid Ahmed Date: Tue, 10 Nov 2026 01:56:24 -0500 Subject: [PATCH 1/3] DINO-839: support loading ui feature flags via backends app props (java backend will parse index.html and load a config json object into it). these feature flags can be changed at runtime. school and tag ui feature flags have been removed (they are no longer in a state where they can be turned off). --- .github/scripts/build-image/index.ts | 11 +-- infra/Dockerfile | 2 - js/README.md | 29 +++++++- js/index.html | 3 + js/runtime-config.ts | 70 +++++++++++++++++++ js/src/app/_component/MiniLeaderboard.tsx | 5 +- .../app/_component/MiniLeaderboardMobile.tsx | 3 +- .../DashboardLeaderboard.tsx | 12 ++-- .../DashboardLeaderboard/MyCurrentPoints.tsx | 5 +- .../leaderboard/_components/Leaderboard.tsx | 55 +++++++-------- js/src/app/user/[userId]/UserProfile.page.tsx | 3 +- .../app/user/all/_components/UserSearch.tsx | 6 +- js/src/components/ui/LeaderboardCard.tsx | 3 +- js/src/components/ui/auth/AvatarButton.tsx | 21 +++--- js/src/components/ui/tags/TagList.tsx | 3 - js/src/lib/config/index.ts | 19 +++++ js/src/lib/ff/duel/index.ts | 5 +- js/src/lib/ff/index.ts | 5 +- js/src/lib/ff/metrics/index.ts | 3 + js/src/lib/ff/school/index.ts | 2 - js/src/lib/ff/tag/index.ts | 2 - js/src/lib/router.tsx | 18 ++--- js/src/lib/test/defaults.tsx | 14 ++++ js/tsconfig.node.json | 2 +- js/vite.config.ts | 9 ++- .../codebloom/common/ff/FFAspect.java | 2 +- .../common/ff/FeatureFlagManager.java | 26 +++++-- .../properties/FeatureFlagConfiguration.java | 5 ++ .../codebloom/shared/tag/TagMetadataList.java | 2 +- .../shared/topic/TopicMetadataList.java | 2 +- .../utilities/ApplicationShellController.java | 47 +++++++++++++ .../complex/ComplexJSTypesGenerator.java | 10 ++- src/main/resources/application-ci.yaml | 7 ++ src/main/resources/application-dev.yaml | 7 ++ src/main/resources/application.yml | 6 ++ .../common/ff/FeatureFlagManagerTest.java | 4 +- .../ApplicationShellControllerTest.java | 54 ++++++++++++++ 37 files changed, 361 insertions(+), 121 deletions(-) create mode 100644 js/runtime-config.ts create mode 100644 js/src/lib/config/index.ts create mode 100644 js/src/lib/ff/metrics/index.ts delete mode 100644 js/src/lib/ff/school/index.ts delete mode 100644 js/src/lib/ff/tag/index.ts create mode 100644 src/main/java/org/patinanetwork/codebloom/utilities/ApplicationShellController.java create mode 100644 src/test/java/org/patinanetwork/codebloom/utilities/ApplicationShellControllerTest.java diff --git a/.github/scripts/build-image/index.ts b/.github/scripts/build-image/index.ts index ab86ce79f..6269daff4 100644 --- a/.github/scripts/build-image/index.ts +++ b/.github/scripts/build-image/index.ts @@ -115,16 +115,7 @@ async function main() { console.log("Building image with following tags:"); tags.forEach((tag) => console.log(tag)); - const buildArgs = { - ...(type === "web" ? { COMMIT_SHA: gitSha } : {}), - ...(environment === "staging" && - type !== "leetcode-auth-stealer-service" && - type !== "leetcode-auth-service" - ? { - VITE_STAGING: true, - } - : {}), - }; + const buildArgs: Record = type === "web" ? { COMMIT_SHA: gitSha } : {}; const originalCwd = process.cwd(); try { diff --git a/infra/Dockerfile b/infra/Dockerfile index c2b536d86..c742e99a6 100644 --- a/infra/Dockerfile +++ b/infra/Dockerfile @@ -12,8 +12,6 @@ RUN corepack enable pnpm && corepack use $(node -p "require('./package.json').pa COPY js/ ./ -ARG VITE_STAGING -ENV VITE_STAGING=${VITE_STAGING} RUN pnpm run build FROM mirror.gcr.io/library/node:22 AS email-build diff --git a/js/README.md b/js/README.md index c66e440da..daae8ab7f 100644 --- a/js/README.md +++ b/js/README.md @@ -31,7 +31,8 @@ js/ │   │   │   ├── schema # input validation schemas used in the frontend │   │   │   ├── types # autogenerated types from OpenAPI schema │   │   │   └── utils -│   │   ├── ff # feature flags +│ │ ├── config # backend-rendered runtime config (#codebloom-config) +│ │ ├── ff # feature flags (read from runtime config) │   │   ├── helper # stateless helper functions │   │   ├── hooks # custom re-usable react hooks │   │   ├── queryProvider.tsx # ReactQueryProvider @@ -125,6 +126,32 @@ which allows us to define a complex relationship of parent tags to child tags. T

+### Feature flags + +UI feature flags live in the backend config under `ff.ui` and are handed to the frontend in `index.html` at page load, so [`src/lib/ff`](./src/lib/ff/) exposes them as plain booleans: no request, no loading state. They are separate from the backend `ff.*` flags enforced by `@FF`; a UI flag only controls visibility. + +Changing a value takes a backend restart and a page reload, not a frontend rebuild. + +#### Adding a UI flag + +1. Add the key under `ff.ui` in [`application.yml`](../src/main/resources/application.yml), and override it in the profile files (`application-dev.yaml`, `application-ci.yaml`) as needed. No Java changes are required. + ```yaml + ff: + ui: + myFeature: false + ``` + > [!IMPORTANT] + > The key reaches the frontend exactly as written, so use the camelCase name the frontend reads. Set it in YAML, not via an environment variable: env var names are uppercase and would arrive as a different key. +2. Regenerate types by running the backend with the `dev` profile (or `just type-gen`). This adds the key to `UiFeatureFlags` in `complex.ts`. +3. Export it from `src/lib/ff`: + ```ts + export const myFeatureFF = runtimeConfig.featureFlags.myFeature; + ``` +4. If the feature has backend endpoints, gate them with `@FF` as well. + +> [!NOTE] +> `pnpm run dev` needs the backend running (`CODEBLOOM_BACKEND_URL`, default `http://localhost:8080`) to supply the flags; without it Vite returns an error rather than guessing values. + ### ApiURL You can view the implementation of `ApiURL` [here](./src/lib/api/common/apiURL.ts). diff --git a/js/index.html b/js/index.html index e29af8ec1..1e824dbff 100644 --- a/js/index.html +++ b/js/index.html @@ -16,6 +16,9 @@ /> +
diff --git a/js/runtime-config.ts b/js/runtime-config.ts new file mode 100644 index 000000000..8098f0f3e --- /dev/null +++ b/js/runtime-config.ts @@ -0,0 +1,70 @@ +import type { Plugin } from "vite"; + +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const placeholder = "__CODEBLOOM_CONFIG__"; + +/** + * Create codebloom-config by calling the Java backend to get fully transformed index.html + * */ +export function runtimeConfigPlugin(backendUrl: string): Plugin { + async function inject(html: string): Promise { + const response = await fetch(new URL("/index.html", backendUrl), { + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) { + throw new Error(`Backend HTML bootstrap failed: HTTP ${response.status}`); + } + + const backendHtml = await response.text(); + const config = backendHtml.match( + /]*\bid=["']codebloom-config["'][^>]*>([\s\S]*?)<\/script>/i, + )?.[1]; + if (!config || !html.includes(placeholder)) { + throw new Error("Application HTML is missing its runtime configuration"); + } + + JSON.parse(config); + return html.replace(placeholder, () => config); + } + + return { + name: "codebloom-runtime-config", + apply: "serve", + transformIndexHtml: { + order: "pre", + handler: inject, + }, + configurePreviewServer(server) { + const html = readFile( + resolve(server.config.root, server.config.build.outDir, "index.html"), + "utf8", + ); + server.middlewares.use((request, response, next) => { + const pathname = new URL(request.url ?? "/", "http://localhost") + .pathname; + const isHtml = + pathname === "/" || + pathname === "/index.html" || + request.headers.accept?.includes("text/html"); + if ( + !isHtml || + pathname.startsWith("/api") || + (request.method !== "GET" && request.method !== "HEAD") + ) { + next(); + return; + } + void html + .then(inject) + .then((body) => { + response.setHeader("Content-Type", "text/html; charset=utf-8"); + response.setHeader("Cache-Control", "no-store"); + response.end(request.method === "HEAD" ? undefined : body); + }) + .catch(next); + }); + }, + }; +} diff --git a/js/src/app/_component/MiniLeaderboard.tsx b/js/src/app/_component/MiniLeaderboard.tsx index fb5856709..d1c38a2c1 100644 --- a/js/src/app/_component/MiniLeaderboard.tsx +++ b/js/src/app/_component/MiniLeaderboard.tsx @@ -5,7 +5,6 @@ import TagList from "@/components/ui/tags/TagList"; import Toast from "@/components/ui/toast/Toast"; import { useCurrentLeaderboardUsersQuery } from "@/lib/api/queries/leaderboard"; import { Tag } from "@/lib/api/types/schema"; -import { tagFF } from "@/lib/ff"; import getOrdinal from "@/lib/helper/ordinal"; import { theme } from "@/lib/theme"; import { @@ -175,7 +174,7 @@ export default function MiniLeaderboardDesktop() { {(entry.nickname || - (tagFF && entry.tags && entry.tags.length > 0)) && ( + (entry.tags && entry.tags.length > 0)) && ( {entry.nickname && ( @@ -188,7 +187,7 @@ export default function MiniLeaderboardDesktop() { )} - {tagFF && entry.tags && entry.tags.length > 0 && ( + {entry.tags && entry.tags.length > 0 && ( )} diff --git a/js/src/app/_component/MiniLeaderboardMobile.tsx b/js/src/app/_component/MiniLeaderboardMobile.tsx index 37b24f535..0f51cec7e 100644 --- a/js/src/app/_component/MiniLeaderboardMobile.tsx +++ b/js/src/app/_component/MiniLeaderboardMobile.tsx @@ -5,7 +5,6 @@ import TagList from "@/components/ui/tags/TagList"; import Toast from "@/components/ui/toast/Toast"; import { useCurrentLeaderboardUsersQuery } from "@/lib/api/queries/leaderboard"; import { Tag } from "@/lib/api/types/schema"; -import { tagFF } from "@/lib/ff/tag"; import getOrdinal from "@/lib/helper/ordinal"; import { theme } from "@/lib/theme"; import { @@ -169,7 +168,7 @@ export default function MiniLeaderboardMobile() { - {tagFF && entry.tags?.length > 0 && ( + {entry.tags?.length > 0 && ( )} diff --git a/js/src/app/dashboard/_components/DashboardLeaderboard/DashboardLeaderboard.tsx b/js/src/app/dashboard/_components/DashboardLeaderboard/DashboardLeaderboard.tsx index 799b46655..29681b4c4 100644 --- a/js/src/app/dashboard/_components/DashboardLeaderboard/DashboardLeaderboard.tsx +++ b/js/src/app/dashboard/_components/DashboardLeaderboard/DashboardLeaderboard.tsx @@ -11,7 +11,6 @@ import { } from "@/lib/api/queries/leaderboard"; import { Api } from "@/lib/api/types"; import { ApiUtils } from "@/lib/api/utils"; -import { tagFF } from "@/lib/ff/tag"; import { formatLeaderboardDateRange, getUserProfileUrl, @@ -194,7 +193,7 @@ export default function LeaderboardForDashboard({ {user.nickname} - {tagFF && user.tags && user.tags.length > 0 && ( + {user.tags && user.tags.length > 0 && ( )} @@ -203,12 +202,9 @@ export default function LeaderboardForDashboard({ {user.discordName} - {!user.nickname && - tagFF && - user.tags && - user.tags.length > 0 && ( - - )} + {!user.nickname && user.tags && user.tags.length > 0 && ( + + )} {user.leetcodeUsername && ( diff --git a/js/src/app/dashboard/_components/DashboardLeaderboard/MyCurrentPoints.tsx b/js/src/app/dashboard/_components/DashboardLeaderboard/MyCurrentPoints.tsx index 288aafa0f..8c9f7f616 100644 --- a/js/src/app/dashboard/_components/DashboardLeaderboard/MyCurrentPoints.tsx +++ b/js/src/app/dashboard/_components/DashboardLeaderboard/MyCurrentPoints.tsx @@ -1,6 +1,5 @@ import TagList from "@/components/ui/tags/TagList"; import { useMyRecentLeaderboardData } from "@/lib/api/queries/leaderboard"; -import { tagFF } from "@/lib/ff/tag"; import { getUserProfileUrl } from "@/lib/helper/leaderboardDateRange"; import { theme } from "@/lib/theme"; import { Flex, Skeleton, Text, Tooltip } from "@mantine/core"; @@ -100,7 +99,7 @@ export default function MyCurrentPoints({ Me {(userData.nickname || - (tagFF && userData.tags && userData.tags.length > 0)) && ( + (userData.tags && userData.tags.length > 0)) && ( {userData.nickname && ( )} {userData.nickname && - tagFF && userData.tags && userData.tags.length > 0 && ( @@ -141,7 +139,6 @@ export default function MyCurrentPoints({ {userData.discordName} {!userData.nickname && - tagFF && userData.tags && userData.tags.length > 0 && ( diff --git a/js/src/app/leaderboard/_components/Leaderboard.tsx b/js/src/app/leaderboard/_components/Leaderboard.tsx index 0287ecb62..85f49f0d4 100644 --- a/js/src/app/leaderboard/_components/Leaderboard.tsx +++ b/js/src/app/leaderboard/_components/Leaderboard.tsx @@ -14,7 +14,6 @@ import { useLeaderboardUsersByIdQuery, } from "@/lib/api/queries/leaderboard"; import { ApiUtils } from "@/lib/api/utils"; -import { schoolFF, tagFF } from "@/lib/ff"; import { formatLeaderboardDateRange, getUserProfileUrl, @@ -171,28 +170,27 @@ function LeaderboardIndex({ style={{ marginLeft: "auto", display: "block" }} buttonName="Filters" > - {schoolFF && - ApiUtils.getAllSupportedTagEnums().map((tagEnum) => ( - { - const metadata = ApiUtils.getMetadataByTagEnum(tagEnum); + {ApiUtils.getAllSupportedTagEnums().map((tagEnum) => ( + { + const metadata = ApiUtils.getMetadataByTagEnum(tagEnum); - return ( - - {metadata.shortName} - {metadata.alt} - - ); - }} - value={filters[tagEnum]} - toggle={() => toggleFilter(tagEnum)} - /> - ))} + return ( + + {metadata.shortName} + {metadata.alt} + + ); + }} + value={filters[tagEnum]} + toggle={() => toggleFilter(tagEnum)} + /> + ))} - {(entry.nickname || - (tagFF && entry.tags?.length > 0)) && ( + {(entry.nickname || entry.tags?.length > 0) && ( {entry.nickname && ( @@ -340,12 +337,10 @@ function LeaderboardIndex({ )} - {entry.nickname && - tagFF && - entry.tags?.length > 0 && ( - - )} - {tagFF && entry.tags?.length > 0 && ( + {entry.nickname && entry.tags?.length > 0 && ( + + )} + {entry.tags?.length > 0 && ( )} diff --git a/js/src/app/user/[userId]/UserProfile.page.tsx b/js/src/app/user/[userId]/UserProfile.page.tsx index b02b4edd0..ed2b0188f 100644 --- a/js/src/app/user/[userId]/UserProfile.page.tsx +++ b/js/src/app/user/[userId]/UserProfile.page.tsx @@ -4,6 +4,7 @@ import UserProfileHeader from "@/app/user/[userId]/_components/UserProfile/UserP import UserTags from "@/app/user/[userId]/_components/UserProfile/UserTags/UserTags"; import MiniUserSubmissions from "@/app/user/[userId]/submissions/_components/UserSubmissions/MiniUserSubmissions"; import ToastWithRedirect from "@/components/ui/toast/ToastWithRedirect"; +import { userMetricsFF } from "@/lib/ff"; import { getUserSubmissionsUrl } from "@/lib/helper/leaderboardDateRange"; import { Center, Button, Flex, Box, Text } from "@mantine/core"; import { Link, useParams, useSearchParams } from "react-router-dom"; @@ -65,7 +66,7 @@ export default function UserProfilePage() { - + {userMetricsFF && } {(user.nickname || - (tagFF && user.tags && user.tags.length > 0)) && ( + (user.tags && user.tags.length > 0)) && ( {user.nickname && ( @@ -183,12 +182,11 @@ export default function UserSearch() { )} {user.nickname && - tagFF && user.tags && user.tags.length > 0 && ( )} - {tagFF && user.tags && user.tags.length > 0 && ( + {user.tags && user.tags.length > 0 && ( )} diff --git a/js/src/components/ui/LeaderboardCard.tsx b/js/src/components/ui/LeaderboardCard.tsx index b851ae642..af32687af 100644 --- a/js/src/components/ui/LeaderboardCard.tsx +++ b/js/src/components/ui/LeaderboardCard.tsx @@ -1,7 +1,6 @@ import CodebloomCard from "@/components/ui/CodebloomCard"; import TagList from "@/components/ui/tags/TagList"; import { Api } from "@/lib/api/types"; -import { tagFF } from "@/lib/ff"; import { getUserProfileUrl } from "@/lib/helper/leaderboardDateRange"; import { OrdinalString } from "@/lib/helper/ordinal"; import { theme } from "@/lib/theme"; @@ -143,7 +142,7 @@ export default function LeaderboardCard({ > {discordName} - {tagFF && displayTags && displayTags.length > 0 && ( + {displayTags && displayTags.length > 0 && ( )} diff --git a/js/src/components/ui/auth/AvatarButton.tsx b/js/src/components/ui/auth/AvatarButton.tsx index d9a6f4adf..e252d257d 100644 --- a/js/src/components/ui/auth/AvatarButton.tsx +++ b/js/src/components/ui/auth/AvatarButton.tsx @@ -1,4 +1,3 @@ -import { schoolFF } from "@/lib/ff"; import { Menu, Avatar } from "@mantine/core"; import { Link } from "react-router-dom"; @@ -26,19 +25,15 @@ export default function AvatarDropdown({ : } - {schoolFF && ( - <> - {userId && ( - - My Profile - - )} - - Settings - - - + {userId && ( + + My Profile + )} + + Settings + + 0; if (!hasTags) return null; diff --git a/js/src/lib/config/index.ts b/js/src/lib/config/index.ts new file mode 100644 index 000000000..4124cf9a9 --- /dev/null +++ b/js/src/lib/config/index.ts @@ -0,0 +1,19 @@ +import type { UiFeatureFlags } from "@/lib/api/types/complex"; + +import { z } from "zod"; + +const runtimeConfigSchema = z.object({ + featureFlags: z.record(z.boolean()), +}); + +function readRuntimeConfig() { + const json = document.getElementById("codebloom-config")?.textContent; + if (!json) { + throw new Error("Missing #codebloom-config runtime configuration"); + } + + const { featureFlags } = runtimeConfigSchema.parse(JSON.parse(json)); + return { featureFlags: featureFlags as UiFeatureFlags }; +} + +export const runtimeConfig = readRuntimeConfig(); diff --git a/js/src/lib/ff/duel/index.ts b/js/src/lib/ff/duel/index.ts index 6dbea166a..bd84262c9 100644 --- a/js/src/lib/ff/duel/index.ts +++ b/js/src/lib/ff/duel/index.ts @@ -1,2 +1,3 @@ -export const duelFF = - import.meta.env.VITE_STAGING === "true" || import.meta.env.DEV; +import { runtimeConfig } from "@/lib/config"; + +export const duelFF = runtimeConfig.featureFlags.duels; diff --git a/js/src/lib/ff/index.ts b/js/src/lib/ff/index.ts index 6d1ba4cf1..56e9badc6 100644 --- a/js/src/lib/ff/index.ts +++ b/js/src/lib/ff/index.ts @@ -1,5 +1,2 @@ -// TODO - Move this to runtime - -export * from "./school"; -export * from "./tag"; export * from "./duel"; +export * from "./metrics"; diff --git a/js/src/lib/ff/metrics/index.ts b/js/src/lib/ff/metrics/index.ts new file mode 100644 index 000000000..e30920195 --- /dev/null +++ b/js/src/lib/ff/metrics/index.ts @@ -0,0 +1,3 @@ +import { runtimeConfig } from "@/lib/config"; + +export const userMetricsFF = runtimeConfig.featureFlags.userMetrics; diff --git a/js/src/lib/ff/school/index.ts b/js/src/lib/ff/school/index.ts deleted file mode 100644 index 3b74bf7ee..000000000 --- a/js/src/lib/ff/school/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const schoolFF = true; -// export const schoolFF = !import.meta.env.PROD; diff --git a/js/src/lib/ff/tag/index.ts b/js/src/lib/ff/tag/index.ts deleted file mode 100644 index dadf47d42..000000000 --- a/js/src/lib/ff/tag/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const tagFF = true; -// export const tagFF = !import.meta.env.PROD; diff --git a/js/src/lib/router.tsx b/js/src/lib/router.tsx index cbb99e497..7bf8b8b35 100644 --- a/js/src/lib/router.tsx +++ b/js/src/lib/router.tsx @@ -22,7 +22,7 @@ import UserProfilePage from "@/app/user/[userId]/UserProfile.page"; import UserSearchPage from "@/app/user/all/UserSearch.page"; import PageShell from "@/components/ui/page/PageShell"; import ToastWithRedirect from "@/components/ui/toast/ToastWithRedirect"; -import { duelFF, schoolFF } from "@/lib/ff"; +import { duelFF } from "@/lib/ff"; import { createBrowserRouter } from "react-router-dom"; export const router = createBrowserRouter([ @@ -154,17 +154,11 @@ export const router = createBrowserRouter([ }, { path: "/settings", - element: - !schoolFF ? - - : - - , + element: ( + + + + ), errorElement: , }, { diff --git a/js/src/lib/test/defaults.tsx b/js/src/lib/test/defaults.tsx index f69d21ec8..83ca545bd 100644 --- a/js/src/lib/test/defaults.tsx +++ b/js/src/lib/test/defaults.tsx @@ -1,6 +1,20 @@ import "@testing-library/jest-dom"; import "@/patches"; +import type { UiFeatureFlags } from "@/lib/api/types/complex"; + +// Mirrors the backend-rendered #codebloom-config element so flag modules can read it on import. +const runtimeConfig = document.createElement("script"); +runtimeConfig.id = "codebloom-config"; +runtimeConfig.type = "application/json"; +runtimeConfig.textContent = JSON.stringify({ + featureFlags: { + duels: true, + userMetrics: true, + } satisfies UiFeatureFlags, +}); +document.head.append(runtimeConfig); + // Mock ResizeObserver for Mantine ScrollArea class ResizeObserverMock { observe() {} diff --git a/js/tsconfig.node.json b/js/tsconfig.node.json index ff0dc7df4..c3fd6b2c4 100644 --- a/js/tsconfig.node.json +++ b/js/tsconfig.node.json @@ -20,5 +20,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "runtime-config.ts"] } diff --git a/js/vite.config.ts b/js/vite.config.ts index 9c55a5daa..1f8638e9b 100644 --- a/js/vite.config.ts +++ b/js/vite.config.ts @@ -3,14 +3,19 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; +// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths -- Vite config is outside src/. +import { runtimeConfigPlugin } from "./runtime-config"; + +const backendUrl = process.env.CODEBLOOM_BACKEND_URL ?? "http://localhost:8080"; + // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), tsconfigPaths()], + plugins: [react(), tsconfigPaths(), runtimeConfigPlugin(backendUrl)], envDir: "../", server: { proxy: { "/api": { - target: "http://localhost:8080", + target: backendUrl, // This is not traditional, but it allows the authenticated redirect to go back to the SPA. changeOrigin: false, }, diff --git a/src/main/java/org/patinanetwork/codebloom/common/ff/FFAspect.java b/src/main/java/org/patinanetwork/codebloom/common/ff/FFAspect.java index eebdca7ed..e6ddf4183 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/ff/FFAspect.java +++ b/src/main/java/org/patinanetwork/codebloom/common/ff/FFAspect.java @@ -30,7 +30,7 @@ public FFAspect(final FeatureFlagManager featureFlagManager) { public Object gateMethodByFeatureFlags(final ProceedingJoinPoint joinPoint, final FF ff) throws Throwable { String expression = ff.value(); - var flags = featureFlagManager.getAllFlags(); + var flags = featureFlagManager.getFlags(); StandardEvaluationContext context = new StandardEvaluationContext(flags); context.addPropertyAccessor(new MapAccessor()); diff --git a/src/main/java/org/patinanetwork/codebloom/common/ff/FeatureFlagManager.java b/src/main/java/org/patinanetwork/codebloom/common/ff/FeatureFlagManager.java index ef4cf950f..14edab7d5 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/ff/FeatureFlagManager.java +++ b/src/main/java/org/patinanetwork/codebloom/common/ff/FeatureFlagManager.java @@ -4,20 +4,22 @@ import java.lang.reflect.Method; import java.util.HashMap; import java.util.Map; +import java.util.stream.Collectors; import org.patinanetwork.codebloom.jda.properties.FeatureFlagConfiguration; +import org.patinanetwork.codebloom.shared.TypableObject; import org.springframework.stereotype.Component; @Component -public class FeatureFlagManager { - private final FeatureFlagConfiguration ff; +public class FeatureFlagManager implements TypableObject { private final Map cachedFlags; + private final Map cachedUiFlags; public FeatureFlagManager(final FeatureFlagConfiguration ff) { - this.ff = ff; - this.cachedFlags = initializeFlags(); + this.cachedFlags = initializeFlags(ff); + this.cachedUiFlags = Map.copyOf(ff.getUi()); } - private Map initializeFlags() { + private Map initializeFlags(final FeatureFlagConfiguration ff) { Map flags = new HashMap<>(); for (Method method : ff.getClass().getMethods()) { if (method.getName().startsWith("is") @@ -35,7 +37,19 @@ private Map initializeFlags() { return Map.copyOf(flags); } - public Map getAllFlags() { + public Map getFlags() { return cachedFlags; } + + public Map getUiFlags() { + return cachedUiFlags; + } + + @Override + public String tsType() { + return cachedUiFlags.keySet().stream() + .sorted() + .map(name -> " " + name + ": boolean;\n") + .collect(Collectors.joining("", "export type UiFeatureFlags = Readonly<{\n", "}>;\n\n")); + } } diff --git a/src/main/java/org/patinanetwork/codebloom/jda/properties/FeatureFlagConfiguration.java b/src/main/java/org/patinanetwork/codebloom/jda/properties/FeatureFlagConfiguration.java index a2dc08c4f..8eaad0e39 100644 --- a/src/main/java/org/patinanetwork/codebloom/jda/properties/FeatureFlagConfiguration.java +++ b/src/main/java/org/patinanetwork/codebloom/jda/properties/FeatureFlagConfiguration.java @@ -1,5 +1,7 @@ package org.patinanetwork.codebloom.jda.properties; +import java.util.HashMap; +import java.util.Map; import lombok.Getter; import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -14,4 +16,7 @@ public class FeatureFlagConfiguration { private boolean duels; private boolean userMetrics; + + /** no need to track the object, backend just writes this directly to the index.html file */ + private Map ui = new HashMap<>(); } diff --git a/src/main/java/org/patinanetwork/codebloom/shared/tag/TagMetadataList.java b/src/main/java/org/patinanetwork/codebloom/shared/tag/TagMetadataList.java index 1a00e910e..30b6c7d32 100644 --- a/src/main/java/org/patinanetwork/codebloom/shared/tag/TagMetadataList.java +++ b/src/main/java/org/patinanetwork/codebloom/shared/tag/TagMetadataList.java @@ -30,7 +30,7 @@ private static TagMetadataObject buildMetadata(Tag tag) { @Getter @Builder public static class TagMetadataObject implements TypableObject { - public static final String TS_TYPE = """ + private static final String TS_TYPE = """ export type TagMetadataObject = { shortName: string; name: string; diff --git a/src/main/java/org/patinanetwork/codebloom/shared/topic/TopicMetadataList.java b/src/main/java/org/patinanetwork/codebloom/shared/topic/TopicMetadataList.java index 30dbe01d0..88073c525 100644 --- a/src/main/java/org/patinanetwork/codebloom/shared/topic/TopicMetadataList.java +++ b/src/main/java/org/patinanetwork/codebloom/shared/topic/TopicMetadataList.java @@ -173,7 +173,7 @@ private static TopicMetadataObject buildMetadata(LeetcodeTopicEnum topic) { @Getter @Builder public static class TopicMetadataObject implements TypableObject { - public static final String TS_TYPE = """ + private static final String TS_TYPE = """ export type TopicMetadataObject = { name: string; aliases?: string[]; diff --git a/src/main/java/org/patinanetwork/codebloom/utilities/ApplicationShellController.java b/src/main/java/org/patinanetwork/codebloom/utilities/ApplicationShellController.java new file mode 100644 index 000000000..5f79ec92a --- /dev/null +++ b/src/main/java/org/patinanetwork/codebloom/utilities/ApplicationShellController.java @@ -0,0 +1,47 @@ +package org.patinanetwork.codebloom.utilities; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.patinanetwork.codebloom.common.ff.FeatureFlagManager; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** Overrides index.html with the injected UI FFs. */ +@RestController +public class ApplicationShellController { + private static final String CONFIG_PLACEHOLDER = "__CODEBLOOM_CONFIG__"; + private final byte[] html; + + public ApplicationShellController( + final FeatureFlagManager featureFlagManager, + final ObjectMapper objectMapper, + @Value("${codebloom.frontend.index}") final Resource index) + throws IOException { + String template = index.getContentAsString(StandardCharsets.UTF_8); + if (!template.contains(CONFIG_PLACEHOLDER)) { + throw new IllegalStateException("Application HTML is missing its runtime configuration placeholder"); + } + + String config = objectMapper + .writeValueAsString(Map.of("featureFlags", featureFlagManager.getUiFlags())) + .replace("<", "\\u003c") + .replace(">", "\\u003e") + .replace("&", "\\u0026"); + html = template.replace(CONFIG_PLACEHOLDER, config).getBytes(StandardCharsets.UTF_8); + } + + @GetMapping({"/", "/index.html"}) + public ResponseEntity index() { + return ResponseEntity.ok() + .contentType(new MediaType("text", "html", StandardCharsets.UTF_8)) + .cacheControl(CacheControl.noStore()) + .body(html); + } +} diff --git a/src/main/java/org/patinanetwork/codebloom/utilities/generator/complex/ComplexJSTypesGenerator.java b/src/main/java/org/patinanetwork/codebloom/utilities/generator/complex/ComplexJSTypesGenerator.java index 9796287c2..e48010ddf 100644 --- a/src/main/java/org/patinanetwork/codebloom/utilities/generator/complex/ComplexJSTypesGenerator.java +++ b/src/main/java/org/patinanetwork/codebloom/utilities/generator/complex/ComplexJSTypesGenerator.java @@ -15,6 +15,7 @@ import java.util.Set; import java.util.regex.Pattern; import lombok.extern.slf4j.Slf4j; +import org.patinanetwork.codebloom.common.ff.FeatureFlagManager; import org.patinanetwork.codebloom.shared.tag.ParentTags; import org.patinanetwork.codebloom.shared.tag.TagMetadataList; import org.patinanetwork.codebloom.shared.topic.TopicMetadataList; @@ -33,6 +34,9 @@ public class ComplexJSTypesGenerator implements CommandLineRunner { @Autowired private ApplicationContext applicationContext; + @Autowired + private FeatureFlagManager featureFlagManager; + private final Set imports = new HashSet<>(); private final StringBuilder tsContent = new StringBuilder(); @@ -73,10 +77,12 @@ private void generateAll() throws Exception { } private void generateTypes() { - tsContent.append(TagMetadataList.TagMetadataObject.TS_TYPE); + tsContent.append(TagMetadataList.TagMetadataObject.builder().build().tsType()); log.info("Generated type: TagMetadataObject"); - tsContent.append(TopicMetadataList.TopicMetadataObject.TS_TYPE); + tsContent.append(TopicMetadataList.TopicMetadataObject.builder().build().tsType()); log.info("Generated type: TopicMetadataObject"); + tsContent.append(featureFlagManager.tsType()); + log.info("Generated type: UiFeatureFlags"); } private void generate(Generator generator) throws Exception { diff --git a/src/main/resources/application-ci.yaml b/src/main/resources/application-ci.yaml index d97c7949b..30f94e600 100644 --- a/src/main/resources/application-ci.yaml +++ b/src/main/resources/application-ci.yaml @@ -1,3 +1,10 @@ +codebloom: + frontend: + index: file:js/index.html + ff: duels: true user-metrics: false + ui: + duels: true + userMetrics: false diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index f8f1bd411..e1d9cadec 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -1,6 +1,13 @@ +codebloom: + frontend: + index: file:js/index.html + ff: duels: true user-metrics: true + ui: + duels: true + userMetrics: true springdoc: swagger-ui: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ffc8294d9..6355dbde2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -93,6 +93,8 @@ codebloom: type: ${CODEBLOOM_EMAIL_TYPE} username: ${CODEBLOOM_EMAIL_USERNAME} password: ${CODEBLOOM_EMAIL_PASSWORD} + frontend: + index: classpath:/static/index.html scheduling: enabled: true notify: @@ -121,6 +123,10 @@ ff: # override per environment as needed duels: false user-metrics: false + # Passed straight through to the UI: keys must be camelCase since they are copied straight to the UI as is. + ui: + duels: false + userMetrics: false resilience4j: retry: diff --git a/src/test/java/org/patinanetwork/codebloom/common/ff/FeatureFlagManagerTest.java b/src/test/java/org/patinanetwork/codebloom/common/ff/FeatureFlagManagerTest.java index e0cd8b5cf..f7bb765b5 100644 --- a/src/test/java/org/patinanetwork/codebloom/common/ff/FeatureFlagManagerTest.java +++ b/src/test/java/org/patinanetwork/codebloom/common/ff/FeatureFlagManagerTest.java @@ -22,7 +22,7 @@ void setUp() { @Test void getAllFlagsReturnsValuesFromConfiguration() { - var flags = featureFlagManager.getAllFlags(); + var flags = featureFlagManager.getFlags(); assertEquals(2, flags.size()); assertEquals(Boolean.TRUE, flags.get("duels")); @@ -31,7 +31,7 @@ void getAllFlagsReturnsValuesFromConfiguration() { @Test void getAllFlagsReturnsImmutableView() { - var flags = featureFlagManager.getAllFlags(); + var flags = featureFlagManager.getFlags(); assertThrows(UnsupportedOperationException.class, () -> flags.put("newFlag", true)); assertFalse(flags.containsKey("newFlag")); diff --git a/src/test/java/org/patinanetwork/codebloom/utilities/ApplicationShellControllerTest.java b/src/test/java/org/patinanetwork/codebloom/utilities/ApplicationShellControllerTest.java new file mode 100644 index 000000000..28cc7ba6b --- /dev/null +++ b/src/test/java/org/patinanetwork/codebloom/utilities/ApplicationShellControllerTest.java @@ -0,0 +1,54 @@ +package org.patinanetwork.codebloom.utilities; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.patinanetwork.codebloom.common.ff.FeatureFlagManager; +import org.patinanetwork.codebloom.jda.properties.FeatureFlagConfiguration; +import org.springframework.core.io.ByteArrayResource; + +class ApplicationShellControllerTest { + private static final String OPEN = ""; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static FeatureFlagManager flags() { + FeatureFlagConfiguration ff = new FeatureFlagConfiguration(); + ff.setDuels(true); + ff.setUserMetrics(true); + ff.getUi().putAll(Map.of("duels", false, "userMetrics", false, "notInJava", true)); + return new FeatureFlagManager(ff); + } + + private static ByteArrayResource html(final String body) { + return new ByteArrayResource(body.getBytes(StandardCharsets.UTF_8)); + } + + private String render(final String template) throws Exception { + var controller = new ApplicationShellController(flags(), objectMapper, html(template)); + return new String(controller.index().getBody(), StandardCharsets.UTF_8); + } + + @Test + void passesUiFlagsThroughIndependentlyOfBackendFlags() throws Exception { + String body = render(OPEN + "__CODEBLOOM_CONFIG__" + CLOSE); + String json = body.substring(OPEN.length(), body.length() - CLOSE.length()); + + // Backend duels/userMetrics are on; the browser sees only ff.ui, including keys Java never declares. + assertEquals( + objectMapper.readTree("{\"featureFlags\":{\"duels\":false,\"userMetrics\":false,\"notInJava\":true}}"), + objectMapper.readTree(json)); + } + + @Test + void refusesTemplateWithoutConfigPlaceholder() { + assertThrows( + IllegalStateException.class, + () -> new ApplicationShellController(flags(), objectMapper, html("
"))); + } +} From 9ff3f59866357800e0f41a60a5e4cec90bb32925 Mon Sep 17 00:00:00 2001 From: Tahmid Ahmed Date: Tue, 10 Nov 2026 02:11:13 -0500 Subject: [PATCH 2/3] DINO-839: fix broken test --- .../leetcode/RefetchIncompleteQuestionsServiceTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/patinanetwork/codebloom/scheduled/leetcode/RefetchIncompleteQuestionsServiceTest.java b/src/test/java/org/patinanetwork/codebloom/scheduled/leetcode/RefetchIncompleteQuestionsServiceTest.java index 8d6864dbd..63f7902c8 100644 --- a/src/test/java/org/patinanetwork/codebloom/scheduled/leetcode/RefetchIncompleteQuestionsServiceTest.java +++ b/src/test/java/org/patinanetwork/codebloom/scheduled/leetcode/RefetchIncompleteQuestionsServiceTest.java @@ -26,7 +26,9 @@ import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; -@SpringBootTest +// "test" profile doesn't set codebloom.frontend.index, so point the shell at the source template. +// TODO: fix this in the future +@SpringBootTest(properties = "codebloom.frontend.index=file:js/index.html") @ActiveProfiles("test") @TestInstance(TestInstance.Lifecycle.PER_CLASS) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) From 8cea7a070c0e7137c6c5e7ab3b7a798f860f7788 Mon Sep 17 00:00:00 2001 From: Tahmid Ahmed Date: Tue, 10 Nov 2026 02:28:32 -0500 Subject: [PATCH 3/3] DINO-839: add tests to pass test coverage --- exclusions.ts | 1 + js/src/__mock__/leaderboard/index.ts | 52 +++++++++++++++++++ .../DashboardLeaderboard.test.tsx | 13 +++++ .../_components/Leaderboard.test.tsx | 13 +++++ js/src/lib/config/index.test.ts | 47 +++++++++++++++++ 5 files changed, 126 insertions(+) create mode 100644 js/src/lib/config/index.test.ts diff --git a/exclusions.ts b/exclusions.ts index 830f73e64..cd4a64785 100644 --- a/exclusions.ts +++ b/exclusions.ts @@ -14,6 +14,7 @@ export const backendExclusions = [ export const frontendExclusions = [ `${frontendBaseDir}/**/*.test.ts`, `${frontendBaseDir}/**/*.test.tsx`, + `${frontendBaseDir}/lib/test/**`, `${frontendBaseDir}/lib/api/queries/**/*.ts`, `${frontendBaseDir}/lib/api/schema/**/*.ts`, ]; diff --git a/js/src/__mock__/leaderboard/index.ts b/js/src/__mock__/leaderboard/index.ts index 5722b289a..0fb8cd6d0 100644 --- a/js/src/__mock__/leaderboard/index.ts +++ b/js/src/__mock__/leaderboard/index.ts @@ -1,4 +1,8 @@ +import type { UnknownApiResponse } from "@/lib/api/common/apiResponse"; +import type { Api } from "@/lib/api/types"; + import { ApiURL } from "@/lib/api/common/apiURL"; +import { Tag } from "@/lib/api/types/schema"; import d from "dayjs"; import { http, HttpResponse } from "msw"; import { v4 as uuid } from "uuid"; @@ -185,6 +189,54 @@ export const currentLeaderboardUsersHandler = http.get( }, ); +const mockLeaderboardUser = ( + index: number, + name: string, + nickname: string | null, + tags: Tag[], +): Api<"IndexedUserWithScoreDto"> => ({ + index, + id: `user-${index}`, + discordId: `discord-${index}`, + discordName: name, + leetcodeUsername: name, + nickname, + admin: false, + profileUrl: null, + tags: tags.map((tag) => ({ + id: `user-${index}-${tag}`, + createdAt: "2026-01-01T00:00:00Z", + userId: `user-${index}`, + tag, + })), + achievements: [], + totalScore: 200 - index * 10, +}); + +/** Mixes nicknames and tags so verified-nickname and tag rendering paths are exercised. */ +export const currentLeaderboardUsersWithTagsHandler = http.get( + currentLeaderboardUsers.url.toString(), + () => { + return HttpResponse.json({ + success: true, + message: "Leaderboard users loaded!", + payload: { + hasNextPage: false, + pages: 1, + pageSize: 20, + items: [ + mockLeaderboardUser(1, "aphrodite", "Aphro", [Tag.Hunter]), + mockLeaderboardUser(2, "poseidon", null, [Tag.Nyu]), + mockLeaderboardUser(3, "hermes", null, []), + mockLeaderboardUser(4, "athena", "Athena", [Tag.Hunter]), + mockLeaderboardUser(5, "ares", null, [Tag.Nyu]), + mockLeaderboardUser(6, "apollo", "Apollo", []), + ], + }, + } satisfies UnknownApiResponse>); + }, +); + export const currentLeaderboardUsersFailedHandler = http.get( currentLeaderboardUsers.url.toString(), () => { diff --git a/js/src/app/dashboard/_components/DashboardLeaderboard/DashboardLeaderboard.test.tsx b/js/src/app/dashboard/_components/DashboardLeaderboard/DashboardLeaderboard.test.tsx index aba0a5d96..3b764849d 100644 --- a/js/src/app/dashboard/_components/DashboardLeaderboard/DashboardLeaderboard.test.tsx +++ b/js/src/app/dashboard/_components/DashboardLeaderboard/DashboardLeaderboard.test.tsx @@ -2,6 +2,7 @@ import { currentLeaderboardUsersErrorHandler, currentLeaderboardUsersFailedHandler, currentLeaderboardUsersHandler, + currentLeaderboardUsersWithTagsHandler, successfulLeaderboardHandlers, } from "@/__mock__/leaderboard"; import LeaderboardForDashboard from "@/app/dashboard/_components/DashboardLeaderboard/DashboardLeaderboard"; @@ -157,4 +158,16 @@ describe("LeaderboardForDashboard", () => { expect(screen.getByText("Me")).toBeInTheDocument(); }); }); + + it("should render tags beside nicknames or discord names", async () => { + server.use(currentLeaderboardUsersWithTagsHandler); + renderProviderFn?.( + , + ); + + // aphrodite/athena: tag beside nickname; poseidon/ares: tag beside discord name. + expect(await screen.findByText("Aphro")).toBeInTheDocument(); + expect(screen.getAllByAltText("Hunter College Logo")).toHaveLength(2); + expect(screen.getAllByAltText("NYU Logo")).toHaveLength(2); + }); }); diff --git a/js/src/app/leaderboard/_components/Leaderboard.test.tsx b/js/src/app/leaderboard/_components/Leaderboard.test.tsx index 98bee3e06..512dfc7f1 100644 --- a/js/src/app/leaderboard/_components/Leaderboard.test.tsx +++ b/js/src/app/leaderboard/_components/Leaderboard.test.tsx @@ -1,5 +1,6 @@ import { currentLeaderboardUsersHandler, + currentLeaderboardUsersWithTagsHandler, successfulLeaderboardHandlers, } from "@/__mock__/leaderboard"; import { CurrentLeaderboard } from "@/app/leaderboard/_components/Leaderboard"; @@ -294,4 +295,16 @@ describe("Leaderboard API Succeeded", () => { }); } }); + + it("should render nicknames and tags for podium and list entries", async () => { + server.use(currentLeaderboardUsersWithTagsHandler); + renderProviderFn?.(); + + // athena (list): nickname + tag, ares (list): tag only, apollo (list): nickname only. + expect(await screen.findByText("Athena")).toBeInTheDocument(); + expect(screen.getByText("Apollo")).toBeInTheDocument(); + // aphrodite (podium) + athena (list) carry Hunter; poseidon (podium) + ares (list) carry NYU. + expect(screen.getAllByAltText("Hunter College Logo")).toHaveLength(2); + expect(screen.getAllByAltText("NYU Logo")).toHaveLength(2); + }); }); diff --git a/js/src/lib/config/index.test.ts b/js/src/lib/config/index.test.ts new file mode 100644 index 000000000..258541def --- /dev/null +++ b/js/src/lib/config/index.test.ts @@ -0,0 +1,47 @@ +describe("runtimeConfig", () => { + let config: HTMLElement; + let original: string | null; + + beforeEach(() => { + const el = document.getElementById("codebloom-config"); + if (!el) { + throw new Error("Test setup must provide #codebloom-config"); + } + config = el; + original = el.textContent; + vi.resetModules(); + }); + + afterEach(() => { + config.textContent = original; + }); + + // `@/lib/config` reads the DOM once at import time, so each case needs a fresh + // module instance via dynamic import; a static import would only see the setup default. + it("reads feature flags from the embedded config", async () => { + config.textContent = JSON.stringify({ + featureFlags: { duels: false, userMetrics: true }, + }); + + const { runtimeConfig } = await import("@/lib/config"); + + expect(runtimeConfig.featureFlags).toEqual({ + duels: false, + userMetrics: true, + }); + }); + + it("fails fast when the embedded config is missing", async () => { + config.textContent = ""; + + await expect(import("@/lib/config")).rejects.toThrow( + "Missing #codebloom-config runtime configuration", + ); + }); + + it("rejects config with non-boolean flag values", async () => { + config.textContent = JSON.stringify({ featureFlags: { duels: "yes" } }); + + await expect(import("@/lib/config")).rejects.toThrow(); + }); +});