The final Composer slice per design doc §12. Lawyer gains full
control over section composition: drag-and-drop reorder, per-section
delete, "+ Add section" picker for custom slugs that don't appear in
the base's default spec. Combined with Slice B's hide toggle, this
closes out the A→F sequence — Composer A→F is complete.
Backend (internal/services/submission_section_service.go, +120 LoC):
- SectionService.Create — adds a new section row to a draft. Validates
section_key + labels + kind (must be prose/requests/evidence).
Auto-assigns next order_index when OrderIndex=0; collisions on
(draft_id, section_key) surface as ErrInvalidInput.
- SectionService.Delete — removes one section by id. Returns
ErrSubmissionSectionNotFound when nothing was deleted.
- SectionService.Reorder — accepts a sequence of section_ids, rewrites
every row's order_index to (1..N)×10 transactionally. Returns the
refreshed list. Sections not present in the sequence are silently
ignored (defensive — partial reorder doesn't lose rows).
Handlers (internal/handlers/submission_sections.go, +180 LoC):
- POST /api/submission-drafts/{draft_id}/sections — owner-scoped via
SubmissionDraftService.Get. 400 on slug collision / invalid kind.
- DELETE /api/submission-drafts/{draft_id}/sections/{section_id} —
owner + section-belongs-to-draft cross-check. 204 on success.
- POST /api/submission-drafts/{draft_id}/sections/reorder — accepts
{"section_order": [uuid, uuid, ...]}; returns refreshed sections list.
Frontend (frontend/src/client/submission-draft.ts, +260 LoC):
- Each section row gains a drag handle (⋮⋮) on the left of the head.
Drag handle is the only draggable element; contentEditable
selections inside the editor body keep working. HTML5 native DnD,
no library.
- Drop-target highlighting via .submission-draft-section--drop-target
(border-top accent). Cleanup on dragend / drop / cancel.
- Per-section "Delete" button next to the existing Hide/Include
toggle. Confirm prompt prevents accidental loss of typed prose.
- "+ Add section" trailing affordance below the section list opens an
inline form (slug + DE label + EN label + kind dropdown). Submit
POSTs to the new endpoint; on success splices the row into
state.view.sections and re-paints.
CSS (frontend/src/styles/global.css, +65 LoC):
- .submission-draft-section-handle (grab cursor + hover background +
active=grabbing).
- .submission-draft-section--dragging / --drop-target visual states.
- .submission-draft-add-section form layout (dashed border + lime
primary submit).
Tests (internal/services/submission_section_slice_f_test.go, NEW,
TEST_DATABASE_URL-gated):
- Create custom section + slug-collision surface as ErrInvalidInput.
- Delete + repeat-delete returns ErrSubmissionSectionNotFound.
- Reorder reverses 10 seeded sections + verifies the resulting
order_index sequence is ascending and matches the input order.
Build hygiene: go build/vet/test -short clean (all packages);
bun run build clean (2906 i18n keys, data-i18n scan clean).
Hard rules honoured:
- NO new migrations (Slice F is pure code on Slice A's schema).
- NO behavior change for pre-Composer drafts (no section rows → no
drag handles to drag).
- {{rule.X}} aliases preserved (custom sections render through the
same composer pipeline as default sections).
- Q2/Q9/Q10 ratifications preserved.
This closes the Composer slice sequence A → F. The full feature set
ratified by m on 2026-05-26 is now in place:
A — base picker + read-only section list (mig 146/147/148)
B — editable prose + anchor-spliced render + MD→OOXML walker
C — building-blocks library + section picker (mig 149)
D — rich prose (headings, lists, blockquote, hyperlinks)
E — specialist bases lg-duesseldorf + upc-formal (mig 150)
F — section reorder / delete / add custom
t-paliad-318 Slice F
153 lines
4.7 KiB
Go
153 lines
4.7 KiB
Go
package services
|
|
|
|
// Live-DB tests for Slice F section service additions (Create + Delete
|
|
// + Reorder). Gated on TEST_DATABASE_URL, mirroring Slice A's pattern.
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/lib/pq"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/db"
|
|
)
|
|
|
|
func TestSectionService_SliceF(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()
|
|
|
|
bases := NewBaseService(pool)
|
|
sections := NewSectionService(pool)
|
|
|
|
// Seed user + draft so we have a draft_id to attach sections to.
|
|
userID := uuid.New()
|
|
cleanup := func() {
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.submission_sections WHERE draft_id IN (SELECT id FROM paliad.submission_drafts WHERE user_id = $1)`, userID)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
|
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
|
}
|
|
cleanup()
|
|
defer cleanup()
|
|
|
|
email := "slice-f-" + userID.String()[:8] + "@hlc.com"
|
|
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); 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, 'Slice F User', 'munich', 'standard', 'de')`,
|
|
userID, email); err != nil {
|
|
t.Fatalf("seed paliad.users: %v", err)
|
|
}
|
|
users := NewUserService(pool)
|
|
projects := NewProjectService(pool, users)
|
|
parties := NewPartyService(pool, projects)
|
|
vars := NewSubmissionVarsService(pool, projects, parties, users)
|
|
renderer := NewSubmissionRenderer()
|
|
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
|
|
drafts.AttachComposer(bases, sections, "HLC")
|
|
|
|
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
|
|
if err != nil {
|
|
t.Fatalf("Create draft: %v", err)
|
|
}
|
|
initial, err := sections.ListForDraft(ctx, d.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListForDraft initial: %v", err)
|
|
}
|
|
if len(initial) != 10 {
|
|
t.Fatalf("expected 10 seeded sections; got %d", len(initial))
|
|
}
|
|
|
|
t.Run("Create custom section", func(t *testing.T) {
|
|
created, err := sections.Create(ctx, SectionCreateInput{
|
|
DraftID: d.ID,
|
|
SectionKey: "berufungsantraege",
|
|
Kind: "requests",
|
|
LabelDE: "Berufungsanträge",
|
|
LabelEN: "Appeal requests",
|
|
Included: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if created.OrderIndex <= 10 {
|
|
t.Errorf("auto-assigned order_index should be > existing max; got %d", created.OrderIndex)
|
|
}
|
|
// Slug collision must surface as ErrInvalidInput.
|
|
_, err = sections.Create(ctx, SectionCreateInput{
|
|
DraftID: d.ID, SectionKey: "berufungsantraege",
|
|
Kind: "prose", LabelDE: "x", LabelEN: "x", Included: true,
|
|
})
|
|
if err == nil {
|
|
t.Errorf("expected unique-key collision error; got nil")
|
|
}
|
|
})
|
|
|
|
t.Run("Delete section", func(t *testing.T) {
|
|
// Grab one of the seeded rows to delete.
|
|
current, _ := sections.ListForDraft(ctx, d.ID)
|
|
var victimID uuid.UUID
|
|
for _, s := range current {
|
|
if s.SectionKey == "exhibits" {
|
|
victimID = s.ID
|
|
break
|
|
}
|
|
}
|
|
if victimID == uuid.Nil {
|
|
t.Fatalf("expected exhibits section to exist")
|
|
}
|
|
if err := sections.Delete(ctx, victimID); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
// Second delete returns not-found.
|
|
if err := sections.Delete(ctx, victimID); err == nil {
|
|
t.Errorf("expected ErrSubmissionSectionNotFound on second delete")
|
|
}
|
|
})
|
|
|
|
t.Run("Reorder sections", func(t *testing.T) {
|
|
current, _ := sections.ListForDraft(ctx, d.ID)
|
|
if len(current) < 3 {
|
|
t.Skipf("need at least 3 sections to test reorder; got %d", len(current))
|
|
}
|
|
// Reverse the order list.
|
|
ids := make([]uuid.UUID, 0, len(current))
|
|
for i := len(current) - 1; i >= 0; i-- {
|
|
ids = append(ids, current[i].ID)
|
|
}
|
|
reordered, err := sections.Reorder(ctx, d.ID, ids)
|
|
if err != nil {
|
|
t.Fatalf("Reorder: %v", err)
|
|
}
|
|
// Verify the first ID in our list now has the lowest order_index.
|
|
if reordered[0].ID != ids[0] {
|
|
t.Errorf("first ID after reorder = %s; want %s", reordered[0].ID, ids[0])
|
|
}
|
|
// Order indices should be ascending.
|
|
prev := 0
|
|
for _, s := range reordered {
|
|
if s.OrderIndex <= prev {
|
|
t.Errorf("non-ascending order_index after reorder: %d (prev=%d) at %s", s.OrderIndex, prev, s.SectionKey)
|
|
}
|
|
prev = s.OrderIndex
|
|
}
|
|
})
|
|
}
|