Element CRUD: add, remove, reorder, and nest elements with placement rules #6
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/deckle#6
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What to build
Structural editing of a page's element list (docs/DECISIONS.md D4, D6): a "new element" picker grouped by schema
group, honoringplacement.allowedIn/allowedAtand the page type's allowed elements; delete with confirm; reorder via drag & drop; nesting into container elements (grid columns) with the same rule enforcement. All mutations go through the element tree and canonical serializer.Acceptance criteria
Blocked by
Agent Brief
Category: enhancement
Summary: Structural editing of a page's element tree: a "new element" picker grouped by sidecar
group, delete with confirmation, drag & drop reordering, and nesting into container elements — all governed by placement rules (placement.allowedIn/allowedAtfrom sidecars plus the page type's allowed elements), all persisted through the element tree and canonical serializer, with single-step undo.Dependency note: this issue is hard-blocked by #3 (schema-generated element form and the canonical save path); the scheduler handles ordering — do not start until #3 is merged. #3 provides the sidecar schema loader (
label,icon,group,params, and aplacementblock that #3 parses and serves but explicitly does not enforce), the harness API that serves schemas and a page's element tree, and the save path through the round-trip engine from #2. This issue is where placement enforcement lands. The issue body'sdocs/DECISIONS.md D4, D6references point at a file that is not committed — this brief is self-contained.Current behavior:
After #3, the tool can edit the params of an element that already exists on a page and save the file canonically, with the preview updating via LiveReload. Sidecars declare a
placementblock, but it is parsed and served without semantics. There is no way to add an element to a page, delete one, change element order, or move an element into or out of a container — the page's structure can only be changed by editing the markdown file by hand. There is no undo of any kind.Desired behavior:
Placement model. Since the decisions doc is absent, this brief fixes the semantics. Two rule sources combine, and an operation is allowed only when every declared constraint passes:
placement(per element type):allowedInlists the parents that may contain the element — names of container element types, plus a designated token for the page top level (e.g.page).allowedAtlists the page types on which the element may appear. An absent or empty list on either axis means "unrestricted on that axis" (consistent with #3's permissiveplacement: {}).typeif set, otherwise its top-level section, otherwise a default), and a minimal editor-side declaration maps page type → allowed elements, styled after the existingeditor/sidecar convention. A page type with no declaration allows all elements. This is the minimal mechanism only — full page-type management is #9.Exact key naming and config location are the implementer's choice as long as both axes (allowed parents, allowed page types) plus the page-type allowlist are representable, enforced, and covered by tests.
Picker. A "new element" affordance at a chosen insertion position (top level between siblings, or inside a container) opens a picker of element types grouped by the sidecar
group, showing each type's schemalabelandicon. The picker offers only the types allowed at that position under the combined rules; nothing disallowed is listed (or it is visibly disabled — but never insertable). Choosing a type inserts a new element at that position with all params set to their schema defaults (required params without defaults left for the form to demand) and an empty body where the body policy allows one, then persists and shows the element form for the new element.Delete. Deleting an element asks for confirmation, naming the element (schema label), and warning when it has children. Confirmation removes the element and its entire subtree; cancel is a no-op.
Drag & drop. Elements can be dragged to reorder among top-level siblings and to move into or out of container elements (e.g. grid columns), including into an empty container. While dragging, valid drop positions show an insertion indicator; positions the rules disallow are visually rejected (no-drop affordance) and dropping there changes nothing. A drop commits the move as one mutation.
Nesting. Container semantics come from the rules above: a container accepts a child only if the child's
allowedInadmits that container (and the page-type allowlist admits the child). Enforcement applies identically to picker insertion and drag/drop moves.Persistence. Every mutation — insert, delete, reorder, move — goes through the element tree and the canonical serializer from #2 and rewrites only that page's file. Text nodes and untouched elements survive unchanged (modulo canonicalization). Nothing mutates markdown as strings. After each successful mutation the preview reflects the change via LiveReload without manual reload. The server is authoritative: a mutation request that violates placement or page-type rules is rejected server-side with an actionable error and leaves the file untouched, regardless of what the client UI allowed.
Undo. The session keeps the pre-mutation state of the last structural mutation. A single undo action reverts that mutation — the file is rewritten (canonically) to its prior structure and the preview updates. One step only: undoing twice does not walk further back, and redo is not required. Undo is scoped to structural mutations; param-edit undo is not required.
Key interfaces:
placementshape in the sidecar schema type from #3 —allowedIn(allowed parents; container names plus a top-level token) andallowedAt(allowed page types), both optional, absent meaning unrestricted. The validation logic that answers "may element type X sit at position P on page Y?" should live where both the server (authoritative) and any future callers can use it, and be unit-testable without HTTP.type/ section / default.Acceptance criteria:
placement.allowedIn/allowedAt; rules are enforced server-side too, and a rule-violating mutation request is rejected without touching the fileOut of scope:
Re-run brief — this issue must be rebuilt against the current
mainThe first AFK run on this issue (branch
afk/6, PR #23, head7cb4e5d) is being abandoned, not merged. It is correct work built on a floor that has since been replaced. Read this before starting; it supersedes the original Agent Brief above where they conflict.Why the first run could not land
The original brief told this issue to "reuse whatever minimal element list #3 left behind" and put #4 (page tree browsing) out of scope. The run did exactly that — it built element CRUD on top of #3's
web/src/components/ElementsPane.tsx.Since then #4 landed (PR #21) and deleted
ElementsPane.tsx, replacing the editor with a multi-pane architecture. #6 and #4 both reshape the element-list surface, but the tracker never recorded a#6 → #4dependency, so they were built concurrently against divergent bases. Result: amodify/deleteconflict on the central component plus deep, independent rewrites ofweb/src/api.tsandinternal/server/server.go. Re-integration by hand would mean re-authoring the feature's UI, so the work is being regenerated on the current floor instead.Those two instructions in the original brief are now void: #4 is merged, and the element list you must build on is #4's — there is no "minimal element list from #3" any more.
The floor you are building on (current
main)Frontend (
web/src/):components/TreePane.tsx(page navigation, left) ·components/PagePane.tsx(the element list, center —FormPanemounts inside it) ·components/FormPane.tsx(schema-generated param form) ·components/PreviewPane.tsx·components/StatusPill.tsx.ElementsPane.tsxdoes not exist. Do not recreate it.selection.ts— the shared selection seam:PageId = {path, lang},ElementSelection = {page, elementPath},createSelection()→{selectedPage, selectPage, selectedElement, selectElement}.schemas.ts, andtest/mockApi.ts(installMockApi()) — the shared test mock. Tests select a page from the tree first; the element list is empty until then.ElementNode = BlockNode | InlineNode | TextNode; children exist only onBlockNode(narrow withnode.kind === "block"before touching.children).Backend (
internal/):(path, lang), never a bare path string.GET /api/page?path=&lang=→{page: {path, lang}, tree};POST /api/page/savebody{page, lang, path, name, values}.GET /api/tree?lang=is the site nav tree (internal/pagetree).internal/pagetree.ContentRoot(contentDir, lang)is the single place a language resolves to a content root — handlers never joincontentDirthemselves.server.New(provider, dist, siteDir, hugo hugobin.Resolved)— 4 args (Hugo pinning, #5).Where this feature belongs now
Element CRUD operates on the element list, and the element list lives in
PagePane. Build the picker / insert / delete / drag-&-drop / undo affordances intoPagePane(extract a sub-component if it grows large), withTreePaneremaining pure page navigation. Selection changes go throughselection.ts.What to salvage from the abandoned run (PR #23,
7cb4e5d)That run's backend and pure-logic modules are good and largely portable — reuse them rather than reinventing:
internal/placement/—Rules(CanInsert/CanMove/AllowedOnPage),PageType(), allowlist loading. Ports verbatim.internal/server/tree.go— tree mechanics (shortcodeAt,pathOf,spliceInto,removeAt,normalizeText, …). Pure, no page-identity coupling. Ports verbatim.internal/server/mutate.go/undo.go— the insert/delete/move + single-step undo logic. Sound; needs(path, lang)threading (below).web/src/rules.ts— picker filtering +computeDrop/canMoveTodrag logic, and its unit tests (drop.test.ts,rules.test.ts). Ports with ~2 edits (narrow onkind === "block"before.children; typeplacementproperly).Backend integration constraints (verified — these all compile)
element.Schema.Placementmust be a typed struct, notmap[string]any:type Placement struct { AllowedIn []string; AllowedAt []string }. Nothing inplacement/compiles otherwise. This is the linchpin — do it first (element→placement→server)./api/page,/save,/mutate,/undo:langthrough mutate/undo.mutateRequestneeds aLangfield; the undo snapshot must carrylangand the resolved absolute path (so undo neither re-resolves the language nor re-derives the content root).HandlergainspagetypesDir,mu sync.Mutex,undo *undoSnapshot— keep the existinghugo hugobin.Resolvedfield and the 4-argNew.placement.PageTypesplits on/— pass it the slash form of the path.Decide these coherently (the first run had no page tree, so it never faced them)
FormPane.onSavedkeeps passing the tree or the full page response.Acceptance criteria
Unchanged from the original brief (picker honoring placement rules, add with schema defaults, delete-with-confirm incl. subtree, drag-&-drop reorder/nest with invalid drops visually rejected and server-side enforced, canonical-serializer persistence visible in preview, single-step undo). Additionally: the feature must integrate with #4's tree navigation and
(path, lang)identity, and all existing tests onmainmust stay green.Closing as superseded: the maintainer has decided on a clean-slate reset of the project (issue #29, PR #30). The v1 implementation this issue builds on is being removed and the plan will be re-drawn from scratch; this spec is retired rather than actioned.