Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 1 addition & 10 deletions .github/scripts/build-image/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = type === "web" ? { COMMIT_SHA: gitSha } : {};

const originalCwd = process.cwd();
try {
Expand Down
1 change: 1 addition & 0 deletions exclusions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
];
2 changes: 0 additions & 2 deletions infra/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 28 additions & 1 deletion js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -125,6 +126,32 @@ which allows us to define a complex relationship of parent tags to child tags. T
</p>
</div>

### 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).
Expand Down
3 changes: 3 additions & 0 deletions js/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
/>
</head>
<body>
<script id="codebloom-config" type="application/json">
__CODEBLOOM_CONFIG__
</script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
Expand Down
70 changes: 70 additions & 0 deletions js/runtime-config.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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(
/<script\b[^>]*\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",
);
Comment thread
tahminator marked this conversation as resolved.
Resolved
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);
});
},
};
}
52 changes: 52 additions & 0 deletions js/src/__mock__/leaderboard/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<Api<"PageIndexedUserWithScoreDto">>);
},
);

export const currentLeaderboardUsersFailedHandler = http.get(
currentLeaderboardUsers.url.toString(),
() => {
Expand Down
5 changes: 2 additions & 3 deletions js/src/app/_component/MiniLeaderboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -175,7 +174,7 @@ export default function MiniLeaderboardDesktop() {
</Flex>
</Flex>
{(entry.nickname ||
(tagFF && entry.tags && entry.tags.length > 0)) && (
(entry.tags && entry.tags.length > 0)) && (
<Flex align="center" gap={5}>
{entry.nickname && (
<Tooltip label="This user is a verified member of the Patina Discord server.">
Expand All @@ -188,7 +187,7 @@ export default function MiniLeaderboardDesktop() {
</Flex>
</Tooltip>
)}
{tagFF && entry.tags && entry.tags.length > 0 && (
{entry.tags && entry.tags.length > 0 && (
<TagList tags={entry.tags} size={16} gap="xs" />
)}
</Flex>
Expand Down
3 changes: 1 addition & 2 deletions js/src/app/_component/MiniLeaderboardMobile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -169,7 +168,7 @@ export default function MiniLeaderboardMobile() {
</Text>
</Flex>
</Tooltip>
{tagFF && entry.tags?.length > 0 && (
{entry.tags?.length > 0 && (
<TagList tags={entry.tags} size={14} gap="xs" />
)}
</Flex>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
currentLeaderboardUsersErrorHandler,
currentLeaderboardUsersFailedHandler,
currentLeaderboardUsersHandler,
currentLeaderboardUsersWithTagsHandler,
successfulLeaderboardHandlers,
} from "@/__mock__/leaderboard";
import LeaderboardForDashboard from "@/app/dashboard/_components/DashboardLeaderboard/DashboardLeaderboard";
Expand Down Expand Up @@ -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?.(
<LeaderboardForDashboard userId={MOCK_USER_IN_TOP5} userTags={[]} />,
);

// 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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -194,7 +193,7 @@ export default function LeaderboardForDashboard({
<Text fw={600} size="sm">
{user.nickname}
</Text>
{tagFF && user.tags && user.tags.length > 0 && (
{user.tags && user.tags.length > 0 && (
<TagList tags={user.tags} size={14} gap="xs" />
)}
</Flex>
Expand All @@ -203,12 +202,9 @@ export default function LeaderboardForDashboard({
<Flex align="center" gap={4}>
<FaDiscord />
<Text size="sm">{user.discordName}</Text>
{!user.nickname &&
tagFF &&
user.tags &&
user.tags.length > 0 && (
<TagList tags={user.tags} size={14} gap="xs" />
)}
{!user.nickname && user.tags && user.tags.length > 0 && (
<TagList tags={user.tags} size={14} gap="xs" />
)}
</Flex>
{user.leetcodeUsername && (
<Flex align="center" gap={4}>
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -100,7 +99,7 @@ export default function MyCurrentPoints({
<Text>Me</Text>
<Flex direction={"column"}>
{(userData.nickname ||
(tagFF && userData.tags && userData.tags.length > 0)) && (
(userData.tags && userData.tags.length > 0)) && (
<Flex align="center" justify="center" gap={5}>
{userData.nickname && (
<Tooltip
Expand All @@ -122,7 +121,6 @@ export default function MyCurrentPoints({
</Tooltip>
)}
{userData.nickname &&
tagFF &&
userData.tags &&
userData.tags.length > 0 && (
<TagList tags={userData.tags} size={16} gap="xs" />
Expand All @@ -141,7 +139,6 @@ export default function MyCurrentPoints({
{userData.discordName}
</Text>
{!userData.nickname &&
tagFF &&
userData.tags &&
userData.tags.length > 0 && (
<TagList tags={userData.tags} size={16} gap="xs" />
Expand Down
13 changes: 13 additions & 0 deletions js/src/app/leaderboard/_components/Leaderboard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
currentLeaderboardUsersHandler,
currentLeaderboardUsersWithTagsHandler,
successfulLeaderboardHandlers,
} from "@/__mock__/leaderboard";
import { CurrentLeaderboard } from "@/app/leaderboard/_components/Leaderboard";
Expand Down Expand Up @@ -294,4 +295,16 @@ describe("Leaderboard API Succeeded", () => {
});
}
});

it("should render nicknames and tags for podium and list entries", async () => {
server.use(currentLeaderboardUsersWithTagsHandler);
renderProviderFn?.(<CurrentLeaderboard />);

// 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);
});
});
Loading