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