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