issue/patch labels
MREADME.md
| @@ -95,5 +95,4 @@ bun run test # Run E2E and unit tests (don't use bun test directly, it | |||
|---|---|---|---|
| 95 | 95 | ||
| 96 | 96 | ## Roadmap | |
| 97 | 97 | - Use [git-bug](https://github.com/git-bug/git-bug) for issue tracking instead of custom implementation | |
| 98 | - | - Issue labels | |
| 99 | 98 | - more repository manipulation through the UI, e.g. file/directory/branch creation, renaming and deletion | |
Msrc/db/index.ts
| @@ -136,6 +136,24 @@ interface ReleaseAssetTable { | |||
|---|---|---|---|
| 136 | 136 | created_at: string; | |
| 137 | 137 | } | |
| 138 | 138 | ||
| 139 | + | interface LabelTable { | |
| 140 | + | id: Generated<number>; | |
| 141 | + | repo_id: number; | |
| 142 | + | name: string; | |
| 143 | + | color: string; | |
| 144 | + | created_at: string; | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | interface IssueLabelTable { | |
| 148 | + | issue_id: number; | |
| 149 | + | label_id: number; | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | interface PatchLabelTable { | |
| 153 | + | patch_id: number; | |
| 154 | + | label_id: number; | |
| 155 | + | } | |
| 156 | + | ||
| 139 | 157 | export interface Database { | |
| 140 | 158 | users: UserTable; | |
| 141 | 159 | passkeys: PasskeyTable; | |
| @@ -150,6 +168,9 @@ export interface Database { | |||
|---|---|---|---|
| 150 | 168 | ssh_keys: SshKeyTable; | |
| 151 | 169 | releases: ReleaseTable; | |
| 152 | 170 | release_assets: ReleaseAssetTable; | |
| 171 | + | labels: LabelTable; | |
| 172 | + | issue_labels: IssueLabelTable; | |
| 173 | + | patch_labels: PatchLabelTable; | |
| 153 | 174 | } | |
| 154 | 175 | ||
| 155 | 176 | // Selectable row types (id is plain number, as returned by queries) | |
| @@ -166,6 +187,7 @@ export type PatchReactionRow = Selectable<PatchReactionTable>; | |||
|---|---|---|---|
| 166 | 187 | export type SshKeyRow = Selectable<SshKeyTable>; | |
| 167 | 188 | export type ReleaseRow = Selectable<ReleaseTable>; | |
| 168 | 189 | export type ReleaseAssetRow = Selectable<ReleaseAssetTable>; | |
| 190 | + | export type LabelRow = Selectable<LabelTable>; | |
| 169 | 191 | ||
| 170 | 192 | const sqlite = new BunDatabase(DB_PATH); | |
| 171 | 193 | sqlite.run("PRAGMA journal_mode=WAL"); | |
| @@ -201,6 +223,26 @@ export const db = new Kysely<Database>({ | |||
|---|---|---|---|
| 201 | 223 | dialect: new BunSqliteDialect({ database: sqlite }), | |
| 202 | 224 | }); | |
| 203 | 225 | ||
| 226 | + | // Migration: create labels tables if missing | |
| 227 | + | sqlite.run(`CREATE TABLE IF NOT EXISTS labels ( | |
| 228 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 229 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, | |
| 230 | + | name TEXT NOT NULL, | |
| 231 | + | color TEXT NOT NULL DEFAULT '#808080', | |
| 232 | + | created_at TEXT NOT NULL, | |
| 233 | + | UNIQUE(repo_id, name) | |
| 234 | + | )`); | |
| 235 | + | sqlite.run(`CREATE TABLE IF NOT EXISTS issue_labels ( | |
| 236 | + | issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 237 | + | label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, | |
| 238 | + | PRIMARY KEY (issue_id, label_id) | |
| 239 | + | )`); | |
| 240 | + | sqlite.run(`CREATE TABLE IF NOT EXISTS patch_labels ( | |
| 241 | + | patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE, | |
| 242 | + | label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, | |
| 243 | + | PRIMARY KEY (patch_id, label_id) | |
| 244 | + | )`); | |
| 245 | + | ||
| 204 | 246 | export async function getRepo(name: string, isAdmin: boolean) { | |
| 205 | 247 | const repo = await db | |
| 206 | 248 | .selectFrom("repositories") | |
Msrc/db/schema.sql
| @@ -136,3 +136,24 @@ CREATE TABLE IF NOT EXISTS release_assets ( | |||
|---|---|---|---|
| 136 | 136 | content_type TEXT NOT NULL, | |
| 137 | 137 | created_at TEXT NOT NULL | |
| 138 | 138 | ); | |
| 139 | + | ||
| 140 | + | CREATE TABLE IF NOT EXISTS labels ( | |
| 141 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 142 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, | |
| 143 | + | name TEXT NOT NULL, | |
| 144 | + | color TEXT NOT NULL DEFAULT '#808080', | |
| 145 | + | created_at TEXT NOT NULL, | |
| 146 | + | UNIQUE(repo_id, name) | |
| 147 | + | ); | |
| 148 | + | ||
| 149 | + | CREATE TABLE IF NOT EXISTS issue_labels ( | |
| 150 | + | issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 151 | + | label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, | |
| 152 | + | PRIMARY KEY (issue_id, label_id) | |
| 153 | + | ); | |
| 154 | + | ||
| 155 | + | CREATE TABLE IF NOT EXISTS patch_labels ( | |
| 156 | + | patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE, | |
| 157 | + | label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, | |
| 158 | + | PRIMARY KEY (patch_id, label_id) | |
| 159 | + | ); | |
Asrc/lib/labelColor.ts
| @@ -0,0 +1,7 @@ | |||
|---|---|---|---|
| 1 | + | export function labelTextColor(hex: string): string { | |
| 2 | + | const r = parseInt(hex.slice(1, 3), 16); | |
| 3 | + | const g = parseInt(hex.slice(3, 5), 16); | |
| 4 | + | const b = parseInt(hex.slice(5, 7), 16); | |
| 5 | + | const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; | |
| 6 | + | return luminance > 0.5 ? "#000000" : "#ffffff"; | |
| 7 | + | } | |
Msrc/routes/issues.tsx
| @@ -1,7 +1,7 @@ | |||
|---|---|---|---|
| 1 | 1 | import { Elysia, t } from "elysia"; | |
| 2 | 2 | import { sql } from "kysely"; | |
| 3 | 3 | import { ALLOWED_REACTIONS, ISSUES_PER_PAGE } from "../constants.ts"; | |
| 4 | - | import { db, getRepo } from "../db/index.ts"; | |
| 4 | + | import { db, getRepo, type LabelRow } from "../db/index.ts"; | |
| 5 | 5 | import { | |
| 6 | 6 | requireAdmin, | |
| 7 | 7 | requireAuth, | |
| @@ -33,12 +33,44 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 33 | 33 | : ("open" as const); | |
| 34 | 34 | const page = Math.max(1, query.page ?? 1); | |
| 35 | 35 | ||
| 36 | - | const allCounts = await db | |
| 37 | - | .selectFrom("issues") | |
| 38 | - | .select(["status", db.fn.countAll<number>().as("count")]) | |
| 36 | + | // Parse label filter: query.labels may be a string or array of strings | |
| 37 | + | const rawLabels = query.labels; | |
| 38 | + | const labelIds: number[] = ( | |
| 39 | + | Array.isArray(rawLabels) | |
| 40 | + | ? rawLabels | |
| 41 | + | : rawLabels | |
| 42 | + | ? [rawLabels] | |
| 43 | + | : [] | |
| 44 | + | ) | |
| 45 | + | .map((v) => parseInt(v, 10)) | |
| 46 | + | .filter((n) => !isNaN(n)); | |
| 47 | + | ||
| 48 | + | const repoLabels = await db | |
| 49 | + | .selectFrom("labels") | |
| 50 | + | .selectAll() | |
| 39 | 51 | .where("repo_id", "=", repo.id) | |
| 40 | - | .groupBy("status") | |
| 52 | + | .orderBy("name", "asc") | |
| 41 | 53 | .execute(); | |
| 54 | + | ||
| 55 | + | let countQuery = db | |
| 56 | + | .selectFrom("issues") | |
| 57 | + | .select(["issues.status", db.fn.countAll<number>().as("count")]) | |
| 58 | + | .where("issues.repo_id", "=", repo.id); | |
| 59 | + | if (labelIds.length > 0) { | |
| 60 | + | countQuery = countQuery.where(({ exists, selectFrom }) => | |
| 61 | + | exists( | |
| 62 | + | selectFrom("issue_labels") | |
| 63 | + | .select("issue_labels.issue_id") | |
| 64 | + | .whereRef( | |
| 65 | + | "issue_labels.issue_id", | |
| 66 | + | "=", | |
| 67 | + | "issues.id", | |
| 68 | + | ) | |
| 69 | + | .where("issue_labels.label_id", "in", labelIds), | |
| 70 | + | ), | |
| 71 | + | ); | |
| 72 | + | } | |
| 73 | + | const allCounts = await countQuery.groupBy("issues.status").execute(); | |
| 42 | 74 | const counts: Record<string, number> = Object.fromEntries( | |
| 43 | 75 | allCounts.map((r) => [r.status, Number(r.count)]), | |
| 44 | 76 | ); | |
| @@ -49,7 +81,7 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 49 | 81 | const safePage = Math.min(page, totalPages); | |
| 50 | 82 | const offset = (safePage - 1) * ISSUES_PER_PAGE; | |
| 51 | 83 | ||
| 52 | - | const issues = await db | |
| 84 | + | let listQuery = db | |
| 53 | 85 | .selectFrom("issues") | |
| 54 | 86 | .leftJoin("users", "users.id", "issues.author_id") | |
| 55 | 87 | .select([ | |
| @@ -67,16 +99,62 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 67 | 99 | "users.avatar_version as author_avatar_version", | |
| 68 | 100 | ]) | |
| 69 | 101 | .where("issues.repo_id", "=", repo.id) | |
| 70 | - | .where("issues.status", "=", status) | |
| 102 | + | .where("issues.status", "=", status); | |
| 103 | + | if (labelIds.length > 0) { | |
| 104 | + | listQuery = listQuery.where(({ exists, selectFrom }) => | |
| 105 | + | exists( | |
| 106 | + | selectFrom("issue_labels") | |
| 107 | + | .select("issue_labels.issue_id") | |
| 108 | + | .whereRef( | |
| 109 | + | "issue_labels.issue_id", | |
| 110 | + | "=", | |
| 111 | + | "issues.id", | |
| 112 | + | ) | |
| 113 | + | .where("issue_labels.label_id", "in", labelIds), | |
| 114 | + | ), | |
| 115 | + | ); | |
| 116 | + | } | |
| 117 | + | const issues = await listQuery | |
| 71 | 118 | .orderBy("issues.number", "desc") | |
| 72 | 119 | .limit(ISSUES_PER_PAGE) | |
| 73 | 120 | .offset(offset) | |
| 74 | 121 | .execute(); | |
| 75 | 122 | ||
| 123 | + | // Batch-fetch labels for displayed issues | |
| 124 | + | const issueIds = issues.map((i) => i.id); | |
| 125 | + | const issueLabelsRows = | |
| 126 | + | issueIds.length > 0 | |
| 127 | + | ? await db | |
| 128 | + | .selectFrom("issue_labels") | |
| 129 | + | .innerJoin( | |
| 130 | + | "labels", | |
| 131 | + | "labels.id", | |
| 132 | + | "issue_labels.label_id", | |
| 133 | + | ) | |
| 134 | + | .select([ | |
| 135 | + | "issue_labels.issue_id", | |
| 136 | + | "labels.id", | |
| 137 | + | "labels.name", | |
| 138 | + | "labels.color", | |
| 139 | + | ]) | |
| 140 | + | .where("issue_labels.issue_id", "in", issueIds) | |
| 141 | + | .execute() | |
| 142 | + | : []; | |
| 143 | + | const labelsByIssueId = new Map<number, LabelRow[]>(); | |
| 144 | + | for (const row of issueLabelsRows) { | |
| 145 | + | const list = labelsByIssueId.get(row.issue_id) ?? []; | |
| 146 | + | list.push({ id: row.id, repo_id: repo.id, name: row.name, color: row.color, created_at: "" }); | |
| 147 | + | labelsByIssueId.set(row.issue_id, list); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | const labelsParam = | |
| 151 | + | labelIds.length > 0 | |
| 152 | + | ? `&labels=${labelIds.map(String).join(",")}` | |
| 153 | + | : ""; | |
| 76 | 154 | const pagination = { | |
| 77 | 155 | page: safePage, | |
| 78 | 156 | totalPages, | |
| 79 | - | pageUrlTemplate: `/${repo.name}/issues?status=${status}&page={page}`, | |
| 157 | + | pageUrlTemplate: `/${repo.name}/issues?status=${status}${labelsParam}&page={page}`, | |
| 80 | 158 | }; | |
| 81 | 159 | return html( | |
| 82 | 160 | <IssueList | |
| @@ -91,6 +169,9 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 91 | 169 | status={status} | |
| 92 | 170 | counts={counts} | |
| 93 | 171 | pagination={pagination} | |
| 172 | + | repoLabels={repoLabels} | |
| 173 | + | selectedLabelIds={labelIds} | |
| 174 | + | labelsByIssueId={labelsByIssueId} | |
| 94 | 175 | />, | |
| 95 | 176 | ); | |
| 96 | 177 | }, | |
| @@ -98,6 +179,7 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 98 | 179 | query: t.Object({ | |
| 99 | 180 | status: t.Optional(t.String()), | |
| 100 | 181 | page: t.Optional(t.Numeric()), | |
| 182 | + | labels: t.Optional(t.Union([t.String(), t.Array(t.String())])), | |
| 101 | 183 | }), | |
| 102 | 184 | }, | |
| 103 | 185 | ) | |
| @@ -241,6 +323,20 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 241 | 323 | ]), | |
| 242 | 324 | ); | |
| 243 | 325 | ||
| 326 | + | const issueLabels = await db | |
| 327 | + | .selectFrom("issue_labels") | |
| 328 | + | .innerJoin("labels", "labels.id", "issue_labels.label_id") | |
| 329 | + | .select(["labels.id", "labels.repo_id", "labels.name", "labels.color", "labels.created_at"]) | |
| 330 | + | .where("issue_labels.issue_id", "=", issue.id) | |
| 331 | + | .execute(); | |
| 332 | + | ||
| 333 | + | const repoLabels = await db | |
| 334 | + | .selectFrom("labels") | |
| 335 | + | .selectAll() | |
| 336 | + | .where("repo_id", "=", repo.id) | |
| 337 | + | .orderBy("name", "asc") | |
| 338 | + | .execute(); | |
| 339 | + | ||
| 244 | 340 | return html( | |
| 245 | 341 | <IssueDetail | |
| 246 | 342 | user={user} | |
| @@ -260,6 +356,8 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 260 | 356 | } | |
| 261 | 357 | reactions={reactions} | |
| 262 | 358 | commentReactions={commentReactions} | |
| 359 | + | issueLabels={issueLabels} | |
| 360 | + | repoLabels={repoLabels} | |
| 263 | 361 | />, | |
| 264 | 362 | ); | |
| 265 | 363 | }) | |
| @@ -583,4 +681,87 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 583 | 681 | }), | |
| 584 | 682 | body: t.Object({ edit_body: t.String() }), | |
| 585 | 683 | }, | |
| 684 | + | ) | |
| 685 | + | ||
| 686 | + | .post( | |
| 687 | + | "/:repo/issues/:number/labels/add", | |
| 688 | + | async ({ params, body, cookie }) => { | |
| 689 | + | const user = await resolveSession(cookie.session.value); | |
| 690 | + | const deny = requireAdmin(user); | |
| 691 | + | if (deny) return deny; | |
| 692 | + | const repo = await getRepo(params.repo, true); | |
| 693 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 694 | + | ||
| 695 | + | const issueNum = parseInt(params.number, 10); | |
| 696 | + | const issue = await db | |
| 697 | + | .selectFrom("issues") | |
| 698 | + | .select(["id"]) | |
| 699 | + | .where("repo_id", "=", repo.id) | |
| 700 | + | .where("number", "=", issueNum) | |
| 701 | + | .executeTakeFirst(); | |
| 702 | + | if (!issue) return new Response("Not found", { status: 404 }); | |
| 703 | + | ||
| 704 | + | const label = await db | |
| 705 | + | .selectFrom("labels") | |
| 706 | + | .select(["id"]) | |
| 707 | + | .where("id", "=", body.label_id) | |
| 708 | + | .where("repo_id", "=", repo.id) | |
| 709 | + | .executeTakeFirst(); | |
| 710 | + | if (!label) { | |
| 711 | + | return new Response(null, { | |
| 712 | + | status: 302, | |
| 713 | + | headers: { Location: `/${repo.name}/issues/${issueNum}` }, | |
| 714 | + | }); | |
| 715 | + | } | |
| 716 | + | ||
| 717 | + | await db | |
| 718 | + | .insertInto("issue_labels") | |
| 719 | + | .values({ issue_id: issue.id, label_id: label.id }) | |
| 720 | + | .onConflict((oc) => oc.doNothing()) | |
| 721 | + | .execute(); | |
| 722 | + | ||
| 723 | + | return new Response(null, { | |
| 724 | + | status: 302, | |
| 725 | + | headers: { Location: `/${repo.name}/issues/${issueNum}` }, | |
| 726 | + | }); | |
| 727 | + | }, | |
| 728 | + | { | |
| 729 | + | params: t.Object({ repo: t.String(), number: t.String() }), | |
| 730 | + | body: t.Object({ label_id: t.Numeric() }), | |
| 731 | + | }, | |
| 732 | + | ) | |
| 733 | + | ||
| 734 | + | .post( | |
| 735 | + | "/:repo/issues/:number/labels/remove", | |
| 736 | + | async ({ params, body, cookie }) => { | |
| 737 | + | const user = await resolveSession(cookie.session.value); | |
| 738 | + | const deny = requireAdmin(user); | |
| 739 | + | if (deny) return deny; | |
| 740 | + | const repo = await getRepo(params.repo, true); | |
| 741 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 742 | + | ||
| 743 | + | const issueNum = parseInt(params.number, 10); | |
| 744 | + | const issue = await db | |
| 745 | + | .selectFrom("issues") | |
| 746 | + | .select(["id"]) | |
| 747 | + | .where("repo_id", "=", repo.id) | |
| 748 | + | .where("number", "=", issueNum) | |
| 749 | + | .executeTakeFirst(); | |
| 750 | + | if (!issue) return new Response("Not found", { status: 404 }); | |
| 751 | + | ||
| 752 | + | await db | |
| 753 | + | .deleteFrom("issue_labels") | |
| 754 | + | .where("issue_id", "=", issue.id) | |
| 755 | + | .where("label_id", "=", body.label_id) | |
| 756 | + | .execute(); | |
| 757 | + | ||
| 758 | + | return new Response(null, { | |
| 759 | + | status: 302, | |
| 760 | + | headers: { Location: `/${repo.name}/issues/${issueNum}` }, | |
| 761 | + | }); | |
| 762 | + | }, | |
| 763 | + | { | |
| 764 | + | params: t.Object({ repo: t.String(), number: t.String() }), | |
| 765 | + | body: t.Object({ label_id: t.Numeric() }), | |
| 766 | + | }, | |
| 586 | 767 | ); | |
Msrc/routes/patches.tsx
| @@ -6,7 +6,7 @@ import { | |||
|---|---|---|---|
| 6 | 6 | MAX_USER_UPLOAD_BYTES, | |
| 7 | 7 | } from "../config.ts"; | |
| 8 | 8 | import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts"; | |
| 9 | - | import { db, getRepo } from "../db/index.ts"; | |
| 9 | + | import { db, getRepo, type LabelRow } from "../db/index.ts"; | |
| 10 | 10 | import { | |
| 11 | 11 | requireAdmin, | |
| 12 | 12 | requireAuth, | |
| @@ -66,12 +66,44 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 66 | 66 | : "open"; | |
| 67 | 67 | const page = Math.max(1, query.page ?? 1); | |
| 68 | 68 | ||
| 69 | - | const allCounts = await db | |
| 70 | - | .selectFrom("patches") | |
| 71 | - | .select(["status", db.fn.countAll<number>().as("count")]) | |
| 69 | + | // Parse label filter | |
| 70 | + | const rawLabels = query.labels; | |
| 71 | + | const labelIds: number[] = ( | |
| 72 | + | Array.isArray(rawLabels) | |
| 73 | + | ? rawLabels | |
| 74 | + | : rawLabels | |
| 75 | + | ? [rawLabels] | |
| 76 | + | : [] | |
| 77 | + | ) | |
| 78 | + | .map((v) => parseInt(v, 10)) | |
| 79 | + | .filter((n) => !isNaN(n)); | |
| 80 | + | ||
| 81 | + | const repoLabels = await db | |
| 82 | + | .selectFrom("labels") | |
| 83 | + | .selectAll() | |
| 72 | 84 | .where("repo_id", "=", repo.id) | |
| 73 | - | .groupBy("status") | |
| 85 | + | .orderBy("name", "asc") | |
| 74 | 86 | .execute(); | |
| 87 | + | ||
| 88 | + | let countQuery = db | |
| 89 | + | .selectFrom("patches") | |
| 90 | + | .select(["patches.status", db.fn.countAll<number>().as("count")]) | |
| 91 | + | .where("patches.repo_id", "=", repo.id); | |
| 92 | + | if (labelIds.length > 0) { | |
| 93 | + | countQuery = countQuery.where(({ exists, selectFrom }) => | |
| 94 | + | exists( | |
| 95 | + | selectFrom("patch_labels") | |
| 96 | + | .select("patch_labels.patch_id") | |
| 97 | + | .whereRef( | |
| 98 | + | "patch_labels.patch_id", | |
| 99 | + | "=", | |
| 100 | + | "patches.id", | |
| 101 | + | ) | |
| 102 | + | .where("patch_labels.label_id", "in", labelIds), | |
| 103 | + | ), | |
| 104 | + | ); | |
| 105 | + | } | |
| 106 | + | const allCounts = await countQuery.groupBy("patches.status").execute(); | |
| 75 | 107 | const counts: Record<string, number> = Object.fromEntries( | |
| 76 | 108 | allCounts.map((r) => [r.status, Number(r.count)]), | |
| 77 | 109 | ); | |
| @@ -82,7 +114,7 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 82 | 114 | const safePage = Math.min(page, totalPages); | |
| 83 | 115 | const offset = (safePage - 1) * PATCHES_PER_PAGE; | |
| 84 | 116 | ||
| 85 | - | const patches = await db | |
| 117 | + | let listQuery = db | |
| 86 | 118 | .selectFrom("patches") | |
| 87 | 119 | .leftJoin("users", "users.id", "patches.author_id") | |
| 88 | 120 | .select([ | |
| @@ -104,16 +136,62 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 104 | 136 | "users.avatar_version as author_avatar_version", | |
| 105 | 137 | ]) | |
| 106 | 138 | .where("patches.repo_id", "=", repo.id) | |
| 107 | - | .where("patches.status", "=", status) | |
| 139 | + | .where("patches.status", "=", status); | |
| 140 | + | if (labelIds.length > 0) { | |
| 141 | + | listQuery = listQuery.where(({ exists, selectFrom }) => | |
| 142 | + | exists( | |
| 143 | + | selectFrom("patch_labels") | |
| 144 | + | .select("patch_labels.patch_id") | |
| 145 | + | .whereRef( | |
| 146 | + | "patch_labels.patch_id", | |
| 147 | + | "=", | |
| 148 | + | "patches.id", | |
| 149 | + | ) | |
| 150 | + | .where("patch_labels.label_id", "in", labelIds), | |
| 151 | + | ), | |
| 152 | + | ); | |
| 153 | + | } | |
| 154 | + | const patches = await listQuery | |
| 108 | 155 | .orderBy("patches.number", "desc") | |
| 109 | 156 | .limit(PATCHES_PER_PAGE) | |
| 110 | 157 | .offset(offset) | |
| 111 | 158 | .execute(); | |
| 112 | 159 | ||
| 160 | + | // Batch-fetch labels for displayed patches | |
| 161 | + | const patchIds = patches.map((p) => p.id); | |
| 162 | + | const patchLabelsRows = | |
| 163 | + | patchIds.length > 0 | |
| 164 | + | ? await db | |
| 165 | + | .selectFrom("patch_labels") | |
| 166 | + | .innerJoin( | |
| 167 | + | "labels", | |
| 168 | + | "labels.id", | |
| 169 | + | "patch_labels.label_id", | |
| 170 | + | ) | |
| 171 | + | .select([ | |
| 172 | + | "patch_labels.patch_id", | |
| 173 | + | "labels.id", | |
| 174 | + | "labels.name", | |
| 175 | + | "labels.color", | |
| 176 | + | ]) | |
| 177 | + | .where("patch_labels.patch_id", "in", patchIds) | |
| 178 | + | .execute() | |
| 179 | + | : []; | |
| 180 | + | const labelsByPatchId = new Map<number, LabelRow[]>(); | |
| 181 | + | for (const row of patchLabelsRows) { | |
| 182 | + | const list = labelsByPatchId.get(row.patch_id) ?? []; | |
| 183 | + | list.push({ id: row.id, repo_id: repo.id, name: row.name, color: row.color, created_at: "" }); | |
| 184 | + | labelsByPatchId.set(row.patch_id, list); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | const labelsParam = | |
| 188 | + | labelIds.length > 0 | |
| 189 | + | ? `&labels=${labelIds.map(String).join(",")}` | |
| 190 | + | : ""; | |
| 113 | 191 | const pagination = { | |
| 114 | 192 | page: safePage, | |
| 115 | 193 | totalPages, | |
| 116 | - | pageUrlTemplate: `/${repo.name}/patches?status=${status}&page={page}`, | |
| 194 | + | pageUrlTemplate: `/${repo.name}/patches?status=${status}${labelsParam}&page={page}`, | |
| 117 | 195 | }; | |
| 118 | 196 | return html( | |
| 119 | 197 | <PatchList | |
| @@ -128,6 +206,9 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 128 | 206 | status={status} | |
| 129 | 207 | counts={counts} | |
| 130 | 208 | pagination={pagination} | |
| 209 | + | repoLabels={repoLabels} | |
| 210 | + | selectedLabelIds={labelIds} | |
| 211 | + | labelsByPatchId={labelsByPatchId} | |
| 131 | 212 | />, | |
| 132 | 213 | ); | |
| 133 | 214 | }, | |
| @@ -135,6 +216,7 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 135 | 216 | query: t.Object({ | |
| 136 | 217 | status: t.Optional(t.String()), | |
| 137 | 218 | page: t.Optional(t.Numeric()), | |
| 219 | + | labels: t.Optional(t.Union([t.String(), t.Array(t.String())])), | |
| 138 | 220 | }), | |
| 139 | 221 | }, | |
| 140 | 222 | ) | |
| @@ -386,6 +468,20 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 386 | 468 | ||
| 387 | 469 | const patchMeta = extractPatchMeta(patch.patch_content); | |
| 388 | 470 | ||
| 471 | + | const patchLabels = await db | |
| 472 | + | .selectFrom("patch_labels") | |
| 473 | + | .innerJoin("labels", "labels.id", "patch_labels.label_id") | |
| 474 | + | .select(["labels.id", "labels.repo_id", "labels.name", "labels.color", "labels.created_at"]) | |
| 475 | + | .where("patch_labels.patch_id", "=", patch.id) | |
| 476 | + | .execute(); | |
| 477 | + | ||
| 478 | + | const repoLabels = await db | |
| 479 | + | .selectFrom("labels") | |
| 480 | + | .selectAll() | |
| 481 | + | .where("repo_id", "=", repo.id) | |
| 482 | + | .orderBy("name", "asc") | |
| 483 | + | .execute(); | |
| 484 | + | ||
| 389 | 485 | return html( | |
| 390 | 486 | <PatchDetail | |
| 391 | 487 | user={user} | |
| @@ -411,6 +507,8 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 411 | 507 | } | |
| 412 | 508 | reactions={reactions} | |
| 413 | 509 | commentReactions={commentReactions} | |
| 510 | + | patchLabels={patchLabels} | |
| 511 | + | repoLabels={repoLabels} | |
| 414 | 512 | />, | |
| 415 | 513 | ); | |
| 416 | 514 | }, | |
| @@ -861,4 +959,87 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 861 | 959 | edit_description: t.Optional(t.String()), | |
| 862 | 960 | }), | |
| 863 | 961 | }, | |
| 962 | + | ) | |
| 963 | + | ||
| 964 | + | .post( | |
| 965 | + | "/:repo/patches/:number/labels/add", | |
| 966 | + | async ({ params, body, cookie }) => { | |
| 967 | + | const user = await resolveSession(cookie.session.value); | |
| 968 | + | const deny = requireAdmin(user); | |
| 969 | + | if (deny) return deny; | |
| 970 | + | const repo = await getRepo(params.repo, true); | |
| 971 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 972 | + | ||
| 973 | + | const patchNum = parseInt(params.number, 10); | |
| 974 | + | const patch = await db | |
| 975 | + | .selectFrom("patches") | |
| 976 | + | .select(["id"]) | |
| 977 | + | .where("repo_id", "=", repo.id) | |
| 978 | + | .where("number", "=", patchNum) | |
| 979 | + | .executeTakeFirst(); | |
| 980 | + | if (!patch) return new Response("Not found", { status: 404 }); | |
| 981 | + | ||
| 982 | + | const label = await db | |
| 983 | + | .selectFrom("labels") | |
| 984 | + | .select(["id"]) | |
| 985 | + | .where("id", "=", body.label_id) | |
| 986 | + | .where("repo_id", "=", repo.id) | |
| 987 | + | .executeTakeFirst(); | |
| 988 | + | if (!label) { | |
| 989 | + | return new Response(null, { | |
| 990 | + | status: 302, | |
| 991 | + | headers: { Location: `/${repo.name}/patches/${patchNum}` }, | |
| 992 | + | }); | |
| 993 | + | } | |
| 994 | + | ||
| 995 | + | await db | |
| 996 | + | .insertInto("patch_labels") | |
| 997 | + | .values({ patch_id: patch.id, label_id: label.id }) | |
| 998 | + | .onConflict((oc) => oc.doNothing()) | |
| 999 | + | .execute(); | |
| 1000 | + | ||
| 1001 | + | return new Response(null, { | |
| 1002 | + | status: 302, | |
| 1003 | + | headers: { Location: `/${repo.name}/patches/${patchNum}` }, | |
| 1004 | + | }); | |
| 1005 | + | }, | |
| 1006 | + | { | |
| 1007 | + | params: t.Object({ repo: t.String(), number: t.String() }), | |
| 1008 | + | body: t.Object({ label_id: t.Numeric() }), | |
| 1009 | + | }, | |
| 1010 | + | ) | |
| 1011 | + | ||
| 1012 | + | .post( | |
| 1013 | + | "/:repo/patches/:number/labels/remove", | |
| 1014 | + | async ({ params, body, cookie }) => { | |
| 1015 | + | const user = await resolveSession(cookie.session.value); | |
| 1016 | + | const deny = requireAdmin(user); | |
| 1017 | + | if (deny) return deny; | |
| 1018 | + | const repo = await getRepo(params.repo, true); | |
| 1019 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 1020 | + | ||
| 1021 | + | const patchNum = parseInt(params.number, 10); | |
| 1022 | + | const patch = await db | |
| 1023 | + | .selectFrom("patches") | |
| 1024 | + | .select(["id"]) | |
| 1025 | + | .where("repo_id", "=", repo.id) | |
| 1026 | + | .where("number", "=", patchNum) | |
| 1027 | + | .executeTakeFirst(); | |
| 1028 | + | if (!patch) return new Response("Not found", { status: 404 }); | |
| 1029 | + | ||
| 1030 | + | await db | |
| 1031 | + | .deleteFrom("patch_labels") | |
| 1032 | + | .where("patch_id", "=", patch.id) | |
| 1033 | + | .where("label_id", "=", body.label_id) | |
| 1034 | + | .execute(); | |
| 1035 | + | ||
| 1036 | + | return new Response(null, { | |
| 1037 | + | status: 302, | |
| 1038 | + | headers: { Location: `/${repo.name}/patches/${patchNum}` }, | |
| 1039 | + | }); | |
| 1040 | + | }, | |
| 1041 | + | { | |
| 1042 | + | params: t.Object({ repo: t.String(), number: t.String() }), | |
| 1043 | + | body: t.Object({ label_id: t.Numeric() }), | |
| 1044 | + | }, | |
| 864 | 1045 | ); | |
Msrc/routes/repos.tsx
| @@ -9,7 +9,7 @@ import { | |||
|---|---|---|---|
| 9 | 9 | REPOS_PER_PAGE, | |
| 10 | 10 | VALID_REPO_NAME_RE, | |
| 11 | 11 | } from "../constants.ts"; | |
| 12 | - | import { db } from "../db/index.ts"; | |
| 12 | + | import { db, type LabelRow } from "../db/index.ts"; | |
| 13 | 13 | import { redirect } from "../lib/redirect.ts"; | |
| 14 | 14 | import { requireAdmin, resolveSession } from "../middleware/session.ts"; | |
| 15 | 15 | import { git, repoPath } from "../services/git.ts"; | |
| @@ -698,15 +698,30 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 698 | 698 | ); | |
| 699 | 699 | }) | |
| 700 | 700 | ||
| 701 | - | .get("/:repo/settings", async ({ params, cookie }) => { | |
| 701 | + | .get("/:repo/settings", async ({ params, query, cookie }) => { | |
| 702 | 702 | const user = await resolveSession(cookie.session.value); | |
| 703 | 703 | const deny = requireAdmin(user); | |
| 704 | 704 | if (deny) return deny; | |
| 705 | 705 | const repo = await getRepo(params.repo, true); | |
| 706 | 706 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 707 | 707 | const branches = await git.branches(repo.name); | |
| 708 | + | const labels = await db | |
| 709 | + | .selectFrom("labels") | |
| 710 | + | .selectAll() | |
| 711 | + | .where("repo_id", "=", repo.id) | |
| 712 | + | .orderBy("name", "asc") | |
| 713 | + | .execute(); | |
| 714 | + | const success = typeof query.success === "string" ? query.success : undefined; | |
| 715 | + | const error = typeof query.error === "string" ? query.error : undefined; | |
| 708 | 716 | return html( | |
| 709 | - | <RepoSettings user={user!} repo={repo} branches={branches} />, | |
| 717 | + | <RepoSettings | |
| 718 | + | user={user!} | |
| 719 | + | repo={repo} | |
| 720 | + | branches={branches} | |
| 721 | + | labels={labels} | |
| 722 | + | success={success} | |
| 723 | + | error={error} | |
| 724 | + | />, | |
| 710 | 725 | ); | |
| 711 | 726 | }) | |
| 712 | 727 | ||
| @@ -733,13 +748,8 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 733 | 748 | ||
| 734 | 749 | // Validate the selected branch exists (only if repo has commits) | |
| 735 | 750 | if (branches.length > 0 && !branches.includes(newBranch)) { | |
| 736 | - | return html( | |
| 737 | - | <RepoSettings | |
| 738 | - | user={user!} | |
| 739 | - | repo={repo} | |
| 740 | - | branches={branches} | |
| 741 | - | error={`Branch "${newBranch}" does not exist.`} | |
| 742 | - | />, | |
| 751 | + | return redirect( | |
| 752 | + | `/${repo.name}/settings?error=${encodeURIComponent(`Branch "${newBranch}" does not exist.`)}`, | |
| 743 | 753 | ); | |
| 744 | 754 | } | |
| 745 | 755 | ||
| @@ -761,19 +771,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 761 | 771 | await git.setHead(repo.name, newBranch).catch(() => {}); | |
| 762 | 772 | } | |
| 763 | 773 | ||
| 764 | - | const updated = await db | |
| 765 | - | .selectFrom("repositories") | |
| 766 | - | .selectAll() | |
| 767 | - | .where("id", "=", repo.id) | |
| 768 | - | .executeTakeFirstOrThrow(); | |
| 769 | - | return html( | |
| 770 | - | <RepoSettings | |
| 771 | - | user={user!} | |
| 772 | - | repo={updated} | |
| 773 | - | branches={branches} | |
| 774 | - | success="Settings saved." | |
| 775 | - | />, | |
| 776 | - | ); | |
| 774 | + | return redirect(`/${repo.name}/settings?success=Settings+saved.`); | |
| 777 | 775 | }, | |
| 778 | 776 | { | |
| 779 | 777 | body: t.Object({ | |
| @@ -800,4 +798,83 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 800 | 798 | await db.deleteFrom("repositories").where("id", "=", repo.id).execute(); | |
| 801 | 799 | ||
| 802 | 800 | return new Response(null, { status: 302, headers: { Location: "/" } }); | |
| 803 | - | }); | |
| 801 | + | }) | |
| 802 | + | ||
| 803 | + | .post( | |
| 804 | + | "/:repo/settings/labels", | |
| 805 | + | async ({ params, body, cookie }) => { | |
| 806 | + | const user = await resolveSession(cookie.session.value); | |
| 807 | + | const deny = requireAdmin(user); | |
| 808 | + | if (deny) return deny; | |
| 809 | + | const repo = await getRepo(params.repo, true); | |
| 810 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 811 | + | ||
| 812 | + | const name = body.name?.trim(); | |
| 813 | + | const color = body.color?.trim(); | |
| 814 | + | ||
| 815 | + | if (!name || name.length > 50) { | |
| 816 | + | return redirect( | |
| 817 | + | `/${repo.name}/settings?error=${encodeURIComponent("Label name must be 1–50 characters.")}`, | |
| 818 | + | ); | |
| 819 | + | } | |
| 820 | + | if (!color || !/^#[0-9a-fA-F]{6}$/.test(color)) { | |
| 821 | + | return redirect( | |
| 822 | + | `/${repo.name}/settings?error=${encodeURIComponent("Invalid color.")}`, | |
| 823 | + | ); | |
| 824 | + | } | |
| 825 | + | ||
| 826 | + | try { | |
| 827 | + | await db | |
| 828 | + | .insertInto("labels") | |
| 829 | + | .values({ | |
| 830 | + | repo_id: repo.id, | |
| 831 | + | name, | |
| 832 | + | color, | |
| 833 | + | created_at: new Date().toISOString(), | |
| 834 | + | }) | |
| 835 | + | .execute(); | |
| 836 | + | } catch { | |
| 837 | + | return redirect( | |
| 838 | + | `/${repo.name}/settings?error=${encodeURIComponent("A label with that name already exists.")}`, | |
| 839 | + | ); | |
| 840 | + | } | |
| 841 | + | ||
| 842 | + | return redirect(`/${repo.name}/settings?success=Label+created.`); | |
| 843 | + | }, | |
| 844 | + | { | |
| 845 | + | body: t.Object({ | |
| 846 | + | name: t.String(), | |
| 847 | + | color: t.String(), | |
| 848 | + | }), | |
| 849 | + | }, | |
| 850 | + | ) | |
| 851 | + | ||
| 852 | + | .post( | |
| 853 | + | "/:repo/settings/labels/delete", | |
| 854 | + | async ({ params, body, cookie }) => { | |
| 855 | + | const user = await resolveSession(cookie.session.value); | |
| 856 | + | const deny = requireAdmin(user); | |
| 857 | + | if (deny) return deny; | |
| 858 | + | const repo = await getRepo(params.repo, true); | |
| 859 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 860 | + | ||
| 861 | + | const label = await db | |
| 862 | + | .selectFrom("labels") | |
| 863 | + | .select(["id", "repo_id"]) | |
| 864 | + | .where("id", "=", body.id) | |
| 865 | + | .executeTakeFirst(); | |
| 866 | + | ||
| 867 | + | if (!label || label.repo_id !== repo.id) { | |
| 868 | + | return redirect( | |
| 869 | + | `/${repo.name}/settings?error=${encodeURIComponent("Label not found.")}`, | |
| 870 | + | ); | |
| 871 | + | } | |
| 872 | + | ||
| 873 | + | await db.deleteFrom("labels").where("id", "=", body.id).execute(); | |
| 874 | + | ||
| 875 | + | return redirect(`/${repo.name}/settings?success=Label+deleted.`); | |
| 876 | + | }, | |
| 877 | + | { | |
| 878 | + | body: t.Object({ id: t.Numeric() }), | |
| 879 | + | }, | |
| 880 | + | ); | |
Msrc/styles/main.css
| @@ -2719,3 +2719,174 @@ | |||
|---|---|---|---|
| 2719 | 2719 | color: var(--color-text-muted); | |
| 2720 | 2720 | font-size: var(--text-xs); | |
| 2721 | 2721 | } | |
| 2722 | + | ||
| 2723 | + | /* Labels */ | |
| 2724 | + | .label-badge { | |
| 2725 | + | display: inline-block; | |
| 2726 | + | padding: 0 var(--space-2); | |
| 2727 | + | border-radius: 2em; | |
| 2728 | + | font-size: var(--text-xs); | |
| 2729 | + | font-weight: 500; | |
| 2730 | + | line-height: 1.6; | |
| 2731 | + | word-break: break-all; | |
| 2732 | + | } | |
| 2733 | + | .issue-labels { | |
| 2734 | + | display: flex; | |
| 2735 | + | flex-wrap: wrap; | |
| 2736 | + | gap: var(--space-1); | |
| 2737 | + | margin-bottom: var(--space-2); | |
| 2738 | + | } | |
| 2739 | + | .issue-labels-row { | |
| 2740 | + | display: flex; | |
| 2741 | + | flex-wrap: wrap; | |
| 2742 | + | align-items: center; | |
| 2743 | + | gap: var(--space-2); | |
| 2744 | + | padding: var(--space-2) 0 var(--space-3); | |
| 2745 | + | } | |
| 2746 | + | .label-badge-wrap { | |
| 2747 | + | display: inline-flex; | |
| 2748 | + | align-items: center; | |
| 2749 | + | gap: var(--space-1); | |
| 2750 | + | } | |
| 2751 | + | .label-remove-form { | |
| 2752 | + | display: inline; | |
| 2753 | + | } | |
| 2754 | + | .label-remove-btn { | |
| 2755 | + | background: none; | |
| 2756 | + | border: none; | |
| 2757 | + | cursor: pointer; | |
| 2758 | + | padding: 0 2px; | |
| 2759 | + | font-size: var(--text-sm); | |
| 2760 | + | line-height: 1; | |
| 2761 | + | color: var(--color-text-muted); | |
| 2762 | + | opacity: 0.7; | |
| 2763 | + | } | |
| 2764 | + | .label-remove-btn:hover { | |
| 2765 | + | opacity: 1; | |
| 2766 | + | } | |
| 2767 | + | .label-add-inline-form { | |
| 2768 | + | display: inline-flex; | |
| 2769 | + | align-items: center; | |
| 2770 | + | gap: var(--space-2); | |
| 2771 | + | } | |
| 2772 | + | .label-select { | |
| 2773 | + | font-size: var(--text-sm); | |
| 2774 | + | padding: var(--space-1) var(--space-2); | |
| 2775 | + | border: 1px solid var(--color-border); | |
| 2776 | + | border-radius: var(--radius-md); | |
| 2777 | + | background: var(--color-bg); | |
| 2778 | + | color: var(--color-text); | |
| 2779 | + | height: 2rem; | |
| 2780 | + | } | |
| 2781 | + | .label-settings-list { | |
| 2782 | + | display: flex; | |
| 2783 | + | flex-direction: column; | |
| 2784 | + | margin-bottom: var(--space-4); | |
| 2785 | + | max-height: 24rem; | |
| 2786 | + | overflow-y: auto; | |
| 2787 | + | } | |
| 2788 | + | .label-settings-item { | |
| 2789 | + | display: flex; | |
| 2790 | + | align-items: center; | |
| 2791 | + | gap: var(--space-3); | |
| 2792 | + | padding: var(--space-2) 0; | |
| 2793 | + | border-bottom: 1px solid var(--color-border-muted); | |
| 2794 | + | font-size: var(--text-sm); | |
| 2795 | + | } | |
| 2796 | + | .label-settings-item:last-child { | |
| 2797 | + | border-bottom: none; | |
| 2798 | + | } | |
| 2799 | + | .label-settings-swatch { | |
| 2800 | + | width: 12px; | |
| 2801 | + | height: 12px; | |
| 2802 | + | border-radius: 50%; | |
| 2803 | + | flex-shrink: 0; | |
| 2804 | + | border: 1px solid rgba(0, 0, 0, 0.15); | |
| 2805 | + | } | |
| 2806 | + | .label-settings-name { | |
| 2807 | + | flex: 1; | |
| 2808 | + | font-weight: 500; | |
| 2809 | + | } | |
| 2810 | + | .label-add-form { | |
| 2811 | + | display: flex; | |
| 2812 | + | align-items: center; | |
| 2813 | + | gap: var(--space-2); | |
| 2814 | + | } | |
| 2815 | + | .label-name-input { | |
| 2816 | + | flex: 1; | |
| 2817 | + | min-width: 0; | |
| 2818 | + | padding: var(--space-2) var(--space-3); | |
| 2819 | + | border: 1px solid var(--color-border); | |
| 2820 | + | border-radius: var(--radius-md); | |
| 2821 | + | background: var(--color-bg); | |
| 2822 | + | color: var(--color-text); | |
| 2823 | + | font-size: var(--text-sm); | |
| 2824 | + | height: 2rem; | |
| 2825 | + | box-sizing: border-box; | |
| 2826 | + | } | |
| 2827 | + | .label-name-input:focus { | |
| 2828 | + | outline: 2px solid var(--color-accent); | |
| 2829 | + | outline-offset: -1px; | |
| 2830 | + | border-color: var(--color-accent); | |
| 2831 | + | } | |
| 2832 | + | .label-color-swatch-label { | |
| 2833 | + | flex-shrink: 0; | |
| 2834 | + | cursor: pointer; | |
| 2835 | + | display: block; | |
| 2836 | + | line-height: 0; | |
| 2837 | + | } | |
| 2838 | + | .label-color-input { | |
| 2839 | + | display: block; | |
| 2840 | + | width: 2rem; | |
| 2841 | + | height: 2rem; | |
| 2842 | + | padding: 2px; | |
| 2843 | + | border: 1px solid var(--color-border); | |
| 2844 | + | border-radius: var(--radius-md); | |
| 2845 | + | background: var(--color-bg); | |
| 2846 | + | cursor: pointer; | |
| 2847 | + | -webkit-appearance: none; | |
| 2848 | + | appearance: none; | |
| 2849 | + | box-sizing: border-box; | |
| 2850 | + | } | |
| 2851 | + | ||
| 2852 | + | /* Label filter (details/summary popup) */ | |
| 2853 | + | .list-header-actions { | |
| 2854 | + | display: flex; | |
| 2855 | + | align-items: center; | |
| 2856 | + | gap: var(--space-2); | |
| 2857 | + | flex-shrink: 0; | |
| 2858 | + | } | |
| 2859 | + | .label-filter { | |
| 2860 | + | position: relative; | |
| 2861 | + | } | |
| 2862 | + | .label-filter-popup { | |
| 2863 | + | position: absolute; | |
| 2864 | + | right: 0; | |
| 2865 | + | top: calc(100% + var(--space-1)); | |
| 2866 | + | z-index: 10; | |
| 2867 | + | background: var(--color-bg); | |
| 2868 | + | border: 1px solid var(--color-border); | |
| 2869 | + | border-radius: var(--radius-lg); | |
| 2870 | + | padding: var(--space-3); | |
| 2871 | + | min-width: 180px; | |
| 2872 | + | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); | |
| 2873 | + | } | |
| 2874 | + | .label-filter-list { | |
| 2875 | + | display: flex; | |
| 2876 | + | flex-direction: column; | |
| 2877 | + | gap: var(--space-2); | |
| 2878 | + | margin-bottom: var(--space-3); | |
| 2879 | + | max-height: 16rem; | |
| 2880 | + | overflow-y: auto; | |
| 2881 | + | } | |
| 2882 | + | .label-filter-item { | |
| 2883 | + | display: flex; | |
| 2884 | + | align-items: center; | |
| 2885 | + | gap: var(--space-2); | |
| 2886 | + | cursor: pointer; | |
| 2887 | + | font-size: var(--text-sm); | |
| 2888 | + | } | |
| 2889 | + | .label-filter-actions { | |
| 2890 | + | display: flex; | |
| 2891 | + | gap: var(--space-2); | |
| 2892 | + | } | |
Msrc/views/issues/IssueDetail.tsx
| @@ -1,8 +1,10 @@ | |||
|---|---|---|---|
| 1 | 1 | import type { | |
| 2 | 2 | IssueCommentRow, | |
| 3 | 3 | IssueRow, | |
| 4 | + | LabelRow, | |
| 4 | 5 | RepositoryRow, | |
| 5 | 6 | } from "../../db/index.ts"; | |
| 7 | + | import { labelTextColor } from "../../lib/labelColor.ts"; | |
| 6 | 8 | import { displayName } from "../../lib/users.ts"; | |
| 7 | 9 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 8 | 10 | import { Avatar } from "../Avatar.tsx"; | |
| @@ -27,6 +29,8 @@ interface IssueDetailProps { | |||
|---|---|---|---|
| 27 | 29 | })[]; | |
| 28 | 30 | reactions: ReactionCount[]; | |
| 29 | 31 | commentReactions: Map<number, ReactionCount[]>; | |
| 32 | + | issueLabels: LabelRow[]; | |
| 33 | + | repoLabels: LabelRow[]; | |
| 30 | 34 | } | |
| 31 | 35 | ||
| 32 | 36 | export function IssueDetail({ | |
| @@ -37,6 +41,8 @@ export function IssueDetail({ | |||
|---|---|---|---|
| 37 | 41 | comments, | |
| 38 | 42 | reactions, | |
| 39 | 43 | commentReactions, | |
| 44 | + | issueLabels, | |
| 45 | + | repoLabels, | |
| 40 | 46 | }: IssueDetailProps) { | |
| 41 | 47 | const canEditIssue = | |
| 42 | 48 | user != null && | |
| @@ -158,6 +164,75 @@ export function IssueDetail({ | |||
|---|---|---|---|
| 158 | 164 | )} | |
| 159 | 165 | </div> | |
| 160 | 166 | ||
| 167 | + | {(issueLabels.length > 0 || user?.isAdmin) && ( | |
| 168 | + | <div class="issue-labels-row"> | |
| 169 | + | {issueLabels.map((label) => ( | |
| 170 | + | <span class="label-badge-wrap"> | |
| 171 | + | <span | |
| 172 | + | class="label-badge" | |
| 173 | + | style={`background:${label.color};color:${labelTextColor(label.color)}`} | |
| 174 | + | > | |
| 175 | + | {label.name} | |
| 176 | + | </span> | |
| 177 | + | {user?.isAdmin && ( | |
| 178 | + | <form | |
| 179 | + | method="POST" | |
| 180 | + | action={`/${repo.name}/issues/${issue.number}/labels/remove`} | |
| 181 | + | class="label-remove-form" | |
| 182 | + | > | |
| 183 | + | <input | |
| 184 | + | type="hidden" | |
| 185 | + | name="label_id" | |
| 186 | + | value={String(label.id)} | |
| 187 | + | /> | |
| 188 | + | <button | |
| 189 | + | type="submit" | |
| 190 | + | class="label-remove-btn" | |
| 191 | + | title="Remove label" | |
| 192 | + | > | |
| 193 | + | × | |
| 194 | + | </button> | |
| 195 | + | </form> | |
| 196 | + | )} | |
| 197 | + | </span> | |
| 198 | + | ))} | |
| 199 | + | {user?.isAdmin && | |
| 200 | + | repoLabels.filter( | |
| 201 | + | (l) => | |
| 202 | + | !issueLabels.some((il) => il.id === l.id), | |
| 203 | + | ).length > 0 && ( | |
| 204 | + | <form | |
| 205 | + | method="POST" | |
| 206 | + | action={`/${repo.name}/issues/${issue.number}/labels/add`} | |
| 207 | + | class="label-add-inline-form" | |
| 208 | + | > | |
| 209 | + | <select name="label_id" class="label-select"> | |
| 210 | + | {repoLabels | |
| 211 | + | .filter( | |
| 212 | + | (l) => | |
| 213 | + | !issueLabels.some( | |
| 214 | + | (il) => il.id === l.id, | |
| 215 | + | ), | |
| 216 | + | ) | |
| 217 | + | .map((label) => ( | |
| 218 | + | <option | |
| 219 | + | value={String(label.id)} | |
| 220 | + | > | |
| 221 | + | {label.name} | |
| 222 | + | </option> | |
| 223 | + | ))} | |
| 224 | + | </select> | |
| 225 | + | <button | |
| 226 | + | type="submit" | |
| 227 | + | class="btn btn-sm btn-secondary" | |
| 228 | + | > | |
| 229 | + | Add label | |
| 230 | + | </button> | |
| 231 | + | </form> | |
| 232 | + | )} | |
| 233 | + | </div> | |
| 234 | + | )} | |
| 235 | + | ||
| 161 | 236 | <div class="timeline-item"> | |
| 162 | 237 | <div class="timeline-author"> | |
| 163 | 238 | <Avatar | |
Msrc/views/issues/IssueList.tsx
| @@ -1,5 +1,6 @@ | |||
|---|---|---|---|
| 1 | - | import type { IssueRow, RepositoryRow } from "../../db/index.ts"; | |
| 1 | + | import type { IssueRow, LabelRow, RepositoryRow } from "../../db/index.ts"; | |
| 2 | 2 | import { formatDate } from "../../lib/formatDate.ts"; | |
| 3 | + | import { labelTextColor } from "../../lib/labelColor.ts"; | |
| 3 | 4 | import { displayName } from "../../lib/users.ts"; | |
| 4 | 5 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 5 | 6 | import { Avatar } from "../Avatar.tsx"; | |
| @@ -18,6 +19,9 @@ interface IssueListProps { | |||
|---|---|---|---|
| 18 | 19 | status: "open" | "closed" | "completed"; | |
| 19 | 20 | counts: Record<string, number>; | |
| 20 | 21 | pagination: PaginationInfo; | |
| 22 | + | repoLabels: LabelRow[]; | |
| 23 | + | selectedLabelIds: number[]; | |
| 24 | + | labelsByIssueId: Map<number, LabelRow[]>; | |
| 21 | 25 | } | |
| 22 | 26 | ||
| 23 | 27 | export function IssueList({ | |
| @@ -27,7 +31,14 @@ export function IssueList({ | |||
|---|---|---|---|
| 27 | 31 | status, | |
| 28 | 32 | counts, | |
| 29 | 33 | pagination, | |
| 34 | + | repoLabels, | |
| 35 | + | selectedLabelIds, | |
| 36 | + | labelsByIssueId, | |
| 30 | 37 | }: IssueListProps) { | |
| 38 | + | const labelsParam = | |
| 39 | + | selectedLabelIds.length > 0 | |
| 40 | + | ? `&labels=${selectedLabelIds.map(String).join(",")}` | |
| 41 | + | : ""; | |
| 31 | 42 | return ( | |
| 32 | 43 | <Layout user={user} title={`Issues — ${repo.name}`}> | |
| 33 | 44 | <div class="container"> | |
| @@ -36,7 +47,7 @@ export function IssueList({ | |||
|---|---|---|---|
| 36 | 47 | <div class="list-header"> | |
| 37 | 48 | <div class="list-header-tabs"> | |
| 38 | 49 | <a | |
| 39 | - | href={`/${repo.name}/issues?status=open`} | |
| 50 | + | href={`/${repo.name}/issues?status=open${labelsParam}`} | |
| 40 | 51 | class={`list-tab${status === "open" ? " active" : ""}`} | |
| 41 | 52 | > | |
| 42 | 53 | Open{" "} | |
| @@ -45,7 +56,7 @@ export function IssueList({ | |||
|---|---|---|---|
| 45 | 56 | )} | |
| 46 | 57 | </a> | |
| 47 | 58 | <a | |
| 48 | - | href={`/${repo.name}/issues?status=completed`} | |
| 59 | + | href={`/${repo.name}/issues?status=completed${labelsParam}`} | |
| 49 | 60 | class={`list-tab${status === "completed" ? " active" : ""}`} | |
| 50 | 61 | > | |
| 51 | 62 | Completed{" "} | |
| @@ -56,7 +67,7 @@ export function IssueList({ | |||
|---|---|---|---|
| 56 | 67 | )} | |
| 57 | 68 | </a> | |
| 58 | 69 | <a | |
| 59 | - | href={`/${repo.name}/issues?status=closed`} | |
| 70 | + | href={`/${repo.name}/issues?status=closed${labelsParam}`} | |
| 60 | 71 | class={`list-tab${status === "closed" ? " active" : ""}`} | |
| 61 | 72 | > | |
| 62 | 73 | Closed{" "} | |
| @@ -65,14 +76,78 @@ export function IssueList({ | |||
|---|---|---|---|
| 65 | 76 | )} | |
| 66 | 77 | </a> | |
| 67 | 78 | </div> | |
| 68 | - | {user && ( | |
| 69 | - | <a | |
| 70 | - | href={`/${repo.name}/issues/new`} | |
| 71 | - | class="btn btn-primary btn-sm" | |
| 72 | - | > | |
| 73 | - | New issue | |
| 74 | - | </a> | |
| 75 | - | )} | |
| 79 | + | <div class="list-header-actions"> | |
| 80 | + | {repoLabels.length > 0 && ( | |
| 81 | + | <details class="label-filter"> | |
| 82 | + | <summary class="btn btn-secondary btn-sm"> | |
| 83 | + | Filter by label | |
| 84 | + | {selectedLabelIds.length > 0 && ( | |
| 85 | + | <span class="tab-count"> | |
| 86 | + | {selectedLabelIds.length} | |
| 87 | + | </span> | |
| 88 | + | )} | |
| 89 | + | </summary> | |
| 90 | + | <div class="label-filter-popup"> | |
| 91 | + | <form | |
| 92 | + | method="GET" | |
| 93 | + | action={`/${repo.name}/issues`} | |
| 94 | + | > | |
| 95 | + | <input | |
| 96 | + | type="hidden" | |
| 97 | + | name="status" | |
| 98 | + | value={status} | |
| 99 | + | /> | |
| 100 | + | <div class="label-filter-list"> | |
| 101 | + | {repoLabels.map((label) => ( | |
| 102 | + | <label class="label-filter-item"> | |
| 103 | + | <input | |
| 104 | + | type="checkbox" | |
| 105 | + | name="labels" | |
| 106 | + | value={String(label.id)} | |
| 107 | + | checked={ | |
| 108 | + | selectedLabelIds.includes( | |
| 109 | + | label.id, | |
| 110 | + | ) | |
| 111 | + | ? true | |
| 112 | + | : undefined | |
| 113 | + | } | |
| 114 | + | /> | |
| 115 | + | <span | |
| 116 | + | class="label-badge" | |
| 117 | + | style={`background:${label.color};color:${labelTextColor(label.color)}`} | |
| 118 | + | > | |
| 119 | + | {label.name} | |
| 120 | + | </span> | |
| 121 | + | </label> | |
| 122 | + | ))} | |
| 123 | + | </div> | |
| 124 | + | <div class="label-filter-actions"> | |
| 125 | + | <button | |
| 126 | + | type="submit" | |
| 127 | + | class="btn btn-primary btn-sm" | |
| 128 | + | > | |
| 129 | + | Apply | |
| 130 | + | </button> | |
| 131 | + | <a | |
| 132 | + | href={`/${repo.name}/issues?status=${status}`} | |
| 133 | + | class="btn btn-secondary btn-sm" | |
| 134 | + | > | |
| 135 | + | Clear | |
| 136 | + | </a> | |
| 137 | + | </div> | |
| 138 | + | </form> | |
| 139 | + | </div> | |
| 140 | + | </details> | |
| 141 | + | )} | |
| 142 | + | {user && ( | |
| 143 | + | <a | |
| 144 | + | href={`/${repo.name}/issues/new`} | |
| 145 | + | class="btn btn-primary btn-sm" | |
| 146 | + | > | |
| 147 | + | New issue | |
| 148 | + | </a> | |
| 149 | + | )} | |
| 150 | + | </div> | |
| 76 | 151 | </div> | |
| 77 | 152 | {issues.length === 0 ? ( | |
| 78 | 153 | <div class="empty-state"> | |
| @@ -80,38 +155,53 @@ export function IssueList({ | |||
|---|---|---|---|
| 80 | 155 | </div> | |
| 81 | 156 | ) : ( | |
| 82 | 157 | <ul class="issue-list"> | |
| 83 | - | {issues.map((issue) => ( | |
| 84 | - | <li class="issue-item"> | |
| 85 | - | <div class="issue-main"> | |
| 86 | - | <span | |
| 87 | - | class={`issue-status-dot ${issue.status}`} | |
| 88 | - | /> | |
| 89 | - | <a | |
| 90 | - | href={`/${repo.name}/issues/${issue.number}`} | |
| 91 | - | class="issue-title" | |
| 92 | - | > | |
| 93 | - | {issue.title} | |
| 94 | - | </a> | |
| 95 | - | <span class="issue-number"> | |
| 96 | - | #{issue.number} | |
| 97 | - | </span> | |
| 98 | - | </div> | |
| 99 | - | <div class="issue-meta"> | |
| 100 | - | <Avatar | |
| 101 | - | userId={issue.author_id} | |
| 102 | - | version={issue.author_avatar_version} | |
| 103 | - | size={20} | |
| 104 | - | /> | |
| 105 | - | <span class="issue-author"> | |
| 106 | - | opened by{" "} | |
| 107 | - | {displayName(issue.author_username)} | |
| 108 | - | </span> | |
| 109 | - | <time datetime={issue.created_at}> | |
| 110 | - | {formatDate(issue.created_at)} | |
| 111 | - | </time> | |
| 112 | - | </div> | |
| 113 | - | </li> | |
| 114 | - | ))} | |
| 158 | + | {issues.map((issue) => { | |
| 159 | + | const labels = labelsByIssueId.get(issue.id) ?? []; | |
| 160 | + | return ( | |
| 161 | + | <li class="issue-item"> | |
| 162 | + | <div class="issue-main"> | |
| 163 | + | <span | |
| 164 | + | class={`issue-status-dot ${issue.status}`} | |
| 165 | + | /> | |
| 166 | + | <a | |
| 167 | + | href={`/${repo.name}/issues/${issue.number}`} | |
| 168 | + | class="issue-title" | |
| 169 | + | > | |
| 170 | + | {issue.title} | |
| 171 | + | </a> | |
| 172 | + | <span class="issue-number"> | |
| 173 | + | #{issue.number} | |
| 174 | + | </span> | |
| 175 | + | </div> | |
| 176 | + | {labels.length > 0 && ( | |
| 177 | + | <div class="issue-labels"> | |
| 178 | + | {labels.map((label) => ( | |
| 179 | + | <span | |
| 180 | + | class="label-badge" | |
| 181 | + | style={`background:${label.color};color:${labelTextColor(label.color)}`} | |
| 182 | + | > | |
| 183 | + | {label.name} | |
| 184 | + | </span> | |
| 185 | + | ))} | |
| 186 | + | </div> | |
| 187 | + | )} | |
| 188 | + | <div class="issue-meta"> | |
| 189 | + | <Avatar | |
| 190 | + | userId={issue.author_id} | |
| 191 | + | version={issue.author_avatar_version} | |
| 192 | + | size={20} | |
| 193 | + | /> | |
| 194 | + | <span class="issue-author"> | |
| 195 | + | opened by{" "} | |
| 196 | + | {displayName(issue.author_username)} | |
| 197 | + | </span> | |
| 198 | + | <time datetime={issue.created_at}> | |
| 199 | + | {formatDate(issue.created_at)} | |
| 200 | + | </time> | |
| 201 | + | </div> | |
| 202 | + | </li> | |
| 203 | + | ); | |
| 204 | + | })} | |
| 115 | 205 | </ul> | |
| 116 | 206 | )} | |
| 117 | 207 | <Pagination {...pagination} /> | |
Msrc/views/patches/PatchDetail.tsx
| @@ -1,10 +1,12 @@ | |||
|---|---|---|---|
| 1 | 1 | import { escapeHtml } from "@kitajs/html"; | |
| 2 | 2 | import type { | |
| 3 | + | LabelRow, | |
| 3 | 4 | PatchCommentRow, | |
| 4 | 5 | PatchRow, | |
| 5 | 6 | RepositoryRow, | |
| 6 | 7 | } from "../../db/index.ts"; | |
| 7 | 8 | import { formatDateTime } from "../../lib/formatDate.ts"; | |
| 9 | + | import { labelTextColor } from "../../lib/labelColor.ts"; | |
| 8 | 10 | import { displayName } from "../../lib/users.ts"; | |
| 9 | 11 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 10 | 12 | import type { RenderedDiffFile } from "../../services/diffHighlight.ts"; | |
| @@ -39,6 +41,8 @@ interface PatchDetailProps { | |||
|---|---|---|---|
| 39 | 41 | })[]; | |
| 40 | 42 | reactions: ReactionCount[]; | |
| 41 | 43 | commentReactions: Map<number, ReactionCount[]>; | |
| 44 | + | patchLabels: LabelRow[]; | |
| 45 | + | repoLabels: LabelRow[]; | |
| 42 | 46 | } | |
| 43 | 47 | ||
| 44 | 48 | export function PatchDetail({ | |
| @@ -53,6 +57,8 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 53 | 57 | comments, | |
| 54 | 58 | reactions, | |
| 55 | 59 | commentReactions, | |
| 60 | + | patchLabels, | |
| 61 | + | repoLabels, | |
| 56 | 62 | }: PatchDetailProps) { | |
| 57 | 63 | const canEdit = | |
| 58 | 64 | user != null && | |
| @@ -214,6 +220,74 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 214 | 220 | </div> | |
| 215 | 221 | )} | |
| 216 | 222 | </div> | |
| 223 | + | {(patchLabels.length > 0 || user?.isAdmin) && ( | |
| 224 | + | <div class="issue-labels-row"> | |
| 225 | + | {patchLabels.map((label) => ( | |
| 226 | + | <span class="label-badge-wrap"> | |
| 227 | + | <span | |
| 228 | + | class="label-badge" | |
| 229 | + | style={`background:${label.color};color:${labelTextColor(label.color)}`} | |
| 230 | + | > | |
| 231 | + | {label.name} | |
| 232 | + | </span> | |
| 233 | + | {user?.isAdmin && ( | |
| 234 | + | <form | |
| 235 | + | method="POST" | |
| 236 | + | action={`${baseUrl}/labels/remove`} | |
| 237 | + | class="label-remove-form" | |
| 238 | + | > | |
| 239 | + | <input | |
| 240 | + | type="hidden" | |
| 241 | + | name="label_id" | |
| 242 | + | value={String(label.id)} | |
| 243 | + | /> | |
| 244 | + | <button | |
| 245 | + | type="submit" | |
| 246 | + | class="label-remove-btn" | |
| 247 | + | title="Remove label" | |
| 248 | + | > | |
| 249 | + | × | |
| 250 | + | </button> | |
| 251 | + | </form> | |
| 252 | + | )} | |
| 253 | + | </span> | |
| 254 | + | ))} | |
| 255 | + | {user?.isAdmin && | |
| 256 | + | repoLabels.filter( | |
| 257 | + | (l) => | |
| 258 | + | !patchLabels.some((pl) => pl.id === l.id), | |
| 259 | + | ).length > 0 && ( | |
| 260 | + | <form | |
| 261 | + | method="POST" | |
| 262 | + | action={`${baseUrl}/labels/add`} | |
| 263 | + | class="label-add-inline-form" | |
| 264 | + | > | |
| 265 | + | <select name="label_id" class="label-select"> | |
| 266 | + | {repoLabels | |
| 267 | + | .filter( | |
| 268 | + | (l) => | |
| 269 | + | !patchLabels.some( | |
| 270 | + | (pl) => pl.id === l.id, | |
| 271 | + | ), | |
| 272 | + | ) | |
| 273 | + | .map((label) => ( | |
| 274 | + | <option | |
| 275 | + | value={String(label.id)} | |
| 276 | + | > | |
| 277 | + | {label.name} | |
| 278 | + | </option> | |
| 279 | + | ))} | |
| 280 | + | </select> | |
| 281 | + | <button | |
| 282 | + | type="submit" | |
| 283 | + | class="btn btn-sm btn-secondary" | |
| 284 | + | > | |
| 285 | + | Add label | |
| 286 | + | </button> | |
| 287 | + | </form> | |
| 288 | + | )} | |
| 289 | + | </div> | |
| 290 | + | )} | |
| 217 | 291 | </div> | |
| 218 | 292 | ||
| 219 | 293 | {/* Subview tabs */} | |
Msrc/views/patches/PatchList.tsx
| @@ -1,5 +1,6 @@ | |||
|---|---|---|---|
| 1 | - | import type { PatchRow, RepositoryRow } from "../../db/index.ts"; | |
| 1 | + | import type { LabelRow, PatchRow, RepositoryRow } from "../../db/index.ts"; | |
| 2 | 2 | import { formatDate } from "../../lib/formatDate.ts"; | |
| 3 | + | import { labelTextColor } from "../../lib/labelColor.ts"; | |
| 3 | 4 | import { displayName } from "../../lib/users.ts"; | |
| 4 | 5 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 5 | 6 | import { Avatar } from "../Avatar.tsx"; | |
| @@ -18,6 +19,9 @@ interface PatchListProps { | |||
|---|---|---|---|
| 18 | 19 | status: string; | |
| 19 | 20 | counts: Record<string, number>; | |
| 20 | 21 | pagination: PaginationInfo; | |
| 22 | + | repoLabels: LabelRow[]; | |
| 23 | + | selectedLabelIds: number[]; | |
| 24 | + | labelsByPatchId: Map<number, LabelRow[]>; | |
| 21 | 25 | } | |
| 22 | 26 | ||
| 23 | 27 | export function PatchList({ | |
| @@ -27,7 +31,14 @@ export function PatchList({ | |||
|---|---|---|---|
| 27 | 31 | status, | |
| 28 | 32 | counts, | |
| 29 | 33 | pagination, | |
| 34 | + | repoLabels, | |
| 35 | + | selectedLabelIds, | |
| 36 | + | labelsByPatchId, | |
| 30 | 37 | }: PatchListProps) { | |
| 38 | + | const labelsParam = | |
| 39 | + | selectedLabelIds.length > 0 | |
| 40 | + | ? `&labels=${selectedLabelIds.map(String).join(",")}` | |
| 41 | + | : ""; | |
| 31 | 42 | return ( | |
| 32 | 43 | <Layout user={user} title={`Patches — ${repo.name}`}> | |
| 33 | 44 | <div class="container"> | |
| @@ -36,7 +47,7 @@ export function PatchList({ | |||
|---|---|---|---|
| 36 | 47 | <div class="list-header"> | |
| 37 | 48 | <div class="list-header-tabs"> | |
| 38 | 49 | <a | |
| 39 | - | href={`/${repo.name}/patches?status=open`} | |
| 50 | + | href={`/${repo.name}/patches?status=open${labelsParam}`} | |
| 40 | 51 | class={`list-tab${status === "open" ? " active" : ""}`} | |
| 41 | 52 | > | |
| 42 | 53 | Open{" "} | |
| @@ -45,7 +56,7 @@ export function PatchList({ | |||
|---|---|---|---|
| 45 | 56 | )} | |
| 46 | 57 | </a> | |
| 47 | 58 | <a | |
| 48 | - | href={`/${repo.name}/patches?status=merged`} | |
| 59 | + | href={`/${repo.name}/patches?status=merged${labelsParam}`} | |
| 49 | 60 | class={`list-tab${status === "merged" ? " active" : ""}`} | |
| 50 | 61 | > | |
| 51 | 62 | Merged{" "} | |
| @@ -54,7 +65,7 @@ export function PatchList({ | |||
|---|---|---|---|
| 54 | 65 | )} | |
| 55 | 66 | </a> | |
| 56 | 67 | <a | |
| 57 | - | href={`/${repo.name}/patches?status=closed`} | |
| 68 | + | href={`/${repo.name}/patches?status=closed${labelsParam}`} | |
| 58 | 69 | class={`list-tab${status === "closed" ? " active" : ""}`} | |
| 59 | 70 | > | |
| 60 | 71 | Closed{" "} | |
| @@ -63,14 +74,78 @@ export function PatchList({ | |||
|---|---|---|---|
| 63 | 74 | )} | |
| 64 | 75 | </a> | |
| 65 | 76 | </div> | |
| 66 | - | {user && ( | |
| 67 | - | <a | |
| 68 | - | href={`/${repo.name}/patches/new`} | |
| 69 | - | class="btn btn-primary btn-sm" | |
| 70 | - | > | |
| 71 | - | Upload patch | |
| 72 | - | </a> | |
| 73 | - | )} | |
| 77 | + | <div class="list-header-actions"> | |
| 78 | + | {repoLabels.length > 0 && ( | |
| 79 | + | <details class="label-filter"> | |
| 80 | + | <summary class="btn btn-secondary btn-sm"> | |
| 81 | + | Filter by label | |
| 82 | + | {selectedLabelIds.length > 0 && ( | |
| 83 | + | <span class="tab-count"> | |
| 84 | + | {selectedLabelIds.length} | |
| 85 | + | </span> | |
| 86 | + | )} | |
| 87 | + | </summary> | |
| 88 | + | <div class="label-filter-popup"> | |
| 89 | + | <form | |
| 90 | + | method="GET" | |
| 91 | + | action={`/${repo.name}/patches`} | |
| 92 | + | > | |
| 93 | + | <input | |
| 94 | + | type="hidden" | |
| 95 | + | name="status" | |
| 96 | + | value={status} | |
| 97 | + | /> | |
| 98 | + | <div class="label-filter-list"> | |
| 99 | + | {repoLabels.map((label) => ( | |
| 100 | + | <label class="label-filter-item"> | |
| 101 | + | <input | |
| 102 | + | type="checkbox" | |
| 103 | + | name="labels" | |
| 104 | + | value={String(label.id)} | |
| 105 | + | checked={ | |
| 106 | + | selectedLabelIds.includes( | |
| 107 | + | label.id, | |
| 108 | + | ) | |
| 109 | + | ? true | |
| 110 | + | : undefined | |
| 111 | + | } | |
| 112 | + | /> | |
| 113 | + | <span | |
| 114 | + | class="label-badge" | |
| 115 | + | style={`background:${label.color};color:${labelTextColor(label.color)}`} | |
| 116 | + | > | |
| 117 | + | {label.name} | |
| 118 | + | </span> | |
| 119 | + | </label> | |
| 120 | + | ))} | |
| 121 | + | </div> | |
| 122 | + | <div class="label-filter-actions"> | |
| 123 | + | <button | |
| 124 | + | type="submit" | |
| 125 | + | class="btn btn-primary btn-sm" | |
| 126 | + | > | |
| 127 | + | Apply | |
| 128 | + | </button> | |
| 129 | + | <a | |
| 130 | + | href={`/${repo.name}/patches?status=${status}`} | |
| 131 | + | class="btn btn-secondary btn-sm" | |
| 132 | + | > | |
| 133 | + | Clear | |
| 134 | + | </a> | |
| 135 | + | </div> | |
| 136 | + | </form> | |
| 137 | + | </div> | |
| 138 | + | </details> | |
| 139 | + | )} | |
| 140 | + | {user && ( | |
| 141 | + | <a | |
| 142 | + | href={`/${repo.name}/patches/new`} | |
| 143 | + | class="btn btn-primary btn-sm" | |
| 144 | + | > | |
| 145 | + | Upload patch | |
| 146 | + | </a> | |
| 147 | + | )} | |
| 148 | + | </div> | |
| 74 | 149 | </div> | |
| 75 | 150 | {patches.length === 0 ? ( | |
| 76 | 151 | <div class="empty-state"> | |
| @@ -78,37 +153,52 @@ export function PatchList({ | |||
|---|---|---|---|
| 78 | 153 | </div> | |
| 79 | 154 | ) : ( | |
| 80 | 155 | <ul class="issue-list"> | |
| 81 | - | {patches.map((patch) => ( | |
| 82 | - | <li class="issue-item"> | |
| 83 | - | <div class="issue-main"> | |
| 84 | - | <span | |
| 85 | - | class={`patch-status-dot ${patch.status}`} | |
| 86 | - | /> | |
| 87 | - | <a | |
| 88 | - | href={`/${repo.name}/patches/${patch.number}`} | |
| 89 | - | class="issue-title" | |
| 90 | - | > | |
| 91 | - | {patch.title} | |
| 92 | - | </a> | |
| 93 | - | <span class="issue-number"> | |
| 94 | - | #{patch.number} | |
| 95 | - | </span> | |
| 96 | - | </div> | |
| 97 | - | <div class="issue-meta"> | |
| 98 | - | <Avatar | |
| 99 | - | userId={patch.author_id} | |
| 100 | - | version={patch.author_avatar_version} | |
| 101 | - | size={20} | |
| 102 | - | /> | |
| 103 | - | <span> | |
| 104 | - | by {displayName(patch.author_username)} | |
| 105 | - | </span> | |
| 106 | - | <time datetime={patch.created_at}> | |
| 107 | - | {formatDate(patch.created_at)} | |
| 108 | - | </time> | |
| 109 | - | </div> | |
| 110 | - | </li> | |
| 111 | - | ))} | |
| 156 | + | {patches.map((patch) => { | |
| 157 | + | const labels = labelsByPatchId.get(patch.id) ?? []; | |
| 158 | + | return ( | |
| 159 | + | <li class="issue-item"> | |
| 160 | + | <div class="issue-main"> | |
| 161 | + | <span | |
| 162 | + | class={`patch-status-dot ${patch.status}`} | |
| 163 | + | /> | |
| 164 | + | <a | |
| 165 | + | href={`/${repo.name}/patches/${patch.number}`} | |
| 166 | + | class="issue-title" | |
| 167 | + | > | |
| 168 | + | {patch.title} | |
| 169 | + | </a> | |
| 170 | + | <span class="issue-number"> | |
| 171 | + | #{patch.number} | |
| 172 | + | </span> | |
| 173 | + | </div> | |
| 174 | + | {labels.length > 0 && ( | |
| 175 | + | <div class="issue-labels"> | |
| 176 | + | {labels.map((label) => ( | |
| 177 | + | <span | |
| 178 | + | class="label-badge" | |
| 179 | + | style={`background:${label.color};color:${labelTextColor(label.color)}`} | |
| 180 | + | > | |
| 181 | + | {label.name} | |
| 182 | + | </span> | |
| 183 | + | ))} | |
| 184 | + | </div> | |
| 185 | + | )} | |
| 186 | + | <div class="issue-meta"> | |
| 187 | + | <Avatar | |
| 188 | + | userId={patch.author_id} | |
| 189 | + | version={patch.author_avatar_version} | |
| 190 | + | size={20} | |
| 191 | + | /> | |
| 192 | + | <span> | |
| 193 | + | by {displayName(patch.author_username)} | |
| 194 | + | </span> | |
| 195 | + | <time datetime={patch.created_at}> | |
| 196 | + | {formatDate(patch.created_at)} | |
| 197 | + | </time> | |
| 198 | + | </div> | |
| 199 | + | </li> | |
| 200 | + | ); | |
| 201 | + | })} | |
| 112 | 202 | </ul> | |
| 113 | 203 | )} | |
| 114 | 204 | <Pagination {...pagination} /> | |
Msrc/views/repos/RepoSettings.tsx
| @@ -1,4 +1,4 @@ | |||
|---|---|---|---|
| 1 | - | import type { RepositoryRow } from "../../db/index.ts"; | |
| 1 | + | import type { LabelRow, RepositoryRow } from "../../db/index.ts"; | |
| 2 | 2 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 3 | 3 | import { Layout } from "../layout.tsx"; | |
| 4 | 4 | import { RepoHeader } from "./RepoHeader.tsx"; | |
| @@ -8,6 +8,7 @@ interface RepoSettingsProps { | |||
|---|---|---|---|
| 8 | 8 | user: SessionUser; | |
| 9 | 9 | repo: RepositoryRow; | |
| 10 | 10 | branches: string[]; | |
| 11 | + | labels: LabelRow[]; | |
| 11 | 12 | success?: string; | |
| 12 | 13 | error?: string; | |
| 13 | 14 | } | |
| @@ -16,6 +17,7 @@ export function RepoSettings({ | |||
|---|---|---|---|
| 16 | 17 | user, | |
| 17 | 18 | repo, | |
| 18 | 19 | branches, | |
| 20 | + | labels, | |
| 19 | 21 | success, | |
| 20 | 22 | error, | |
| 21 | 23 | }: RepoSettingsProps) { | |
| @@ -129,6 +131,65 @@ export function RepoSettings({ | |||
|---|---|---|---|
| 129 | 131 | Save settings | |
| 130 | 132 | </button> | |
| 131 | 133 | </form> | |
| 134 | + | <div class="form-card"> | |
| 135 | + | <h2 class="section-title">Labels</h2> | |
| 136 | + | {labels.length > 0 && ( | |
| 137 | + | <div class="label-settings-list"> | |
| 138 | + | {labels.map((label) => ( | |
| 139 | + | <div class="label-settings-item"> | |
| 140 | + | <span | |
| 141 | + | class="label-settings-swatch" | |
| 142 | + | style={`background:${label.color}`} | |
| 143 | + | /> | |
| 144 | + | <span class="label-settings-name"> | |
| 145 | + | {label.name} | |
| 146 | + | </span> | |
| 147 | + | <form | |
| 148 | + | method="POST" | |
| 149 | + | action={`/${repo.name}/settings/labels/delete`} | |
| 150 | + | > | |
| 151 | + | <input | |
| 152 | + | type="hidden" | |
| 153 | + | name="id" | |
| 154 | + | value={String(label.id)} | |
| 155 | + | /> | |
| 156 | + | <button | |
| 157 | + | class="btn btn-danger btn-sm" | |
| 158 | + | type="submit" | |
| 159 | + | > | |
| 160 | + | Delete | |
| 161 | + | </button> | |
| 162 | + | </form> | |
| 163 | + | </div> | |
| 164 | + | ))} | |
| 165 | + | </div> | |
| 166 | + | )} | |
| 167 | + | <form | |
| 168 | + | method="POST" | |
| 169 | + | action={`/${repo.name}/settings/labels`} | |
| 170 | + | class="label-add-form" | |
| 171 | + | > | |
| 172 | + | <input | |
| 173 | + | class="form-input label-name-input" | |
| 174 | + | type="text" | |
| 175 | + | name="name" | |
| 176 | + | placeholder="Label name" | |
| 177 | + | maxlength="50" | |
| 178 | + | required | |
| 179 | + | /> | |
| 180 | + | <label class="label-color-swatch-label" title="Pick a color"> | |
| 181 | + | <input | |
| 182 | + | type="color" | |
| 183 | + | name="color" | |
| 184 | + | value="#808080" | |
| 185 | + | class="label-color-input" | |
| 186 | + | /> | |
| 187 | + | </label> | |
| 188 | + | <button type="submit" class="btn btn-secondary btn-sm"> | |
| 189 | + | Add label | |
| 190 | + | </button> | |
| 191 | + | </form> | |
| 192 | + | </div> | |
| 132 | 193 | <div class="danger-zone"> | |
| 133 | 194 | <h2 class="section-title danger-title">Danger zone</h2> | |
| 134 | 195 | <div class="form-card danger-card"> | |
Mtests/e2e.test.ts
| @@ -2888,3 +2888,259 @@ describe('file editing', () => { | |||
|---|---|---|---|
| 2888 | 2888 | } finally { await page.close(); } | |
| 2889 | 2889 | }); | |
| 2890 | 2890 | }); | |
| 2891 | + | ||
| 2892 | + | // ─── Labels ─────────────────────────────────────────────────────────────────── | |
| 2893 | + | ||
| 2894 | + | describe('labels', () => { | |
| 2895 | + | let adminCtx: BrowserContext; | |
| 2896 | + | let issueUrl: string; | |
| 2897 | + | let patchUrl: string; | |
| 2898 | + | ||
| 2899 | + | const VALID_PATCH = [ | |
| 2900 | + | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001', | |
| 2901 | + | 'From: Test User <test@example.com>', | |
| 2902 | + | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 2903 | + | 'Subject: [PATCH] Add label-test.txt', | |
| 2904 | + | '', | |
| 2905 | + | '---', | |
| 2906 | + | 'diff --git a/label-test.txt b/label-test.txt', | |
| 2907 | + | 'new file mode 100644', | |
| 2908 | + | 'index 0000000..9daeafb', | |
| 2909 | + | '--- /dev/null', | |
| 2910 | + | '+++ b/label-test.txt', | |
| 2911 | + | '@@ -0,0 +1 @@', | |
| 2912 | + | '+x', | |
| 2913 | + | '', | |
| 2914 | + | ].join('\n'); | |
| 2915 | + | ||
| 2916 | + | beforeAll(async () => { | |
| 2917 | + | adminCtx = await loggedInContext(); | |
| 2918 | + | ||
| 2919 | + | // Create a dedicated repo for label tests | |
| 2920 | + | const page = await adminCtx.newPage(); | |
| 2921 | + | try { | |
| 2922 | + | await page.goto(`${BASE}/new`); | |
| 2923 | + | await page.fill('[name=name]', 'label-repo'); | |
| 2924 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 2925 | + | await page.waitForURL(`${BASE}/label-repo`); | |
| 2926 | + | } finally { await page.close(); } | |
| 2927 | + | ||
| 2928 | + | // Create an issue | |
| 2929 | + | const issuePage = await adminCtx.newPage(); | |
| 2930 | + | try { | |
| 2931 | + | await issuePage.goto(`${BASE}/label-repo/issues/new`); | |
| 2932 | + | await issuePage.fill('[name=title]', 'Labelled issue'); | |
| 2933 | + | await issuePage.click('form[action$="/issues"] button[type=submit]'); | |
| 2934 | + | await issuePage.waitForURL(/\/label-repo\/issues\/\d+/); | |
| 2935 | + | issueUrl = issuePage.url(); | |
| 2936 | + | } finally { await issuePage.close(); } | |
| 2937 | + | ||
| 2938 | + | // Create a patch | |
| 2939 | + | writeTempFile('/tmp/label-test.patch', VALID_PATCH); | |
| 2940 | + | const patchPage = await adminCtx.newPage(); | |
| 2941 | + | try { | |
| 2942 | + | await patchPage.goto(`${BASE}/label-repo/patches/new`); | |
| 2943 | + | await patchPage.fill('[name=title]', 'Labelled patch'); | |
| 2944 | + | await patchPage.locator('[name=patch_file]').setInputFiles('/tmp/label-test.patch'); | |
| 2945 | + | await patchPage.click('form[action$="/patches"] button[type=submit]'); | |
| 2946 | + | await patchPage.waitForURL(/\/label-repo\/patches\/\d+/); | |
| 2947 | + | patchUrl = patchPage.url(); | |
| 2948 | + | } finally { await patchPage.close(); } | |
| 2949 | + | }); | |
| 2950 | + | ||
| 2951 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 2952 | + | ||
| 2953 | + | test('create label in repo settings', async () => { | |
| 2954 | + | const page = await adminCtx.newPage(); | |
| 2955 | + | try { | |
| 2956 | + | await page.goto(`${BASE}/label-repo/settings`); | |
| 2957 | + | await page.fill('input[name=name]', 'bug'); | |
| 2958 | + | await page.locator('input[type=color][name=color]').evaluate( | |
| 2959 | + | (el: any) => { el.value = '#ff0000'; }, | |
| 2960 | + | ); | |
| 2961 | + | await page.click('form[action$="/settings/labels"] button[type=submit]'); | |
| 2962 | + | await page.waitForURL(/\/label-repo\/settings/); | |
| 2963 | + | expect(await page.locator('.label-settings-name').allTextContents()).toContain('bug'); | |
| 2964 | + | } finally { await page.close(); } | |
| 2965 | + | }); | |
| 2966 | + | ||
| 2967 | + | test('create a second label', async () => { | |
| 2968 | + | const page = await adminCtx.newPage(); | |
| 2969 | + | try { | |
| 2970 | + | await page.goto(`${BASE}/label-repo/settings`); | |
| 2971 | + | await page.fill('input[name=name]', 'enhancement'); | |
| 2972 | + | await page.locator('input[type=color][name=color]').evaluate( | |
| 2973 | + | (el: any) => { el.value = '#00aa00'; }, | |
| 2974 | + | ); | |
| 2975 | + | await page.click('form[action$="/settings/labels"] button[type=submit]'); | |
| 2976 | + | await page.waitForURL(/\/label-repo\/settings/); | |
| 2977 | + | const badges = await page.locator('.label-settings-name').allTextContents(); | |
| 2978 | + | expect(badges).toContain('bug'); | |
| 2979 | + | expect(badges).toContain('enhancement'); | |
| 2980 | + | } finally { await page.close(); } | |
| 2981 | + | }); | |
| 2982 | + | ||
| 2983 | + | test('duplicate label name is rejected', async () => { | |
| 2984 | + | const page = await adminCtx.newPage(); | |
| 2985 | + | try { | |
| 2986 | + | await page.goto(`${BASE}/label-repo/settings`); | |
| 2987 | + | await page.fill('input[name=name]', 'bug'); | |
| 2988 | + | await page.locator('input[type=color][name=color]').evaluate( | |
| 2989 | + | (el: any) => { el.value = '#0000ff'; }, | |
| 2990 | + | ); | |
| 2991 | + | await page.click('form[action$="/settings/labels"] button[type=submit]'); | |
| 2992 | + | await page.waitForURL(/\/label-repo\/settings/); | |
| 2993 | + | expect(await page.locator('.form-error').isVisible()).toBe(true); | |
| 2994 | + | } finally { await page.close(); } | |
| 2995 | + | }); | |
| 2996 | + | ||
| 2997 | + | test('non-admin cannot create labels', async () => { | |
| 2998 | + | const ctx = await browser.newContext(); | |
| 2999 | + | try { | |
| 3000 | + | const r = await ctx.request.post(`${BASE}/label-repo/settings/labels`, { | |
| 3001 | + | form: { name: 'nope', color: '#123456' }, | |
| 3002 | + | maxRedirects: 0, | |
| 3003 | + | }); | |
| 3004 | + | // Unauthenticated → redirected to /login | |
| 3005 | + | expect(r.status()).toBe(302); | |
| 3006 | + | expect(r.headers()['location']).toContain('/login'); | |
| 3007 | + | } finally { await ctx.close(); } | |
| 3008 | + | }); | |
| 3009 | + | ||
| 3010 | + | test('assign label to issue', async () => { | |
| 3011 | + | const page = await adminCtx.newPage(); | |
| 3012 | + | try { | |
| 3013 | + | await page.goto(issueUrl); | |
| 3014 | + | await page.selectOption('select[name=label_id]', { label: 'bug' }); | |
| 3015 | + | await page.click('form[action$="/labels/add"] button[type=submit]'); | |
| 3016 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 3017 | + | expect(await page.locator('.label-badge').allTextContents()).toContain('bug'); | |
| 3018 | + | } finally { await page.close(); } | |
| 3019 | + | }); | |
| 3020 | + | ||
| 3021 | + | test('label appears on issue list', async () => { | |
| 3022 | + | const page = await adminCtx.newPage(); | |
| 3023 | + | try { | |
| 3024 | + | await page.goto(`${BASE}/label-repo/issues`); | |
| 3025 | + | const item = page.locator('.issue-item').filter({ hasText: 'Labelled issue' }); | |
| 3026 | + | expect(await item.locator('.label-badge').allTextContents()).toContain('bug'); | |
| 3027 | + | } finally { await page.close(); } | |
| 3028 | + | }); | |
| 3029 | + | ||
| 3030 | + | test('filter issues by label shows only matching issues', async () => { | |
| 3031 | + | // Create a second issue without the label | |
| 3032 | + | const createPage = await adminCtx.newPage(); | |
| 3033 | + | try { | |
| 3034 | + | await createPage.goto(`${BASE}/label-repo/issues/new`); | |
| 3035 | + | await createPage.fill('[name=title]', 'Unlabelled issue'); | |
| 3036 | + | await createPage.click('form[action$="/issues"] button[type=submit]'); | |
| 3037 | + | await createPage.waitForURL(/\/label-repo\/issues\/\d+/); | |
| 3038 | + | } finally { await createPage.close(); } | |
| 3039 | + | ||
| 3040 | + | // Open filter popup and apply label filter | |
| 3041 | + | const page = await adminCtx.newPage(); | |
| 3042 | + | try { | |
| 3043 | + | await page.goto(`${BASE}/label-repo/issues`); | |
| 3044 | + | // Get the label id from the checkbox | |
| 3045 | + | const checkbox = page.locator('.label-filter-item input[name=labels]').first(); | |
| 3046 | + | const labelId = await checkbox.getAttribute('value'); | |
| 3047 | + | expect(labelId).toBeTruthy(); | |
| 3048 | + | ||
| 3049 | + | // Navigate with the filter applied via URL | |
| 3050 | + | await page.goto(`${BASE}/label-repo/issues?labels=${labelId}`); | |
| 3051 | + | const titles = await page.locator('.issue-title').allTextContents(); | |
| 3052 | + | expect(titles.some(t => t.includes('Labelled issue'))).toBe(true); | |
| 3053 | + | expect(titles.some(t => t.includes('Unlabelled issue'))).toBe(false); | |
| 3054 | + | } finally { await page.close(); } | |
| 3055 | + | }); | |
| 3056 | + | ||
| 3057 | + | test('filter popup is visible without JS', async () => { | |
| 3058 | + | // details/summary is a native HTML element — verify it renders | |
| 3059 | + | const page = await adminCtx.newPage(); | |
| 3060 | + | try { | |
| 3061 | + | await page.goto(`${BASE}/label-repo/issues`); | |
| 3062 | + | expect(await page.locator('details.label-filter').isVisible()).toBe(true); | |
| 3063 | + | expect(await page.locator('details.label-filter summary').isVisible()).toBe(true); | |
| 3064 | + | } finally { await page.close(); } | |
| 3065 | + | }); | |
| 3066 | + | ||
| 3067 | + | test('remove label from issue', async () => { | |
| 3068 | + | const page = await adminCtx.newPage(); | |
| 3069 | + | try { | |
| 3070 | + | await page.goto(issueUrl); | |
| 3071 | + | await page.click('form[action$="/labels/remove"] button[type=submit]'); | |
| 3072 | + | await page.waitForURL(new RegExp(issueUrl.replace(BASE, ''))); | |
| 3073 | + | // Label badge should no longer appear in the labels row | |
| 3074 | + | const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents(); | |
| 3075 | + | expect(labelBadges).not.toContain('bug'); | |
| 3076 | + | } finally { await page.close(); } | |
| 3077 | + | }); | |
| 3078 | + | ||
| 3079 | + | test('assign label to patch', async () => { | |
| 3080 | + | const page = await adminCtx.newPage(); | |
| 3081 | + | try { | |
| 3082 | + | await page.goto(patchUrl); | |
| 3083 | + | await page.selectOption('select[name=label_id]', { label: 'enhancement' }); | |
| 3084 | + | await page.click('form[action$="/labels/add"] button[type=submit]'); | |
| 3085 | + | await page.waitForURL(new RegExp(patchUrl.replace(BASE, ''))); | |
| 3086 | + | expect(await page.locator('.label-badge').allTextContents()).toContain('enhancement'); | |
| 3087 | + | } finally { await page.close(); } | |
| 3088 | + | }); | |
| 3089 | + | ||
| 3090 | + | test('label appears on patch list', async () => { | |
| 3091 | + | const page = await adminCtx.newPage(); | |
| 3092 | + | try { | |
| 3093 | + | await page.goto(`${BASE}/label-repo/patches`); | |
| 3094 | + | const item = page.locator('.issue-item').filter({ hasText: 'Labelled patch' }); | |
| 3095 | + | expect(await item.locator('.label-badge').allTextContents()).toContain('enhancement'); | |
| 3096 | + | } finally { await page.close(); } | |
| 3097 | + | }); | |
| 3098 | + | ||
| 3099 | + | test('filter patches by label', async () => { | |
| 3100 | + | const page = await adminCtx.newPage(); | |
| 3101 | + | try { | |
| 3102 | + | await page.goto(`${BASE}/label-repo/patches`); | |
| 3103 | + | // Find the checkbox for the 'enhancement' label specifically | |
| 3104 | + | const checkbox = page.locator('.label-filter-item').filter({ hasText: 'enhancement' }) | |
| 3105 | + | .locator('input[name=labels]'); | |
| 3106 | + | const labelId = await checkbox.getAttribute('value'); | |
| 3107 | + | expect(labelId).toBeTruthy(); | |
| 3108 | + | ||
| 3109 | + | await page.goto(`${BASE}/label-repo/patches?labels=${labelId}`); | |
| 3110 | + | const titles = await page.locator('.issue-title').allTextContents(); | |
| 3111 | + | expect(titles.some(t => t.includes('Labelled patch'))).toBe(true); | |
| 3112 | + | } finally { await page.close(); } | |
| 3113 | + | }); | |
| 3114 | + | ||
| 3115 | + | test('remove label from patch', async () => { | |
| 3116 | + | const page = await adminCtx.newPage(); | |
| 3117 | + | try { | |
| 3118 | + | await page.goto(patchUrl); | |
| 3119 | + | await page.click('form[action$="/labels/remove"] button[type=submit]'); | |
| 3120 | + | await page.waitForURL(new RegExp(patchUrl.replace(BASE, ''))); | |
| 3121 | + | const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents(); | |
| 3122 | + | expect(labelBadges).not.toContain('enhancement'); | |
| 3123 | + | } finally { await page.close(); } | |
| 3124 | + | }); | |
| 3125 | + | ||
| 3126 | + | test('delete label removes it from settings list', async () => { | |
| 3127 | + | const page = await adminCtx.newPage(); | |
| 3128 | + | try { | |
| 3129 | + | await page.goto(`${BASE}/label-repo/settings`); | |
| 3130 | + | const bugItem = page.locator('.label-settings-item').filter({ hasText: 'bug' }); | |
| 3131 | + | await bugItem.locator('form[action$="/labels/delete"] button').click(); | |
| 3132 | + | await page.waitForURL(/\/label-repo\/settings/); | |
| 3133 | + | const badges = await page.locator('.label-settings-name').allTextContents(); | |
| 3134 | + | expect(badges).not.toContain('bug'); | |
| 3135 | + | } finally { await page.close(); } | |
| 3136 | + | }); | |
| 3137 | + | ||
| 3138 | + | test('deleted label no longer appears in filter popup', async () => { | |
| 3139 | + | const page = await adminCtx.newPage(); | |
| 3140 | + | try { | |
| 3141 | + | await page.goto(`${BASE}/label-repo/issues`); | |
| 3142 | + | const filterLabels = await page.locator('.label-filter-item').allTextContents(); | |
| 3143 | + | expect(filterLabels.every(t => !t.includes('bug'))).toBe(true); | |
| 3144 | + | } finally { await page.close(); } | |
| 3145 | + | }); | |
| 3146 | + | }); | |