feat(t-paliad-125): sort project pickers by tree path with depth indent
The /events Project filter dropdown was sorted by `updated_at DESC`, so a recently-touched Case appeared above its parent Client and cousins interleaved unrelated branches — m's report (2026-05-04): "Siemens cases come directly after 'mandant vs Gegner' and are not under 'Siemens-AG'". Backend: switch ProjectService.List to ORDER BY p.path so every descendant immediately follows its ancestor — the same ordering BuildTree produces. Both callers (handleListProjects, searchProjects) gain a stable, hierarchical default that matches user expectation. Frontend: add project-indent.ts shared helper and apply NBSP indent prefix in every <select> picker fed by /api/projects: events filter, /deadlines/new, /appointments/new, checklist new-instance modal, Fristenrechner save modal. NBSP avoids browser whitespace collapse inside <option> labels. Multi-parent repetition is out of scope (data model has singular parent_id today). Tests: project_list_order_test pins the path-order contract against a seeded mixed-recency tree.
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
let allProjects: Project[] = [];
|
||||
@@ -30,8 +32,9 @@ function populateProjects() {
|
||||
`<option value="">${esc(t("appointments.field.akte.none"))}</option>`,
|
||||
];
|
||||
for (const a of allProjects) {
|
||||
const indent = projectIndent(a.path);
|
||||
opts.push(
|
||||
`<option value="${esc(a.id)}">${esc(a.reference || "")} — ${esc(a.title)}</option>`,
|
||||
`<option value="${esc(a.id)}">${indent}${esc(a.reference || "")} — ${esc(a.title)}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = opts.join("");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
interface ChecklistItem {
|
||||
labelDE: string;
|
||||
@@ -48,6 +49,7 @@ interface AkteSummary {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
let template: Checklist | null = null;
|
||||
@@ -230,7 +232,7 @@ function renderAkteOptions() {
|
||||
projects.forEach((a) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = a.id;
|
||||
opt.textContent = `${a.reference || ""} — ${a.title}`;
|
||||
opt.textContent = `${projectIndent(a.path)}${a.reference || ""} — ${a.title}`;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { attachEventTypePicker, type PickerHandle } from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let currentUserAdmin = false;
|
||||
@@ -9,6 +10,7 @@ interface Project {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface DeadlineRule {
|
||||
@@ -51,8 +53,9 @@ async function loadProjects() {
|
||||
for (const p of projects) {
|
||||
const isSelected = preselectedProjectID === p.id ? " selected" : "";
|
||||
const ref = p.reference || "";
|
||||
const indent = projectIndent(p.path);
|
||||
options.push(
|
||||
`<option value="${esc(p.id)}"${isSelected}>${esc(ref)} \u2014 ${esc(p.title)}</option>`,
|
||||
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} \u2014 ${esc(p.title)}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = options.join("");
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type EventType,
|
||||
type FilterHandle,
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
// EventsPage shared client (t-paliad-110). Drives /deadlines and
|
||||
// /appointments off the same shell — the route handler injects
|
||||
@@ -78,6 +79,7 @@ interface Project {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
@@ -881,8 +883,9 @@ function populateProjectFilter() {
|
||||
`<option value="${PERSONAL}">${esc(t("appointments.filter.akte.personal"))}</option>`,
|
||||
];
|
||||
for (const a of allProjects) {
|
||||
const indent = projectIndent(a.path);
|
||||
options.push(
|
||||
`<option value="${esc(a.id)}">${esc(a.reference || "")} — ${esc(a.title)}</option>`,
|
||||
`<option value="${esc(a.id)}">${indent}${esc(a.reference || "")} — ${esc(a.title)}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = options.join("");
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
interface AdjustmentHoliday {
|
||||
Date: string;
|
||||
@@ -206,6 +207,7 @@ interface ProjectOption {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
@@ -293,9 +295,10 @@ async function openSaveModal() {
|
||||
sel.innerHTML = projects
|
||||
.map((p) => {
|
||||
const ref = (p.reference || "").trim();
|
||||
const indent = projectIndent(p.path);
|
||||
const label = ref
|
||||
? `${escHtml(ref)} \u2014 ${escHtml(p.title)}`
|
||||
: escHtml(p.title);
|
||||
? `${indent}${escHtml(ref)} \u2014 ${escHtml(p.title)}`
|
||||
: `${indent}${escHtml(p.title)}`;
|
||||
return `<option value="${escAttr(p.id)}">${label}</option>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
26
frontend/src/client/project-indent.ts
Normal file
26
frontend/src/client/project-indent.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Helpers for rendering /api/projects rows as a flat indented list in
|
||||
// `<select>` pickers (events filter, /deadlines/new, /appointments/new,
|
||||
// Fristenrechner save modal, …). Backend `ProjectService.List` returns
|
||||
// rows ordered by `path`, so a parent always precedes its descendants.
|
||||
// Indenting by the path's dot-segment depth recreates the tree shape
|
||||
// inside an HTML <option>.
|
||||
//
|
||||
// ` ` (NBSP) is used because some browsers collapse plain leading
|
||||
// spaces inside <option> labels.
|
||||
|
||||
const INDENT_UNIT = " ";
|
||||
|
||||
// projectDepth returns the 0-based depth of a project from its `path`
|
||||
// (dot-separated UUIDs of the ancestor chain including self). Roots are
|
||||
// depth 0; a child is depth 1; etc. A missing/empty path falls back to 0
|
||||
// so we still render a usable label for legacy data.
|
||||
export function projectDepth(path: string | null | undefined): number {
|
||||
if (!path) return 0;
|
||||
return path.split(".").length - 1;
|
||||
}
|
||||
|
||||
// projectIndent returns the leading whitespace prefix for a project label
|
||||
// at the given depth. Each level of nesting adds two NBSPs.
|
||||
export function projectIndent(path: string | null | undefined): string {
|
||||
return INDENT_UNIT.repeat(projectDepth(path));
|
||||
}
|
||||
Reference in New Issue
Block a user