Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Status legend: ✅ Implemented · 🚧 In progress · 📋 Proposed
| [custom-domains](custom-domains.md) | 📋 Proposed | Serve a user's profile from their own domain |
| [draft-with-ai](draft-with-ai.md) | 📋 Proposed | Server-side "Draft with AI" nanopub authoring |
| [uri-schemes](uri-schemes.md) | ✅ Implemented | Accepts and renders `ipfs:`, `ipns:`, `did:` and `at:` URIs alongside `http(s)`, with configurable outbound resolvers and scheme-aware short labels ([#655](https://github.com/knowledgepixels/nanodash/issues/655)) |
| [locked-prefilled-values](locked-prefilled-values.md) | ✅ Implemented | `locked=` states that a value pre-filled via URL args cannot be changed in the form (per field and per repetition, so pre-filled keys can be fixed while more can still be added); `locked-statements=` fixes the set of repetitions ([#678](https://github.com/knowledgepixels/nanodash/issues/678)) |
| [claude-code-chat](claude-code-chat.md) | 🚧 In progress | Chat panel backed by the user's local Claude Code, acting on Nanodash via an MCP endpoint (Tier 2 of [#434](https://github.com/knowledgepixels/nanodash/issues/434)) |

When a doc's status changes, update both its `**Status:**` line and the row here.
161 changes: 161 additions & 0 deletions docs/locked-prefilled-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Locking pre-filled values and statements

**Status:** ✅ Implemented ([#678](https://github.com/knowledgepixels/nanodash/issues/678)).

## Goal

A link into the publish form can pre-fill fields with URL parameters
(`?template=…&param_public-key=…`). Some of those values are not the user's to change:
the "Create Introduction" link fills in the public key of the local key pair, and an
introduction that declares a different key is simply wrong. The `locked` parameter lets
the link say so: the field is shown with its value, but cannot be edited.

Locking is per field and, for repeatable statements, **per repetition**. That is what
lets a link pre-fill and fix the keys a user already has while leaving them free to add
more. A second parameter, `locked-statements`, fixes the *set* of repetitions instead:
no adding, no removing. The two are independent — a locked value in an unlocked
statement can still be dropped by removing its repetition, and a locked statement's
values can still be edited.

## The `locked` parameter

`locked` names the URL parameters it locks, with the same prefixes that set them:

```
/publish?template=…
&param_public-key=<key>
&locked=param_public-key
```

- `param_x` — assertion template, `prparam_x` — provenance template, `piparamN_x` — the
Nth publication-info template. A name **without** a prefix refers to the assertion
template, so `locked=public-key` and `locked=param_public-key` are the same thing.
- Several names can be given as a comma-separated list, as repeated `locked` parameters,
or both.
- A lock on a parameter that carries no value is ignored (and logged). Locking an empty
field would leave it empty and uneditable — for a required field, unpublishable.

### Repetitions

Repetition suffixes are part of the name, following the convention the pre-fill
parameters already use: `x` is the first repetition, `x__1` the second, and so on (the
relative `x__.1` form works too and is translated along with the value). So

```
&param_public-key=<key A>&param_public-key__1=<key B>
&locked=param_public-key,param_public-key__1
```

pre-fills two key groups, fixes both, and leaves any group the user adds with "+" fully
editable.

Only *narrow-scope* placeholders — those used in a single top-level statement — get
repetition suffixes. A placeholder used in several statements is wide-scope: it has one
shared model and no suffix, so locking it locks it everywhere. In the "Introducing a
user" template that is exactly the intent: `user` is wide-scope (it appears in the name
statement and in the key group) and locks as a whole, while `public-key` is narrow-scope
to the repeatable key group and locks per key.

## Locking statements: `locked-statements`

`locked-statements` names the statements whose repetitions are fixed. The `+` and `-`
buttons of such a statement are not rendered, so the form publishes exactly the
repetitions the link pre-filled:

```
&param_public-key=<key A>&param_public-key__1=<key B>
&locked=param_public-key,param_public-key__1
&locked-statements=public-key
```

It takes the same template prefixes as `locked` (`param_` / `prparam_` / `piparamN_`,
bare = assertion template). A statement is named either by its node in the template
(`st2`) or by a placeholder that occurs in that statement and no other (`public-key`) —
the latter is the name a link author is more likely to have at hand. A placeholder used
in several statements is wide-scope and names none of them. The name is resolved when the
lock is queried, not when it is parsed, because the statements are only built afterwards.

Unlike a locked value, a locked statement genuinely holds: Wicket does not invoke the
listener of a component that is not visible, so a hidden `+` cannot be triggered by
editing the page either.

**Optional statements** need no separate parameter to be kept: an optional statement is
dropped by leaving one of its fields empty (`StatementItem.addTriplesTo`), so a locked
value already forces it to stay. Forcing an optional statement to stay *absent* is a
different feature — hiding the field rather than locking it — and is deliberately not
part of this.

## Locks stated by a view action

A view's entry action fills form fields from the row it sits on, through its
`col:field` query mappings (docs/magic-query-params.md). Writing the field as
`!field` locks it as well:

```
local_pubkey:!public-key__.1
```

The action then fills the field and emits `locked=param_public-key__.1` with it. This
is for values an action *determines* rather than proposes — the "append my local key to
this introduction" action fills in the local public key, and a different key would make
the introduction wrong. It applies to `param_` targets only: a raw `@` key is a
fill-mode switch, not a form field.

Note the relative repetition name (`__.1`, "one more than what the source had"), which
the lock follows through the rewrite into an absolute name.

**Deploy order:** a view nanopublication that starts using `!` should be published only
once the deployment reads it, since an older version treats `!public-key__.1` as a field
name of its own and silently fills nothing.

## What locking does *not* do

- **It is not enforcement.** The lock lives in the URL, so anyone can edit the URL before
loading the form. It prevents accidental edits, not deliberate ones. A wrong key in an
introduction is caught where it matters anyway: the signature won't match.
- **A value lock does not lock the statement.** The "-" button still removes a repetition
group, locked value and all — you cannot change the value, but you can drop the whole
statement. `locked-statements` is what stops that, and it is deliberately a separate
decision.

## Implementation

- `TemplateContext` keeps a `lockedParams` set beside its parameter map, with
`setLocked`/`isLocked`/`moveLock`/`clearLock`. `isLocked(IRI)` takes the postfix of the
placeholder IRI as handed to the form component, which already carries the repetition
suffix — that is where per-repetition granularity comes from for free.
- `PublishForm.applyLocks` parses both parameters into those contexts, right after the
loop that reads the `param_`/`prparam_`/`piparamN_` values; `forEachLockedName` holds
the prefix handling both share.
- `TemplateContext.setStatementLocked`/`isStatementLocked` keep the statement locks, whose
names are matched against the statement node and against the placeholders narrow-scoped
to it. `StatementItem.updateViewElements` hides the repetition buttons of a locked
statement.
- `AbstractContextComponent.lockIfNeeded` attaches a behavior that marks the field
uneditable in the browser, adds an explanatory `title` and a `locked-value` class, and
decides at **render** time rather than at construction time, because the lock state can
change while the form is open (see below). Every editable placeholder item calls it
where it registers its form component.

The component stays **enabled** as far as Wicket is concerned, and its value keeps being
submitted with the form. Disabling it in Wicket looks right and is wrong: the browser
sends nothing for a disabled control, and the form then reads the field as an emptied
one — the locked value disappears from the field and its required-value check fails.
(This is what the live check caught; unit tests that only rendered the form did not.)
So text inputs and text areas are marked `readonly`, which browsers do submit, and
choice fields — which render as `select`, and HTML has no readonly for those — are
marked `disabled` with their value mirrored in a hidden field of the same name. Either
way the form receives the locked value, unchanged, and publishes it as it would have
anyway.
- `StatementItem.RepetitionGroup.remove()` shifts locks with the values. Removing a
repetition group does not delete a slot: it shifts the values of the following groups
up through fixed placeholder slots and drops the last one. The lock belongs to the
pre-filled value rather than to the slot, so it has to travel with it — otherwise
removing a locked repetition would leave the value that slides into its place
uneditable. Only IRIs that actually get a repetition suffix are shifted, under the same
condition `transform()` applies; the statement's constants are in the same set and one
of them can share a placeholder's postfix (`rdfs:comment` and a `comment` placeholder,
say), which would shift the same lock twice and undo it.

Tests: `LockedFieldTest` (per-repetition locking, lock shifting on removal, rendering),
`PublishFormLockTest` (parameter parsing).
8 changes: 8 additions & 0 deletions docs/magic-query-params.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,14 @@ Today an action carries a single `queryVar:templateParam` mapping that only sets
`"derive_target:@derive-a local_pubkey:public-key__.1"` — the first drives
visibility (conditional target), the second supplies the key.

A third form was added later (issue #678): a `!` in front of the field name
(`local_pubkey:!public-key__.1`) also **locks** the field, so the form shows the
value the action filled in but does not let the user change it — for values an
action *determines* rather than proposes, such as the local public key an
introduction is to declare. It emits the `locked` page parameter alongside the
`param_` one; see docs/locked-prefilled-values.md. It applies to `param_` targets
only, as a raw `@` key is not a form field.

### Echo-as-column (no code)

A query may `SELECT` a magic variable back out
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
package com.knowledgepixels.nanodash.component;

import com.knowledgepixels.nanodash.template.TemplateContext;
import org.apache.wicket.Component;
import org.apache.wicket.behavior.Behavior;
import org.apache.wicket.markup.ComponentTag;
import org.apache.wicket.markup.html.form.FormComponent;
import org.apache.wicket.markup.html.panel.Panel;
import org.apache.wicket.util.string.Strings;
import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.model.ValueFactory;
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;

Expand All @@ -13,6 +19,82 @@ public abstract class AbstractContextComponent extends Panel implements ContextC
protected TemplateContext context;
protected static ValueFactory vf = SimpleValueFactory.getInstance();

private static final String LOCKED_FIELD_MESSAGE = "This value is set by the link that opened this form and cannot be changed here.";

/**
* Makes the given form component follow the lock state of the given placeholder: a locked
* placeholder is shown with its pre-filled value but cannot be edited (issue #678).
* <p>
* The component stays enabled as far as Wicket is concerned, and the value keeps being
* submitted with the form: a locked field is still an ordinary field holding a value, and
* disabling it in Wicket would make the browser send nothing for it, which the form then reads
* as an emptied field and reports as a missing required value. Text inputs are marked
* {@code readonly}, which browsers submit; anything else (the choice fields, which HTML has no
* readonly for) is marked {@code disabled} and its value mirrored in a hidden field of the
* same name, so that what reaches the form is the locked value either way.
* <p>
* The state is decided at render time rather than at construction time, because it can change
* while the form is open: removing a repetition group shifts the values of the following
* groups up into its slot, and the lock travels with the value (see
* {@link TemplateContext#moveLock(String, String)}).
* <p>
* This is a guardrail rather than enforcement: the lock is stated in the URL that opened the
* form, so it stops accidental edits, not deliberate ones.
*
* @param component the form component to lock when its placeholder is locked
* @param iri the placeholder IRI, including any repetition suffix
*/
protected void lockIfNeeded(final FormComponent<?> component, final IRI iri) {
final TemplateContext c = context;
component.add(new Behavior() {

// What the tag turned out to be in this render pass, so that afterRender knows
// whether the value still needs a hidden mirror field.
private boolean renderedAsTextInput = false;

@Override
public void onComponentTag(Component component, ComponentTag tag) {
renderedAsTextInput = isTextInput(tag);
if (!c.isLocked(iri)) return;
// A field that is simply greyed out leaves the user guessing why they can't type
// in it, so say where the value came from.
tag.put("title", LOCKED_FIELD_MESSAGE);
tag.append("class", "locked-value", " ");
if (renderedAsTextInput) {
tag.put("readonly", "readonly");
} else {
tag.put("disabled", "disabled");
}
}

@Override
public void afterRender(Component component) {
if (!c.isLocked(iri) || renderedAsTextInput) return;
// Browsers don't submit a disabled control, so mirror its value in a hidden field
// of the same name to keep the form seeing the value it rendered.
FormComponent<?> fc = (FormComponent<?>) component;
String value = fc.getValue() == null ? "" : fc.getValue();
component.getResponse().write("<input type=\"hidden\" name=\""
+ Strings.escapeMarkup(fc.getInputName()) + "\" value=\""
+ Strings.escapeMarkup(value) + "\" />");
}

});
}

/**
* Whether the tag renders as a control that browsers submit while readonly. Decided on the
* rendered tag rather than on the Java class, because a choice field can be a text component
* that renders as a {@code select} (the select2-based choice fields are).
*/
private static boolean isTextInput(ComponentTag tag) {
String name = tag.getName().toLowerCase();
if ("textarea".equals(name)) return true;
if (!"input".equals(name)) return false;
String type = tag.getAttribute("type");
return !"checkbox".equalsIgnoreCase(type) && !"radio".equalsIgnoreCase(type) && !"hidden".equalsIgnoreCase(type);
}

/**
* Constructor for AbstractContextComponent.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ public Collection<String> toChoices(Collection<String> ids) {
textfield.add(new AttributeAppender("class", " wide"));
textfield.add(new Validator(iri, template, "", context));
context.getComponents().add(textfield);
lockIfNeeded(textfield, iri);

tooltipDescription = new Label("description", new IModel<String>() {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ public Collection<String> toChoices(Collection<String> ids) {
textfield.add(new AttributeAppender("class", " wide"));
textfield.add(new Validator(iri, template, prefix, context));
context.getComponents().add(textfield);
lockIfNeeded(textfield, iri);

tooltipDescription = new Label("description", new IModel<String>() {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ public IriTextfieldItem(String id, String parentId, final IRI iriP, boolean opti
}
textfield.add(new Validator(iri, template, prefixModel, context));
context.getComponents().add(textfield);
lockIfNeeded(textfield, iri);
if (template.getLabel(iri) != null) {
textfield.add(new AttributeModifier("placeholder", template.getLabel(iri).replaceFirst(" - .*$", "")));
textfield.setLabel(Model.of(template.getLabel(iri)));
Expand Down Expand Up @@ -214,6 +215,7 @@ protected void onUpdate(AjaxRequestTarget target) {

});
context.getComponents().add(prefixChoice);
lockIfNeeded(prefixChoice, iri);
add(prefixChoice);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ public LiteralDateItem(String id, final IRI iri, boolean optional, TemplateConte

context.getComponentModels().put(iri, dateComponent.getModel());
context.getComponents().add(dateComponent);
lockIfNeeded(dateComponent, iri);
dateComponent.add(new ValueItem.KeepValueAfterRefreshBehavior());
dateComponent.add(new InvalidityHighlighting());
add(dateComponent);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ public LiteralDateTimeItem(String id, final IRI iri, boolean optional, TemplateC

context.getComponentModels().put(iri, zonedDateTimePicker.getModel());
context.getComponents().add(zonedDateTimePicker);
lockIfNeeded(zonedDateTimePicker, iri);
zonedDateTimePicker.add(new ValueItem.KeepValueAfterRefreshBehavior());
zonedDateTimePicker.add(new InvalidityHighlighting());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ protected void onUpdate(AjaxRequestTarget target) {
});
context.getComponentModels().put(iri, tc.getModel());
context.getComponents().add(tc);
lockIfNeeded(tc, iri);
tc.add(new ValueItem.KeepValueAfterRefreshBehavior());
tc.add(new InvalidityHighlighting());
add(tc);
Expand Down Expand Up @@ -180,6 +181,7 @@ public boolean isRequired() {
langChoice.add(new ValueItem.KeepValueAfterRefreshBehavior());
langChoice.add(new LangTagValidator(possibleTags));
context.getComponents().add(langChoice);
lockIfNeeded(langChoice, iri);
add(langChoice);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ public ProfileAccountPanel(String id, String userIriString) {
+ "&param_key-declaration=" + Utils.urlEncode(shortKey)
+ "&param_key-declaration-ref=" + Utils.urlEncode(shortKey)
+ "&param_key-location=" + Utils.urlEncode(prefs.getWebsiteUrl())
// The introduction is about this user and this key: an introduction declaring
// anything else is wrong, so both values are shown but fixed (issue #678).
+ "&locked=" + Utils.urlEncode("param_user,param_public-key")
+ "&context=" + Utils.urlEncode(userIriString)
+ "&postpub-redirect-url=" + Utils.urlEncode(aboutUrl)
+ "&link-message=" + Utils.urlEncode("Check the checkbox at the end of this page and press 'Publish' to "
Expand Down
Loading