From 6106b811e546353c7a20eea021656a3e0ae04869 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Fri, 28 Aug 2026 12:51:53 +0200 Subject: [PATCH 1/3] feat: allow any URI or a local name in agent placeholders The agent placeholder offered a typed term only when it started with http(s):// or matched the ORCID pattern, so a URI in one of the other allowed schemes, or a name to be minted locally, could not be entered at all: the dropdown just said "No results found". Only the dropdown was in the way. TemplateContext already mints a plain name under the namespace of the nanopublication being published, and the validator already accepted one. - Offer any URI in an allowed scheme (Utils.isUriValue), not just http(s). - Offer a plain name that the validator accepts as a locally minted identifier, last in the list, so that matching known users keep the top. - Show a value that will be minted as "local:john (mint locally)", the note being set apart from the term itself. The rule lives in TemplateContext.isToBeMinted, mirroring processValue, and is used by the guided and restricted choice items too, where such a value can turn up after a fill. - Don't blow up on a value that is not a URI (createIRI throws), and show a value with no known name once rather than as "value (value)". - Use the generic user icon, not the logged-in user's picture, for an agent that isn't a parseable IRI. - Blank the href of a URI link built on a model when the value is not a URI, the way the String overload already does for local URIs. Closes #652 Co-Authored-By: Claude Opus 5 (1M context) --- .../com/knowledgepixels/nanodash/Utils.java | 49 ++++- .../nanodash/component/AgentChoiceItem.java | 53 ++++- .../nanodash/component/GuidedChoiceItem.java | 3 + .../component/RestrictedChoiceItem.java | 4 + .../nanodash/template/TemplateContext.java | 23 +++ src/main/webapp/style.css | 8 + .../component/AgentChoiceItemTest.java | 188 ++++++++++++++++++ 7 files changed, 321 insertions(+), 7 deletions(-) create mode 100644 src/test/java/com/knowledgepixels/nanodash/component/AgentChoiceItemTest.java diff --git a/src/main/java/com/knowledgepixels/nanodash/Utils.java b/src/main/java/com/knowledgepixels/nanodash/Utils.java index da79e819..c56005b3 100644 --- a/src/main/java/com/knowledgepixels/nanodash/Utils.java +++ b/src/main/java/com/knowledgepixels/nanodash/Utils.java @@ -990,7 +990,10 @@ public static String getPageParametersAsString(PageParameters params) { * @param selectItem the Select2Choice component to set the escape markup for */ public static void setSelect2ChoiceMinimalEscapeMarkup(Select2Choice selectItem) { - selectItem.getSettings().setEscapeMarkup("function(markup) {" + "return markup" + ".replaceAll('<','<').replaceAll('>', '>')" + ".replace(/^(.*?) - /, '$1
')" + ".replace(/\\((https?:[\\S]+)\\)$/, '
$1')" + ".replace(/^([^<].*)$/, '$1')" + ";}"); + // The note of a to-be-minted value is not part of the value, so it is set in its own + // span and styled as an aside rather than as the term itself (issue #652). + String noteRegex = TO_BE_MINTED_NOTE.replace("(", "\\(").replace(")", "\\)"); + selectItem.getSettings().setEscapeMarkup("function(markup) {" + "return markup" + ".replaceAll('<','<').replaceAll('>', '>')" + ".replace(/^(.*?) - /, '$1
')" + ".replace(/\\((https?:[\\S]+)\\)$/, '
$1')" + ".replace(/^(.*) " + noteRegex + "$/, '$1 " + TO_BE_MINTED_NOTE + "')" + ".replace(/^([^<].*)$/, '$1')" + ";}"); } /** @@ -1123,6 +1126,26 @@ public static String getUriLabel(String uri) { return uriLabel; } + /** + * How a value that a nanopublication will mint under its own namespace is shown in a choice + * field: with the local prefix in front of it and marked as not being an identifier yet, e.g. + * "local:john (to be minted)" (issue #652). See + * {@link com.knowledgepixels.nanodash.template.TemplateContext#isToBeMinted(IRI, String)} for + * which values these are. + * + * @param value the plain name held for the placeholder + * @return the label to show for it + */ + public static String getToBeMintedLabel(String value) { + return LocalUri.PREFIX + value + " " + TO_BE_MINTED_NOTE; + } + + /** + * The note appended to a to-be-minted value, set apart from the value itself by + * {@link #setSelect2ChoiceMinimalEscapeMarkup(Select2Choice)}. + */ + private static final String TO_BE_MINTED_NOTE = "(mint locally)"; + /** * Gets an ExternalLink with a URI label. * @@ -1142,7 +1165,29 @@ public static ExternalLink getUriLink(String markupId, String uri) { * @return an ExternalLink with the URI label */ public static ExternalLink getUriLink(String markupId, IModel model) { - return new ExternalLink(markupId, model, new UriLabelModel(model)); + return new ExternalLink(markupId, new UriHrefModel(model), new UriLabelModel(model)); + } + + /** + * The href of a URI link: empty for anything that isn't a URI to link to, so that a local + * URI or a locally minted name (issue #652) isn't turned into a relative link. This mirrors + * what {@link #getUriLink(String, String)} does with local URIs. + */ + private static class UriHrefModel implements IModel { + + private IModel uriModel; + + public UriHrefModel(IModel uriModel) { + this.uriModel = uriModel; + } + + @Override + public String getObject() { + String uri = uriModel.getObject(); + if (uri == null || isLocalURI(uri) || !isUriValue(uri)) return ""; + return uri; + } + } private static class UriLabelModel implements IModel { diff --git a/src/main/java/com/knowledgepixels/nanodash/component/AgentChoiceItem.java b/src/main/java/com/knowledgepixels/nanodash/component/AgentChoiceItem.java index e03f2f0a..67a08349 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/AgentChoiceItem.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/AgentChoiceItem.java @@ -47,12 +47,36 @@ public class AgentChoiceItem extends AbstractContextComponent { private static final Logger logger = LoggerFactory.getLogger(AgentChoiceItem.class); private String getChoiceLabel(String choiceId) { - IRI iri = vf.createIRI(choiceId); + IRI iri; + try { + iri = vf.createIRI(choiceId); + } catch (IllegalArgumentException ex) { + // A manually entered local name (issue #652) isn't a URI yet -- it is minted under + // the namespace of the nanopublication at publication time -- so there is no agent + // to look up, and the name itself is the best label we have. + return choiceId; + } String name = User.getName(iri); if (name != null) return name; return choiceId; } + /** + * Whether a manually entered term can be minted as a local identifier, i.e. everything that + * is neither a URI nor an ORCID and that {@link IriTextfieldItem.Validator} accepts once the + * local prefix is put in front of it (issue #652). + * + * @param term the term typed into the field + * @return true if the term can be offered as a locally minted identifier + */ + private static boolean isLocalName(String term) { + if (term == null || term.isBlank()) return false; + if (Utils.isUriValue(term) || term.matches(ProfilePage.ORCID_PATTERN)) return false; + // Same rule as the validator: no colon, hash or whitespace, and well-formed as a local URI. + if (!term.matches("[^:#\\s]+")) return false; + return Utils.isWellFormedUri(LocalUri.PREFIX + term); + } + /** * Constructor for AgentChoiceItem. * @@ -87,8 +111,14 @@ public AgentChoiceItem(String id, String parentId, final IRI iriP, boolean optio @Override public String getDisplayValue(String choiceId) { if (choiceId == null || choiceId.isEmpty()) return ""; + // A manually entered name is not an identifier yet, so it is shown as the local + // URI it will be minted into rather than as a bare word (issue #652). + if (context.isToBeMinted(iri, choiceId)) return Utils.getToBeMintedLabel(choiceId); String label = getChoiceLabel(choiceId); - if (label == null || label.isBlank()) { + // No name to show for an agent that isn't a known user -- a manually entered URI + // (issue #652) -- and repeating the value as its own label would only render it + // twice. + if (label == null || label.isBlank() || label.equals(choiceId)) { return choiceId; } return label + " (" + choiceId + ")"; @@ -116,7 +146,10 @@ public void query(String term, int page, Response response) { } return; } - if (term.startsWith("https://") || term.startsWith("http://")) { + // Any URI in an allowed scheme can be entered manually, not just http(s) ones + // (issue #652). + final String typedTerm = term; + if (Utils.isUriValue(term)) { response.add(term); } else if (term.matches(ProfilePage.ORCID_PATTERN)) { response.add("https://orcid.org/" + term); @@ -153,6 +186,14 @@ public void query(String term, int page, Response response) { response.add(iri.stringValue()); } } + + // Anything else the validator accepts is offered as a locally minted identifier + // (issue #652): typing "john-doe" mints it under the namespace of the + // nanopublication being published. It comes last, so that the known users the + // term matches keep the top of the list. + if (isLocalName(typedTerm) && !response.getResults().contains(typedTerm)) { + response.add(typedTerm); + } } @Override @@ -165,7 +206,7 @@ public Collection toChoices(Collection ids) { textfield.getSettings().getAjax(true).setDelay(500); textfield.getSettings().setCloseOnSelect(true); String placeholder = template.getLabel(iri); - if (placeholder == null) placeholder = "select user or paste ORCID/URL"; + if (placeholder == null) placeholder = "select user or type name/ORCID/URI"; textfield.getSettings().setPlaceholder(placeholder); Utils.setSelect2ChoiceMinimalEscapeMarkup(textfield); textfield.getSettings().setAllowClear(true); @@ -204,7 +245,9 @@ protected void onComponentTag(ComponentTag tag) { try { selectedIri = vf.createIRI(selectedValue); } catch (IllegalArgumentException e) { - selectedIri = NanodashSession.get().getUserIri(); + // A locally minted name (issue #652) identifies somebody other than the + // logged-in user, so it gets the generic icon rather than their picture. + selectedIri = null; } } else { selectedIri = NanodashSession.get().getUserIri(); diff --git a/src/main/java/com/knowledgepixels/nanodash/component/GuidedChoiceItem.java b/src/main/java/com/knowledgepixels/nanodash/component/GuidedChoiceItem.java index 823d0a45..edd5a8b3 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/GuidedChoiceItem.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/GuidedChoiceItem.java @@ -144,6 +144,9 @@ public GuidedChoiceItem(String id, String parentId, final IRI iriP, boolean opti @Override public String getDisplayValue(String choiceId) { if (choiceId == null || choiceId.isEmpty()) return ""; + // A value that has no identifier yet is shown as the local URI it will be minted + // into rather than as a bare word (issue #652). + if (context.isToBeMinted(iri, choiceId)) return Utils.getToBeMintedLabel(choiceId); String label = getChoiceLabel(choiceId); if (label == null || label.isBlank()) { return choiceId; diff --git a/src/main/java/com/knowledgepixels/nanodash/component/RestrictedChoiceItem.java b/src/main/java/com/knowledgepixels/nanodash/component/RestrictedChoiceItem.java index 19ff2439..fc10bd83 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/RestrictedChoiceItem.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/RestrictedChoiceItem.java @@ -88,6 +88,10 @@ public RestrictedChoiceItem(String id, String parentId, IRI iri, boolean optiona @Override public String getDisplayValue(String choiceId) { if (choiceId == null || choiceId.isEmpty()) return ""; + // A value that has no identifier yet is shown as the local URI it will be minted + // into rather than as a bare word (issue #652). Values that stay literals (they + // contain a space, say) keep being shown as they are. + if (context.isToBeMinted(iri, choiceId)) return Utils.getToBeMintedLabel(choiceId); if (!Utils.isUriValue(choiceId)) { return choiceId; } diff --git a/src/main/java/com/knowledgepixels/nanodash/template/TemplateContext.java b/src/main/java/com/knowledgepixels/nanodash/template/TemplateContext.java index d028da65..d1c8090b 100644 --- a/src/main/java/com/knowledgepixels/nanodash/template/TemplateContext.java +++ b/src/main/java/com/knowledgepixels/nanodash/template/TemplateContext.java @@ -370,6 +370,29 @@ public boolean hasUnresolvedPrefix(IRI iri) { return token != null && resolvePrefixBase(token) == null; } + /** + * Whether the given value, held for the given placeholder, will be minted as a new IRI under + * the namespace of the nanopublication being published, rather than referring to an existing + * one (issue #652). This mirrors what {@link #processValue(Value)} does with a plain name that + * has no prefix in front of it, and lets the form show such a value as what it is rather than + * as a bare word. + * + * @param iri the placeholder IRI + * @param value the value currently held for that placeholder + * @return true if publishing would mint the value under the target namespace + */ + public boolean isToBeMinted(IRI iri, String value) { + // The same rule processValue applies: a plain name (no colon, hash or space) gets the + // target namespace put in front of it. The colon also rules out anything that is a URI. + if (value == null || !value.matches("[^:# ]+")) return false; + // A space-/namespace-dependent prefix mints the resource under the space or maintained + // resource instead, so it is not a local identifier of this nanopublication. + if (hasDynamicPrefix(iri)) return false; + if (template.isLocalResource(iri)) return true; + String prefix = getPrefix(iri); + return prefix == null || prefix.isEmpty(); + } + private String resolvePrefixBase(String token) { String base = DynamicPrefix.resolveFromContext(token, navigationContextId); if (base != null && !base.isEmpty()) return base; diff --git a/src/main/webapp/style.css b/src/main/webapp/style.css index 0f183866..7a819039 100644 --- a/src/main/webapp/style.css +++ b/src/main/webapp/style.css @@ -1002,6 +1002,14 @@ a.actionlink { font-weight: bold; } +/* The note on a value that the nanopublication will mint itself: an aside about the value, + not part of it, so it stays out of the way of the term next to it (issue #652). */ +span.mint-note { + font-weight: normal; + font-style: italic; + vertical-align: middle; +} + .select2-dropdown.select2-dropdown--below { min-width: 600px !important; margin-top: -1px; diff --git a/src/test/java/com/knowledgepixels/nanodash/component/AgentChoiceItemTest.java b/src/test/java/com/knowledgepixels/nanodash/component/AgentChoiceItemTest.java new file mode 100644 index 00000000..7597f11b --- /dev/null +++ b/src/test/java/com/knowledgepixels/nanodash/component/AgentChoiceItemTest.java @@ -0,0 +1,188 @@ +package com.knowledgepixels.nanodash.component; + +import com.knowledgepixels.nanodash.WicketApplication; +import com.knowledgepixels.nanodash.domain.User; +import com.knowledgepixels.nanodash.domain.UserData; +import com.knowledgepixels.nanodash.template.ContextType; +import com.knowledgepixels.nanodash.template.Template; +import com.knowledgepixels.nanodash.template.TemplateContext; +import com.knowledgepixels.nanodash.template.TemplateData; +import com.knowledgepixels.nanodash.template.TemplateTestUtil; +import org.apache.wicket.Component; +import org.apache.wicket.util.tester.WicketTester; +import org.eclipse.rdf4j.model.IRI; +import org.eclipse.rdf4j.model.ValueFactory; +import org.eclipse.rdf4j.model.impl.SimpleValueFactory; +import org.eclipse.rdf4j.model.vocabulary.RDF; +import org.eclipse.rdf4j.model.vocabulary.RDFS; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.nanopub.NanopubCreator; +import org.nanopub.vocabulary.NTEMPLATE; +import org.wicketstuff.select2.Response; +import org.wicketstuff.select2.Select2Choice; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * Component-level tests for what an agent placeholder accepts: known users are suggested, but + * any URI in an allowed scheme and any locally minted name can also be entered by hand + * (issue #652). + */ +public class AgentChoiceItemTest { + + private static final ValueFactory vf = SimpleValueFactory.getInstance(); + + private static final String NP_URI = "https://w3id.org/np/RAAbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_AbCdE"; + private static final IRI AGENT = vf.createIRI(NP_URI + "/agent"); + + private WicketTester tester; + private MockedStatic templateDataMockedStatic; + private MockedStatic userMockedStatic; + + @BeforeEach + void setUp() { + tester = new WicketTester(new WicketApplication()); + templateDataMockedStatic = mockStatic(TemplateData.class); + // The known users are looked up on every keystroke; keep the test off the network. + userMockedStatic = mockStatic(User.class, CALLS_REAL_METHODS); + userMockedStatic.when(User::getUserData).thenReturn(mock(UserData.class)); + } + + @AfterEach + void tearDown() { + userMockedStatic.close(); + templateDataMockedStatic.close(); + } + + /** + * Builds a one-statement template whose object is an agent placeholder, and returns an + * initialized context for it. + */ + private TemplateContext agentContext() throws Exception { + NanopubCreator creator = new NanopubCreator(NP_URI); + creator.addProvenanceStatement(vf.createStatement(creator.getAssertionUri(), RDFS.SEEALSO, creator.getAssertionUri())); + creator.addPubinfoStatement(vf.createStatement(creator.getNanopubUri(), RDFS.SEEALSO, creator.getNanopubUri())); + IRI templateNode = creator.getAssertionUri(); + IRI st1 = vf.createIRI(NP_URI + "/st1"); + creator.addAssertionStatement(templateNode, RDF.TYPE, NTEMPLATE.ASSERTION_TEMPLATE); + creator.addAssertionStatement(templateNode, RDFS.LABEL, vf.createLiteral("Agent component test template")); + creator.addAssertionStatement(templateNode, NTEMPLATE.HAS_STATEMENT, st1); + creator.addAssertionStatement(st1, RDF.SUBJECT, vf.createIRI("http://example.com/subject")); + creator.addAssertionStatement(st1, RDF.PREDICATE, vf.createIRI("http://example.com/hasAgent")); + creator.addAssertionStatement(st1, RDF.OBJECT, AGENT); + creator.addAssertionStatement(AGENT, RDF.TYPE, NTEMPLATE.AGENT_PLACEHOLDER); + creator.addAssertionStatement(AGENT, RDFS.LABEL, vf.createLiteral("agent")); + Template template = TemplateTestUtil.parseTemplate(creator.finalizeNanopub()); + + TemplateData templateDataMock = mock(TemplateData.class); + templateDataMockedStatic.when(TemplateData::get).thenReturn(templateDataMock); + when(templateDataMock.getTemplate(NP_URI)).thenReturn(template); + + TemplateContext context = new TemplateContext(ContextType.ASSERTION, NP_URI, "statement", (String) null); + context.initStatements(); + return context; + } + + @SuppressWarnings("unchecked") + private List suggestionsFor(TemplateContext context, String term) { + Select2Choice field = null; + for (Component c : context.getComponents()) { + if (c instanceof Select2Choice) field = (Select2Choice) c; + } + assertTrue(field != null, "the agent placeholder must render a Select2 choice"); + Response response = new Response<>(); + field.getProvider().query(term, 0, response); + return response.getResults(); + } + + @SuppressWarnings("unchecked") + private String displayValueFor(TemplateContext context, String choiceId) { + for (Component c : context.getComponents()) { + if (c instanceof Select2Choice) return ((Select2Choice) c).getProvider().getDisplayValue(choiceId); + } + throw new AssertionError("the agent placeholder must render a Select2 choice"); + } + + /** + * A name that has no identifier yet is shown as the local URI it will be minted into, so that + * it doesn't read like an existing agent. + */ + @Test + void toBeMintedNameIsShownWithTheLocalPrefix() throws Exception { + assertEquals("local:john-doe (mint locally)", displayValueFor(agentContext(), "john-doe")); + } + + /** + * A URI of an agent that is not a known user has no name to show, so it must be rendered once + * rather than as "value (value)". + */ + @Test + void uriWithoutAKnownNameIsShownOnce() throws Exception { + TemplateContext context = agentContext(); + assertEquals("https://example.com/agents/jd", displayValueFor(context, "https://example.com/agents/jd")); + assertEquals("did:plc:z72i7hdynmk6r22z27h6tvur", displayValueFor(context, "did:plc:z72i7hdynmk6r22z27h6tvur")); + } + + @Test + void httpUriIsOffered() throws Exception { + assertTrue(suggestionsFor(agentContext(), "https://example.com/agents/jd").contains("https://example.com/agents/jd")); + } + + @Test + void orcidIsOfferedAsUri() throws Exception { + List suggestions = suggestionsFor(agentContext(), "0000-0002-1267-0234"); + assertTrue(suggestions.contains("https://orcid.org/0000-0002-1267-0234")); + // The bare ORCID itself would be a valid local name, but offering it next to the ORCID + // URI would only be a confusing near-duplicate. + assertFalse(suggestions.contains("0000-0002-1267-0234")); + } + + @Test + void uriInAnyAllowedSchemeIsOffered() throws Exception { + assertTrue(suggestionsFor(agentContext(), "did:plc:z72i7hdynmk6r22z27h6tvur").contains("did:plc:z72i7hdynmk6r22z27h6tvur")); + } + + @Test + void plainNameIsOfferedAsLocallyMintedIdentifier() throws Exception { + assertTrue(suggestionsFor(agentContext(), "john-doe").contains("john-doe")); + } + + @Test + void termWithWhitespaceIsNotOffered() throws Exception { + // A name with a space cannot be minted into a well-formed IRI, so the validator would + // reject it; it must not be offered as a choice in the first place. + assertTrue(suggestionsFor(agentContext(), "john doe").isEmpty()); + } + + /** + * The counterpart of the suggestion: a locally minted name ends up as an IRI under the + * namespace of the nanopublication being published. + */ + @Test + @SuppressWarnings("unchecked") + void plainNameIsMintedUnderTheTargetNamespace() throws Exception { + TemplateContext context = agentContext(); + ((org.apache.wicket.model.IModel) context.getComponentModels().get(AGENT)).setObject("john-doe"); + assertEquals(vf.createIRI(Template.DEFAULT_TARGET_NAMESPACE + "john-doe"), context.processIri(AGENT)); + } + + @Test + @SuppressWarnings("unchecked") + void enteredUriIsKeptAsIs() throws Exception { + TemplateContext context = agentContext(); + ((org.apache.wicket.model.IModel) context.getComponentModels().get(AGENT)).setObject("did:plc:z72i7hdynmk6r22z27h6tvur"); + assertEquals(vf.createIRI("did:plc:z72i7hdynmk6r22z27h6tvur"), context.processIri(AGENT)); + } + +} From 6aac2d0251f9f5efc49a2ad9d91ea9b1ed7abbec Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Fri, 28 Aug 2026 12:59:28 +0200 Subject: [PATCH 2/3] feat: offer plain names in guided choices, leave restricted ones alone A guided choice suggests values but does not limit them, so a plain name for a resource that has no identifier yet belongs there as much as in the agent placeholder: it is minted under the prefix of the field, or under the namespace of the nanopublication when the field has none, in which case the value is shown as "local:john (mint locally)" as well. A restricted choice, on the other hand, is meant to be restricted. It never offered such a name, and it no longer marks one either. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/knowledgepixels/nanodash/Utils.java | 18 ++ .../nanodash/component/AgentChoiceItem.java | 11 +- .../nanodash/component/GuidedChoiceItem.java | 10 + .../component/RestrictedChoiceItem.java | 4 - .../component/GuidedChoiceItemTest.java | 182 ++++++++++++++++++ 5 files changed, 213 insertions(+), 12 deletions(-) create mode 100644 src/test/java/com/knowledgepixels/nanodash/component/GuidedChoiceItemTest.java diff --git a/src/main/java/com/knowledgepixels/nanodash/Utils.java b/src/main/java/com/knowledgepixels/nanodash/Utils.java index c56005b3..be00c496 100644 --- a/src/main/java/com/knowledgepixels/nanodash/Utils.java +++ b/src/main/java/com/knowledgepixels/nanodash/Utils.java @@ -1126,6 +1126,24 @@ public static String getUriLabel(String uri) { return uriLabel; } + /** + * Whether a term typed into a choice field can be entered as a plain name for a resource that + * has no identifier yet (issue #652): it is not a URI already, and the IRI validator accepts + * it once a prefix is put in front of it -- the field's own, or the local one when it has + * none, in which case the nanopublication mints it under its own namespace. + * + * @param term the term typed into the field + * @return true if the term can be offered as a plain name + */ + public static boolean isPlainName(String term) { + if (term == null || term.isBlank()) return false; + if (isUriValue(term)) return false; + // Same rule as the validator: no colon, hash or whitespace, and well-formed as a URI once + // prefixed. + if (!term.matches("[^:#\\s]+")) return false; + return isWellFormedUri(LocalUri.PREFIX + term); + } + /** * How a value that a nanopublication will mint under its own namespace is shown in a choice * field: with the local prefix in front of it and marked as not being an identifier yet, e.g. diff --git a/src/main/java/com/knowledgepixels/nanodash/component/AgentChoiceItem.java b/src/main/java/com/knowledgepixels/nanodash/component/AgentChoiceItem.java index 67a08349..3d267fba 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/AgentChoiceItem.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/AgentChoiceItem.java @@ -62,19 +62,14 @@ private String getChoiceLabel(String choiceId) { } /** - * Whether a manually entered term can be minted as a local identifier, i.e. everything that - * is neither a URI nor an ORCID and that {@link IriTextfieldItem.Validator} accepts once the - * local prefix is put in front of it (issue #652). + * Whether a manually entered term can be minted as a local identifier: a plain name that is + * not an ORCID, which is offered as the ORCID URI instead (issue #652). * * @param term the term typed into the field * @return true if the term can be offered as a locally minted identifier */ private static boolean isLocalName(String term) { - if (term == null || term.isBlank()) return false; - if (Utils.isUriValue(term) || term.matches(ProfilePage.ORCID_PATTERN)) return false; - // Same rule as the validator: no colon, hash or whitespace, and well-formed as a local URI. - if (!term.matches("[^:#\\s]+")) return false; - return Utils.isWellFormedUri(LocalUri.PREFIX + term); + return Utils.isPlainName(term) && !term.matches(ProfilePage.ORCID_PATTERN); } /** diff --git a/src/main/java/com/knowledgepixels/nanodash/component/GuidedChoiceItem.java b/src/main/java/com/knowledgepixels/nanodash/component/GuidedChoiceItem.java index edd5a8b3..9cbb378e 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/GuidedChoiceItem.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/GuidedChoiceItem.java @@ -170,6 +170,7 @@ public void query(String term, int page, Response response) { response.addAll(possibleValues); return; } + final String typedTerm = term; if (Utils.isUriValue(term)) { if (prefix == null || term.startsWith(prefix)) { response.add(term); @@ -186,6 +187,15 @@ public void query(String term, int page, Response response) { for (String v : context.getTemplate().getPossibleValuesFromApi(iri, term, labelMap)) { if (!alreadyAddedMap.containsKey(v)) response.add(v); } + + // A guided choice only suggests values, it doesn't limit them, so a plain name for + // a resource that has no identifier yet can be entered as well (issue #652): it is + // minted under the prefix of the field, or under the namespace of the + // nanopublication when the field has none. It comes last, so that the suggestions + // the term matches keep the top of the list. + if (Utils.isPlainName(typedTerm) && !response.getResults().contains(typedTerm)) { + response.add(typedTerm); + } } @Override diff --git a/src/main/java/com/knowledgepixels/nanodash/component/RestrictedChoiceItem.java b/src/main/java/com/knowledgepixels/nanodash/component/RestrictedChoiceItem.java index fc10bd83..19ff2439 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/RestrictedChoiceItem.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/RestrictedChoiceItem.java @@ -88,10 +88,6 @@ public RestrictedChoiceItem(String id, String parentId, IRI iri, boolean optiona @Override public String getDisplayValue(String choiceId) { if (choiceId == null || choiceId.isEmpty()) return ""; - // A value that has no identifier yet is shown as the local URI it will be minted - // into rather than as a bare word (issue #652). Values that stay literals (they - // contain a space, say) keep being shown as they are. - if (context.isToBeMinted(iri, choiceId)) return Utils.getToBeMintedLabel(choiceId); if (!Utils.isUriValue(choiceId)) { return choiceId; } diff --git a/src/test/java/com/knowledgepixels/nanodash/component/GuidedChoiceItemTest.java b/src/test/java/com/knowledgepixels/nanodash/component/GuidedChoiceItemTest.java new file mode 100644 index 00000000..8509d091 --- /dev/null +++ b/src/test/java/com/knowledgepixels/nanodash/component/GuidedChoiceItemTest.java @@ -0,0 +1,182 @@ +package com.knowledgepixels.nanodash.component; + +import com.knowledgepixels.nanodash.WicketApplication; +import com.knowledgepixels.nanodash.domain.User; +import com.knowledgepixels.nanodash.domain.UserData; +import com.knowledgepixels.nanodash.template.ContextType; +import com.knowledgepixels.nanodash.template.Template; +import com.knowledgepixels.nanodash.template.TemplateContext; +import com.knowledgepixels.nanodash.template.TemplateData; +import com.knowledgepixels.nanodash.template.TemplateTestUtil; +import org.apache.wicket.Component; +import org.apache.wicket.util.tester.WicketTester; +import org.eclipse.rdf4j.model.IRI; +import org.eclipse.rdf4j.model.ValueFactory; +import org.eclipse.rdf4j.model.impl.SimpleValueFactory; +import org.eclipse.rdf4j.model.vocabulary.RDF; +import org.eclipse.rdf4j.model.vocabulary.RDFS; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.nanopub.NanopubCreator; +import org.nanopub.vocabulary.NTEMPLATE; +import org.wicketstuff.select2.Response; +import org.wicketstuff.select2.Select2Choice; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * Component-level tests for what a guided choice placeholder accepts: it suggests values but does + * not limit them, so a plain name for a resource that has no identifier yet can be entered too + * (issue #652). + */ +public class GuidedChoiceItemTest { + + private static final ValueFactory vf = SimpleValueFactory.getInstance(); + + private static final String NP_URI = "https://w3id.org/np/RAAbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_AbCdE"; + private static final IRI THING = vf.createIRI(NP_URI + "/thing"); + + private MockedStatic templateDataMockedStatic; + private MockedStatic userMockedStatic; + + @BeforeEach + void setUp() { + new WicketTester(new WicketApplication()); + templateDataMockedStatic = mockStatic(TemplateData.class); + // Building the form looks users up; keep the test off the network. + userMockedStatic = mockStatic(User.class, CALLS_REAL_METHODS); + userMockedStatic.when(User::getUserData).thenReturn(mock(UserData.class)); + } + + @AfterEach + void tearDown() { + userMockedStatic.close(); + templateDataMockedStatic.close(); + } + + /** + * Builds a one-statement template whose object is a guided choice placeholder with the given + * prefix (none if null), and returns an initialized context for it. + */ + private TemplateContext guidedContext(String prefix) throws Exception { + NanopubCreator creator = new NanopubCreator(NP_URI); + creator.addProvenanceStatement(vf.createStatement(creator.getAssertionUri(), RDFS.SEEALSO, creator.getAssertionUri())); + creator.addPubinfoStatement(vf.createStatement(creator.getNanopubUri(), RDFS.SEEALSO, creator.getNanopubUri())); + IRI templateNode = creator.getAssertionUri(); + IRI st1 = vf.createIRI(NP_URI + "/st1"); + creator.addAssertionStatement(templateNode, RDF.TYPE, NTEMPLATE.ASSERTION_TEMPLATE); + creator.addAssertionStatement(templateNode, RDFS.LABEL, vf.createLiteral("Guided choice component test template")); + creator.addAssertionStatement(templateNode, NTEMPLATE.HAS_STATEMENT, st1); + creator.addAssertionStatement(st1, RDF.SUBJECT, vf.createIRI("http://example.com/subject")); + creator.addAssertionStatement(st1, RDF.PREDICATE, vf.createIRI("http://example.com/hasThing")); + creator.addAssertionStatement(st1, RDF.OBJECT, THING); + creator.addAssertionStatement(THING, RDF.TYPE, NTEMPLATE.GUIDED_CHOICE_PLACEHOLDER); + creator.addAssertionStatement(THING, RDFS.LABEL, vf.createLiteral("thing")); + if (prefix != null) { + creator.addAssertionStatement(THING, NTEMPLATE.HAS_PREFIX, vf.createLiteral(prefix)); + } + Template template = TemplateTestUtil.parseTemplate(creator.finalizeNanopub()); + + TemplateData templateDataMock = mock(TemplateData.class); + templateDataMockedStatic.when(TemplateData::get).thenReturn(templateDataMock); + when(templateDataMock.getTemplate(NP_URI)).thenReturn(template); + + TemplateContext context = new TemplateContext(ContextType.ASSERTION, NP_URI, "statement", (String) null); + context.initStatements(); + return context; + } + + @SuppressWarnings("unchecked") + private Select2Choice fieldOf(TemplateContext context) { + for (Component c : context.getComponents()) { + if (c instanceof Select2Choice) return (Select2Choice) c; + } + throw new AssertionError("the guided choice placeholder must render a Select2 choice"); + } + + private List suggestionsFor(TemplateContext context, String term) { + Response response = new Response<>(); + fieldOf(context).getProvider().query(term, 0, response); + return response.getResults(); + } + + @Test + void plainNameIsOffered() throws Exception { + assertTrue(suggestionsFor(guidedContext(null), "john").contains("john")); + } + + @Test + void uriIsOffered() throws Exception { + assertTrue(suggestionsFor(guidedContext(null), "https://example.com/thing").contains("https://example.com/thing")); + } + + @Test + void termWithWhitespaceIsNotOffered() throws Exception { + // A name with a space cannot be turned into a well-formed IRI, so the validator would + // reject it; it must not be offered as a choice in the first place. + assertTrue(suggestionsFor(guidedContext(null), "john doe").isEmpty()); + } + + /** + * Without a prefix, the nanopublication mints the name under its own namespace, and the field + * says so. + */ + @Test + void plainNameIsShownAsMintedLocally() throws Exception { + assertEquals("local:john (mint locally)", fieldOf(guidedContext(null)).getProvider().getDisplayValue("john")); + } + + /** + * With a prefix, the name is minted under that prefix instead, which the field shows next to + * the value -- so it is not a local identifier and must not be marked as one. + */ + @Test + void plainNameUnderAPrefixIsNotMintedLocally() throws Exception { + TemplateContext context = guidedContext("https://example.org/"); + assertTrue(suggestionsFor(context, "john").contains("john")); + assertEquals("john", fieldOf(context).getProvider().getDisplayValue("john")); + } + + /** + * A restricted choice is restricted: it must keep offering only what the template allows. + */ + @Test + void restrictedChoiceOffersNoPlainName() throws Exception { + NanopubCreator creator = new NanopubCreator(NP_URI); + creator.addProvenanceStatement(vf.createStatement(creator.getAssertionUri(), RDFS.SEEALSO, creator.getAssertionUri())); + creator.addPubinfoStatement(vf.createStatement(creator.getNanopubUri(), RDFS.SEEALSO, creator.getNanopubUri())); + IRI templateNode = creator.getAssertionUri(); + IRI st1 = vf.createIRI(NP_URI + "/st1"); + creator.addAssertionStatement(templateNode, RDF.TYPE, NTEMPLATE.ASSERTION_TEMPLATE); + creator.addAssertionStatement(templateNode, RDFS.LABEL, vf.createLiteral("Restricted choice component test template")); + creator.addAssertionStatement(templateNode, NTEMPLATE.HAS_STATEMENT, st1); + creator.addAssertionStatement(st1, RDF.SUBJECT, vf.createIRI("http://example.com/subject")); + creator.addAssertionStatement(st1, RDF.PREDICATE, vf.createIRI("http://example.com/hasThing")); + creator.addAssertionStatement(st1, RDF.OBJECT, THING); + creator.addAssertionStatement(THING, RDF.TYPE, NTEMPLATE.RESTRICTED_CHOICE_PLACEHOLDER); + creator.addAssertionStatement(THING, RDFS.LABEL, vf.createLiteral("thing")); + creator.addAssertionStatement(THING, NTEMPLATE.POSSIBLE_VALUE, vf.createIRI("https://example.com/allowed-thing")); + Template template = TemplateTestUtil.parseTemplate(creator.finalizeNanopub()); + TemplateData templateDataMock = mock(TemplateData.class); + templateDataMockedStatic.when(TemplateData::get).thenReturn(templateDataMock); + when(templateDataMock.getTemplate(NP_URI)).thenReturn(template); + TemplateContext context = new TemplateContext(ContextType.ASSERTION, NP_URI, "statement", (String) null); + context.initStatements(); + + List suggestions = suggestionsFor(context, "john"); + assertFalse(suggestions.contains("john"), "a restricted choice must not offer a made-up name"); + assertTrue(suggestions.isEmpty()); + assertEquals("john", fieldOf(context).getProvider().getDisplayValue("john")); + } + +} From 42d47897e5f234e83bb78bb7b5239c361dd83b71 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Fri, 28 Aug 2026 13:13:06 +0200 Subject: [PATCH 3/3] fix: align the "mint locally" note with the value in a selected choice In the selected choice the value is an inline-block pushed down by 3px, so the note next to it, being a plain inline span, sat that much higher. It gets the same treatment now. Co-Authored-By: Claude Opus 5 (1M context) --- src/main/webapp/style.css | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/webapp/style.css b/src/main/webapp/style.css index 7a819039..f076b128 100644 --- a/src/main/webapp/style.css +++ b/src/main/webapp/style.css @@ -993,7 +993,8 @@ a.actionlink { max-height: 28px; } -.select2-selection__rendered > span.term { +.select2-selection__rendered > span.term, +.select2-selection__rendered > span.mint-note { display: inline-block; margin-top: 3px; } @@ -1002,8 +1003,9 @@ a.actionlink { font-weight: bold; } -/* The note on a value that the nanopublication will mint itself: an aside about the value, - not part of it, so it stays out of the way of the term next to it (issue #652). */ +/* The note on a value that the nanopublication will mint itself: an aside about the value, not + part of it, so it is set apart from the term next to it -- but only in weight and slant, since + it sits on the same line and shares the rules above with that term (issue #652). */ span.mint-note { font-weight: normal; font-style: italic;