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:
m
2026-05-04 19:30:37 +02:00
parent 062630ca38
commit 4d7c74994a
8 changed files with 183 additions and 7 deletions

View File

@@ -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("");

View File

@@ -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);
});
}

View File

@@ -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("");

View File

@@ -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("");

View File

@@ -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("");

View 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));
}

View File

@@ -0,0 +1,132 @@
package services
import (
"context"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// TestProjectList_OrderedByPath pins the t-paliad-125 contract: ProjectService.List
// returns rows ordered by `path`, so any descendant immediately follows its
// ancestor — the same ordering BuildTree produces. Picker dropdowns rely on
// this so they can render the project tree as a flat indented list. A previous
// `ORDER BY updated_at DESC` interleaved cousins by recency and broke the
// visual hierarchy in the /events Project filter (m's report 2026-05-04).
//
// Skipped when TEST_DATABASE_URL is unset.
func TestProjectList_OrderedByPath(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
adminID := uuid.New()
// Two siblings (clientA, clientB) chosen so clientA's UUID sorts BEFORE
// clientB's — this lets us assert that an updated child of clientA still
// appears between clientA and clientB rather than floating to the top by
// recency. We retry until we get suitable IDs (UUIDv4 ordering is random).
var clientA, clientB uuid.UUID
for {
a, b := uuid.New(), uuid.New()
if a.String() < b.String() {
clientA, clientB = a, b
break
}
}
caseUnderA := uuid.New()
caseUnderB := uuid.New()
cleanup := func() {
for _, pid := range []uuid.UUID{caseUnderA, caseUnderB, clientA, clientB} {
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id = $1`, pid)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, pid)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, pid)
}
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, adminID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, adminID)
}
cleanup()
defer cleanup()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $2)`,
adminID, "list-order@hlc.com"); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, $2, 'List Order', 'munich', 'global_admin', 'de')`,
adminID, "list-order@hlc.com"); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
// Insert order is (B, caseB, A, caseA) so updated_at DESC would put A's
// branch first across both clients — interleaving caseUnderA between
// clientB rows. Path order must override that and group each branch.
rows := []struct {
id uuid.UUID
typ string
parent *uuid.UUID
title string
reference string
}{
{clientB, "client", nil, "Bravo Corp", "2026/9101"},
{caseUnderB, "case", &clientB, "Bravo Case", "2026/9102"},
{clientA, "client", nil, "Alpha Corp", "2026/9103"},
{caseUnderA, "case", &clientA, "Alpha Case", "2026/9104"},
}
for _, p := range rows {
var parent any
if p.parent != nil {
parent = *p.parent
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, parent_id, path, title, reference, status, created_by)
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7)`,
p.id, p.typ, parent, p.id.String(), p.title, p.reference, adminID); err != nil {
t.Fatalf("seed paliad.projects %s: %v", p.id, err)
}
}
users := NewUserService(pool)
projects := NewProjectService(pool, users)
got, err := projects.List(ctx, adminID, ProjectFilter{})
if err != nil {
t.Fatalf("List: %v", err)
}
// Restrict to the seed set to ignore unrelated rows in the shared dev DB.
seed := map[uuid.UUID]bool{clientA: true, caseUnderA: true, clientB: true, caseUnderB: true}
var ids []uuid.UUID
for _, p := range got {
if seed[p.ID] {
ids = append(ids, p.ID)
}
}
want := []uuid.UUID{clientA, caseUnderA, clientB, caseUnderB}
if len(ids) != len(want) {
t.Fatalf("List returned %d seed rows, want %d (got=%v)", len(ids), len(want), ids)
}
for i, id := range want {
if ids[i] != id {
t.Errorf("List[%d] = %s, want %s — order is not path-based", i, ids[i], id)
}
}
}

View File

@@ -182,9 +182,13 @@ func (s *ProjectService) List(ctx context.Context, userID uuid.UUID, f ProjectFi
args["search"] = "%" + s + "%"
}
// Path order keeps every descendant immediately under its ancestor —
// the same ordering BuildTree produces — so list pickers (events filter,
// /deadlines/new, /appointments/new, …) can render the project tree as a
// flat indented list. Recency sort would interleave cousins by last-touch.
query := `SELECT ` + projectColumns + ` FROM paliad.projects p
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY p.updated_at DESC`
ORDER BY p.path`
stmt, err := s.db.PrepareNamedContext(ctx, query)
if err != nil {