001/*
002 *  Copyright 2021 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016
017package org.ametys.plugins.forms.dao;
018
019import java.util.ArrayList;
020import java.util.Arrays;
021import java.util.Collection;
022import java.util.HashMap;
023import java.util.List;
024import java.util.Map;
025import java.util.Optional;
026import java.util.Set;
027
028import org.apache.avalon.framework.component.Component;
029import org.apache.avalon.framework.context.Context;
030import org.apache.avalon.framework.context.ContextException;
031import org.apache.avalon.framework.context.Contextualizable;
032import org.apache.avalon.framework.service.ServiceException;
033import org.apache.avalon.framework.service.ServiceManager;
034import org.apache.avalon.framework.service.Serviceable;
035import org.apache.cocoon.Constants;
036import org.apache.cocoon.ProcessingException;
037import org.apache.commons.collections.ListUtils;
038import org.apache.commons.lang.StringUtils;
039import org.apache.commons.lang3.ArrayUtils;
040
041import org.ametys.core.observation.Event;
042import org.ametys.core.observation.ObservationManager;
043import org.ametys.core.right.RightManager;
044import org.ametys.core.ui.Callable;
045import org.ametys.core.upload.UploadManager;
046import org.ametys.core.user.CurrentUserProvider;
047import org.ametys.core.user.UserIdentity;
048import org.ametys.core.util.I18nUtils;
049import org.ametys.core.util.JSONUtils;
050import org.ametys.plugins.forms.FormEvents;
051import org.ametys.plugins.forms.question.FormQuestionType;
052import org.ametys.plugins.forms.question.FormQuestionTypeExtensionPoint;
053import org.ametys.plugins.forms.question.sources.AbstractSourceType;
054import org.ametys.plugins.forms.question.sources.ChoiceOption;
055import org.ametys.plugins.forms.question.sources.ChoiceSourceType;
056import org.ametys.plugins.forms.question.sources.ChoiceSourceTypeExtensionPoint;
057import org.ametys.plugins.forms.question.types.ChoicesListQuestionType;
058import org.ametys.plugins.forms.repository.CopyFormUpdater;
059import org.ametys.plugins.forms.repository.CopyFormUpdaterExtensionPoint;
060import org.ametys.plugins.forms.repository.Form;
061import org.ametys.plugins.forms.repository.FormEntry;
062import org.ametys.plugins.forms.repository.FormPage;
063import org.ametys.plugins.forms.repository.FormPageRule;
064import org.ametys.plugins.forms.repository.FormPageRule.PageRuleType;
065import org.ametys.plugins.forms.repository.FormQuestion;
066import org.ametys.plugins.forms.repository.type.Rule;
067import org.ametys.plugins.forms.repository.type.Rule.QuestionRuleType;
068import org.ametys.plugins.forms.rights.FormsDirectoryRightAssignmentContext;
069import org.ametys.plugins.repository.AmetysObjectResolver;
070import org.ametys.plugins.repository.UnknownAmetysObjectException;
071import org.ametys.plugins.repository.jcr.NameHelper;
072import org.ametys.runtime.i18n.I18nizableText;
073import org.ametys.runtime.model.DefinitionContext;
074import org.ametys.runtime.model.ElementDefinition;
075import org.ametys.runtime.model.Model;
076import org.ametys.runtime.model.ModelItem;
077import org.ametys.runtime.model.View;
078import org.ametys.runtime.plugin.component.AbstractLogEnabled;
079import org.ametys.web.parameters.ParametersManager;
080
081/** DAO for manipulating form questions */
082public class FormQuestionDAO extends AbstractLogEnabled implements Serviceable, Component, Contextualizable
083{
084    /** The Avalon role */
085    public static final String ROLE = FormQuestionDAO.class.getName();
086
087    /** Name for rules root jcr node */
088    public static final String RULES_ROOT = "ametys-internal:form-page-rules";
089    
090    /** Ametys object resolver. */
091    protected AmetysObjectResolver _resolver;
092    /** Observer manager. */
093    protected ObservationManager _observationManager;
094    /** The current user provider. */
095    protected CurrentUserProvider _currentUserProvider;
096    /** Manager for retrieving uploaded files */
097    protected UploadManager _uploadManager;
098    /** JSON helper */
099    protected JSONUtils _jsonUtils;
100    /** I18n Utils */
101    protected I18nUtils _i18nUtils;
102    /** The form question type extension point */
103    protected FormQuestionTypeExtensionPoint _formQuestionTypeExtensionPoint;
104    /** The parameters manager */
105    protected ParametersManager _parametersManager;
106    /** The Avalon context */
107    protected Context _context;
108    /** The cocoon context */
109    protected org.apache.cocoon.environment.Context _cocoonContext;
110    /** The choice source type extension point */
111    protected ChoiceSourceTypeExtensionPoint _choiceSourceTypeExtensionPoint;
112    /**The form DAO */
113    protected FormDAO _formDAO;
114    /** The right manager */
115    protected RightManager _rightManager;
116    /** The copy form updater extension point */
117    protected CopyFormUpdaterExtensionPoint _copyFormEP;
118    
119    @Override
120    public void service(ServiceManager serviceManager) throws ServiceException
121    {
122        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
123        _observationManager = (ObservationManager) serviceManager.lookup(ObservationManager.ROLE);
124        _parametersManager = (ParametersManager) serviceManager.lookup(ParametersManager.ROLE);
125        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
126        _uploadManager = (UploadManager) serviceManager.lookup(UploadManager.ROLE);
127        _jsonUtils = (JSONUtils) serviceManager.lookup(JSONUtils.ROLE);
128        _i18nUtils = (I18nUtils) serviceManager.lookup(I18nUtils.ROLE);
129        _formQuestionTypeExtensionPoint = (FormQuestionTypeExtensionPoint) serviceManager.lookup(FormQuestionTypeExtensionPoint.ROLE);
130        _parametersManager = (ParametersManager) serviceManager.lookup(ParametersManager.ROLE);
131        _formDAO = (FormDAO) serviceManager.lookup(FormDAO.ROLE);
132        _rightManager = (RightManager) serviceManager.lookup(RightManager.ROLE);
133        _copyFormEP = (CopyFormUpdaterExtensionPoint) serviceManager.lookup(CopyFormUpdaterExtensionPoint.ROLE);
134    }
135    
136    @Override
137    public void contextualize(Context context) throws ContextException
138    {
139        _context = context;
140        _cocoonContext = (org.apache.cocoon.environment.Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
141    }
142    
143    /**
144     * Provides the current user.
145     * @return the user which cannot be <code>null</code>.
146     */
147    protected UserIdentity _getCurrentUser()
148    {      
149        return _currentUserProvider.getUser();
150    }
151    
152    /**
153     * Gets properties of a form question
154     * @param id The id of the form question
155     * @return The properties
156     */
157    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
158    public Map<String, Object> getQuestionProperties (String id)
159    {
160        try
161        {
162            FormQuestion question = _resolver.resolveById(id);
163            return getQuestionProperties(question, true);
164        }
165        catch (UnknownAmetysObjectException e)
166        {
167            getLogger().warn("Can't find question with id: {}. It probably has just been deleted", id, e);
168            Map<String, Object> infos = new HashMap<>();
169            infos.put("id", id);
170            return infos;
171        }
172    }
173    
174    /**
175     * Gets properties of a form question
176     * @param question The form question
177     * @param withRight <code>true</code> to have the rights in the properties
178     * @return The properties
179     */
180    public Map<String, Object> getQuestionProperties (FormQuestion question, boolean withRight)
181    {
182        Map<String, Object> properties = new HashMap<>();
183        
184        boolean hasTerminalRule = _hasTerminalRule(question);
185        List<String> questionTitlesWithRule = _getQuestionTitlesWithRule(question);
186        List<String> pageTitlesWithRule = _getPageTitlesWithRule(question);
187
188        properties.put("type", "question"); 
189        properties.put("hasTerminalRule", hasTerminalRule);
190        properties.put("pageTitlesWithRule", pageTitlesWithRule);
191        properties.put("questionTitlesWithRule", questionTitlesWithRule);
192        properties.put("isReadRestricted", question.isReadRestricted());
193        properties.put("isModifiable", question.isModifiable());
194        properties.put("hasChildren", false);
195
196        /** Use in the bus message */
197        properties.put("id", question.getId());
198        properties.put("title", question.getTitle());
199        properties.put("questionType", question.getType().getId());
200        properties.put("pageId", question.getFormPage().getId());
201        properties.put("formId", question.getForm().getId());
202        properties.put("iconGlyph", question.getType().getIconGlyph());
203        properties.put("typeLabel", question.getType().getLabel());
204        properties.put("hasEntries", !question.getForm().getEntries().isEmpty());
205        properties.put("hasRule", hasTerminalRule || !pageTitlesWithRule.isEmpty() || !questionTitlesWithRule.isEmpty());
206        properties.put("isConfigured", question.getType().isQuestionConfigured(question));
207        
208        if (withRight)
209        {
210            properties.put("rights", _getUserRights(question));
211        }
212        else
213        {
214            properties.put("canWrite", _formDAO.hasWriteRightOnForm(_currentUserProvider.getUser(), question));
215        }
216        
217        return properties;
218    }
219    
220    /**
221     * Get options from the choice list question
222     * @param questionId the choice list question id
223     * @return the map of option
224     */
225    @Callable (rights = FormDAO.HANDLE_FORMS_RIGHT_ID, rightContext = FormsDirectoryRightAssignmentContext.ID, paramIndex = 0)
226    public Map<String, I18nizableText> getChoiceListQuestionOptions (String questionId)
227    {
228        FormQuestion question = _resolver.resolveById(questionId);
229        if (question.getType() instanceof ChoicesListQuestionType type)
230        {
231            return type.getOptions(question);
232        }
233        
234        return new HashMap<>();
235    }
236
237    /**
238     * Get user rights for the given form question
239     * @param question the form question
240     * @return the set of rights
241     */
242    protected Set<String> _getUserRights (FormQuestion question)
243    {
244        UserIdentity user = _currentUserProvider.getUser();
245        return _rightManager.getUserRights(user, question);
246    }
247    
248    private boolean _hasTerminalRule(FormQuestion question)
249    {
250        return question.getPageRules()
251                .stream()
252                .map(FormPageRule::getType)
253                .filter(t -> t == PageRuleType.FINISH)
254                .findAny()
255                .isPresent();
256    }
257
258    /**
259     * Get the question titles having rule concerning the given question
260     * @param question the question
261     * @return the list of question titles
262     */
263    protected List<String> _getQuestionTitlesWithRule(FormQuestion question)
264    {
265        return question.getForm()
266            .getQuestionsRule(question.getId())
267            .keySet()
268            .stream()
269            .map(FormQuestion::getTitle)
270            .toList();
271    }
272    
273    /**
274     * Get the page titles having rule concerning the given question
275     * @param question the question
276     * @return the list of page titles
277     */
278    protected List<String> _getPageTitlesWithRule(FormQuestion question)
279    {
280        return question.getPageRules()
281            .stream()
282            .filter(r -> r.getType() != PageRuleType.FINISH)
283            .map(FormPageRule::getPageId)
284            .distinct()
285            .map(this::_getFormPage)
286            .map(FormPage::getTitle)
287            .toList();
288    }
289    
290    private FormPage _getFormPage(String pageId)
291    {
292        return _resolver.resolveById(pageId);
293    }
294    
295    /**
296     * Get view for question type
297     * @param typeID id of the question type
298     * @param formId id of the form
299     * @return the view parsed in json for configurableFormPanel 
300     * @throws ProcessingException  error while parsing view to json
301     */
302    @Callable (rights = FormDAO.HANDLE_FORMS_RIGHT_ID, rightContext = FormsDirectoryRightAssignmentContext.ID, paramIndex = 1)
303    public Map<String, Object> getQuestionParametersDefinitions(String typeID, String formId) throws ProcessingException
304    {
305        Map<String, Object> response = new HashMap<>();
306        Form form = _resolver.resolveById(formId);
307        FormQuestionType questionType = _formQuestionTypeExtensionPoint.getExtension(typeID);
308        View view = questionType.getView(form);
309        response.put("parameters", view.toJSON(DefinitionContext.newInstance().withEdition(true)));
310        response.put("questionNames", form.getQuestionsNames());
311        return response;
312    }
313    
314    /**
315     * Get questions parameters values
316     * @param questionID id of current question
317     * @return map of question parameters value 
318     */
319    @Callable (rights = FormDAO.HANDLE_FORMS_RIGHT_ID, rightContext = FormsDirectoryRightAssignmentContext.ID, paramIndex = 0)
320    public Map<String, Object> getQuestionParametersValues(String questionID)
321    {
322        Map<String, Object> results = new HashMap<>();
323        
324        FormQuestion question = _resolver.resolveById(questionID);
325        FormQuestionType type = question.getType();
326        Collection< ? extends ModelItem> questionModelItems = type.getModel().getModelItems();
327        Map<String, Object> parametersValues = _parametersManager.getParametersValues(questionModelItems, question, StringUtils.EMPTY);
328        results.put("values", parametersValues);
329        
330        // Repeater values aren't handled by getParametersValues()
331        @SuppressWarnings("unchecked")
332        List<Map<String, Object>> repeaters = _parametersManager.getRepeatersValues((Collection<ModelItem>) questionModelItems, question, StringUtils.EMPTY);
333        results.put("repeaters", repeaters);
334        results.put("fieldToDisable", _getFieldNameToDisable(question));
335        
336        return results;
337    }
338    
339    private List<String> _getFieldNameToDisable(FormQuestion question)
340    {
341        Form form = question.getForm();
342        if (form.getEntries().isEmpty())
343        {
344            return List.of();
345        }
346        
347        return question.getType().getFieldToDisableIfFormPublished(question);
348    }
349    
350    /**
351     * Creates a {@link FormQuestion}.
352     * @param pageId id of current page
353     * @param typeId id of FormQuestionType
354     * @return The id of the created form question, the id of the page and the id of the form
355     */
356    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
357    public Map<String, Object> createQuestion(String pageId, String typeId)
358    {
359        Map<String, Object> result = new HashMap<>();
360        FormPage page = _resolver.resolveById(pageId);
361
362        _formDAO.checkHandleFormRight(page);
363        
364        FormQuestionType type = _formQuestionTypeExtensionPoint.getExtension(typeId);
365        Form form = page.getForm();
366        
367        String defaultTitle = _i18nUtils.translate(type.getDefaultTitle());
368        String nameForForm = form.findUniqueQuestionName(NameHelper.filterName(defaultTitle));
369        
370        String id = nameForForm;
371        int i = 1;
372        while (page.hasChild(id))
373        {
374            id = nameForForm + "-" + i;
375            i++;
376        }
377        FormQuestion question = page.createChild(id, "ametys:form-question");
378        question.setNameForForm(nameForForm);
379        question.setTypeId(typeId);
380        
381        Model model = question.getType().getModel();
382        for (ModelItem modelItem : model.getModelItems())
383        {
384            if (modelItem instanceof ElementDefinition)
385            {
386                Object defaultValue = ((ElementDefinition) modelItem).getDefaultValue();
387                if (defaultValue != null)
388                {
389                    question.setValue(modelItem.getPath(), defaultValue);
390                }
391            }
392        }
393        
394        question.setTitle(form.findUniqueQuestionTitle(defaultTitle));
395        
396        page.saveChanges();
397        
398        Map<String, Object> eventParams = new HashMap<>();
399        eventParams.put("form", page.getForm());
400        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
401        
402        result.put("id", question.getId());
403        result.put("pageId", page.getId());
404        result.put("formId", question.getForm().getId());
405        result.put("type", typeId);
406        return result;
407    }
408    
409    /**
410     * Rename a {@link FormQuestion}
411     * @param id The id of the question 
412     * @param newName The new name of the question
413     * @return A result map
414     */
415    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
416    public Map<String, String> renameQuestion (String id, String newName)
417    {
418        Map<String, String> results = new HashMap<>();
419        
420        FormQuestion question = _resolver.resolveById(id);
421        _formDAO.checkHandleFormRight(question);
422        
423        question.setTitle(newName);
424        question.saveChanges();
425        
426        Map<String, Object> eventParams = new HashMap<>();
427        eventParams.put("form", question.getForm());
428        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
429        
430        results.put("id", id);
431        results.put("newName", newName);
432        results.put("formId", question.getForm().getId());
433        
434        return results;
435    }
436    
437    /**
438     * Edits a {@link FormQuestion}.
439     * @param questionId id of current question
440     * @param values The question's values
441     * @return The id of the edited form question, the id of the page and the id of the form
442     */
443    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
444    public Map<String, Object> editQuestion (String questionId, Map<String, Object> values)
445    {
446        Map<String, Object> result = new HashMap<>();
447        Map<String, I18nizableText> errors = new HashMap<>();
448        
449        FormQuestion question = _resolver.resolveById(questionId);
450        _formDAO.checkHandleFormRight(question);
451        
452        Form parentForm = question.getForm();
453        String questionName = StringUtils.defaultString((String) values.get("name-for-form"));
454        
455        // if question can not be answered by user, id can't be changed and is unique by default
456        if (!question.getType().canBeAnsweredByUser(question) || questionName.equals(question.getNameForForm()) || parentForm.isQuestionNameUnique(questionName))
457        {
458            FormQuestionType type = question.getType();
459            type.validateQuestionValues(values, errors);
460
461            if (!errors.isEmpty())
462            {
463                result.put("errors", errors);
464                return result;
465            }
466
467            _parametersManager.setParameterValues(question.getDataHolder(), type.getModel().getModelItems(), values);
468            type.doAdditionalOperations(question, values);
469            
470            question.saveChanges();
471            
472            Map<String, Object> eventParams = new HashMap<>();
473            eventParams.put("form", parentForm);
474            _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
475            
476            result.put("id", question.getId());
477            result.put("pageId", question.getParent().getId());
478            result.put("formId", parentForm.getId());
479            result.put("type", question.getType().toString());
480        }
481        else
482        {
483            errors.put("duplicate_name", new I18nizableText("plugin.forms", "PLUGINS_FORMS_QUESTIONS_SET_ID_ERROR"));
484            result.put("errors", errors);
485            getLogger().error("An error occurred creating the question. The identifier value '" + questionName + "' is already used.");
486        }
487        
488        return result;
489    }
490    
491    /**
492     * Deletes a {@link FormQuestion}.
493     * @param id The id of the form question to delete
494     * @return The id of the form question, the id of the page and the id of the form
495     */
496    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
497    public Map<String, String> deleteQuestion (String id)
498    {
499        FormQuestion question = _resolver.resolveById(id);
500        _formDAO.checkHandleFormRight(question);
501        
502        question.getForm().deleteQuestionsRule(question.getId());
503        
504        FormPage page = question.getParent();
505        question.remove();
506        
507        page.saveChanges();
508        
509        Map<String, Object> eventParams = new HashMap<>();
510        eventParams.put("form", page.getForm());
511        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
512        
513        return Map.of("id", id);
514    }
515
516    /**
517     * Copies and pastes a form question.
518     * @param pageId The id of the page, target of the copy
519     * @param questionId The id of the question to copy
520     * @return The id of the created question, the id of the page and the id of the form
521     */
522    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
523    public Map<String, String> copyQuestion(String pageId, String questionId)
524    {
525        Map<String, String> result = new HashMap<>();
526        
527        FormQuestion originalQuestion = _resolver.resolveById(questionId);
528        _formDAO.checkHandleFormRight(originalQuestion);
529        
530        FormPage parentPage = _resolver.resolveById(pageId);
531        
532        Form parentForm = parentPage.getForm();
533        
534        String questionName = parentForm.findUniqueQuestionName(originalQuestion.getNameForForm());
535        FormQuestion questionCopy = parentPage.createChild(questionName, "ametys:form-question");
536        originalQuestion.copyTo(questionCopy);
537        
538        String copyTitle = _i18nUtils.translate(new I18nizableText("plugin.forms", "PLUGIN_FORMS_TREE_COPY_NAME_PREFIX")) + originalQuestion.getTitle();
539        questionCopy.setTitle(parentForm.findUniqueQuestionTitle(copyTitle));
540        questionCopy.setTypeId(originalQuestion.getType().getId());
541        questionCopy.setNameForForm(questionName);
542        
543        for (String epId : _copyFormEP.getExtensionsIds())
544        {
545            CopyFormUpdater copyFormUpdater = _copyFormEP.getExtension(epId);
546            copyFormUpdater.updateFormQuestion(originalQuestion, questionCopy);
547        }
548        
549        parentPage.saveChanges();
550        
551        Map<String, Object> eventParams = new HashMap<>();
552        eventParams.put("form", parentForm);
553        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
554        
555        result.put("id", questionCopy.getId());
556        result.put("pageId", parentPage.getId());
557        result.put("formId", parentForm.getId());
558        result.put("type", questionCopy.getType().getId());
559        
560        return result;
561    }
562    
563    /**
564     * Gets the page rules for a form question.
565     * @param id The id of the form question.
566     * @param number The question number
567     * @return The rules
568     * @throws Exception error while getting choice options
569     */
570    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
571    public Map<String, Object> getRules (String id, int number) throws Exception
572    {
573        Map<String, Object> result = new HashMap<>();
574        
575        FormQuestion question = _resolver.resolveById(id);
576        _formDAO.checkHandleFormRight(question);
577        
578        FormQuestionType type = question.getType();
579        if (type instanceof ChoicesListQuestionType cLType)
580        {
581            ChoiceSourceType sourceType = cLType.getSourceType(question);
582          
583            result.put("id", question.getId());
584            result.put("number", String.valueOf(number));
585            result.put("title", question.getTitle());
586            
587            List<Object> rules = new ArrayList<>();
588            for (FormPageRule rule : question.getPageRules())
589            {
590                String option = rule.getOption();
591                Map<String, Object> enumParam = new HashMap<>();
592                enumParam.put(AbstractSourceType.QUESTION_PARAM_KEY, question);
593                I18nizableText label = sourceType.getEntry(new ChoiceOption(option), enumParam);
594                
595                Map<String, Object> resultRule = new HashMap<>();
596                resultRule.put("option", option);
597                resultRule.put("optionLabel", label);
598                resultRule.put("type", rule.getType());
599                String pageId = rule.getPageId();
600                if (pageId != null)
601                {
602                    try
603                    {
604                        FormPage page = _resolver.resolveById(pageId);
605                        resultRule.put("page", pageId);
606                        resultRule.put("pageName", page.getTitle());
607                    }
608                    catch (UnknownAmetysObjectException e)
609                    {
610                        // Page does not exist anymore
611                    }
612                }
613                        
614                rules.add(resultRule);
615            }
616            
617            result.put("rules", rules);
618        }
619        
620        return result;
621    }
622
623    /**
624     * Adds a new rule to a question.
625     * @param id The question id
626     * @param option The option
627     * @param rule The rule type
628     * @param page The page to jump or skip
629     * @return An empty map, or an error
630     */
631    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
632    public Map<String, Object> addPageRule (String id, String option, String rule, String page)
633    {
634        Map<String, Object> result = new HashMap<>();
635        
636        FormQuestion question = _resolver.resolveById(id);
637        _formDAO.checkHandleFormRight(question);
638        
639        // Check if exists
640        if (question.hasPageRule(option))
641        {
642            result.put("error", "already-exists");
643            return result;
644        }
645        
646        question.addPageRules(option, PageRuleType.valueOf(rule), page);
647        question.saveChanges();
648        
649        Map<String, Object> eventParams = new HashMap<>();
650        eventParams.put("form", question.getForm());
651        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
652        
653        result.put("id", question.getId());
654        result.put("pageId", question.getFormPage().getId());
655        result.put("formId", question.getForm().getId());
656        result.put("type", question.getType().getId());
657        return result;
658    }
659    
660    /**
661     * Deletes a rule to a question.
662     * @param id The question id
663     * @param option The option to delete
664     * @return An empty map
665     */
666    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
667    public Map<String, Object> deletePageRule (String id, String option)
668    {
669        FormQuestion question = _resolver.resolveById(id);
670        _formDAO.checkHandleFormRight(question);
671        
672        question.deletePageRule(option);
673        question.saveChanges();
674        
675        Map<String, Object> eventParams = new HashMap<>();
676        eventParams.put("form", question.getForm());
677        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
678        
679        return new HashMap<>();
680    }
681    
682    /**
683     * Record for entry values coming from input or from the entry
684     * @param inputValues the inputValues. Can be null if the entry is not null
685     * @param entry the form entry. Can be null if the input values is not null
686     */
687    public record FormEntryValues(Map<String, Object> inputValues, FormEntry entry) 
688    {
689        Object getValue(String attributeName)
690        {
691            if (inputValues != null)
692            {
693                return inputValues.get(attributeName);
694            }
695            else
696            {
697                return entry.getValue(attributeName);
698            }
699        }
700    }
701    
702    /**
703     * Get the list of active question depending of the form rules
704     * @param form the form
705     * @param entryValues the entry values to compute rules
706     * @param currentStepId the current step id. Can be empty if the form has no workflow
707     * @param onlyWritableQuestion <code>true</code> to have only writable question
708     * @param onlyReadableQuestion <code>true</code> to have only readable question
709     * @return the list of active question depending of the form rules
710     */
711    public List<FormQuestion> getRuleFilteredQuestions(Form form, FormEntryValues entryValues, Optional<Long> currentStepId, boolean onlyWritableQuestion, boolean onlyReadableQuestion)
712    {
713        List<FormQuestion> filteredQuestions = new ArrayList<>();
714        for (FormQuestion activeQuestion : _getActiveQuestions(form, entryValues, currentStepId, onlyWritableQuestion, onlyReadableQuestion))
715        {
716            if (!activeQuestion.getType().onlyForDisplay(activeQuestion))
717            {
718                Optional<Rule> firstQuestionRule = activeQuestion.getFirstQuestionRule();
719                if (firstQuestionRule.isPresent())
720                {
721                    Rule rule = firstQuestionRule.get();
722                    FormQuestion sourceQuestion = _resolver.resolveById(rule.getSourceId());
723                    List<String> ruleValues = _getRuleValues(entryValues, sourceQuestion.getNameForForm());
724                    boolean equalsRuleOption = ruleValues.contains(rule.getOption());
725                    QuestionRuleType ruleAction = rule.getAction();
726                    
727                    if (!equalsRuleOption && ruleAction.equals(QuestionRuleType.HIDE)
728                            || equalsRuleOption && ruleAction.equals(QuestionRuleType.SHOW))
729                    {
730                        filteredQuestions.add(activeQuestion);
731                    }
732                }
733                else
734                {
735                    filteredQuestions.add(activeQuestion);
736                }
737            }
738        }
739        
740        return filteredQuestions;
741    }
742    
743    /**
744     * Get a list of the form questions not being hidden by a rule
745     * @param form the current form
746     * @param entryValues the entry values
747     * @param currentStepId current step of the entry. Can be empty if the form has no workflow
748     * @param onlyWritableQuestion <code>true</code> to have only writable question
749     * @param onlyReadableQuestion <code>true</code> to have only readable question
750     * @return a list of visible questions
751     */
752    protected List<FormQuestion> _getActiveQuestions(Form form, FormEntryValues entryValues, Optional<Long> currentStepId, boolean onlyWritableQuestion, boolean onlyReadableQuestion)
753    {
754        String nextActivePage = null;
755        List<FormQuestion> activeQuestions = new ArrayList<>();
756        for (FormPage page : form.getPages())
757        {
758            if (nextActivePage == null || page.getId().equals(nextActivePage))
759            {
760                nextActivePage = null;
761                for (FormQuestion question : page.getQuestions())
762                {
763                    if (currentStepId.isEmpty() // no current step id, ignore rights access
764                        || (!onlyReadableQuestion || question.canRead(currentStepId.get())) 
765                            && 
766                           (!onlyWritableQuestion || question.canWrite(currentStepId.get())))
767                    {
768                        activeQuestions.add(question);
769                    }
770                    
771                    if (question.getType() instanceof ChoicesListQuestionType type && !type.getSourceType(question).remoteData())
772                    {
773                        List<String> ruleValues = _getRuleValues(entryValues, question.getNameForForm());
774                        for (FormPageRule rule : question.getPageRules())
775                        {
776                            if (ruleValues.contains(rule.getOption()))
777                            {
778                                nextActivePage = _getNextActivePage(rule);
779                            }
780                        }
781                    }
782                }
783            }
784            
785            FormPageRule rule = page.getRule();
786            if (rule != null && nextActivePage == null)
787            {
788                nextActivePage = _getNextActivePage(rule);
789            }
790        }
791        return activeQuestions;
792    }
793
794    private String _getNextActivePage(FormPageRule rule)
795    {
796        return rule.getType() == PageRuleType.FINISH
797                ? "finish"
798                : rule.getPageId();
799    }
800
801    private List<String> _getRuleValues(FormEntryValues entryValues, String nameForForm)
802    {
803        Object ruleValue =  entryValues.getValue(nameForForm);
804        if (ruleValue == null)
805        {
806            return ListUtils.EMPTY_LIST;
807        }
808        
809        if (ruleValue.getClass().isArray())
810        {
811            String[] stringArray = ArrayUtils.toStringArray((Object[]) ruleValue);
812            return Arrays.asList(stringArray);
813        }
814        else
815        {
816            return List.of(ruleValue.toString());
817        }
818    }
819}