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
39 changes: 39 additions & 0 deletions api/src/services/identity.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ export type ProfileActivityRow = {
base_slug: string | null;
review_case_id: string | null;
revision_id: string | null;
subjects: Array<{ slug: string; name: string }>;
};

// Earliest revision id per parent, so a row can be tagged as the "creation"
Expand Down Expand Up @@ -463,6 +464,7 @@ export async function getProfileActivity(
base_slug: guide ? (slugByBase.get(guide.guide_base_id) ?? null) : null,
review_case_id: caseId,
revision_id: rev.id,
subjects: [],
});
}
}
Expand Down Expand Up @@ -509,6 +511,7 @@ export async function getProfileActivity(
base_slug: null,
review_case_id: null,
revision_id: rev.id,
subjects: [],
});
}
}
Expand Down Expand Up @@ -615,11 +618,47 @@ export async function getProfileActivity(
? caseId
: null,
revision_id: rev ?? null,
subjects: [],
});
}
}
}

// Batch-fetch subject tags for all guide revision rows in one query.
const guideRevisionIds = rows
.filter((r) => r.content_kind === "guide" && r.revision_id !== null)
.map((r) => r.revision_id as string);

if (guideRevisionIds.length > 0) {
const { data: tagData, error: tagError } = await supabase
.from("guide_revision_subjects")
.select("guide_revision_id, subjects(slug, name)")
.in("guide_revision_id", guideRevisionIds);

if (tagError) fail(tagError);

// Build a map from revision_id -> subject list, then hydrate the rows.
const subjectsByRev = new Map<
string,
Array<{ slug: string; name: string }>
>();
for (const tag of tagData ?? []) {
const subj = tag.subjects;
if (!subj || typeof subj !== "object" || Array.isArray(subj)) continue;
const { slug, name } = subj as { slug: string | null; name: string };
if (slug === null) continue;
const list = subjectsByRev.get(tag.guide_revision_id) ?? [];
list.push({ slug, name });
subjectsByRev.set(tag.guide_revision_id, list);
}

for (const row of rows) {
if (row.content_kind === "guide" && row.revision_id !== null) {
row.subjects = subjectsByRev.get(row.revision_id) ?? [];
}
}
}

rows.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
return rows;
}
43 changes: 43 additions & 0 deletions api/tests/identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
createGuideRevision,
createPublishedGuide,
} from "./factories/guides";
import { createSubject, tagGuideRevision } from "./factories/subjects";
import { expectToMatchSpec } from "./openapi";

describe("GET /me", () => {
Expand Down Expand Up @@ -197,4 +198,46 @@ describe("GET /profiles/{username}", () => {
expect(res.status).toBe(404);
await expectToMatchSpec(res, "GET", "/profiles/{username}");
});

it("includes subject tags on guide revision activity rows", async () => {
const { token, userId } = await makeUser();
const username = await getUsername(userId);
const subject = await createSubject();
const { revision } = await createPublishedGuide({ authorId: userId });
await tagGuideRevision(revision.id, subject.id);

const res = await app.request(`/profiles/${username}`, auth(token), env);

expect(res.status).toBe(200);
const body = (await res.json()) as {
activity: Array<{
revision_id: string | null;
subjects: Array<{ slug: string; name: string }>;
}>;
};
const row = body.activity.find((r) => r.revision_id === revision.id);
expect(row).toBeDefined();
expect(row?.subjects).toEqual(
expect.arrayContaining([{ slug: subject.slug, name: subject.name }])
);
});

it("returns an empty subjects array for untagged guide revisions", async () => {
const { token, userId } = await makeUser();
const username = await getUsername(userId);
const { revision } = await createPublishedGuide({ authorId: userId });

const res = await app.request(`/profiles/${username}`, auth(token), env);

expect(res.status).toBe(200);
const body = (await res.json()) as {
activity: Array<{
revision_id: string | null;
subjects: Array<{ slug: string; name: string }>;
}>;
};
const row = body.activity.find((r) => r.revision_id === revision.id);
expect(row).toBeDefined();
expect(row?.subjects).toEqual([]);
});
});
4 changes: 2 additions & 2 deletions app/src/components/ActivityColumnFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,9 @@ export function ChoiceColumnFilter({
setFilters,
}: {
label: string;
field: "type" | "status";
field: "type" | "status" | "subject";
options: ReadonlyArray<{
value: ActivityTypeFilter | ActivityStatusFilter;
value: ActivityTypeFilter | ActivityStatusFilter | string;
label: string;
}>;
search: ActivityFilters;
Expand Down
62 changes: 61 additions & 1 deletion app/src/components/profile/ActivityTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
activityStatusLabel,
activityTypeLabel,
filterActivity,
getActivitySubjectOptions,
} from "@/lib/profile";
import { formatDate } from "@/lib/guideUtils";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -95,6 +96,7 @@ export function ActivityTable({
const hasFilters = Boolean(
search.type?.length ||
search.status?.length ||
search.subject?.length ||
search.title ||
search.summary ||
search.from ||
Expand All @@ -120,6 +122,10 @@ export function ActivityTable({
? "No activity matches these filters."
: "No activity available yet.";

// Derive subject options from the full activity (before filtering) so all
// available subject choices are always visible, even when other filters are active.
const subjectOptions = getActivitySubjectOptions(activity);

return (
<>
<div className="md:hidden">
Expand Down Expand Up @@ -151,6 +157,15 @@ export function ActivityTable({
search={search}
setFilters={setFilters}
/>
{subjectOptions.length > 0 && (
<ChoiceColumnFilter
label="Subject"
field="subject"
options={subjectOptions}
search={search}
setFilters={setFilters}
/>
)}
</div>

{pageRows.length === 0 ? (
Expand Down Expand Up @@ -188,6 +203,20 @@ export function ActivityTable({
</p>
)}

{row.subjects.length > 0 && (
<div className="flex flex-wrap gap-1">
{row.subjects.map((s) => (
<Badge
key={s.slug}
variant="outline"
className="mono-micro rounded-full border border-badge-border bg-badge tracking-[0.08em] text-badge-foreground"
>
{s.name}
</Badge>
))}
</div>
)}

<div className="flex items-center justify-between gap-3">
<span className="mono-micro text-muted-foreground">
{formatDate(new Date(row.created_at))}
Expand Down Expand Up @@ -256,6 +285,19 @@ export function ActivityTable({
setFilters={setFilters}
/>
</TableHead>
<TableHead className="px-4 py-3 font-mono text-[14px] font-bold tracking-[0.08em] uppercase">
{subjectOptions.length > 0 ? (
<ChoiceColumnFilter
label="Subject"
field="subject"
options={subjectOptions}
search={search}
setFilters={setFilters}
/>
) : (
"Subject"
)}
</TableHead>
<TableHead className="px-4 py-3 font-mono text-[14px] font-bold tracking-[0.08em] uppercase">
Review Case
</TableHead>
Expand All @@ -265,7 +307,7 @@ export function ActivityTable({
{pageRows.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
colSpan={7}
className="px-4 py-6 text-center text-sm text-muted-foreground"
>
{emptyMessage}
Expand Down Expand Up @@ -303,6 +345,24 @@ export function ActivityTable({
</Badge>
</TableCell>

<TableCell className="px-4 py-3">
{row.subjects.length > 0 ? (
<div className="flex flex-wrap gap-1">
{row.subjects.map((s) => (
<Badge
key={s.slug}
variant="outline"
className="mono-micro rounded-full border border-badge-border bg-badge tracking-[0.08em] text-badge-foreground"
>
{s.name}
</Badge>
))}
</div>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>

<TableCell className="px-4 py-3">
{row.review_case_id ? (
<Button
Expand Down
93 changes: 92 additions & 1 deletion app/src/lib/__tests__/profile.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
import { describe, expect, it } from "vitest";
import { getAvatarUrl, getInitials } from "../profile";
import {
filterActivity,
getActivitySubjectOptions,
getAvatarUrl,
getInitials,
} from "../profile";
import type { ProfileActivityItem } from "@bluelearn/schemas";

function makeRow(
overrides: Partial<ProfileActivityItem> = {}
): ProfileActivityItem {
return {
content_kind: "guide",
is_variant: false,
is_creation: true,
title: "Test",
change_summary: null,
created_at: new Date().toISOString(),
status: "published",
target_slug: "test-guide",
base_slug: "test-base",
review_case_id: null,
revision_id: crypto.randomUUID(),
subjects: [],
...overrides,
};
}

describe("getInitials", () => {
it("extracts up to two initials from single-word and multi-word names", () => {
Expand Down Expand Up @@ -27,3 +53,68 @@ describe("getAvatarUrl", () => {
expect(getAvatarUrl(undefined)).toBe("");
});
});

describe("filterActivity — subject", () => {
const js = { slug: "javascript", name: "JavaScript" };
const react = { slug: "react", name: "React" };

const rowJs = makeRow({ subjects: [js] });
const rowReact = makeRow({ subjects: [react] });
const rowBoth = makeRow({ subjects: [js, react] });
const rowNone = makeRow({ subjects: [] });

it("returns all rows when no subject filter is set", () => {
const result = filterActivity([rowJs, rowReact, rowNone], {});
expect(result).toHaveLength(3);
});

it("keeps only rows that match at least one selected subject slug", () => {
const result = filterActivity([rowJs, rowReact, rowBoth, rowNone], {
subject: ["javascript"],
});
expect(result).toContain(rowJs);
expect(result).toContain(rowBoth);
expect(result).not.toContain(rowReact);
expect(result).not.toContain(rowNone);
});

it("matches rows that have any of the selected subjects (OR semantics)", () => {
const result = filterActivity([rowJs, rowReact, rowBoth, rowNone], {
subject: ["javascript", "react"],
});
expect(result).toContain(rowJs);
expect(result).toContain(rowReact);
expect(result).toContain(rowBoth);
expect(result).not.toContain(rowNone);
});

it("returns empty array when no rows match the selected subject", () => {
const result = filterActivity([rowNone], { subject: ["javascript"] });
expect(result).toHaveLength(0);
});
});

describe("getActivitySubjectOptions", () => {
it("derives unique sorted subject options from activity rows", () => {
const rows = [
makeRow({ subjects: [{ slug: "react", name: "React" }] }),
makeRow({
subjects: [
{ slug: "javascript", name: "JavaScript" },
{ slug: "react", name: "React" },
],
}),
makeRow({ subjects: [] }),
];
const options = getActivitySubjectOptions(rows);
expect(options).toEqual([
{ value: "javascript", label: "JavaScript" },
{ value: "react", label: "React" },
]);
});

it("returns an empty array when no rows have subjects", () => {
const rows = [makeRow({ subjects: [] }), makeRow({ subjects: [] })];
expect(getActivitySubjectOptions(rows)).toEqual([]);
});
});
Loading
Loading