Skip to content

Commit

Permalink
feat: lunatic tooltip (#801)
Browse files Browse the repository at this point in the history
- refactor: method to gather all labels in in EnoCatalog class
- feat(tooltip): utils class with regex to make tooltips Lunatic-compliant
- feat(tooltip): add a Eno processing class to clean tooltips in labels
- feat(tooltip): update method that add quotes in static labels in "resolve labels" DDI processing
- test: unit and integration tests on the tooltip feature
  • Loading branch information
nsenave authored Nov 28, 2023
1 parent 89de4ac commit 95c7c1d
Show file tree
Hide file tree
Showing 13 changed files with 2,162 additions and 55 deletions.
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ plugins {

allprojects {
group = 'fr.insee.eno'
version = '3.12.4'
version = '3.13.0'
sourceCompatibility = '17'
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import fr.insee.eno.core.model.EnoQuestionnaire;
import fr.insee.eno.core.parameter.EnoParameters;
import fr.insee.eno.core.parameter.Format;
import fr.insee.eno.core.processing.ProcessingPipeline;
import fr.insee.eno.core.processing.common.steps.*;
import fr.insee.eno.core.reference.EnoCatalog;
Expand Down Expand Up @@ -39,6 +40,10 @@ public void applyProcessing(EnoQuestionnaire enoQuestionnaire) {
.thenIf(parameters.isCommentSection(), new EnoAddCommentSection(prefixingStep))
.then(new EnoInsertComponentFilters())
.then(new EnoResolveBindingReferences());

// Tooltip processing that is common to DDI and Pogues, but only concerns Lunatic
if (Format.LUNATIC.equals(parameters.getOutFormat()))
new EnoCleanTooltips(enoCatalog).apply(enoQuestionnaire);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package fr.insee.eno.core.processing.common.steps;

import fr.insee.eno.core.model.EnoQuestionnaire;
import fr.insee.eno.core.model.label.EnoLabel;
import fr.insee.eno.core.processing.ProcessingStep;
import fr.insee.eno.core.reference.EnoCatalog;
import fr.insee.eno.core.utils.TooltipUtils;

/**
* Processing to make tooltip in labels from Pogues or DDI compliant with Lunatic.
* @see TooltipUtils for details.
* */
public class EnoCleanTooltips implements ProcessingStep<EnoQuestionnaire> {

private final TooltipUtils tooltipUtils = new TooltipUtils();

private final EnoCatalog enoCatalog;

public EnoCleanTooltips(EnoCatalog enoCatalog) {
this.enoCatalog = enoCatalog;
}

/**
* For each label present in the given Eno questionnaire, clean Lunatic tooltips.
* @param enoQuestionnaire Eno questionnaire.
*/
@Override
public void apply(EnoQuestionnaire enoQuestionnaire) {
enoCatalog.getLabels().forEach(this::cleanTooltipsInLabel);
}

private void cleanTooltipsInLabel(EnoLabel enoLabel) {
enoLabel.setValue(tooltipUtils.cleanTooltips(enoLabel.getValue()));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
public class DDIInProcessing {

public void applyProcessing(EnoQuestionnaire enoQuestionnaire) {
//
EnoCatalog enoCatalog = new EnoCatalog(enoQuestionnaire);
//
ProcessingPipeline<EnoQuestionnaire> processingPipeline = new ProcessingPipeline<>();
processingPipeline.start(enoQuestionnaire)
Expand All @@ -18,8 +16,11 @@ public void applyProcessing(EnoQuestionnaire enoQuestionnaire) {
.then(new DDIResolveVariableReferencesInExpressions())
.then(new DDIInsertDeclarations())
.then(new DDIInsertControls())
.then(new DDIInsertCodeLists())
.then(new DDIResolveVariableReferencesInLabels(enoCatalog))
.then(new DDIInsertCodeLists());
//
EnoCatalog enoCatalog = new EnoCatalog(enoQuestionnaire);
//
processingPipeline.then(new DDIResolveVariableReferencesInLabels(enoCatalog))
.then(new DDIResolveSequencesStructure())
.then(new DDIResolveFiltersScope())
.then(new DDIResolveLoopsScope());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
package fr.insee.eno.core.processing.in.steps.ddi;

import fr.insee.eno.core.model.EnoQuestionnaire;
import fr.insee.eno.core.model.code.CodeItem;
import fr.insee.eno.core.model.code.CodeList;
import fr.insee.eno.core.model.declaration.Declaration;
import fr.insee.eno.core.model.declaration.Instruction;
import fr.insee.eno.core.model.label.EnoLabel;
import fr.insee.eno.core.model.navigation.Control;
import fr.insee.eno.core.model.question.Question;
import fr.insee.eno.core.model.question.SimpleMultipleChoiceQuestion;
import fr.insee.eno.core.model.sequence.AbstractSequence;
import fr.insee.eno.core.model.variable.Variable;
import fr.insee.eno.core.processing.ProcessingStep;
import fr.insee.eno.core.reference.EnoCatalog;
Expand Down Expand Up @@ -37,25 +29,7 @@ public class DDIResolveVariableReferencesInLabels implements ProcessingStep<EnoQ
* @param enoQuestionnaire Eno questionnaire to be processed.
*/
public void apply(EnoQuestionnaire enoQuestionnaire) {
// Sequences and subsequences
enoQuestionnaire.getSequences().stream().map(AbstractSequence::getLabel).forEach(this::resolveLabel);
enoQuestionnaire.getSubsequences().stream().map(AbstractSequence::getLabel).forEach(this::resolveLabel);
// Questions
enoCatalog.getQuestions().stream().map(Question::getLabel).forEach(this::resolveLabel);
// Declarations, instructions and controls within components
enoCatalog.getComponents().forEach(enoComponent -> {
enoComponent.getDeclarations().stream().map(Declaration::getLabel).forEach(this::resolveLabel);
enoComponent.getInstructions().stream().map(Instruction::getLabel).forEach(this::resolveLabel);
});
enoCatalog.getQuestions().forEach(enoQuestion ->
enoQuestion.getControls().stream().map(Control::getMessage).forEach(this::resolveLabel));
// Code lists
enoQuestionnaire.getCodeLists().stream().map(CodeList::getCodeItems).forEach(this::resolveCodeItemsLabel);
// Code lists in multiple response questions (might be refactored afterward)
enoQuestionnaire.getMultipleResponseQuestions().stream()
.filter(SimpleMultipleChoiceQuestion.class::isInstance)
.map(SimpleMultipleChoiceQuestion.class::cast)
.forEach(this::resolveCodeResponsesLabel);
enoCatalog.getLabels().forEach(this::resolveLabel);
}

/**
Expand All @@ -75,13 +49,20 @@ private void resolveLabel(EnoLabel enoLabel) {
enoLabel.setValue(resolvedValue);
}

/** For now, Pogues allows users to input invalid VTL expressions for static labels
* (i.e. to input a value without quotes), and Pogues does not add the missing quotes. */
/**
* For now, Pogues allows users to input invalid VTL expressions for static labels
* (i.e. to input a value without quotes), and Pogues does not add the missing quotes.
* @param staticLabel A static label value.
* @return The label value surrounded with quotes
*/
private String toValidStringExpression(String staticLabel) {
// Remove eventual quote characters that might be in the middle of the label
staticLabel = staticLabel.replace("\"", "");
// Return the label value surrounded with quotes
return "\"" + staticLabel + "\"";
StringBuilder stringBuilder = new StringBuilder();
if (! staticLabel.startsWith("\""))
stringBuilder.append("\"");
stringBuilder.append(staticLabel);
if(! staticLabel.endsWith("\""))
stringBuilder.append("\"");
return stringBuilder.toString();
}

/**
Expand All @@ -105,16 +86,4 @@ private List<Variable> getReferencedVariables(EnoLabel enoLabel) {
.toList();
}

private void resolveCodeItemsLabel(List<CodeItem> codeItems) {
for (CodeItem codeItem : codeItems) {
resolveLabel(codeItem.getLabel());
// Recursive call in case of nested code items
resolveCodeItemsLabel(codeItem.getCodeItems());
}
}

private void resolveCodeResponsesLabel(SimpleMultipleChoiceQuestion multipleChoiceQuestion) {
multipleChoiceQuestion.getCodeResponses().forEach(codeResponse -> resolveLabel(codeResponse.getLabel()));
}

}
59 changes: 55 additions & 4 deletions eno-core/src/main/java/fr/insee/eno/core/reference/EnoCatalog.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
package fr.insee.eno.core.reference;

import fr.insee.eno.core.model.*;
import fr.insee.eno.core.model.EnoComponent;
import fr.insee.eno.core.model.EnoQuestionnaire;
import fr.insee.eno.core.model.code.CodeItem;
import fr.insee.eno.core.model.code.CodeList;
import fr.insee.eno.core.model.declaration.Declaration;
import fr.insee.eno.core.model.declaration.Instruction;
import fr.insee.eno.core.model.label.EnoLabel;
import fr.insee.eno.core.model.navigation.Control;
import fr.insee.eno.core.model.question.Question;
import fr.insee.eno.core.model.question.SimpleMultipleChoiceQuestion;
import fr.insee.eno.core.model.response.CodeResponse;
import fr.insee.eno.core.model.sequence.AbstractSequence;
import fr.insee.eno.core.model.sequence.Sequence;
import fr.insee.eno.core.model.sequence.Subsequence;
import fr.insee.eno.core.model.variable.Variable;

import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.*;

/** Class designed to be used in processing to easily access different kinds of Eno objects. */
public class EnoCatalog {
Expand All @@ -23,6 +31,9 @@ public class EnoCatalog {
Map<String, EnoComponent> componentMap = new HashMap<>();
/** Map of collected variables stored by their reference (warning: keys = not ids). */
Map<String, Variable> variableMap = new HashMap<>();
/** List with all labels that are in the questionnaire. */
private final Collection<EnoLabel> labels = new ArrayDeque<>();
// NB: https://stackoverflow.com/questions/6129805/what-is-the-fastest-java-collection-with-the-basic-functionality-of-a-queue

public EnoCatalog(EnoQuestionnaire enoQuestionnaire) {
complementaryIndexing(enoQuestionnaire);
Expand All @@ -38,6 +49,8 @@ private void complementaryIndexing(EnoQuestionnaire enoQuestionnaire) {
componentMap.putAll(sequenceMap);
componentMap.putAll(subsequenceMap);
componentMap.putAll(questionMap);
// Labels
gatherLabels(enoQuestionnaire);
// Variables
enoQuestionnaire.getVariables().forEach(variable -> variableMap.put(variable.getReference(), variable));
}
Expand Down Expand Up @@ -67,5 +80,43 @@ public Collection<Variable> getVariables() {
public Collection<EnoComponent> getComponents() {
return componentMap.values();
}
public Collection<EnoLabel> getLabels() {
return labels;
}

private void gatherLabels(EnoQuestionnaire enoQuestionnaire) {
// Sequences and subsequences
labels.addAll(enoQuestionnaire.getSequences().stream().map(AbstractSequence::getLabel).toList());
labels.addAll(enoQuestionnaire.getSubsequences().stream().map(AbstractSequence::getLabel).toList());
// Questions
labels.addAll(this.getQuestions().stream().map(Question::getLabel).filter(Objects::nonNull).toList());
// Declarations, instructions and controls within components
this.getComponents().forEach(enoComponent -> {
labels.addAll(enoComponent.getDeclarations().stream().map(Declaration::getLabel).toList());
labels.addAll(enoComponent.getInstructions().stream().map(Instruction::getLabel).toList());
});
// Controls
this.getQuestions().forEach(enoQuestion ->
labels.addAll(enoQuestion.getControls().stream().map(Control::getMessage).toList()));
// Code lists
enoQuestionnaire.getCodeLists().stream().map(CodeList::getCodeItems).forEach(this::gatherLabelsFromCodeItems);
// Code lists in multiple response questions (might be refactored afterward)
enoQuestionnaire.getMultipleResponseQuestions().stream()
.filter(SimpleMultipleChoiceQuestion.class::isInstance)
.map(SimpleMultipleChoiceQuestion.class::cast)
.forEach(this::gatherLabelsFromCodeResponses);
}

private void gatherLabelsFromCodeItems(List<CodeItem> codeItems) {
for (CodeItem codeItem : codeItems) {
labels.add(codeItem.getLabel());
// Recursive call in case of nested code items
gatherLabelsFromCodeItems(codeItem.getCodeItems());
}
}

private void gatherLabelsFromCodeResponses(SimpleMultipleChoiceQuestion multipleChoiceQuestion) {
labels.addAll(multipleChoiceQuestion.getCodeResponses().stream().map(CodeResponse::getLabel).toList());
}

}
47 changes: 47 additions & 0 deletions eno-core/src/main/java/fr/insee/eno/core/utils/TooltipUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package fr.insee.eno.core.utils;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* <p>In the current implementation, tooltips are inputted this way in Pogues:
* <code>[some word](. "Tooltip's content")</code>.</p>
* <p>Yet tooltips work with simple quotes in Lunatic, so simple quotes must be replaced with apostrophes,
* and double quotes with simple quotes to be valid:
* </code>[some word](. 'Tooltip’s content')</code>.</p>
* */
public class TooltipUtils {

private static final String APOSTROPHE_CHARACTER = "’";
private static final String DOUBLE_QUOTES_TOOLTIP_REGEX = "\\[[^\\]]+\\]\\(\\.\\s\"[^\"]+\"\\)";

private final Pattern pattern;

public TooltipUtils() {
// compile pattern only once
this.pattern = Pattern.compile(DOUBLE_QUOTES_TOOLTIP_REGEX);
}

/**
* Searches for Lunatic tooltips in the given string. If any, these are cleaned to ensure compliance with Lunatic
* (Lunatic tooltips work with single quotes).
* @param label String value of a label.
* @return String value of the label with Lunatic-compliant tooltips.
*/
public String cleanTooltips(String label) {
Matcher matcher = pattern.matcher(label);

StringBuilder result = new StringBuilder();

while (matcher.find()) {
String tooltip = matcher.group();
tooltip = tooltip.replace("'", APOSTROPHE_CHARACTER);
tooltip = tooltip.replace("\"", "'");
matcher.appendReplacement(result, tooltip);
}

matcher.appendTail(result);
return result.toString();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package fr.insee.eno.core.processing.common.steps;

import fr.insee.eno.core.DDIToEno;
import fr.insee.eno.core.exceptions.business.DDIParsingException;
import fr.insee.eno.core.model.EnoQuestionnaire;
import fr.insee.eno.core.model.question.SimpleMultipleChoiceQuestion;
import fr.insee.eno.core.parameter.EnoParameters;
import fr.insee.eno.core.parameter.Format;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class EnoCleanTooltipsTest {

@Test
void integrationTestFromDDI() throws DDIParsingException {
// Given + When
//
EnoParameters enoParameters = EnoParameters.of(
EnoParameters.Context.DEFAULT, EnoParameters.ModeParameter.CAWI, Format.LUNATIC);
enoParameters.setSequenceNumbering(false);
enoParameters.setQuestionNumberingMode(EnoParameters.QuestionNumberingMode.NONE);
enoParameters.setArrowCharInQuestions(false);
//
EnoQuestionnaire enoQuestionnaire = DDIToEno.transform(
this.getClass().getClassLoader().getResourceAsStream("integration/ddi/ddi-tooltips.xml"),
enoParameters);

// Then
// NB: pay attention to the difference between simple quote and apostrophe
assertEquals("\"Sequence [label](. 'There is a tooltip on the label.')\"",
enoQuestionnaire.getSequences().get(0).getLabel().getValue());
assertEquals("\"Declaration with a tooltip [here](. 'Tooltip’s content.').\"",
enoQuestionnaire.getSequences().get(0).getInstructions().get(0).getLabel().getValue());
//
String expectedQuestionLabel = "\"Question label with a [tooltip](. " +
"'The tooltip, also known as infotip or hint, is a common graphical user interface element in which, " +
"when hovering over a screen element or component, a text box displays information about that element." +
"').\"";
assertEquals(expectedQuestionLabel, enoQuestionnaire.getSingleResponseQuestions().get(0).getLabel().getValue());
assertEquals("\"Before question [label](. 'Some text').\"",
enoQuestionnaire.getSingleResponseQuestions().get(0).getDeclarations().get(0).getLabel().getValue());
assertEquals("\"After question [label](. 'Some text').\"",
enoQuestionnaire.getSingleResponseQuestions().get(0).getInstructions().get(0).getLabel().getValue());
assertEquals("\"Error message with a [tooltip](. 'Some text')\"",
enoQuestionnaire.getSingleResponseQuestions().get(0).getControls().get(0).getMessage().getValue());
//
assertEquals("\"[Code 1](. 'Tooltip text of code 1')\"",
enoQuestionnaire.getCodeLists().get(0).getCodeItems().get(0).getLabel().getValue());
assertEquals("\"[Code 2](. 'Tooltip text of code 2')\"",
enoQuestionnaire.getCodeLists().get(0).getCodeItems().get(1).getLabel().getValue());
//
SimpleMultipleChoiceQuestion simpleMCQ = (SimpleMultipleChoiceQuestion)
enoQuestionnaire.getMultipleResponseQuestions().get(0);
assertEquals("\"[Code 1](. 'Tooltip text of code 1')\"",
simpleMCQ.getCodeResponses().get(0).getLabel().getValue());
assertEquals("\"[Code 2](. 'Tooltip text of code 2')\"",
simpleMCQ.getCodeResponses().get(1).getLabel().getValue());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@ static void mapQuestionnaire() throws DDIParsingException {
DDIDeserializer.deserialize(DDIResolveVariableReferencesInLabelsTest.class.getClassLoader()
.getResourceAsStream("integration/ddi/ddi-labels.xml")),
enoQuestionnaire);
EnoCatalog enoCatalog = new EnoCatalog(enoQuestionnaire);
new DDIInsertDeclarations().apply(enoQuestionnaire);
new DDIInsertControls().apply(enoQuestionnaire);
new DDIInsertCodeLists().apply(enoQuestionnaire);
EnoCatalog enoCatalog = new EnoCatalog(enoQuestionnaire);
// When
new DDIResolveVariableReferencesInLabels(enoCatalog).apply(enoQuestionnaire);
// Then
Expand Down
Loading

0 comments on commit 95c7c1d

Please sign in to comment.