mirror of
https://github.com/encounter/decomp.dev.git
synced 2026-07-10 03:18:48 -07:00
Add sub-category selector for hierarchical categories (#15)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
use std::{borrow::Cow, iter};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::{
|
||||
@@ -86,6 +86,85 @@ struct ReportCategoryItem<'a> {
|
||||
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)]
|
||||
struct ReportTemplateVersion<'a> {
|
||||
id: &'a str,
|
||||
@@ -598,8 +677,9 @@ async fn render_report(
|
||||
current_user: Option<CurrentUser>,
|
||||
mut ctx: TemplateContext,
|
||||
) -> 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;
|
||||
let current_category = *current_category_ref;
|
||||
|
||||
let mut commit_message = report.commit.message.clone();
|
||||
if commit_message.is_none() {
|
||||
@@ -689,24 +769,12 @@ async fn render_report(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
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 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 CategorySelection { categories, current_top_index, current_sub_index } =
|
||||
build_category_selection(&canonical_url, &report.report.categories, current_category, default_category);
|
||||
let current_top_category = &categories[current_top_index];
|
||||
let current_category_item = current_sub_index
|
||||
.map(|i| ¤t_top_category.subcategories[i])
|
||||
.unwrap_or(¤t_top_category.category);
|
||||
|
||||
let prev_commit_path = project_info.prev_commit.as_deref().map(|commit| {
|
||||
let url = request_url.with_path(&format!(
|
||||
@@ -946,11 +1014,26 @@ async fn render_report(
|
||||
h6 { "Units" }
|
||||
@if categories.len() > 1 {
|
||||
details.dropdown {
|
||||
summary { (current_category.name) }
|
||||
summary { (current_top_category.category.name) }
|
||||
ul {
|
||||
@for category in &categories {
|
||||
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 ¤t_top_category.subcategories {
|
||||
li {
|
||||
a href=(sub.path) { (sub.name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -999,8 +1082,9 @@ async fn render_history(
|
||||
mut ctx: TemplateContext,
|
||||
result: Vec<ReportHistoryEntry>,
|
||||
) -> 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;
|
||||
let current_category = *current_category_ref;
|
||||
|
||||
let request_url = Url::parse(&uri.to_string()).context("Failed to parse URI")?;
|
||||
let project_base_path =
|
||||
@@ -1025,30 +1109,13 @@ async fn render_history(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let all_url = canonical_url.query_param(
|
||||
"category",
|
||||
if project_info.project.default_category.as_deref().is_none_or(|c| c == "all") {
|
||||
None
|
||||
} else {
|
||||
Some("all")
|
||||
},
|
||||
);
|
||||
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 default_category = project_info.project.default_category();
|
||||
let CategorySelection { categories, current_top_index, current_sub_index } =
|
||||
build_category_selection(&canonical_url, &report.report.categories, current_category, default_category);
|
||||
let current_top_category = &categories[current_top_index];
|
||||
let current_category_item = current_sub_index
|
||||
.map(|i| ¤t_top_category.subcategories[i])
|
||||
.unwrap_or(¤t_top_category.category);
|
||||
|
||||
let project_name = if let Some(label) = 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 {
|
||||
details.dropdown title="Category" {
|
||||
summary { (current_category.name) }
|
||||
summary { (current_top_category.category.name) }
|
||||
ul {
|
||||
@for category in &categories {
|
||||
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 ¤t_top_category.subcategories {
|
||||
li {
|
||||
a href=(sub.path) { (sub.name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+59
-5
@@ -110,11 +110,13 @@ const ProjectForm = () => {
|
||||
project: string | null;
|
||||
version: string | null;
|
||||
category: string | null;
|
||||
subcategory: string | null;
|
||||
};
|
||||
const [selected, setSelected] = useState<Selected>({
|
||||
project: null,
|
||||
version: null,
|
||||
category: null,
|
||||
subcategory: null,
|
||||
});
|
||||
const [mode, setMode] = useState('overview');
|
||||
const [format, setFormat] = useState('json');
|
||||
@@ -125,6 +127,25 @@ const ProjectForm = () => {
|
||||
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(() => {
|
||||
setLoadingProjects(true);
|
||||
fetchProjects()
|
||||
@@ -142,6 +163,7 @@ const ProjectForm = () => {
|
||||
project: project.id,
|
||||
version: null,
|
||||
category: null,
|
||||
subcategory: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -186,7 +208,10 @@ const ProjectForm = () => {
|
||||
}
|
||||
}
|
||||
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}`;
|
||||
@@ -374,6 +399,7 @@ const ProjectForm = () => {
|
||||
project: e.target.value,
|
||||
version: null,
|
||||
category: null,
|
||||
subcategory: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -396,6 +422,7 @@ const ProjectForm = () => {
|
||||
project: existing.project,
|
||||
version: e.target.value,
|
||||
category: null,
|
||||
subcategory: null,
|
||||
}))
|
||||
}
|
||||
>
|
||||
@@ -417,19 +444,46 @@ const ProjectForm = () => {
|
||||
setSelected((existing) => ({
|
||||
project: existing.project,
|
||||
version: existing.version,
|
||||
category: e.target.value,
|
||||
category: e.target.value || null,
|
||||
subcategory: null,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{currentProject?.report_categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
{Object.entries(categories).map(([id, info]) => (
|
||||
<option key={id} value={id}>
|
||||
{info.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</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">
|
||||
<label>
|
||||
Mode
|
||||
|
||||
+4
-3
@@ -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%.
|
||||
export function formatPercent(value: number) {
|
||||
if (value !== 0.0 && value !== 100.0) {
|
||||
value = clamp(value, 0.01, 99.99);
|
||||
let clamped = value;
|
||||
if (clamped !== 0.0 && clamped !== 100.0) {
|
||||
clamped = clamp(clamped, 0.01, 99.99);
|
||||
}
|
||||
return `${value.toFixed(2)}%`;
|
||||
return `${clamped.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user