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));
|
||||
}
|
||||
132
internal/services/project_list_order_test.go
Normal file
132
internal/services/project_list_order_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user