Add sub-category selector for hierarchical categories (#15)

This commit is contained in:
Luke Street
2025-10-07 20:38:39 -06:00
committed by GitHub
parent 0c268f25aa
commit c42c1f7088
3 changed files with 194 additions and 57 deletions
+131 -49
View File
@@ -1,4 +1,4 @@
use std::{borrow::Cow, iter}; use std::borrow::Cow;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use axum::{ use axum::{
@@ -86,6 +86,85 @@ struct ReportCategoryItem<'a> {
path: String, path: String,
} }
#[derive(Serialize, Clone)]
struct ReportCategoryGroup<'a> {
category: ReportCategoryItem<'a>,
subcategories: Vec<ReportCategoryItem<'a>>,
}
struct CategorySelection<'a> {
categories: Vec<ReportCategoryGroup<'a>>,
current_top_index: usize,
current_sub_index: Option<usize>,
}
fn build_category_selection<'a>(
canonical_url: &Url,
report_categories: &'a [ReportCategory],
current_category: Option<&'a ReportCategory>,
default_category: &str,
) -> CategorySelection<'a> {
let all_url =
canonical_url.query_param("category", if default_category == "all" { None } else { Some("all") });
let all_category =
ReportCategoryItem { id: "all", name: "All", path: all_url.path_and_query().to_string() };
let mut categories = vec![ReportCategoryGroup { category: all_category, subcategories: Vec::new() }];
for c in report_categories {
if let Some((parent_id, _)) = c.id.split_once('.') {
let path =
canonical_url.query_param("category", Some(&c.id)).path_and_query().to_string();
if let Some(group) = categories.iter_mut().find(|g| g.category.id == parent_id) {
group.subcategories.push(ReportCategoryItem { id: &c.id, name: &c.name, path });
} else {
let parent_path = canonical_url
.query_param("category", Some(parent_id))
.path_and_query()
.to_string();
categories.push(ReportCategoryGroup {
category: ReportCategoryItem {
id: parent_id,
name: parent_id,
path: parent_path,
},
subcategories: vec![ReportCategoryItem { id: &c.id, name: &c.name, path }],
});
}
} else {
let path =
canonical_url.query_param("category", Some(&c.id)).path_and_query().to_string();
if let Some(group) = categories.iter_mut().find(|g| g.category.id == c.id) {
group.category.name = &c.name;
group.category.path = path;
} else {
categories.push(ReportCategoryGroup {
category: ReportCategoryItem { id: &c.id, name: &c.name, path },
subcategories: Vec::new(),
});
}
}
}
let current_category_id = current_category.map(|c| c.id.as_str()).unwrap_or("all");
let (current_top_id, current_sub_id) = current_category_id
.split_once('.')
.map(|(top, _)| (top, Some(current_category_id)))
.unwrap_or((current_category_id, None));
let current_top_index = categories
.iter()
.position(|c| c.category.id == current_top_id)
.unwrap_or(0);
let current_sub_index = current_sub_id.and_then(|id| {
categories[current_top_index]
.subcategories
.iter()
.position(|sc| sc.id == id)
});
CategorySelection { categories, current_top_index, current_sub_index }
}
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
struct ReportTemplateVersion<'a> { struct ReportTemplateVersion<'a> {
id: &'a str, id: &'a str,
@@ -598,8 +677,9 @@ async fn render_report(
current_user: Option<CurrentUser>, current_user: Option<CurrentUser>,
mut ctx: TemplateContext, mut ctx: TemplateContext,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let Scope { report, project_info, measures, current_category, current_unit, units, label } = let Scope { report, project_info, measures, current_category: current_category_ref, current_unit, units, label } =
scope; scope;
let current_category = *current_category_ref;
let mut commit_message = report.commit.message.clone(); let mut commit_message = report.commit.message.clone();
if commit_message.is_none() { if commit_message.is_none() {
@@ -689,24 +769,12 @@ async fn render_report(
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let all_url = canonical_url let CategorySelection { categories, current_top_index, current_sub_index } =
.query_param("category", if default_category == "all" { None } else { Some("all") }); build_category_selection(&canonical_url, &report.report.categories, current_category, default_category);
let all_category = let current_top_category = &categories[current_top_index];
ReportCategoryItem { id: "all", name: "All", path: all_url.path_and_query().to_string() }; let current_category_item = current_sub_index
let current_category = current_category .map(|i| &current_top_category.subcategories[i])
.map(|c| { .unwrap_or(&current_top_category.category);
let path =
canonical_url.query_param("category", Some(&c.id)).path_and_query().to_string();
ReportCategoryItem { id: &c.id, name: &c.name, path }
})
.unwrap_or_else(|| all_category.clone());
let categories = iter::once(all_category)
.chain(report.report.categories.iter().map(|c| {
let path =
canonical_url.query_param("category", Some(&c.id)).path_and_query().to_string();
ReportCategoryItem { id: &c.id, name: &c.name, path }
}))
.collect::<Vec<_>>();
let prev_commit_path = project_info.prev_commit.as_deref().map(|commit| { let prev_commit_path = project_info.prev_commit.as_deref().map(|commit| {
let url = request_url.with_path(&format!( let url = request_url.with_path(&format!(
@@ -946,11 +1014,26 @@ async fn render_report(
h6 { "Units" } h6 { "Units" }
@if categories.len() > 1 { @if categories.len() > 1 {
details.dropdown { details.dropdown {
summary { (current_category.name) } summary { (current_top_category.category.name) }
ul { ul {
@for category in &categories { @for category in &categories {
li { li {
a href=(category.path) { (category.name) } a href=(category.category.path) { (category.category.name) }
}
}
}
}
@if current_top_category.subcategories.len() > 0 {
details.dropdown {
summary { (current_category_item.name) }
ul {
li {
a href=(current_top_category.category.path) { (current_top_category.category.name) }
}
@for sub in &current_top_category.subcategories {
li {
a href=(sub.path) { (sub.name) }
}
} }
} }
} }
@@ -999,8 +1082,9 @@ async fn render_history(
mut ctx: TemplateContext, mut ctx: TemplateContext,
result: Vec<ReportHistoryEntry>, result: Vec<ReportHistoryEntry>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let Scope { report, project_info, measures, current_category, current_unit, units: _, label } = let Scope { report, project_info, measures, current_category: current_category_ref, current_unit, units: _, label } =
scope; scope;
let current_category = *current_category_ref;
let request_url = Url::parse(&uri.to_string()).context("Failed to parse URI")?; let request_url = Url::parse(&uri.to_string()).context("Failed to parse URI")?;
let project_base_path = let project_base_path =
@@ -1025,30 +1109,13 @@ async fn render_history(
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let all_url = canonical_url.query_param( let default_category = project_info.project.default_category();
"category", let CategorySelection { categories, current_top_index, current_sub_index } =
if project_info.project.default_category.as_deref().is_none_or(|c| c == "all") { build_category_selection(&canonical_url, &report.report.categories, current_category, default_category);
None let current_top_category = &categories[current_top_index];
} else { let current_category_item = current_sub_index
Some("all") .map(|i| &current_top_category.subcategories[i])
}, .unwrap_or(&current_top_category.category);
);
let all_category =
ReportCategoryItem { id: "all", name: "All", path: all_url.path_and_query().to_string() };
let current_category = current_category
.map(|c| {
let path =
canonical_url.query_param("category", Some(&c.id)).path_and_query().to_string();
ReportCategoryItem { id: &c.id, name: &c.name, path }
})
.unwrap_or_else(|| all_category.clone());
let categories = iter::once(all_category)
.chain(report.report.categories.iter().map(|c| {
let path =
canonical_url.query_param("category", Some(&c.id)).path_and_query().to_string();
ReportCategoryItem { id: &c.id, name: &c.name, path }
}))
.collect::<Vec<_>>();
let project_name = if let Some(label) = label { let project_name = if let Some(label) = label {
Cow::Owned(format!("{} ({})", project_info.project.name(), label)) Cow::Owned(format!("{} ({})", project_info.project.name(), label))
@@ -1116,11 +1183,26 @@ async fn render_history(
} }
@if current_unit.is_none() && categories.len() > 1 { @if current_unit.is_none() && categories.len() > 1 {
details.dropdown title="Category" { details.dropdown title="Category" {
summary { (current_category.name) } summary { (current_top_category.category.name) }
ul { ul {
@for category in &categories { @for category in &categories {
li { li {
a href=(category.path) { (category.name) } a href=(category.category.path) { (category.category.name) }
}
}
}
}
@if current_top_category.subcategories.len() > 0 {
details.dropdown title="Subcategory" {
summary { (current_category_item.name) }
ul {
li {
a href=(current_top_category.category.path) { (current_top_category.category.name) }
}
@for sub in &current_top_category.subcategories {
li {
a href=(sub.path) { (sub.name) }
}
} }
} }
} }
+59 -5
View File
@@ -110,11 +110,13 @@ const ProjectForm = () => {
project: string | null; project: string | null;
version: string | null; version: string | null;
category: string | null; category: string | null;
subcategory: string | null;
}; };
const [selected, setSelected] = useState<Selected>({ const [selected, setSelected] = useState<Selected>({
project: null, project: null,
version: null, version: null,
category: null, category: null,
subcategory: null,
}); });
const [mode, setMode] = useState('overview'); const [mode, setMode] = useState('overview');
const [format, setFormat] = useState('json'); const [format, setFormat] = useState('json');
@@ -125,6 +127,25 @@ const ProjectForm = () => {
null, null,
); );
const categories = useMemo(() => {
const result: Record<
string,
{ name: string; subcategories: CategoryResponse[] }
> = {};
currentProject?.report_categories.forEach((c) => {
const [top, sub] = c.id.split('.', 2);
if (!result[top]) {
result[top] = { name: c.id === top ? c.name : top, subcategories: [] };
}
if (sub) {
result[top].subcategories.push({ id: sub, name: c.name });
} else {
result[top].name = c.name;
}
});
return result;
}, [currentProject]);
useEffect(() => { useEffect(() => {
setLoadingProjects(true); setLoadingProjects(true);
fetchProjects() fetchProjects()
@@ -142,6 +163,7 @@ const ProjectForm = () => {
project: project.id, project: project.id,
version: null, version: null,
category: null, category: null,
subcategory: null,
}); });
} }
} }
@@ -186,7 +208,10 @@ const ProjectForm = () => {
} }
} }
if (selected.category) { if (selected.category) {
url.searchParams.append('category', selected.category); const cat = selected.subcategory
? `${selected.category}.${selected.subcategory}`
: selected.category;
url.searchParams.append('category', cat);
} }
} }
url.pathname += `.${format}`; url.pathname += `.${format}`;
@@ -374,6 +399,7 @@ const ProjectForm = () => {
project: e.target.value, project: e.target.value,
version: null, version: null,
category: null, category: null,
subcategory: null,
}) })
} }
> >
@@ -396,6 +422,7 @@ const ProjectForm = () => {
project: existing.project, project: existing.project,
version: e.target.value, version: e.target.value,
category: null, category: null,
subcategory: null,
})) }))
} }
> >
@@ -417,19 +444,46 @@ const ProjectForm = () => {
setSelected((existing) => ({ setSelected((existing) => ({
project: existing.project, project: existing.project,
version: existing.version, version: existing.version,
category: e.target.value, category: e.target.value || null,
subcategory: null,
})) }))
} }
> >
<option value="">Default</option> <option value="">Default</option>
{currentProject?.report_categories.map((category) => ( {Object.entries(categories).map(([id, info]) => (
<option key={category.id} value={category.id}> <option key={id} value={id}>
{category.name} {info.name}
</option> </option>
))} ))}
</select> </select>
</label> </label>
</div> </div>
{selected.category &&
categories[selected.category]?.subcategories.length > 0 && (
<div className="grid">
<label>
Subcategory
<select
name="subcategory"
value={selected.subcategory || ''}
disabled={loadingProject || !currentProject}
onChange={(e) =>
setSelected((existing) => ({
...existing,
subcategory: e.target.value || null,
}))
}
>
<option value="">Default</option>
{categories[selected.category].subcategories.map((sub) => (
<option key={sub.id} value={sub.id}>
{sub.name}
</option>
))}
</select>
</label>
</div>
)}
<div className="grid"> <div className="grid">
<label> <label>
Mode Mode
+4 -3
View File
@@ -4,8 +4,9 @@ export function clamp(value: number, min: number, max: number) {
// Formats a progress percentage as a string, and prevents partial matches from being rounded to 0.00% or 100.00%. // Formats a progress percentage as a string, and prevents partial matches from being rounded to 0.00% or 100.00%.
export function formatPercent(value: number) { export function formatPercent(value: number) {
if (value !== 0.0 && value !== 100.0) { let clamped = value;
value = clamp(value, 0.01, 99.99); if (clamped !== 0.0 && clamped !== 100.0) {
clamped = clamp(clamped, 0.01, 99.99);
} }
return `${value.toFixed(2)}%`; return `${clamped.toFixed(2)}%`;
} }