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 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| ¤t_top_category.subcategories[i])
|
||||||
.map(|c| {
|
.unwrap_or(¤t_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 ¤t_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| ¤t_top_category.subcategories[i])
|
||||||
},
|
.unwrap_or(¤t_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 ¤t_top_category.subcategories {
|
||||||
|
li {
|
||||||
|
a href=(sub.path) { (sub.name) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-5
@@ -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
@@ -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)}%`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user