From 84ed6674d33215ac545bd89c6632f4cdec74b89f Mon Sep 17 00:00:00 2001 From: liberxue <5831248+Liberxue@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:28:03 +0800 Subject: [PATCH] feat(bug): add markdown description and fix empty handler dropdown Add an optional markdown `description` to bugs, end-to-end: - migration 0111 adds the `description` column to ms_bug - domain NewBug/Bug carry description; create/update use cases and the pg/in_memory/http adapters persist and return it - web: MarkdownEditor for create/edit, MarkdownRenderer in a read-only detail drawer opened on row click Fix the empty assignee dropdown: useMemberOptions now merges project members with the global user directory, so a handler can always be selected even when a project has no members. Handler names link to /user. --- crates/bug/src/adapters/http.rs | 10 ++ crates/bug/src/adapters/in_memory.rs | 3 + crates/bug/src/adapters/pg.rs | 19 +++- crates/bug/src/application/change_status.rs | 11 +- crates/bug/src/application/create_bug.rs | 26 +++-- crates/bug/src/application/custom_fields.rs | 2 +- crates/bug/src/application/followers.rs | 11 +- crates/bug/src/application/list_bugs.rs | 33 +++++- crates/bug/src/application/relations.rs | 11 +- crates/bug/src/application/update_meta.rs | 19 +++- crates/bug/src/domain/bug.rs | 12 ++ crates/bug/src/ports/bug_repository.rs | 6 +- .../migrations/0111_bug_description.sql | 2 + web/src/api.ts | 8 +- web/src/pages/Bugs.tsx | 106 +++++++++++++++--- 15 files changed, 235 insertions(+), 44 deletions(-) create mode 100644 crates/migrate/migrations/0111_bug_description.sql diff --git a/crates/bug/src/adapters/http.rs b/crates/bug/src/adapters/http.rs index d117e741..02e503f6 100644 --- a/crates/bug/src/adapters/http.rs +++ b/crates/bug/src/adapters/http.rs @@ -109,6 +109,9 @@ struct BugResponse { /// Handler user id. #[serde(skip_serializing_if = "Option::is_none")] handler: Option, + /// Markdown description body (defect details). + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, /// Last mutator's user id (stamped on every mutation). #[serde(skip_serializing_if = "Option::is_none")] updated_by: Option, @@ -131,6 +134,7 @@ impl From for BugResponse { created_by: b.created_by, severity: b.severity, handler: b.handler, + description: b.description, updated_by: b.updated_by, updated_at: b.updated_at, custom_fields: b.custom_fields, @@ -148,6 +152,8 @@ struct CreateBugRequest { severity: Option, /// Handler user id (optional). handler: Option, + /// Markdown description body (optional). + description: Option, /// Custom field values, key → string value (max 32 keys, key ≤ 64 chars, value ≤ 2000 chars). #[serde(default)] custom_fields: BTreeMap, @@ -171,6 +177,7 @@ async fn create_bug( Some(&user.user_id), req.severity.as_deref(), req.handler.as_deref(), + req.description.as_deref(), &req.custom_fields, ) .await @@ -261,6 +268,8 @@ struct UpdateBugRequest { severity: Option, /// Handler user id; absent clears. handler: Option, + /// Markdown description body; absent keeps the current value. + description: Option, } /// Updates title/severity/handler and stamps updated_by/updated_at with the caller. @@ -281,6 +290,7 @@ async fn update_bug( req.title.as_deref(), req.severity.as_deref(), req.handler.as_deref(), + req.description.as_deref(), Some(&user.user_id), ) .await diff --git a/crates/bug/src/adapters/in_memory.rs b/crates/bug/src/adapters/in_memory.rs index 4cce8262..c825557c 100644 --- a/crates/bug/src/adapters/in_memory.rs +++ b/crates/bug/src/adapters/in_memory.rs @@ -82,6 +82,7 @@ impl BugRepository for InMemoryBugRepository { created_by: new_bug.created_by.clone(), severity: new_bug.severity.clone(), handler: new_bug.handler.clone(), + description: new_bug.description.clone(), updated_by: new_bug.created_by.clone(), updated_at: Some(tick(seq)), custom_fields: new_bug.custom_fields.clone(), @@ -136,6 +137,7 @@ impl BugRepository for InMemoryBugRepository { title: &str, severity: Option<&str>, handler: Option<&str>, + description: Option<&str>, operator: Option<&str>, ) -> Result, RepoError> { let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -145,6 +147,7 @@ impl BugRepository for InMemoryBugRepository { b.title = title.to_string(); b.severity = severity.map(str::to_string); b.handler = handler.map(str::to_string); + b.description = description.map(str::to_string); b.updated_by = operator.map(str::to_string); b.updated_at = Some(tick(seq)); b.clone() diff --git a/crates/bug/src/adapters/pg.rs b/crates/bug/src/adapters/pg.rs index 70f2741c..6c932869 100644 --- a/crates/bug/src/adapters/pg.rs +++ b/crates/bug/src/adapters/pg.rs @@ -45,7 +45,7 @@ fn json_to_fields(v: &serde_json::Value) -> BTreeMap { } const BUG_COLS: &str = "id, project_id, title, status, deleted, created_at, created_by, \ - severity, handler, updated_by, updated_at::text AS updated_at, custom_fields"; + severity, handler, description, updated_by, updated_at::text AS updated_at, custom_fields"; fn row_to_bug(row: &sqlx::postgres::PgRow) -> Result { let custom: serde_json::Value = row.try_get("custom_fields").map_err(map_err)?; @@ -59,6 +59,7 @@ fn row_to_bug(row: &sqlx::postgres::PgRow) -> Result { created_by: row.try_get("created_by").map_err(map_err)?, severity: row.try_get("severity").map_err(map_err)?, handler: row.try_get("handler").map_err(map_err)?, + description: row.try_get("description").map_err(map_err)?, updated_by: row.try_get("updated_by").map_err(map_err)?, updated_at: row.try_get("updated_at").map_err(map_err)?, custom_fields: json_to_fields(&custom), @@ -108,8 +109,8 @@ impl BugRepository for PgBugRepository { // updated_by/updated_at seed from the creator so the audit pair is never empty. let row = sqlx::query(&format!( "INSERT INTO ms_bug (project_id, title, status, created_by, custom_fields, \ - severity, handler, updated_by, updated_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $4, now()) RETURNING {BUG_COLS}" + severity, handler, description, updated_by, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $4, now()) RETURNING {BUG_COLS}" )) .bind(&new_bug.project_id) .bind(&new_bug.title) @@ -118,6 +119,7 @@ impl BugRepository for PgBugRepository { .bind(fields_to_json(&new_bug.custom_fields)) .bind(&new_bug.severity) .bind(&new_bug.handler) + .bind(&new_bug.description) .fetch_one(&self.pool) .await .map_err(map_err)?; @@ -171,10 +173,12 @@ impl BugRepository for PgBugRepository { title: &str, severity: Option<&str>, handler: Option<&str>, + description: Option<&str>, operator: Option<&str>, ) -> Result, RepoError> { let row = sqlx::query(&format!( "UPDATE ms_bug SET title = $2, severity = $3, handler = $4, \ + description = COALESCE($6, description), \ updated_by = $5, updated_at = now() \ WHERE id = $1 AND deleted = false RETURNING {BUG_COLS}" )) @@ -183,6 +187,7 @@ impl BugRepository for PgBugRepository { .bind(severity) .bind(handler) .bind(operator) + .bind(description) .fetch_optional(&self.pool) .await .map_err(map_err)?; @@ -368,7 +373,7 @@ mod tests { // Meta update replaces severity/handler and stamps the operator. let updated = repo - .update_meta(&bug.id, "login crash (modified)", Some("P0"), None, Some("dave")) + .update_meta(&bug.id, "login crash (modified)", Some("P0"), None, None, Some("dave")) .await .expect("meta") .expect("some"); @@ -376,7 +381,11 @@ mod tests { assert_eq!(updated.severity.as_deref(), Some("P0")); assert_eq!(updated.handler, None); assert_eq!(updated.updated_by.as_deref(), Some("dave")); - assert!(repo.update_meta("ghost", "x", None, None, None).await.expect("meta").is_none()); + assert!(repo + .update_meta("ghost", "x", None, None, None, None) + .await + .expect("meta") + .is_none()); // Full replacement; empty map clears all. repo.set_custom_fields(&bug.id, &cf(&[("env", "prod")]), Some("erin")) diff --git a/crates/bug/src/application/change_status.rs b/crates/bug/src/application/change_status.rs index 639548da..5170a56c 100644 --- a/crates/bug/src/application/change_status.rs +++ b/crates/bug/src/application/change_status.rs @@ -52,7 +52,16 @@ mod tests { async fn seed_bug(repo: &InMemoryBugRepository) -> String { let create = CreateBugUseCase::new(Arc::new(repo.clone())); create - .execute("p1", "boom", "NEW", None, None, None, &std::collections::BTreeMap::new()) + .execute( + "p1", + "boom", + "NEW", + None, + None, + None, + None, + &std::collections::BTreeMap::new(), + ) .await .expect("seed") .id diff --git a/crates/bug/src/application/create_bug.rs b/crates/bug/src/application/create_bug.rs index 8508fe86..e2050416 100644 --- a/crates/bug/src/application/create_bug.rs +++ b/crates/bug/src/application/create_bug.rs @@ -40,6 +40,7 @@ impl CreateBugUseCase { created_by: Option<&str>, severity: Option<&str>, handler: Option<&str>, + description: Option<&str>, custom_fields: &BTreeMap, ) -> Result { let custom_fields = normalize_custom_fields(custom_fields)?; @@ -48,6 +49,7 @@ impl CreateBugUseCase { .with_created_by(created_by) .with_severity(severity) .with_handler(normalize_handler(handler)) + .with_description(description.map(|s| s.to_string())) .with_custom_fields(custom_fields); let flow = self.repo.status_flow(project_id).await?; @@ -71,7 +73,7 @@ mod tests { let repo = InMemoryBugRepository::with_default_flow("p1"); let uc = CreateBugUseCase::new(Arc::new(repo)); let bug = uc - .execute("p1", "login crash", "NEW", Some("alice"), None, None, &BTreeMap::new()) + .execute("p1", "login crash", "NEW", Some("alice"), None, None, None, &BTreeMap::new()) .await .expect("ok"); assert_eq!(bug.status, "NEW"); @@ -95,6 +97,7 @@ mod tests { None, Some(" p0 "), Some(" bob "), + None, &BTreeMap::new(), ) .await @@ -108,7 +111,7 @@ mod tests { let repo = InMemoryBugRepository::with_default_flow("p1"); let uc = CreateBugUseCase::new(Arc::new(repo)); let err = uc - .execute("p1", "x", "NEW", None, Some("SEV1"), None, &BTreeMap::new()) + .execute("p1", "x", "NEW", None, Some("SEV1"), None, None, &BTreeMap::new()) .await .unwrap_err(); assert_eq!(err, CreateBugError::Validation(BugError::InvalidSeverity("SEV1".into()))); @@ -119,12 +122,15 @@ mod tests { let repo = InMemoryBugRepository::with_default_flow("p1"); let uc = CreateBugUseCase::new(Arc::new(repo)); let raw = BTreeMap::from([(" severity ".to_string(), "P0".to_string())]); - let bug = uc.execute("p1", "login crash", "NEW", None, None, None, &raw).await.expect("ok"); + let bug = + uc.execute("p1", "login crash", "NEW", None, None, None, None, &raw).await.expect("ok"); assert_eq!(bug.custom_fields, BTreeMap::from([("severity".to_string(), "P0".to_string())])); // Blank key is a validation error. let bad = BTreeMap::from([(" ".to_string(), "v".to_string())]); assert_eq!( - uc.execute("p1", "crashed again", "NEW", None, None, None, &bad).await.unwrap_err(), + uc.execute("p1", "crashed again", "NEW", None, None, None, None, &bad) + .await + .unwrap_err(), CreateBugError::Validation(BugError::EmptyCustomFieldKey) ); } @@ -133,8 +139,10 @@ mod tests { async fn rejects_unknown_initial_status() { let repo = InMemoryBugRepository::with_default_flow("p1"); let uc = CreateBugUseCase::new(Arc::new(repo)); - let err = - uc.execute("p1", "x", "GHOST", None, None, None, &BTreeMap::new()).await.unwrap_err(); + let err = uc + .execute("p1", "x", "GHOST", None, None, None, None, &BTreeMap::new()) + .await + .unwrap_err(); assert_eq!(err, CreateBugError::Validation(BugError::UnknownStatus("GHOST".into()))); } @@ -142,8 +150,10 @@ mod tests { async fn rejects_blank_title() { let repo = InMemoryBugRepository::with_default_flow("p1"); let uc = CreateBugUseCase::new(Arc::new(repo)); - let err = - uc.execute("p1", " ", "NEW", None, None, None, &BTreeMap::new()).await.unwrap_err(); + let err = uc + .execute("p1", " ", "NEW", None, None, None, None, &BTreeMap::new()) + .await + .unwrap_err(); assert_eq!(err, CreateBugError::Validation(BugError::EmptyTitle)); } } diff --git a/crates/bug/src/application/custom_fields.rs b/crates/bug/src/application/custom_fields.rs index 5132512e..8433430a 100644 --- a/crates/bug/src/application/custom_fields.rs +++ b/crates/bug/src/application/custom_fields.rs @@ -55,7 +55,7 @@ mod tests { async fn seed_bug(repo: &InMemoryBugRepository) -> String { CreateBugUseCase::new(Arc::new(repo.clone())) - .execute("p1", "boom", "NEW", None, None, None, &fields(&[("severity", "P1")])) + .execute("p1", "boom", "NEW", None, None, None, None, &fields(&[("severity", "P1")])) .await .expect("seed") .id diff --git a/crates/bug/src/application/followers.rs b/crates/bug/src/application/followers.rs index 1742e5ca..653f2f1b 100644 --- a/crates/bug/src/application/followers.rs +++ b/crates/bug/src/application/followers.rs @@ -60,7 +60,16 @@ mod tests { async fn seed_bug(repo: &InMemoryBugRepository) -> String { CreateBugUseCase::new(Arc::new(repo.clone())) - .execute("p1", "boom", "NEW", None, None, None, &std::collections::BTreeMap::new()) + .execute( + "p1", + "boom", + "NEW", + None, + None, + None, + None, + &std::collections::BTreeMap::new(), + ) .await .expect("seed") .id diff --git a/crates/bug/src/application/list_bugs.rs b/crates/bug/src/application/list_bugs.rs index 44a120ec..787fbe8d 100644 --- a/crates/bug/src/application/list_bugs.rs +++ b/crates/bug/src/application/list_bugs.rs @@ -36,11 +36,29 @@ mod tests { let repo = Arc::new(InMemoryBugRepository::with_default_flow("p1")); let create = crate::application::CreateBugUseCase::new(repo.clone()); create - .execute("p1", "first", "NEW", None, None, None, &std::collections::BTreeMap::new()) + .execute( + "p1", + "first", + "NEW", + None, + None, + None, + None, + &std::collections::BTreeMap::new(), + ) .await .expect("ok"); create - .execute("p1", "second", "NEW", None, None, None, &std::collections::BTreeMap::new()) + .execute( + "p1", + "second", + "NEW", + None, + None, + None, + None, + &std::collections::BTreeMap::new(), + ) .await .expect("ok"); @@ -55,7 +73,16 @@ mod tests { async fn isolates_by_project() { let repo = Arc::new(InMemoryBugRepository::with_default_flow("p1")); crate::application::CreateBugUseCase::new(repo.clone()) - .execute("p1", "boom", "NEW", None, None, None, &std::collections::BTreeMap::new()) + .execute( + "p1", + "boom", + "NEW", + None, + None, + None, + None, + &std::collections::BTreeMap::new(), + ) .await .expect("ok"); diff --git a/crates/bug/src/application/relations.rs b/crates/bug/src/application/relations.rs index 7e83476b..05a78842 100644 --- a/crates/bug/src/application/relations.rs +++ b/crates/bug/src/application/relations.rs @@ -82,7 +82,16 @@ mod tests { async fn seed_bug(repo: &InMemoryBugRepository) -> String { CreateBugUseCase::new(Arc::new(repo.clone())) - .execute("p1", "boom", "NEW", None, None, None, &std::collections::BTreeMap::new()) + .execute( + "p1", + "boom", + "NEW", + None, + None, + None, + None, + &std::collections::BTreeMap::new(), + ) .await .expect("seed") .id diff --git a/crates/bug/src/application/update_meta.rs b/crates/bug/src/application/update_meta.rs index 62977abf..d5b7c9fe 100644 --- a/crates/bug/src/application/update_meta.rs +++ b/crates/bug/src/application/update_meta.rs @@ -34,6 +34,7 @@ impl UpdateBugMetaUseCase { title: Option<&str>, severity: Option<&str>, handler: Option<&str>, + description: Option<&str>, operator: Option<&str>, ) -> Result { let severity = normalize_severity(severity)?; @@ -44,7 +45,14 @@ impl UpdateBugMetaUseCase { Some(t) => t.to_string(), }; self.repo - .update_meta(bug_id, &title, severity.as_deref(), handler.as_deref(), operator) + .update_meta( + bug_id, + &title, + severity.as_deref(), + handler.as_deref(), + description, + operator, + ) .await? .ok_or(UpdateBugMetaError::BugNotFound) } @@ -65,6 +73,7 @@ mod tests { Some("alice"), Some("P2"), Some("bob"), + None, &std::collections::BTreeMap::new(), ) .await @@ -78,7 +87,7 @@ mod tests { let uc = UpdateBugMetaUseCase::new(Arc::new(repo)); let bug = uc - .execute(&seeded.id, Some("boom v2"), Some(" p0 "), Some(" carol "), Some("dave")) + .execute(&seeded.id, Some("boom v2"), Some(" p0 "), Some(" carol "), None, Some("dave")) .await .expect("ok"); assert_eq!(bug.title, "boom v2"); @@ -97,7 +106,7 @@ mod tests { let seeded = seed_bug(&repo).await; let uc = UpdateBugMetaUseCase::new(Arc::new(repo)); - let bug = uc.execute(&seeded.id, None, None, None, Some("dave")).await.expect("ok"); + let bug = uc.execute(&seeded.id, None, None, None, None, Some("dave")).await.expect("ok"); assert_eq!(bug.title, "boom"); assert_eq!(bug.severity, None); assert_eq!(bug.handler, None); @@ -110,11 +119,11 @@ mod tests { let uc = UpdateBugMetaUseCase::new(Arc::new(repo)); assert_eq!( - uc.execute(&seeded.id, None, Some("HIGH"), None, None).await.unwrap_err(), + uc.execute(&seeded.id, None, Some("HIGH"), None, None, None).await.unwrap_err(), UpdateBugMetaError::Validation(BugError::InvalidSeverity("HIGH".into())) ); assert_eq!( - uc.execute("ghost", None, None, None, None).await.unwrap_err(), + uc.execute("ghost", None, None, None, None, None).await.unwrap_err(), UpdateBugMetaError::BugNotFound ); } diff --git a/crates/bug/src/domain/bug.rs b/crates/bug/src/domain/bug.rs index 9af9a28c..93b3919d 100644 --- a/crates/bug/src/domain/bug.rs +++ b/crates/bug/src/domain/bug.rs @@ -89,6 +89,8 @@ pub struct NewBug { pub severity: Option, /// Handler user id. pub handler: Option, + /// Markdown description body (defect details); blank → None. + pub description: Option, /// Validated custom field values (see `normalize_custom_fields`); field definitions /// live in the project template. pub custom_fields: BTreeMap, @@ -106,6 +108,7 @@ impl NewBug { created_by: None, severity: None, handler: None, + description: None, custom_fields: BTreeMap::new(), }) } @@ -126,6 +129,12 @@ impl NewBug { self } + /// Attaches a markdown description; blank text collapses to None. + pub fn with_description(mut self, description: Option) -> Self { + self.description = description.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + self + } + /// Attaches validated custom fields (caller runs `normalize_custom_fields` first). pub fn with_custom_fields(mut self, custom_fields: BTreeMap) -> Self { self.custom_fields = custom_fields; @@ -146,6 +155,8 @@ pub struct Bug { pub severity: Option, /// Handler user id. pub handler: Option, + /// Markdown description body (defect details). + pub description: Option, /// Last mutator's user id; stamped by every mutation, seeded from created_by on insert. pub updated_by: Option, /// Last mutation time as timestamptz text (adapter-formatted). @@ -198,6 +209,7 @@ mod tests { created_by: None, severity: None, handler: None, + description: None, updated_by: None, updated_at: None, custom_fields: BTreeMap::new(), diff --git a/crates/bug/src/ports/bug_repository.rs b/crates/bug/src/ports/bug_repository.rs index 59b9de5b..5d284a2d 100644 --- a/crates/bug/src/ports/bug_repository.rs +++ b/crates/bug/src/ports/bug_repository.rs @@ -31,14 +31,16 @@ pub trait BugRepository: Send + Sync { ) -> Result<(), RepoError>; /// Updates title/severity/handler (severity/handler are full replacements, - /// None clears) and stamps updated_by/updated_at. Returns the updated bug, - /// or None when it doesn't exist. + /// None clears) and the markdown description (None keeps the current value, + /// Some sets it, including an empty string to clear). Stamps updated_by/ + /// updated_at. Returns the updated bug, or None when it doesn't exist. async fn update_meta( &self, id: &str, title: &str, severity: Option<&str>, handler: Option<&str>, + description: Option<&str>, operator: Option<&str>, ) -> Result, RepoError>; diff --git a/crates/migrate/migrations/0111_bug_description.sql b/crates/migrate/migrations/0111_bug_description.sql new file mode 100644 index 00000000..af373799 --- /dev/null +++ b/crates/migrate/migrations/0111_bug_description.sql @@ -0,0 +1,2 @@ +-- Bug description: markdown body for defect details (rendered in the bug detail view). +ALTER TABLE ms_bug ADD COLUMN IF NOT EXISTS description TEXT; diff --git a/web/src/api.ts b/web/src/api.ts index aeb4f816..02b897af 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1302,6 +1302,8 @@ export interface Bug { severity?: string | null /** Handler user id. */ handler?: string | null + /** Markdown description body (defect details). */ + description?: string | null /** Last mutator's user id (stamped on every mutation). */ updatedBy?: string | null /** Last mutation time (timestamptz text). */ @@ -1966,9 +1968,9 @@ export const api = { // Bugs — list/create/status transitions all backend-driven (project-scoped, newest first) bugs: (projectId: string) => projectId ? http.get(`/bug?projectId=${encodeURIComponent(projectId)}`) : Promise.resolve([] as Bug[]), - createBug: (b: { projectId: string; title: string; initialStatus: string; severity?: string; handler?: string; customFields?: Record }) => http.post('/bug', b), - // Meta update: severity/handler are full replacements (omit to clear); omitted title keeps the current one. - updateBug: (id: string, b: { title?: string; severity?: string; handler?: string }) => http.put(`/bug/${encodeURIComponent(id)}`, b), + createBug: (b: { projectId: string; title: string; initialStatus: string; severity?: string; handler?: string; description?: string; customFields?: Record }) => http.post('/bug', b), + // Meta update: severity/handler are full replacements (omit to clear); omitted title keeps the current one; omitted description keeps the current value. + updateBug: (id: string, b: { title?: string; severity?: string; handler?: string; description?: string }) => http.put(`/bug/${encodeURIComponent(id)}`, b), setBugStatus: (id: string, status: string) => http.post(`/bug/${id}/status`, { status }), // In-app notifications (message center): always scoped to the current session user. diff --git a/web/src/pages/Bugs.tsx b/web/src/pages/Bugs.tsx index 6e4ae963..1b7b151a 100644 --- a/web/src/pages/Bugs.tsx +++ b/web/src/pages/Bugs.tsx @@ -1,11 +1,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { Button, Empty, Form, Input, Select, Space, Tag, Tooltip } from 'antd' +import { Button, Descriptions, Empty, Form, Input, Select, Space, Tag, Tooltip } from 'antd' import ResizableDrawer from '../components/ResizableDrawer' import { message, modal } from '../feedback' import { LinkOutlined } from '@ant-design/icons' +import { MarkdownEditor } from '../components/MarkdownEditor' +import { MarkdownRenderer } from '../components/MarkdownRenderer' import { api, ApiError, userIdStore, type Bug, type BugRelation } from '../api' import { useApp } from '../context' import { useI18n } from '../i18n' +import { useScopedNavigate } from '../scope' import { SelectProjectEmpty } from '../components/Page' import { Workspace, WorkList, useWorkTabs } from '../components/Workspace' import { useListView, type ListColumn } from '../components/ListView' @@ -15,17 +18,24 @@ import { CF_GROUP, CustomFieldItems, collectCustomValues, useFieldTemplate } fro const STATUSES = ['NEW', 'RESOLVED', 'CLOSED', 'REOPENED', 'REJECTED'] const SEVERITIES = ['P0', 'P1', 'P2', 'P3'] -/** Handler candidates: project members with display names resolved (id fallback). */ +/** Handler candidates: project members first, falling back to (and merged with) the global + * user directory so the assignee list is never empty even for projects with no members. */ function useMemberOptions(projectId: string) { const [options, setOptions] = useState<{ value: string; label: string }[]>([]) useEffect(() => { let alive = true - api - .projectMembers(projectId) - .then(async (ms) => { - const ids = [...new Set(ms.map((m) => m.userId).filter(Boolean))] - const names = ids.length ? await api.userNames(ids).catch(() => ({}) as Record) : {} - if (alive) setOptions(ids.map((id) => ({ value: id, label: names[id] || id }))) + Promise.all([ + api.projectMembers(projectId).catch(() => [] as { userId?: string }[]), + api.users().catch(() => ({ items: [] as { id?: string }[] })), + ]) + .then(async ([ms, us]) => { + const ids = [ + ...ms.map((m) => m.userId).filter(Boolean), + ...us.items.map((u) => u.id).filter(Boolean), + ] as string[] + const uniq = [...new Set(ids)] + const names = uniq.length ? await api.userNames(uniq).catch(() => ({}) as Record) : {} + if (alive) setOptions(uniq.map((id) => ({ value: id, label: names[id] || id }))) }) .catch(() => {}) return () => { alive = false } @@ -127,10 +137,13 @@ function BugsList({ items, loading, projectId, refresh, setItems, relBug, setRel t: (k: string, d?: string) => string }) { const [editBug, setEditBug] = useState(null) + const [detailBug, setDetailBug] = useState(null) + // Jump to the global user directory so a bug's handler can be inspected in /user. + const nav = useScopedNavigate() // Resolve handler/updater ids in the current rows to display names. const [names, setNames] = useState>({}) useEffect(() => { - const ids = [...new Set(items.flatMap((b) => [b.handler, b.updatedBy]).filter((x): x is string => !!x))] + const ids = [...new Set(items.flatMap((b) => [b.createdBy, b.handler, b.updatedBy]).filter((x): x is string => !!x))] if (!ids.length) { setNames({}); return } api.userNames(ids).then(setNames).catch(() => setNames({})) }, [items]) @@ -157,7 +170,9 @@ function BugsList({ items, loading, projectId, refresh, setItems, relBug, setRel label: t('bug.handler', '处理人'), title: t('bug.handler', '处理人'), width: 120, - render: (_, r) => nameOf(r.handler), + render: (_, r) => (r.handler + ? nav('/user')} title={t('bug.openUser', '在用户管理中查看')}>{nameOf(r.handler)} + : '-'), }, { key: 'updatedBy', @@ -262,6 +277,7 @@ function BugsList({ items, loading, projectId, refresh, setItems, relBug, setRel newLabel={t('bug.new', '新建缺陷')} extraActions={lv.toolbar} onRefresh={refresh} + onRowClick={(r) => setDetailBug(r)} columns={lv.columns} data={lv.rows} pagination={lv.pagination} @@ -280,6 +296,7 @@ function BugsList({ items, loading, projectId, refresh, setItems, relBug, setRel setItems((prev) => prev.map((x) => (x.id === b.id ? { ...x, ...b } : x))) }} /> + setDetailBug(null)} /> ) } @@ -288,17 +305,17 @@ function BugsList({ items, loading, projectId, refresh, setItems, relBug, setRel function EditBugDrawer({ bug, projectId, onClose, onSaved }: { bug: Bug | null; projectId: string; onClose: () => void; onSaved: (b: Bug) => void }) { const { t } = useI18n() const members = useMemberOptions(projectId) - const [form] = Form.useForm<{ title: string; severity?: string; handler?: string }>() + const [form] = Form.useForm<{ title: string; severity?: string; handler?: string; description?: string }>() const [saving, setSaving] = useState(false) useEffect(() => { - if (bug) form.setFieldsValue({ title: bug.title || '', severity: bug.severity || undefined, handler: bug.handler || undefined }) + if (bug) form.setFieldsValue({ title: bug.title || '', severity: bug.severity || undefined, handler: bug.handler || undefined, description: bug.description || undefined }) }, [bug, form]) const save = async () => { const v = await form.validateFields() if (!bug) return setSaving(true) try { - const b = await api.updateBug(bug.id, { title: v.title, severity: v.severity, handler: v.handler }) + const b = await api.updateBug(bug.id, { title: v.title, severity: v.severity, handler: v.handler, description: v.description }) message.success(t('bug.updated', '缺陷已更新')) onSaved(b) } catch (e) { @@ -334,11 +351,66 @@ function EditBugDrawer({ bug, projectId, onClose, onSaved }: { bug: Bug | null; + + + + +