diff --git a/inception-ui-kb/src/main/java/de/tudarmstadt/ukp/inception/ui/kb/feature/ConceptFeatureEditor.html b/inception-ui-kb/src/main/java/de/tudarmstadt/ukp/inception/ui/kb/feature/ConceptFeatureEditor.html index 25455de48b2..63eefaae17e 100644 --- a/inception-ui-kb/src/main/java/de/tudarmstadt/ukp/inception/ui/kb/feature/ConceptFeatureEditor.html +++ b/inception-ui-kb/src/main/java/de/tudarmstadt/ukp/inception/ui/kb/feature/ConceptFeatureEditor.html @@ -31,6 +31,11 @@ +
+
+ +
+
diff --git a/inception-ui-kb/src/main/java/de/tudarmstadt/ukp/inception/ui/kb/feature/ConceptFeatureEditor.java b/inception-ui-kb/src/main/java/de/tudarmstadt/ukp/inception/ui/kb/feature/ConceptFeatureEditor.java index d5409cdd88a..f87d20b2403 100644 --- a/inception-ui-kb/src/main/java/de/tudarmstadt/ukp/inception/ui/kb/feature/ConceptFeatureEditor.java +++ b/inception-ui-kb/src/main/java/de/tudarmstadt/ukp/inception/ui/kb/feature/ConceptFeatureEditor.java @@ -21,32 +21,48 @@ import static java.util.Arrays.asList; import static java.util.Collections.emptyList; import static org.apache.commons.lang3.StringUtils.isNotBlank; +import static org.apache.wicket.event.Broadcast.BUBBLE; import static org.apache.wicket.markup.head.JavaScriptHeaderItem.forReference; import java.util.Collections; import java.util.List; +import java.util.Locale; +import org.apache.commons.lang3.StringUtils; import org.apache.uima.cas.CAS; import org.apache.wicket.MarkupContainer; +import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.ajax.attributes.AjaxRequestAttributes; +import org.apache.wicket.ajax.form.AjaxFormChoiceComponentUpdatingBehavior; +import org.apache.wicket.ajax.form.AjaxFormComponentUpdatingBehavior; import org.apache.wicket.core.request.handler.IPartialPageRequestHandler; import org.apache.wicket.feedback.IFeedback; import org.apache.wicket.markup.head.IHeaderResponse; +import org.apache.wicket.markup.html.basic.Label; import org.apache.wicket.markup.html.form.FormComponent; import org.apache.wicket.markup.html.link.ExternalLink; import org.apache.wicket.model.CompoundPropertyModel; import org.apache.wicket.model.IModel; import org.apache.wicket.model.LoadableDetachableModel; import org.apache.wicket.model.Model; +import org.apache.wicket.request.IRequestParameters; import org.apache.wicket.request.cycle.RequestCycle; +import org.apache.wicket.request.http.WebRequest; import org.apache.wicket.spring.injection.annot.SpringBean; +import org.apache.wicket.util.convert.IConverter; +import org.apache.wicket.util.string.StringValue; +import org.danekja.java.util.function.serializable.SerializableFunction; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.googlecode.wicket.jquery.core.JQueryBehavior; + import de.tudarmstadt.ukp.clarin.webanno.api.annotation.action.AnnotationActionHandler; import de.tudarmstadt.ukp.clarin.webanno.api.annotation.feature.FeatureSupport; import de.tudarmstadt.ukp.clarin.webanno.api.annotation.feature.FeatureSupportRegistry; import de.tudarmstadt.ukp.clarin.webanno.api.annotation.feature.editor.FeatureEditor; import de.tudarmstadt.ukp.clarin.webanno.api.annotation.feature.editor.KendoChoiceDescriptionScriptReference; +import de.tudarmstadt.ukp.clarin.webanno.api.annotation.feature.event.FeatureEditorValueChangedEvent; import de.tudarmstadt.ukp.clarin.webanno.api.annotation.keybindings.KeyBindingsPanel; import de.tudarmstadt.ukp.clarin.webanno.api.annotation.model.AnnotatorState; import de.tudarmstadt.ukp.clarin.webanno.api.annotation.model.FeatureState; @@ -67,7 +83,8 @@ public class ConceptFeatureEditor private static final long serialVersionUID = 7763348613632105600L; - private FormComponent focusComponent; + private AutoCompleteField focusComponent; + private Label description; private IriInfoBadge iriBadge; private ExternalLink openIriLink; @@ -92,7 +109,7 @@ public ConceptFeatureEditor(String aId, MarkupContainer aItem, IModel + add(focusComponent = new AutoCompleteField(MID_VALUE, _query -> getCandidates(aStateModel, aHandler, _query))); AnnotationFeature feat = getModelObject().feature; @@ -103,6 +120,11 @@ public ConceptFeatureEditor(String aId, MarkupContainer aItem, IModel getLabelComponent().isVisible()))); + + description = new Label("description", LoadableDetachableModel.of(this::descriptionValue)); + description.setOutputMarkupPlaceholderTag(true); + description.add(visibleWhen(() -> getLabelComponent().isVisible())); + add(description); } @Override @@ -112,7 +134,17 @@ public void renderHead(IHeaderResponse aResponse) aResponse.render(forReference(KendoChoiceDescriptionScriptReference.get())); } - + + private String descriptionValue() + { + return getModel().map(FeatureState::getValue) + .map(value -> (KBHandle) value) + .map(KBHandle::getDescription) + .map(value -> StringUtils.abbreviate(value, 130)) + .orElse("no description") + .getObject(); + } + private String iriTooltipValue() { return getModel().map(FeatureState::getValue) @@ -174,10 +206,191 @@ private ConceptFeatureTraits readFeatureTraits(AnnotationFeature aAnnotationFeat ConceptFeatureTraits traits = fs.readTraits(aAnnotationFeature); return traits; } + + @Override + public void addFeatureUpdateBehavior() + { + focusComponent.add(new AjaxFormComponentUpdatingBehavior("change") + { + private static final long serialVersionUID = -8944946839865527412L; + + @Override + protected void updateAjaxAttributes(AjaxRequestAttributes aAttributes) + { + super.updateAjaxAttributes(aAttributes); + aAttributes.getDynamicExtraParameters() + .add(focusComponent.getIdentifierDynamicAttributeScript()); + addDelay(aAttributes, 250); + } + + @Override + protected void onUpdate(AjaxRequestTarget aTarget) + { + aTarget.add(description); + send(focusComponent, BUBBLE, + new FeatureEditorValueChangedEvent(ConceptFeatureEditor.this, aTarget)); + } + }); + } @Override public FormComponent getFocusComponent() { return focusComponent; } + + /** + * Special version of the {@link KnowledgeBaseItemAutoCompleteField} for used in the concept + * feature editor. + */ + public static class AutoCompleteField + extends KnowledgeBaseItemAutoCompleteField + { + private static final long serialVersionUID = 5461442869971269291L; + + private IConverter converter; + private List choiceCache; + private boolean allowChoiceCache = false; + + public AutoCompleteField(String aId, + SerializableFunction> aChoiceProvider) + { + super(aId, aChoiceProvider); + converter = newConverter(); + } + + @Override + public void onConfigure(JQueryBehavior aBehavior) + { + super.onConfigure(aBehavior); + + // We need to explicitly trigger the change event on the input element in order to + // trigger the Wicket AJAX update (if there is one). If we do not do this, then Kendo + // will "forget" to trigger a change event if the label of the newly selected item is + // the same as the label of the previously selected item!!! + // Using the default select behavior of AutoCompleteTextField which is coupled to the + // onSelected(AjaxRequestTarget aTarget) callback does unfortunatle not work well + // because onSelected does not tell us when the auto-complete field is CLEARED! + aBehavior.setOption("select", String.join(" ", + "function (e) {", + " e.sender.element.trigger('change');", + "}")); + } + + @Override + protected List getChoices(String aInput) + { + if (!allowChoiceCache || choiceCache == null) { + choiceCache = super.getChoices(aInput); + } + return choiceCache; + } + + @Override + public String[] getInputAsArray() + { + // If the web request includes the additional "identifier" parameter which is supposed + // to contain the IRI of the selected item instead of its label, then we use that as the + // value. + WebRequest request = getWebRequest(); + IRequestParameters requestParameters = request.getRequestParameters(); + StringValue identifier = requestParameters + .getParameterValue(getInputName() + ":identifier"); + + if (!identifier.isEmpty()) { + return new String[] { identifier.toString() }; + } + + return super.getInputAsArray(); + } + + /** + * When using this input component with an {@link AjaxFormChoiceComponentUpdatingBehavior}, + * it is necessary to request the identifier of the selected item as an additional dynamic + * attribute, otherwise no distinction can be made between two items with the same label! + */ + public String getIdentifierDynamicAttributeScript() + { + return String.join(" ", + "var item = $(attrs.event.target).data('kendoAutoComplete').dataItem();", + "if (item) {", + " return [{", + " 'name': '" + getInputName() + ":identifier', ", + " 'value': $(attrs.event.target).data('kendoAutoComplete').dataItem().identifier", + " }]", + "}", + "return [];"); + } + + @Override + public IConverter getConverter(Class aType) + { + if (aType != null && aType.isAssignableFrom(this.getType())) { + return (IConverter) converter; + } + + return super.getConverter(aType); + } + + private IConverter newConverter() + { + return new IConverter() { + + private static final long serialVersionUID = 1L; + + @Override + public KBHandle convertToObject(String value, Locale locale) + { + if (value == null) { + return null; + } + + if (value.equals(getModelValue())) { + return getModelObject(); + } + + // Check choices only here since fetching choices can take some time. If we + // already have choices from a previous query, then we use them instead of + // reloading all the choices. This avoids having to load the choices when + // opening the dropdown AND when selecting one of the items from it. + List choices; + try { + allowChoiceCache = true; + choices = getChoices(value); + } + finally { + allowChoiceCache = false; + } + + if (choices.isEmpty()) { + return null; + } + + // Check if we can find a match by the identifier. The identifier is unique + // while the same label may appear on multiple items + for (KBHandle handle : choices) { + if (value.equals(handle.getIdentifier())) { + return handle; + } + } + +// // Check labels if there was no match on the identifier +// for (KBHandle handle : choices) { +// if (value.equals(getRenderer().getText(handle))) { +// return handle; +// } +// } + + // If there was no match at all, return null + return null; + } + + @Override + public String convertToString(KBHandle value, Locale locale) + { + return getRenderer().getText(value); + } + }; + } + } } diff --git a/inception-ui-kb/src/main/java/de/tudarmstadt/ukp/inception/ui/kb/feature/ConceptFeatureSupport.java b/inception-ui-kb/src/main/java/de/tudarmstadt/ukp/inception/ui/kb/feature/ConceptFeatureSupport.java index c79967ec7a3..a3e523d10e1 100644 --- a/inception-ui-kb/src/main/java/de/tudarmstadt/ukp/inception/ui/kb/feature/ConceptFeatureSupport.java +++ b/inception-ui-kb/src/main/java/de/tudarmstadt/ukp/inception/ui/kb/feature/ConceptFeatureSupport.java @@ -138,11 +138,11 @@ public boolean accepts(AnnotationFeature aFeature) } @Override - public String renderFeatureValue(AnnotationFeature aFeature, String aLabel) + public String renderFeatureValue(AnnotationFeature aFeature, String aIdentifier) { String renderValue = null; - if (aLabel != null) { - return labelCache.get(new Key(aFeature, aLabel)).getUiLabel(); + if (aIdentifier != null) { + return labelCache.get(new Key(aFeature, aIdentifier)).getUiLabel(); } return renderValue; } @@ -202,7 +202,10 @@ public KBHandle wrapFeatureValue(AnnotationFeature aFeature, CAS aCAS, Object aV { if (aValue instanceof String) { String identifier = (String) aValue; - return new KBHandle(identifier, renderFeatureValue(aFeature, identifier)); + String label = renderFeatureValue(aFeature, identifier); + String description = labelCache.get(new Key(aFeature, identifier)).getDescription(); + + return new KBHandle(identifier, label, description); } else if (aValue instanceof KBHandle) { return (KBHandle) aValue; diff --git a/inception-ui-kb/src/main/java/de/tudarmstadt/ukp/inception/ui/kb/feature/KnowledgeBaseItemAutoCompleteField.java b/inception-ui-kb/src/main/java/de/tudarmstadt/ukp/inception/ui/kb/feature/KnowledgeBaseItemAutoCompleteField.java index 8cd7d0e6f8b..7e58ac40a44 100644 --- a/inception-ui-kb/src/main/java/de/tudarmstadt/ukp/inception/ui/kb/feature/KnowledgeBaseItemAutoCompleteField.java +++ b/inception-ui-kb/src/main/java/de/tudarmstadt/ukp/inception/ui/kb/feature/KnowledgeBaseItemAutoCompleteField.java @@ -35,6 +35,9 @@ import de.tudarmstadt.ukp.inception.kb.graph.KBHandle; +/** + * Auto-complete field for accessing a knowledge base. + */ public class KnowledgeBaseItemAutoCompleteField extends AutoCompleteTextField { @@ -76,7 +79,7 @@ protected List getChoices(String aInput) { return choiceProvider.apply(aInput); } - + @Override public void onConfigure(JQueryBehavior behavior) { @@ -94,6 +97,14 @@ public void onConfigure(JQueryBehavior behavior) "function(e) {", " e.sender.list.width(Math.max($(window).width()*0.3,300));", "}")); + + // Reset the values in the dropdown listbox to avoid that when opening the dropdown the next + // time ALL items with the same label as the selected item appear as selected + behavior.setOption("filtering", String.join(" ", + "function(e) {", + " e.sender.listView.value([]);", + "}")); + // Prevent scrolling action from closing the dropdown while the focus is on the input field // Use one-third of the browser width but not less than 300 pixels. This is better than // using the Kendo auto-sizing feature because that sometimes doesn't get the width right. @@ -127,7 +138,7 @@ public String getText() sb.append(" [${ data.rank }]"); sb.append(" "); sb.append(" # } #"); - sb.append(" ${ data.name }"); + sb.append(" ${ data.uiLabel }"); sb.append(" "); sb.append("
"); sb.append(" ${ data.identifier }"); @@ -148,7 +159,6 @@ public String getText() public List getTextProperties() { List properties = new ArrayList<>(); - properties.add("name"); properties.add("identifier"); properties.add("description"); properties.add("rank");