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