Compare commits
7 Commits
mai/tesla/
...
mai/knuth/
| Author | SHA1 | Date | |
|---|---|---|---|
| 61210943d9 | |||
| 74783e7a89 | |||
| 062afb6cc5 | |||
| 47b869dddf | |||
| c4c4fa267f | |||
| d555d5f679 | |||
| 875d0c149a |
@@ -210,65 +210,6 @@ describe("placeWidgets — vertical (multi-row) widgets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — includeHidden (edit mode)", () => {
|
||||
test("hidden widgets are skipped by default", () => {
|
||||
const out = placeWidgets([
|
||||
spec("visible", 0, 0, 6),
|
||||
spec("hidden", 0, 0, 6, 1, false),
|
||||
]);
|
||||
expect(out.has("visible")).toBe(true);
|
||||
expect(out.has("hidden")).toBe(false);
|
||||
});
|
||||
|
||||
test("includeHidden:true places hidden widgets after visible ones", () => {
|
||||
// Regression for m/paliad#73 / t-paliad-238: in edit mode hidden
|
||||
// widgets MUST receive a placement, otherwise applyLayout leaves
|
||||
// their inline grid-column empty and CSS Grid auto-flows them as
|
||||
// 1×1 slivers ("super slim greyed-out column").
|
||||
const out = placeWidgets([
|
||||
spec("active", 0, 0, 12),
|
||||
spec("hidden", 0, 0, 6, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.has("hidden")).toBe(true);
|
||||
const h = out.get("hidden")!;
|
||||
// Must keep its requested width (6), not collapse to 1.
|
||||
expect(h.w).toBe(6);
|
||||
// Must land below the visible widget — never overlap or steal cells.
|
||||
expect(h.y).toBeGreaterThanOrEqual(1);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("includeHidden two-pass: visible widgets keep priority over hidden", () => {
|
||||
// Hidden widget stored at (0, 0) shouldn't displace a visible
|
||||
// widget that wants (0, 0). The visible pass runs first, claims
|
||||
// (0, 0); the hidden widget is then placed wherever free — the
|
||||
// placer happily fits it next to the visible widget on the same
|
||||
// row if there's room. The hard invariant is just no-overlap.
|
||||
const out = placeWidgets([
|
||||
spec("active", 0, 0, 6),
|
||||
spec("hidden-at-origin", 0, 0, 6, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.get("active")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.has("hidden-at-origin")).toBe(true);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("multiple hidden widgets all receive valid placements", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 12),
|
||||
spec("h1", undefined, undefined, 6, 1, false),
|
||||
spec("h2", undefined, undefined, 6, 1, false),
|
||||
spec("h3", undefined, undefined, 12, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.size).toBe(4);
|
||||
for (const r of out.values()) {
|
||||
expect(r.w).toBeGreaterThanOrEqual(1);
|
||||
expect(r.x + r.w).toBeLessThanOrEqual(GRID_COLUMNS);
|
||||
}
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clamp helpers", () => {
|
||||
test("clampW respects min/max bounds", () => {
|
||||
expect(clampW(2, { min_w: 4, max_w: 12 })).toBe(4);
|
||||
|
||||
@@ -133,30 +133,10 @@ function findFreeSlot(
|
||||
return { x: 0, y: startY + MAX_SCAN_ROWS };
|
||||
}
|
||||
|
||||
// PlaceOptions tunes the placer for the caller's render-vs-persist
|
||||
// needs.
|
||||
export interface PlaceOptions {
|
||||
// When true, hidden widgets are placed too — for edit-mode rendering
|
||||
// where the user can see + un-hide them inline. The two-pass order
|
||||
// (visible first, then hidden) guarantees hidden widgets never
|
||||
// displace visible ones: they get whatever cells are left below the
|
||||
// active layout. Default false matches view-mode behaviour and the
|
||||
// persistence path (materializePositions) where hidden widgets
|
||||
// retain their stored coordinates instead of being repacked.
|
||||
//
|
||||
// Without this option, hidden widgets in edit mode were left without
|
||||
// an explicit grid-column inline style by applyLayout(), so CSS Grid
|
||||
// auto-flowed them into the next free cell at 1×1 — the "super slim
|
||||
// greyed-out column" symptom of m/paliad#73 / t-paliad-238.
|
||||
includeHidden?: boolean;
|
||||
}
|
||||
|
||||
// placeWidgets assigns no-overlap grid coordinates to widgets. By
|
||||
// default only visible widgets receive placements; pass
|
||||
// {includeHidden:true} to also place hidden widgets after the visible
|
||||
// pass (used by applyLayout in edit mode).
|
||||
// placeWidgets assigns no-overlap grid coordinates to every visible
|
||||
// widget. Hidden widgets are skipped and contribute no placement.
|
||||
//
|
||||
// Algorithm — per pass:
|
||||
// Algorithm: iterate widgets in input order. For each visible widget:
|
||||
// 1. Clamp w/h against catalog bounds.
|
||||
// 2. If the spec carries explicit x and y, try that slot. On a
|
||||
// collision, search downward starting at the requested y for the
|
||||
@@ -170,15 +150,8 @@ export interface PlaceOptions {
|
||||
// real-world layout — placing the explicit widgets first would change
|
||||
// the visual order, so we keep input order and let auto-flow widgets
|
||||
// step around any explicit blockers via the same collision search.
|
||||
//
|
||||
// Two-pass behaviour for hidden widgets: the visible pass owns its
|
||||
// own auto-flow cursor; the hidden pass continues from where the
|
||||
// visible pass left off so the hidden widgets stack right under the
|
||||
// active layout. The shared Occupancy bitmap guarantees the second
|
||||
// pass can never overlap a placed visible widget.
|
||||
export function placeWidgets(
|
||||
widgets: WidgetPlacementInput[],
|
||||
options: PlaceOptions = {},
|
||||
): Map<string, PlacedRect> {
|
||||
const out = new Map<string, PlacedRect>();
|
||||
const occ = new Occupancy();
|
||||
@@ -192,7 +165,8 @@ export function placeWidgets(
|
||||
let cursorY = 0;
|
||||
let rowMaxH = 0;
|
||||
|
||||
const placeOne = (w: WidgetPlacementInput): void => {
|
||||
for (const w of widgets) {
|
||||
if (!w.visible) continue;
|
||||
const dw = clampW(w.w ?? w.bound?.default_w ?? GRID_COLUMNS, w.bound);
|
||||
const dh = clampH(w.h ?? w.bound?.default_h ?? 1, w.bound);
|
||||
|
||||
@@ -236,28 +210,6 @@ export function placeWidgets(
|
||||
|
||||
occ.mark(placed.x, placed.y, dw, dh);
|
||||
out.set(w.key, { x: placed.x, y: placed.y, w: dw, h: dh });
|
||||
};
|
||||
|
||||
// Pass 1: visible widgets. They own the active layout.
|
||||
for (const w of widgets) {
|
||||
if (!w.visible) continue;
|
||||
placeOne(w);
|
||||
}
|
||||
|
||||
// Pass 2: hidden widgets (edit-mode only). Wrap the cursor to the
|
||||
// start of the next row before the second pass so the hidden tray
|
||||
// visually separates from the active layout — even if the last
|
||||
// visible widget left half a row open.
|
||||
if (options.includeHidden) {
|
||||
if (cursorX > 0) {
|
||||
cursorY += rowMaxH || 1;
|
||||
cursorX = 0;
|
||||
rowMaxH = 0;
|
||||
}
|
||||
for (const w of widgets) {
|
||||
if (w.visible) continue;
|
||||
placeOne(w);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
|
||||
@@ -1922,15 +1922,10 @@ function applyLayout(): void {
|
||||
if (k) byKey.set(k, el);
|
||||
});
|
||||
|
||||
// Compute effective placements. In edit mode we also include hidden
|
||||
// widgets so they render at their stored (or default) dimensions
|
||||
// dimmed-but-visible — without this they'd inherit no inline grid-
|
||||
// column and CSS Grid would auto-flow them as 1×1 slivers, producing
|
||||
// the "super slim greyed-out column" symptom (m/paliad#73). In view
|
||||
// mode hidden widgets are display:none and reserve no cells.
|
||||
const placements = computePlacements(currentLayout.widgets, {
|
||||
includeHidden: editMode,
|
||||
});
|
||||
// Compute effective placements (with auto-flow fill-in for missing
|
||||
// y values). The visible widgets are placed deterministically so the
|
||||
// grid renders identically across reloads.
|
||||
const placements = computePlacements(currentLayout.widgets);
|
||||
|
||||
for (const w of currentLayout.widgets) {
|
||||
const el = byKey.get(w.key);
|
||||
@@ -1957,15 +1952,7 @@ function applyLayout(): void {
|
||||
// overlap invariant: if two widgets request colliding cells (drag-drop
|
||||
// swap with mismatched widths, resize-grow into a sibling, etc.) the
|
||||
// later one is shifted down to the next free row. See m/paliad#70.
|
||||
//
|
||||
// includeHidden=true is used by applyLayout in edit mode to also place
|
||||
// hidden widgets after the visible pass — so the hidden tray renders
|
||||
// at proper size below the active layout. Default (false) matches the
|
||||
// persistence + render paths where hidden widgets carry no placement.
|
||||
function computePlacements(
|
||||
widgets: DashboardWidgetRef[],
|
||||
options: { includeHidden?: boolean } = {},
|
||||
): Map<string, PlacedRect> {
|
||||
function computePlacements(widgets: DashboardWidgetRef[]): Map<string, PlacedRect> {
|
||||
const inputs: WidgetPlacementInput[] = widgets.map((w) => ({
|
||||
key: w.key,
|
||||
visible: w.visible,
|
||||
@@ -1975,7 +1962,7 @@ function computePlacements(
|
||||
h: w.h,
|
||||
bound: toBound(lookupCatalog(w.key)),
|
||||
}));
|
||||
return placeWidgets(inputs, options);
|
||||
return placeWidgets(inputs);
|
||||
}
|
||||
|
||||
function clampW(w: number, def: WidgetCatalogEntry | undefined): number {
|
||||
|
||||
@@ -112,23 +112,23 @@ export function renderDashboard(): string {
|
||||
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
|
||||
<CollapsibleSection id="summary" widgetKey="deadline-summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
|
||||
<div className="dashboard-summary-grid">
|
||||
<a href="/deadlines?status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
|
||||
<a href="/events?type=deadline&status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
|
||||
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.overdue">Überfällig</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=today" className="dashboard-card dashboard-card-today" id="dashboard-card-today">
|
||||
<a href="/events?type=deadline&status=today" className="dashboard-card dashboard-card-today" id="dashboard-card-today">
|
||||
<div className="dashboard-card-count" id="dashboard-count-today">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.today">Heute</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=this_week" className="dashboard-card dashboard-card-amber" id="dashboard-card-thisweek">
|
||||
<a href="/events?type=deadline&status=this_week" className="dashboard-card dashboard-card-amber" id="dashboard-card-thisweek">
|
||||
<div className="dashboard-card-count" id="dashboard-count-this-week">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.this_week">Diese Woche</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=next_week" className="dashboard-card dashboard-card-green" id="dashboard-card-nextweek">
|
||||
<a href="/events?type=deadline&status=next_week" className="dashboard-card dashboard-card-green" id="dashboard-card-nextweek">
|
||||
<div className="dashboard-card-count" id="dashboard-count-next-week">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.next_week">Nächste Woche</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=later" className="dashboard-card dashboard-card-later" id="dashboard-card-later">
|
||||
<a href="/events?type=deadline&status=later" className="dashboard-card dashboard-card-later" id="dashboard-card-later">
|
||||
<div className="dashboard-card-count" id="dashboard-count-later">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.later">Später</div>
|
||||
</a>
|
||||
|
||||
@@ -30,6 +30,78 @@ type Entry struct {
|
||||
// Entries lists everything shipped so far, newest first. Append new rows
|
||||
// at the top.
|
||||
var Entries = []Entry{
|
||||
{
|
||||
Date: "2026-05-21",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Konfigurierbares Dashboard",
|
||||
TitleEN: "Configurable dashboard",
|
||||
BodyDE: "Das Dashboard lässt sich jetzt frei zusammenstellen: Widgets per Drag-and-drop verschieben, in der Größe ändern und einzeln konfigurieren. Der Katalog umfasst Fristen-Ampel, Termine, Agenda, Inbox-Übersicht, angepinnte Projekte und Schnellaktionen. Admins können eine kanzleiweite Standardanordnung festlegen, von der jeder Nutzer startet und sie nach Wunsch anpasst.",
|
||||
BodyEN: "The dashboard can now be assembled freely: drag-and-drop widgets, resize them and configure each one individually. The catalog covers the deadline traffic-light, appointments, agenda, inbox summary, pinned projects and quick actions. Admins can set a firm-wide default layout that every user starts from and then tweaks to taste.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-20",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Eigene Einreichungs-Checklisten",
|
||||
TitleEN: "User-authored checklists",
|
||||
BodyDE: "Eigene Checklisten lassen sich per Wizard anlegen und gezielt mit einzelnen Kolleg:innen, einem Büro, einer Partnereinheit oder einem Projekt teilen. Admins können besonders gute Vorlagen kanzleiweit unter „Geteilte Vorlagen\" freigeben. Wird eine Vorlage später überarbeitet, erscheint an laufenden Instanzen ein Hinweis-Badge auf die neuere Version.",
|
||||
BodyEN: "Build your own filing checklists through a wizard and share them explicitly with individual colleagues, an office, a partner unit or a project. Admins can promote the best templates firm-wide under „Shared templates\". When a template is later revised, running instances surface a notice badge pointing at the newer version.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-20",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Genehmigungen: Änderungen vorschlagen",
|
||||
TitleEN: "Approvals: suggest changes",
|
||||
BodyDE: "Im Inbox gibt es eine dritte Aktion neben „Genehmigen\" und „Ablehnen\": „Änderungen vorschlagen\". Ein Modal zeigt den ursprünglichen Wert, der Gegenvorschlag wandert mit einem Kommentar zurück an die Antragsteller:in. Der gesamte Austausch erscheint im Verlauf des Eintrags.",
|
||||
BodyEN: "Inbox now offers a third action alongside „Approve\" and „Reject\": „Suggest changes\". A modal shows the original value, the counter-proposal travels back to the requester together with a comment. The full exchange shows up in the entry's Verlauf.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-20",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Mandant:innen-Rolle und automatische Projekt-Codes",
|
||||
TitleEN: "Client role and auto-derived project codes",
|
||||
BodyDE: "Mandant:innen lassen sich jetzt als eigene Rolle in das Team eines Projekts aufnehmen — separat von HLC-Mitgliedern und mit eigenem Sichtbarkeitsumfang. Außerdem leitet Paliad pro Projekt einen kompakten Code aus dem Baum ab (etwa /9999-1-EP123-CFI) und zeigt ihn als zweites Badge im Header und in jedem Projekt-Picker.",
|
||||
BodyEN: "Clients can now be added to a project's team as their own role — separate from HLC members and with their own visibility scope. In addition, Paliad derives a compact code per project from the ancestor tree (e.g. /9999-1-EP123-CFI) and shows it as a second badge in the header and in every project picker.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-19",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Datenexport — Excel, CSV, JSON",
|
||||
TitleEN: "Data export — Excel, CSV, JSON",
|
||||
BodyDE: "Unter Einstellungen → Datenexport lassen sich alle sichtbaren Projekte, Fristen, Termine, Notizen und Checklisten als Excel-, CSV- oder JSON-Datei herunterladen. Auf jeder Projekt-Seite gibt es zusätzlich einen „Daten exportieren\"-Button, der nur den jeweiligen Teilbaum mitnimmt.",
|
||||
BodyEN: "Settings → Data export lets you download every project, deadline, appointment, note and checklist you can see as an Excel, CSV or JSON file. Each project page additionally offers a „Daten exportieren\" button that exports just that subtree.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-15",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Eigene Sichten — Liste, Karten, Kalender, Timeline",
|
||||
TitleEN: "Custom views — list, cards, calendar, timeline",
|
||||
BodyDE: "Eigene Filter über Fristen, Termine und Projekte lassen sich speichern und als Liste, Karten, Kalender oder Timeline rendern. Jede Sicht erhält einen permanenten Link, lässt sich als SVG, PNG, CSV, JSON oder iCal exportieren und erscheint in der Seitenleiste unter „Meine Sichten\".",
|
||||
BodyEN: "Custom filters over deadlines, appointments and projects can be saved and rendered as list, cards, calendar or timeline. Each view gets a permalink, can be exported as SVG, PNG, CSV, JSON or iCal and shows up in the sidebar under „Meine Sichten\".",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-07",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Projekte-Seite mit Baum, Pinnungen und Karten-Ansicht",
|
||||
TitleEN: "Projects page with tree, pins and cards view",
|
||||
BodyDE: "Die Projekte-Seite öffnet jetzt mit einem zusammenklappbaren Baum, Volltextsuche und Chips für Mandant, Ort und Status. Häufig genutzte Projekte lassen sich oben anpinnen; die alternative Karten-Ansicht erlaubt frei per Drag-and-drop sortierbare Layouts pro Nutzer.",
|
||||
BodyEN: "The Projects page now opens with a collapsible tree, full-text search and chips for client, location and status. Frequently used projects can be pinned to the top; the alternative cards view supports per-user drag-and-drop layouts.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-06",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Vier-Augen-Prinzip für Fristen und Termine",
|
||||
TitleEN: "Four-eyes principle for deadlines and appointments",
|
||||
BodyDE: "Pro Projekt lässt sich festlegen, dass Anlegen, Ändern, Abhaken und Löschen von Fristen oder Terminen durch eine zweite Person freigegeben werden müssen. Anfragen erscheinen im Inbox, am Eintrag selbst und mit „PENDING\"-Vermerk im CalDAV-Kalender. Admins pflegen die Regeln zentral unter /admin/approval-policies.",
|
||||
BodyEN: "Per project you can require that creating, editing, completing or deleting a deadline or appointment must be cleared by a second person. Requests show up in the inbox, on the entry itself and as a „PENDING\" marker in the CalDAV calendar. Admins maintain the rules centrally under /admin/approval-policies.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-05",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Fristenrechner v3 — Entscheidungsbaum, Begriffe, DE/EPA/DPMA",
|
||||
TitleEN: "Deadline calculator v3 — decision tree, concepts, DE/EPA/DPMA",
|
||||
BodyDE: "Der Fristenrechner wurde grundlegend überarbeitet: ein Entscheidungsbaum führt durch Verfahren und Fristart, eine neue Begriffsebene fasst Wiedereinsetzung, Säumnis, Schriftsatznachreichung und Weiterbehandlung als wiederverwendbare Konzepte zusammen. Der Regelbestand wurde um deutsche Verfahren (PatG, BPatG, BGH), EPA- und DPMA-Strecken erweitert, mit aktuellen Werten und Querverweisen.",
|
||||
BodyEN: "The deadline calculator has been overhauled from the ground up: a decision tree walks you through proceeding and deadline type, and a new concept layer treats Wiedereinsetzung, default, post-filing and further processing as reusable cross-cutting building blocks. The rule corpus has been extended with German proceedings (PatG, BPatG, BGH), EPO and DPMA tracks, with current values and cross-references.",
|
||||
},
|
||||
{
|
||||
Date: "2026-04-30",
|
||||
Tag: TagFeature,
|
||||
|
||||
@@ -11,8 +11,15 @@ import "net/http"
|
||||
// to the canonical /events?type=deadline (t-paliad-115). Detail page
|
||||
// /deadlines/{id} stays type-specific. Drop this redirect once we're
|
||||
// confident no caches / bookmarks / external links still hit the old URL.
|
||||
//
|
||||
// Preserves the incoming query string so filter params (e.g. status=this_week
|
||||
// from the dashboard summary cards) survive the redirect.
|
||||
func handleDeadlinesListRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/events?type=deadline", http.StatusMovedPermanently)
|
||||
target := "/events?type=deadline"
|
||||
if r.URL.RawQuery != "" {
|
||||
target += "&" + r.URL.RawQuery
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
func handleDeadlinesNewPage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -57,3 +57,29 @@ func TestStandaloneCalendarHandlers_RedirectToEventsKalender(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// /deadlines list redirect must forward the incoming query string so legacy
|
||||
// dashboard cards and external bookmarks like /deadlines?status=this_week
|
||||
// land at /events?type=deadline&status=this_week instead of losing the
|
||||
// filter. Regression for m's 2026-05-21 14:20 report.
|
||||
func TestDeadlinesListRedirect_PreservesQueryString(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"/deadlines", "/events?type=deadline"},
|
||||
{"/deadlines?status=this_week", "/events?type=deadline&status=this_week"},
|
||||
{"/deadlines?status=overdue&project_id=abc", "/events?type=deadline&status=overdue&project_id=abc"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleDeadlinesListRedirect(w, req)
|
||||
if w.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("%s: status = %d, want 301", tc.path, w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Location"); got != tc.want {
|
||||
t.Fatalf("%s: Location = %q, want %q", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,12 +226,10 @@ func validatePosition(i int, w DashboardWidgetRef, def WidgetDef) error {
|
||||
}
|
||||
|
||||
// SanitizeForRead applies the forgiving read-path rules: drop entries whose
|
||||
// keys are not in the catalog (catalog has shrunk), bump the version to
|
||||
// the current one if missing, and clamp w/h/x against the catalog's
|
||||
// MinW/MaxW/MinH/MaxH/grid bounds so a stale row with out-of-range sizes
|
||||
// can't strand the user with unrenderable widgets (m/paliad#73). Settings
|
||||
// on surviving entries pass through unchanged — invalid settings on read
|
||||
// are not worth aborting over and the next write will reject them anyway.
|
||||
// keys are not in the catalog (catalog has shrunk) and bump the version to
|
||||
// the current one if missing. Settings on surviving entries pass through
|
||||
// unchanged — invalid settings on read are not worth aborting over and the
|
||||
// next write will reject them anyway.
|
||||
//
|
||||
// Returns true if anything was changed; callers can use that to decide
|
||||
// whether to PUT the cleaned spec back.
|
||||
@@ -246,88 +244,16 @@ func (s *DashboardLayoutSpec) SanitizeForRead() bool {
|
||||
}
|
||||
out := make([]DashboardWidgetRef, 0, len(s.Widgets))
|
||||
for _, w := range s.Widgets {
|
||||
def, ok := LookupWidgetDef(w.Key)
|
||||
if !ok {
|
||||
if _, ok := LookupWidgetDef(w.Key); !ok {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if normalizePosition(&w, def) {
|
||||
changed = true
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
s.Widgets = out
|
||||
return changed
|
||||
}
|
||||
|
||||
// normalizePosition clamps a widget's W/H/X to the catalog bounds and the
|
||||
// grid extent. Returns true if any field was modified. Zero W/H stay zero
|
||||
// (auto-flow / default sentinel — the placer fills them in). Negative X
|
||||
// snaps to 0; X+W overflowing the grid snaps X down.
|
||||
func normalizePosition(w *DashboardWidgetRef, def WidgetDef) bool {
|
||||
changed := false
|
||||
|
||||
if w.W < 0 {
|
||||
w.W = 0
|
||||
changed = true
|
||||
}
|
||||
if w.W > DashboardGridColumns {
|
||||
w.W = DashboardGridColumns
|
||||
changed = true
|
||||
}
|
||||
// W == 0 is the "auto / default" sentinel — leave it untouched so
|
||||
// downstream renderers can substitute DefaultW. Only clamp non-zero
|
||||
// values against the per-widget Min/Max.
|
||||
if w.W > 0 {
|
||||
if def.MinW > 0 && w.W < def.MinW {
|
||||
w.W = def.MinW
|
||||
changed = true
|
||||
}
|
||||
if def.MaxW > 0 && w.W > def.MaxW {
|
||||
w.W = def.MaxW
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if w.H < 0 {
|
||||
w.H = 0
|
||||
changed = true
|
||||
}
|
||||
if w.H > MaxGridRowSpan {
|
||||
w.H = MaxGridRowSpan
|
||||
changed = true
|
||||
}
|
||||
if w.H > 0 {
|
||||
if def.MinH > 0 && w.H < def.MinH {
|
||||
w.H = def.MinH
|
||||
changed = true
|
||||
}
|
||||
if def.MaxH > 0 && w.H > def.MaxH {
|
||||
w.H = def.MaxH
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if w.X < 0 {
|
||||
w.X = 0
|
||||
changed = true
|
||||
}
|
||||
if w.X >= DashboardGridColumns {
|
||||
w.X = DashboardGridColumns - 1
|
||||
changed = true
|
||||
}
|
||||
if w.W > 0 && w.X+w.W > DashboardGridColumns {
|
||||
w.X = DashboardGridColumns - w.W
|
||||
changed = true
|
||||
}
|
||||
if w.Y < 0 {
|
||||
w.Y = 0
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
// ParseDashboardLayoutSpec decodes JSON bytes and validates. Used by the
|
||||
// HTTP handler on incoming request bodies.
|
||||
func ParseDashboardLayoutSpec(b []byte) (DashboardLayoutSpec, error) {
|
||||
|
||||
@@ -279,128 +279,6 @@ func TestDashboardLayoutSpec_SanitizeForRead_DropsUnknownKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashboardLayoutSpec_SanitizeForRead_ClampsOutOfRange covers the
|
||||
// m/paliad#73 recovery path: a stale row in user_dashboard_layouts
|
||||
// carrying a W below MinW (or above MaxW) must be normalised on load so
|
||||
// the user doesn't get stranded with super-slim columns. Pre-fix the
|
||||
// sanitizer only dropped unknown keys; sizes passed through verbatim.
|
||||
func TestDashboardLayoutSpec_SanitizeForRead_ClampsOutOfRange(t *testing.T) {
|
||||
// upcoming-deadlines: MinW=4, MaxW=12, MinH=1, MaxH=4 (per catalog).
|
||||
def, ok := LookupWidgetDef(WidgetUpcomingDeadlines)
|
||||
if !ok {
|
||||
t.Fatal("LookupWidgetDef(WidgetUpcomingDeadlines) = !ok")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
in DashboardWidgetRef
|
||||
wantW int
|
||||
wantH int
|
||||
wantX int
|
||||
wantY int
|
||||
wantOK bool // expected SanitizeForRead-returns-true
|
||||
}{
|
||||
{
|
||||
name: "W below MinW snaps to MinW",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 1, H: 1},
|
||||
wantW: def.MinW,
|
||||
wantH: 1,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "W above MaxW snaps to MaxW",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 99, H: 1},
|
||||
wantW: DashboardGridColumns,
|
||||
wantH: 1,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "W above grid width snaps to grid width",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 50, H: 1},
|
||||
wantW: DashboardGridColumns,
|
||||
wantH: 1,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "H above MaxGridRowSpan snaps to MaxGridRowSpan",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 6, H: 99},
|
||||
wantW: 6,
|
||||
wantH: def.MaxH, // upcoming-deadlines MaxH=4 < MaxGridRowSpan=5
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "X+W overflowing grid snaps X down",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 10, Y: 0, W: 6, H: 1},
|
||||
wantW: 6,
|
||||
wantH: 1,
|
||||
wantX: 6, // 12 - 6 = 6
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "W=0 stays 0 (auto / default sentinel)",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 0, H: 0},
|
||||
wantW: 0,
|
||||
wantH: 0,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "negative X snaps to 0",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: -3, Y: 0, W: 6, H: 1},
|
||||
wantW: 6,
|
||||
wantH: 1,
|
||||
wantX: 0,
|
||||
wantOK: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := DashboardLayoutSpec{Version: LayoutSpecVersion, Widgets: []DashboardWidgetRef{tc.in}}
|
||||
changed := s.SanitizeForRead()
|
||||
if changed != tc.wantOK {
|
||||
t.Errorf("SanitizeForRead returned %v; want %v", changed, tc.wantOK)
|
||||
}
|
||||
if len(s.Widgets) != 1 {
|
||||
t.Fatalf("expected 1 widget after sanitize, got %d", len(s.Widgets))
|
||||
}
|
||||
got := s.Widgets[0]
|
||||
if got.W != tc.wantW {
|
||||
t.Errorf("W = %d; want %d", got.W, tc.wantW)
|
||||
}
|
||||
if got.H != tc.wantH {
|
||||
t.Errorf("H = %d; want %d", got.H, tc.wantH)
|
||||
}
|
||||
if got.X != tc.wantX {
|
||||
t.Errorf("X = %d; want %d", got.X, tc.wantX)
|
||||
}
|
||||
if got.Y != tc.wantY {
|
||||
t.Errorf("Y = %d; want %d", got.Y, tc.wantY)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashboardLayoutSpec_SanitizeForRead_ClampedSpecPassesValidate is
|
||||
// the round-trip guarantee — after the sanitiser heals a stale row, the
|
||||
// result must be acceptable to Validate so the next PUT doesn't reject
|
||||
// the user's layout. Without this guarantee, sanitizing on read could
|
||||
// produce a layout the validator won't accept on the autosave path.
|
||||
func TestDashboardLayoutSpec_SanitizeForRead_ClampedSpecPassesValidate(t *testing.T) {
|
||||
s := DashboardLayoutSpec{
|
||||
Version: LayoutSpecVersion,
|
||||
Widgets: []DashboardWidgetRef{
|
||||
{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 1, H: 1},
|
||||
{Key: WidgetUpcomingDeadlines, Visible: true, X: 50, Y: 0, W: 99, H: 99}, // duplicate key — Validate will reject; this case checks size clamp at least
|
||||
},
|
||||
}
|
||||
// Trim to one widget for the validate assertion (duplicates are a
|
||||
// separate concern).
|
||||
s.Widgets = s.Widgets[:1]
|
||||
s.SanitizeForRead()
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Errorf("Validate after SanitizeForRead returned %v; want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_SanitizeForRead_NoopOnClean(t *testing.T) {
|
||||
s := FactoryDefaultLayout()
|
||||
if s.SanitizeForRead() {
|
||||
|
||||
@@ -59,10 +59,16 @@ type projectChainRow struct {
|
||||
ProceedingCode *string `db:"proceeding_code"`
|
||||
}
|
||||
|
||||
// BuildProjectCode walks the ancestor chain via the existing
|
||||
// paliad.projects.path ltree and returns the assembled code. One DB
|
||||
// round-trip per call; suitable for per-row use in single-project
|
||||
// projection paths.
|
||||
// BuildProjectCode walks the ancestor chain via paliad.projects.path
|
||||
// and returns the assembled code. One DB round-trip per call; suitable
|
||||
// for per-row use in single-project projection paths.
|
||||
//
|
||||
// paliad.projects.path is stored as TEXT (dot-separated UUIDs), not as
|
||||
// the ltree extension type — see export_service.go comment "ltree as
|
||||
// text" and can_see_project's string_to_array decomposition. Ancestor
|
||||
// walks use the same string_to_array(path, '.')::uuid[] pattern as the
|
||||
// canonical visibility predicate; ltree operators (@>, nlevel) would
|
||||
// raise "operator does not exist: text @> text" at runtime.
|
||||
//
|
||||
// For list endpoints with many rows, the call still scales fine for
|
||||
// firm-scale datasets (order-of-100s); if profiling later flags it as
|
||||
@@ -72,10 +78,12 @@ func BuildProjectCode(ctx context.Context, db sqlx.QueryerContext, projectID uui
|
||||
SELECT p.id, p.type, p.title, p.reference, p.opponent_code,
|
||||
p.patent_number, p.proceeding_type_id,
|
||||
pt.code AS proceeding_code
|
||||
FROM paliad.projects p
|
||||
FROM paliad.projects target
|
||||
JOIN paliad.projects p
|
||||
ON p.id = ANY(string_to_array(target.path, '.')::uuid[])
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
|
||||
WHERE p.path @> (SELECT path FROM paliad.projects WHERE id = $1)
|
||||
ORDER BY nlevel(p.path)
|
||||
WHERE target.id = $1
|
||||
ORDER BY array_position(string_to_array(target.path, '.')::uuid[], p.id)
|
||||
`
|
||||
rows := []projectChainRow{}
|
||||
if err := sqlx.SelectContext(ctx, db, &rows, query, projectID); err != nil {
|
||||
@@ -102,8 +110,13 @@ func PopulateProjectCodes(ctx context.Context, db sqlx.QueryerContext, targets [
|
||||
ids[i] = t.ID.String()
|
||||
}
|
||||
|
||||
// One CTE-based query: for each target id, fetch the full ancestor
|
||||
// chain joined to proceeding_types, ordered so we can group in Go.
|
||||
// One query: for each target id, fetch the full ancestor chain
|
||||
// joined to proceeding_types, ordered so we can group in Go.
|
||||
//
|
||||
// Ancestor walk uses string_to_array(path, '.')::uuid[] — same shape
|
||||
// as can_see_project. paliad.projects.path is TEXT, so ltree
|
||||
// operators (@>, nlevel) would fail with "operator does not exist:
|
||||
// text @> text". See BuildProjectCode doc comment for context.
|
||||
const query = `
|
||||
WITH targets AS (
|
||||
SELECT id, path
|
||||
@@ -114,9 +127,10 @@ func PopulateProjectCodes(ctx context.Context, db sqlx.QueryerContext, targets [
|
||||
p.id, p.type, p.title, p.reference, p.opponent_code,
|
||||
p.patent_number, p.proceeding_type_id,
|
||||
pt.code AS proceeding_code,
|
||||
nlevel(p.path) AS chain_level
|
||||
array_position(string_to_array(t.path, '.')::uuid[], p.id) AS chain_level
|
||||
FROM targets t
|
||||
JOIN paliad.projects p ON p.path @> t.path
|
||||
JOIN paliad.projects p
|
||||
ON p.id = ANY(string_to_array(t.path, '.')::uuid[])
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
|
||||
ORDER BY t.id, chain_level
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user