Editing API Reference
Editable<T>
Contract for editable modules.
Required methods:
Component buildEditView()T applyEdits(Map<String, String> formData)
Optional methods:
ValidationResult validate(Map<String, String> formData)List<EditableChild> getEditableChildren()
ValidationResult defensively copies error lists and returns immutable error collections.
EditMode
OWNER_EDIT: direct applicationUSER_EDIT: staged/approval-oriented flow
EditModalBuilder
Primary builder for edit modal UI.
Common methods:
withTitle(...)withModuleId(...)withEditable(...),withEditView(...), or bothwithSaveUrl(...)withDeleteUrl(...)withChildEditUrl(...)withChildDeleteUrl(...)withPageContainerId(...)withModalContainerId(...)hideDelete()
Provide a non-null property view with withEditView(...), or an editable whose
buildEditView() supplies it. Both setters may be used together: the explicit view wins
for the property form, while the editable still supplies child handles. This is how the
private editing demo combines extra property fields with child editing. A save URL is required;
child action URLs are optional and do not construct the property form.
The returned Modal disables backdrop closing; Escape and the header close button remain
enabled. The footer Close button clears the configured modal container. Load the framework
CSS/JS once in the page shell for focus entry, Tab containment and connected-launcher
restoration, including external HTMX/OOB removal. Save uses hx-swap="none"; applications
must return OOB updates to clear the modal and update content. If an update also replaces
the launcher, the old disconnected element cannot receive focus.
See the Modal rendering and interaction contract and editing flow guide.
withSaveUrl and withDeleteUrl accept complete caller URLs. They do not substitute
braced tokens or encode identifiers. withModuleId does not construct these URLs.
Child edit/delete URLs are templates with only {id}. Tokens must occupy a whole path
segment or a whole query parameter value, for example /children/{id}/edit or
/children/edit?id={id}. Repeated tokens, omitted tokens and fixed URLs are allowed.
null disables the corresponding child action. Non-null templates are validated by the
setter; raw child ids are validated and encoded only when an action actually substitutes
{id} during build(). Empty child lists and templates without {id} do not validate ids.
EditableChild stores its id without global validation.
Migration: legacy child templates such as /child-{id}, /{id}.json, ?{id}=value,
https://{id}/edit or #child-{id} now throw IllegalArgumentException. Move the token into
a full segment or query value. Pass raw ids, not pre-encoded strings. See
URL template and identifier rules.
EditablePage
EditablePage.create(pageId) emits a page wrapper and row-insert controls. It renders one
control with position 0 for an empty page, or one after each supplied row with positions
1, 2, and so on. Insert controls retain hx-target="closest .insert-row-section" and
hx-swap="beforebegin".
withInsertRowUrlTemplate(template) sets only these insert URLs. Its default is
/api/pages/{pageId}/rows/insert?position={position}; supported tokens are {pageId} and
{position}. Position is the framework's decimal insertion index. The page does not
change endpoint configuration on rows supplied through addEditableRow.
Templates are checked at the setter; a used page id is checked and encoded at render time. Creation does not validate the page id. Rendering and compilation leave stored wrapper attributes, children and row configuration unchanged. Generic inherited children remain stored but are not emitted. Templates retain this renderer live, including later row/URL configuration. Finish configuration before sharing; row delegates, descendants and request contexts have separate sharing obligations. These generic insertion indices are not a private controller's position policy.
EditableRow
EditableRow.wrap(Row row, String rowId, String pageId) creates an outer
div.editable-row-wrapper with id row-{rowId}. Its content renders in this order:
- The supplied
Row, unchanged, with its own id, classes, attributes, text and children. - A separate row containing modules registered through
addEditableModule(module, moduleId). - The add-module control, when adding is permitted and registration capacity remains.
The supplied row and registered modules are independent content, not a single editable grid.
The wrapper does not merge, deduplicate or move source children into editable columns.
Callers must avoid registering content already present in the supplied row. Outer wrapper
attributes do not replace or propagate to the supplied row. An empty source row is retained;
null omits only that source row, not the generated row or eligible control.
Each wrapper render passes the same RenderContext to the supplied row exactly once,
including a custom render(RenderContext) override. Rendering through Template retains
this dispatch and resolves changing context values rather than flattening the source renderer.
The wrapper does not mutate the supplied row's structure; custom renderers and descendants
remain responsible for their own behavior. The wrapper also leaves its own stored attributes,
children and registration configuration unchanged during rendering and compilation. Generic
inherited children remain stored but are not emitted. Templates keep this renderer live, including
later registration, gate, mode and URL changes. Finish configuration before sharing; registered
modules, source delegates and contexts must independently meet their lifecycle/sharing requirements.
Configuration methods:
addEditableModule(module, moduleId): appends a registration in order and assigns that id to the underlying module. It throwsIllegalStateExceptionif capacity is reached.withMaxModules(max): sets registration capacity (default3, minimum1). Source children do not count toward this capacity.withEditMode(mode): sets the generated module controls' edit mode (defaultOWNER_EDIT).withCanAddModule(canAdd): permits or hides the add control (defaulttrue); it does not gate the registered modules' edit/delete controls or enforce server authorization.
Generated columns use integer width 12 / registeredModuleCount; counts above twelve use
fill columns. Source columns retain their configured widths. Three independent setters
configure generated endpoint conventions:
| Setter | Default | Supported tokens |
|---|---|---|
withModuleEditUrlTemplate(template) |
/api/pages/{pageId}/modules/{moduleId}/edit |
{pageId}, {rowId}, {moduleId} |
withModuleDeleteUrlTemplate(template) |
/api/pages/{pageId}/modules/{moduleId}/delete |
{pageId}, {rowId}, {moduleId} |
withAddModuleFormUrlTemplate(template) |
/api/pages/{pageId}/rows/{rowId}/add-module-form |
{pageId}, {rowId} |
Setters check the template immediately. Render validates and encodes only ids actually used
by generated URLs. No registered modules means no module URLs; a hidden or capacity-blocked
add control does not expand its template. Default module URLs do not use rowId.
The edit mode is appended to edit/delete URLs as editMode, before any fragment. Edit
and add controls target #edit-modal-container with innerHTML; delete targets its generated
module wrapper with outerHTML. Applications supply these endpoints and enforce authorization,
CSRF and business validation. Wrapper controls alone do not grant permission.
A row-scoped route configuration, independent of page insertion:
package example;
import io.mindspice.simplypages.editing.EditablePage;
import io.mindspice.simplypages.editing.EditableRow;
import io.mindspice.simplypages.layout.Row;
import io.mindspice.simplypages.modules.ContentModule;
public final class EditingRoutes {
public static EditablePage page() {
String pageId = "tenant/a"; // raw business id, not "tenant%2Fa"
EditableRow row = EditableRow.wrap(new Row(), "row-1", pageId)
.withModuleEditUrlTemplate("/editor/{pageId}/{rowId}/{moduleId}/edit")
.withModuleDeleteUrlTemplate("/editor/{pageId}/{rowId}/{moduleId}/delete")
.withAddModuleFormUrlTemplate("/editor/add?page={pageId}&row={rowId}")
.addEditableModule(ContentModule.create().withContent("Body"), "module-1");
return EditablePage.create(pageId)
.withInsertRowUrlTemplate("/editor/insert?page={pageId}&position={position}")
.addEditableRow(row);
}
}
This emits tenant%2Fa wherever the page token occurs. It does not register server routes
or establish that a server accepts encoded slashes.
URL template and identifier rules
These rules apply to the four page/row template setters and modal child templates:
- Tokens occupy whole path segments or whole query parameter values. Scheme, authority,
query-key, fragment and partial-segment/value placements are rejected. Unknown tokens or
unmatched braces are rejected. New page/row template setters also reject
null. - In the browser-relevant structure, token-bearing absolute URLs use
scheme://nonempty-literal-authority; protocol-relative URLs use//nonempty-literal-authority. Root-relative paths, relative paths and query-only references are supported. Scheme shorthands (https:{id},https:/{id}), empty-authority forms (https:///{id},///{id}), and opaque schemes such as templatedmailto:are not supported. This is a template grammar, not a scheme or destination allowlist. - Placement is checked against both the literal template and browser-relevant structure:
leading/trailing ASCII C0 controls/spaces are trimmed, TAB/LF/CR are ignored, and path
backslashes are treated as separators for classification only. For example,
https://{id}/edit,h<TAB>ttps://{id}/editand\\/{id}/editare rejected because browsers would put the id in the authority. Harmless literal variants remain byte-for-byte unchanged. - Repeated or omitted tokens and fixed URLs are supported. An empty fixed URL contains no tokens; it does not validate ids. Fixed URLs do not acquire this structural validation, including non-hierarchical URLs. Literal URL portions remain exactly as configured.
- Expansion makes one pass over the original template. Braces inside an id are data, not additional tokens. The framework does not normalize literal URL text or encode the whole URL.
- Ids are raw opaque values. UTF-8 bytes other than RFC 3986 unreserved characters
(
A-Z a-z 0-9 - . _ ~) are percent-encoded. Spaces become%20, not+. Slash, backslash,?,#, quotes and braces are encoded.a%2Fbbecomesa%252Fb, nota%2Fb. - Used ids must be non-null, nonempty, not whitespace/space-character-only, not exactly
.or.., and well-formed UTF-16. Supplementary Unicode is supported. Mixed nonblank values retain surrounding spaces; no trimming, case folding, Unicode normalization or decoding occurs. - Invalid ids throw
IllegalArgumentExceptiononly at actual interpolation (page/row render, or modal child-action build). Omitted tokens and disabled/absent actions do not introduce descriptor-wide validation. DOM ids and CSS selectors are not changed by this URL policy.
Migration: framework-generated page/row and child URLs now encode raw ids. Do not pass pre-encoded ids; literal percent signs are deliberately encoded again. Exact dot ids are rejected because browsers normalize dot path segments even when their dots are percent-encoded. Partial legacy child-token placements must move to full segments or query values.
Literal URL configuration is trusted application input. Applications own route mappings, encoded-slash acceptance, the number of decoding steps, business-id lookup, authorization, CSRF and endpoint policy. Component encoding does not supply URI allowlisting or certify server-side request handling. Broad URL/style validation is not part of this contract.
EditableModule complete URLs
EditableModule.withEditUrl(url) and withDeleteUrl(url) accept complete caller URLs, not
implicit templates. Braces and existing percent sequences remain untouched. Null/empty URLs
or disabled permission flags hide the respective control.
With a non-null edit mode, the wrapper appends editMode=OWNER_EDIT or editMode=USER_EDIT
to the pre-fragment query. It retains all existing parameters, including an existing editMode,
and preserves the fragment exactly. Empty queries and trailing ?/& receive no extra separator.
For example, /edit?tab=details#section?note becomes
/edit?tab=details&editMode=OWNER_EDIT#section?note. A null mode leaves the complete URL unchanged.
SimpleListModule item identity and lifecycle
findItem(id) returns the first item with a non-null raw ListItem.getId() exactly equal to
a non-null query. removeItem(id) removes every such duplicate. Null queries match/remove
nothing; unassigned ids are skipped. Non-null spelling is literal, including empty strings,
spaces, case and Unicode. No trimming, normalization or descriptor fallback occurs.
getEditableChildren() remains model-derived. An unassigned raw id receives a positional
item-N descriptor without assigning that id to the item. Descriptors can renumber and can
collide with caller-assigned raw ids; they are not stable persistence identities. getItems()
returns a copied list, not the mutable model collection.
Adding/removing items is configuration-only: it changes the model and current descriptors but
does not build or rebuild an already-built child tree. Configure before building. applyEdits
explicitly rebuilds, making current model changes visible in markup. Applications own their
editing lookup/persistence policy and must not infer it from these raw convenience methods.
ModuleEditHandler<T>
Service-layer contract for edit, update, and delete handlers.
renderEditForm(String moduleId)handleUpdate(String moduleId, Map<String, String> editData, EditMode editMode)handleDelete(String moduleId, EditMode editMode)
AuthWrapper
Authorization wrappers:
require(...)requireForEdit(...)requireForDelete(...)requireForCreate(...)
AuthorizationChecker
App-owned permission contract:
canEdit(String moduleId, String userId)canDelete(String moduleId, String userId)getEditMode(String moduleId, String userId)