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