001/*
002 *  Copyright 2022 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 */
016package org.ametys.plugins.forms.helper;
017
018import java.util.ArrayList;
019import java.util.Arrays;
020import java.util.HashMap;
021import java.util.LinkedHashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.Objects;
025import java.util.Optional;
026import java.util.Set;
027import java.util.function.BiFunction;
028import java.util.function.Function;
029import java.util.stream.Collectors;
030
031import org.apache.avalon.framework.component.Component;
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.environment.Request;
036import org.apache.commons.lang3.ArrayUtils;
037import org.apache.commons.lang3.StringUtils;
038import org.apache.commons.lang3.Strings;
039import org.apache.commons.math3.util.IntegerSequence.Incrementor;
040
041import org.ametys.cms.data.type.ModelItemTypeExtensionPoint;
042import org.ametys.cms.search.SearchResults;
043import org.ametys.cms.search.SortOrder;
044import org.ametys.cms.search.advanced.AbstractTreeNode;
045import org.ametys.cms.search.advanced.AdvancedQueryBuilder;
046import org.ametys.cms.search.advanced.TreeMaker;
047import org.ametys.cms.search.advanced.TreeMaker.ClientSideCriterionWrapper;
048import org.ametys.cms.search.query.MatchNoneQuery;
049import org.ametys.cms.search.query.Query;
050import org.ametys.cms.search.query.Query.Operator;
051import org.ametys.cms.search.solr.SearcherFactory.FacetDefinition;
052import org.ametys.cms.search.solr.SearcherFactory.SortDefinition;
053import org.ametys.core.ui.Callable;
054import org.ametys.core.user.UserManager;
055import org.ametys.core.util.JSONUtils;
056import org.ametys.plugins.forms.dao.FormDAO;
057import org.ametys.plugins.forms.dao.FormEntryDAO;
058import org.ametys.plugins.forms.helper.FormEntriesSearchHelper.FormEntryColumn;
059import org.ametys.plugins.forms.indexing.solr.SolrFormEntryIndexer;
060import org.ametys.plugins.forms.question.FormQuestionType;
061import org.ametys.plugins.forms.question.types.SearcheableQuestionType;
062import org.ametys.plugins.forms.repository.Form;
063import org.ametys.plugins.forms.repository.FormEntry;
064import org.ametys.plugins.forms.repository.FormQuestion;
065import org.ametys.plugins.forms.rights.FormsDirectoryRightAssignmentContext;
066import org.ametys.plugins.forms.search.criteria.AbstractFormEntrySearchCriterionDefinition;
067import org.ametys.plugins.forms.search.criteria.FormEntryAuthorSearchCriterionDefinition;
068import org.ametys.plugins.forms.search.criteria.FormEntryLastUpdateSearchCriterionDefinition;
069import org.ametys.plugins.forms.search.criteria.FormEntrySubmissionDateSearchCriterionDefinition;
070import org.ametys.plugins.forms.search.criteria.FormEntryWorkflowStepSearchCriterionDefinition;
071import org.ametys.plugins.forms.search.criteria.FormQuestionSearchCriterionDefinition;
072import org.ametys.plugins.repository.AmetysObjectResolver;
073import org.ametys.runtime.i18n.I18nizableText;
074import org.ametys.runtime.model.DefinitionContext;
075import org.ametys.runtime.model.ModelItem;
076import org.ametys.runtime.plugin.component.AbstractLogEnabled;
077import org.ametys.web.frontoffice.search.instance.model.SearchServiceCriterion;
078import org.ametys.web.frontoffice.search.instance.model.SearchServiceCriterionMode;
079import org.ametys.web.frontoffice.search.instance.model.SearchServiceCriterionMode.CriterionWrappedValue;
080import org.ametys.web.frontoffice.search.metamodel.RestrictedEnumerator;
081import org.ametys.web.frontoffice.search.metamodel.RestrictedEnumerator.RestrictedValues;
082import org.ametys.web.frontoffice.search.metamodel.SearchServiceCriterionDefinition;
083import org.ametys.web.frontoffice.search.requesttime.input.impl.FormSearchUserInputs;
084import org.ametys.web.repository.page.ZoneItem;
085
086/**
087 * The helper to handle admin dashboard
088 */
089public class FormAdminDashboardHelper extends AbstractLogEnabled implements Serviceable, Component
090{
091    /** Avalon ROLE. */
092    public static final String ROLE = FormAdminDashboardHelper.class.getName();
093    
094    /** Prefix for the name of column based on form entry. */
095    public static final String COLUMN_NAME_PREFIX = "form-entry-column$";
096    
097    /** Prefix for the name of column based on form question. */
098    public static final String QUESTION_COLUMN_NAME_PREFIX = "form-entry-column$question$";
099    
100    /** The name of the column to display form entry data */
101    public static final String LABEL_COLUMN_NAME = "label";
102    
103    /** The name of the column to display form entry history */
104    public static final String HISTORY_COLUMN_NAME = "history";
105    
106    /** The name of the column to display form entry actions */
107    public static final String ACTIONS_COLUMN_NAME = "actions";
108    
109    /** The id of the right to access the dashboard configuration. */
110    public static final String CONFIGURE_DASHBOARD_RIGHT_ID = "Right_Form_Configure_Dashboard";
111    
112    /** The Ametys Object resolver */
113    protected AmetysObjectResolver _resolver;
114
115    /** The criterion type extension point */
116    protected ModelItemTypeExtensionPoint _criterionTypeExtensionPoint;
117    
118    /** The tree maker */
119    protected TreeMaker _treeMaker;
120    
121    /** The JSON utils */
122    protected JSONUtils _json;
123    
124    /** The builder of advanced queries */
125    protected AdvancedQueryBuilder _advancedQueryBuilder;
126    
127    /** The form entry DAO */
128    protected FormEntryDAO _formEntryDAO;
129    
130    /** The form DAO */
131    protected FormDAO _formDAO;
132    
133    /** The user manager */
134    protected UserManager _userManager;
135    
136    /** The form entries search helper */
137    protected FormEntriesSearchHelper _formEntriesSearchHelper;
138    
139    public void service(ServiceManager manager) throws ServiceException
140    {
141        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
142        _criterionTypeExtensionPoint = (ModelItemTypeExtensionPoint) manager.lookup(ModelItemTypeExtensionPoint.ROLE_CRITERION_DEFINITION);
143        _treeMaker = (TreeMaker) manager.lookup(TreeMaker.ROLE);
144        _json = (JSONUtils) manager.lookup(JSONUtils.ROLE);
145        _advancedQueryBuilder = (AdvancedQueryBuilder) manager.lookup(AdvancedQueryBuilder.ROLE);
146        _formEntryDAO = (FormEntryDAO) manager.lookup(FormEntryDAO.ROLE);
147        _formDAO = (FormDAO) manager.lookup(FormDAO.ROLE);
148        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
149        _formEntriesSearchHelper = (FormEntriesSearchHelper) manager.lookup(FormEntriesSearchHelper.ROLE);
150    }
151    
152    /**
153     * Get the parameters needed for the admin dashboard, based on the request parameters and the form configuration.
154     * @param request the request containing the search parameters
155     * @param zoneItem the zone item holding the admin dashboard
156     * @param form the form 
157     * @param contextualParameters the contextual parameters
158     * @return the dashboard search parameters built from the request parameters and the form configuration
159     */
160    public DashboardAdminParameters getDashboardAdminParameters(Request request, ZoneItem zoneItem, Form form, Map<String, Object> contextualParameters)
161    {
162        String lang = _formDAO.getFormLocale(form);
163
164        FormSearchUserInputs userInputs = new FormSearchUserInputs(request);
165        AbstractTreeNode<SearchServiceCriterion<?>> criterionTree = createCriterionTree(form, contextualParameters);
166        return new DashboardAdminParameters(
167                form,
168                criterionTree,
169                userInputs,
170                actionableEntriesOnly(zoneItem),
171                lang
172        );
173    }
174    
175    /**
176     * <code>true</code> if the dashboard should display only entries on which the current user has at least one available action, <code>false</code> if it should display all entries on which the current user has read rights.
177     * @param zoneItem the zone item holding the admin dashboard
178     * @return <code>true</code> if the dashboard should display only entries on which the current user has at least one available action, <code>false</code> if it should display all entries on which the current user has read rights
179     */
180    public boolean actionableEntriesOnly(ZoneItem zoneItem)
181    {
182        return zoneItem.getServiceParameters().getValueOrDefault("actionableEntriesOnly");
183    }
184    
185    /**
186     * Get a map of column name / sort definition from the request parameters
187     * @param dashboardAdminParameters the dashboard admin parameters
188     * @return a map of column name / sort definition from the request parameters
189     */
190    public Map<String, SortDefinition> getSortDefinitions(DashboardAdminParameters dashboardAdminParameters)
191    {
192        Form form = dashboardAdminParameters.form();
193        Map<String, FormEntryDashboardColumn> columns = getFormEntryColumns(form)
194                .stream()
195                .collect(Collectors.toMap(
196                        column -> column.id(), 
197                        column -> column)
198                    );
199        
200        Map<String, SortDefinition> sortDefinitions = dashboardAdminParameters.userInputs()
201                .sorts()
202                .stream()
203                .filter(sort -> columns.containsKey(sort.getLeft()))
204                .collect(Collectors.toMap(
205                    sort -> sort.getLeft(), 
206                    sort -> new SortDefinition(columns.get(sort.getLeft()).sortFieldName(), sort.getRight()))
207                );
208        
209        if (sortDefinitions.isEmpty())
210        {
211            // Try to use the initial sort configured on the form
212            String initialSortColumn = form.getSearchInitialSortColumn();
213            if (StringUtils.isNotBlank(initialSortColumn))
214            {
215                FormEntryDashboardColumn column = columns.get(initialSortColumn);
216                if (column != null && StringUtils.isNotBlank(column.sortFieldName()))
217                {
218                    String initialOrder = form.getSearchInitialSortOrder();
219                    sortDefinitions.put(initialSortColumn, new SortDefinition(column.sortFieldName(), SortOrder.valueOf(initialOrder)));
220                }
221            }
222        }
223        
224        return sortDefinitions;
225    }
226    
227    /**
228     * Get a map of criterion name / criterion value from the request parameters
229     * @param request the request
230     * @param form the form
231     * @return a map of criterion name / criterion value from the request parameters
232     */
233    public Map<String, Object> getCriteriaValues(Request request, Form form)
234    {
235        FormSearchUserInputs userInputs = new FormSearchUserInputs(request);
236        return userInputs.criteria();
237    }
238    
239    /**
240     * Create the criterion tree for a form based on the search criteria JSON stored on the form.
241     * @param form the form
242     * @param contextualParameters the contextual parameters
243     * @return the criterion tree
244     */
245    public AbstractTreeNode<SearchServiceCriterion<?>> createCriterionTree(Form form, Map<String, Object> contextualParameters)
246    {
247        Map<String, Object> searchCriteria = _json.convertJsonToMap(form.getSearchCriteria());
248        Map<String, SearchServiceCriterionDefinition> criterionDefinitions = getAllCriterionDefinitions(form)
249                .stream()
250                .collect(Collectors.toMap(c -> c.getName(), c -> c));
251        
252        Incrementor incrementor = Incrementor.create()
253                .withStart(0)
254                .withMaximalCount(Integer.MAX_VALUE);
255        
256        Function<ClientSideCriterionWrapper, SearchServiceCriterion<?>> leafValueMaker = crit -> _createCriterion(crit, criterionDefinitions, incrementor, contextualParameters);
257        return _treeMaker.create(searchCriteria, leafValueMaker);
258    }
259    
260    private <T> SearchServiceCriterion _createCriterion(ClientSideCriterionWrapper critWrapper, Map<String, SearchServiceCriterionDefinition> criterionDefinitions, Incrementor incrementor, Map<String, Object> contextualParameters)
261    {
262        String criterionDefId = critWrapper.getId();
263        SearchServiceCriterionDefinition<T> criterionDefinition = criterionDefinitions.get(criterionDefId);
264        Objects.requireNonNull(criterionDefinition, String.format("The criterion definition for id '%s' must be non null", criterionDefId));
265        
266        // Generate an id
267        incrementor.increment();
268        String name = criterionDefId + "$" + incrementor.getCount();
269        
270        Map<String, Object> otherProperties = critWrapper.getOtherProperties();
271        String mode = (String) otherProperties.get("mode");
272        
273        RestrictedValues<T> restrictedValues = null;
274        if ("RESTRICTED_USER_INPUT".equals(mode))
275        {
276            @SuppressWarnings("unchecked")
277            List<T> values = ((List<Object>) otherProperties.get("restrictedValues")).stream()
278                                                                                     .map(v -> criterionDefinition.convertRestrictedValue(v, contextualParameters))
279                                                                                     .toList();
280            restrictedValues = _restrictedValues(values, criterionDefinition, contextualParameters);
281        }
282        Object staticValue = "STATIC".equals(mode) ? critWrapper.getValue() : null;
283        
284        return new SearchServiceCriterion<>(
285                name,
286                criterionDefinition,
287                critWrapper.getStringOperator(),
288                SearchServiceCriterionMode.valueOf(mode),
289                restrictedValues,
290                staticValue
291        );
292    }
293    
294    private <T> RestrictedValues<T> _restrictedValues(List<T> values, SearchServiceCriterionDefinition<T> criterionDefinition, Map<String, Object> contextualParameters)
295    {
296        RestrictedEnumerator<T> enumerator = criterionDefinition.getRestrictedEnumerator(contextualParameters);
297        if (enumerator != null)
298        {
299            try
300            {
301                return enumerator.getRestrictedEntriesFor(values);
302            }
303            catch (Exception e)
304            {
305                // An error occurred while retrieving restricted values
306                throw new IllegalStateException("An unexpected error occured. Unable to compute restricted values for criterion '" + criterionDefinition.getName() + "'", e);
307            }
308        }
309        else
310        {
311            throw new IllegalStateException("An unexpected error occured. There must be restricted values at this point.");
312        }
313    }
314    
315    /**
316     * Build a query from a criterion tree and user criteria values.
317     * @param tree the criterion tree
318     * @param criteriaValues the user criteria values, as a map of criterion name to value
319     * @param lang the language to use for building the query
320     * @param contextualParameters the contextual parameters
321     * @return the built query
322     */
323    public Query getQuery(AbstractTreeNode<SearchServiceCriterion<?>> tree, Map<String, Object> criteriaValues, String lang, Map<String, Object> contextualParameters)
324    {
325        if (tree != null)
326        {
327            return _advancedQueryBuilder.build(
328                    tree, 
329                    crit -> singleCriterionToQuery(crit, criteriaValues, lang, contextualParameters)
330                    );
331        }
332        
333        return null;
334    }
335    
336    private <T> Query singleCriterionToQuery(SearchServiceCriterion<T> criterion, Map<String, Object> userCriteria, String lang, Map<String, Object> contextualParameters)
337    {
338        SearchServiceCriterionMode mode = criterion.getMode();
339        CriterionWrappedValue val = mode.getValue(criterion, userCriteria, contextualParameters);
340        
341        // The criterion was not filled by the visitor or it is filtered
342        // Put an empty query. It will be ignored by And/OrQuery
343        Query joinedQuery;
344        if (val.getValue() == null)
345        {
346            joinedQuery = criterion.getCriterionDefinition().getEmptyValueQuery(lang, contextualParameters);
347        }
348        else
349        {
350            SearchServiceCriterionDefinition<T> criterionDefinition = criterion.getCriterionDefinition();
351            BiFunction<CriterionWrappedValue, Operator, Query> queryFunctionFromTransformedValAndRealOperator = (transformedVal, realOperator) -> criterionDefinition.getQuery(transformedVal.getValue(), realOperator, lang, contextualParameters);
352            
353            joinedQuery = _treeMaker.toQuery(val, criterion.getOperator(), queryFunctionFromTransformedValAndRealOperator, lang, contextualParameters);
354        }
355        
356        return Optional.ofNullable(joinedQuery).orElse(new MatchNoneQuery());
357    }
358    
359    /**
360     * Get form common criterion definitions for a given form.
361     * @param form the form
362     * @return the list of form common criterion definitions
363     */
364    protected List<AbstractFormEntrySearchCriterionDefinition> getFormCommonCriterionDefinitions(Form form)
365    {
366        List<AbstractFormEntrySearchCriterionDefinition> criteria = new ArrayList<>();
367        
368        FormEntryAuthorSearchCriterionDefinition formEntryAuthorSearchCriterionDefinition = new FormEntryAuthorSearchCriterionDefinition(form);
369        criteria.add(formEntryAuthorSearchCriterionDefinition);
370        
371        FormEntrySubmissionDateSearchCriterionDefinition formEntrySubmissionDateSearchCriterionDefinition = new FormEntrySubmissionDateSearchCriterionDefinition(form);
372        criteria.add(formEntrySubmissionDateSearchCriterionDefinition);
373        
374        FormEntryLastUpdateSearchCriterionDefinition formEntryLastUpdateSearchCriterionDefinition = new FormEntryLastUpdateSearchCriterionDefinition(form);
375        criteria.add(formEntryLastUpdateSearchCriterionDefinition);
376        
377        return criteria;
378    }
379    
380    /**
381     * Get form workflow criterion definitions for a given form.
382     * @param form the form
383     * @return the list of form workflow criterion definitions
384     */
385    protected List<AbstractFormEntrySearchCriterionDefinition> getFormWorkflowCriterionDefinitions(Form form)
386    {
387        List<AbstractFormEntrySearchCriterionDefinition> criteria = new ArrayList<>();
388        
389        FormEntryWorkflowStepSearchCriterionDefinition formEntryWorkflowStepSearchCriterionDefinition = new FormEntryWorkflowStepSearchCriterionDefinition(form);
390        criteria.add(formEntryWorkflowStepSearchCriterionDefinition);
391        
392        return criteria;
393    }
394    
395    /**
396     * Get form question criterion definitions for a given form.
397     * @param form the form
398     * @return the list of form question criterion definitions
399     */
400    protected List<FormQuestionSearchCriterionDefinition> getFormQuestionCriterionDefinitions(Form form)
401    {
402        List<FormQuestionSearchCriterionDefinition> criteria = new ArrayList<>();
403        for (FormQuestion question : form.getQuestions())
404        {
405            FormQuestionType type = question.getType();
406            if (type instanceof SearcheableQuestionType searcheableType)
407            {
408                criteria.add(searcheableType.getCriterionDefinition(question));
409            }
410        }
411        
412        return criteria;
413    }
414    
415    /**
416     * Get all criterion definitions for a given form.
417     * @param form the form
418     * @return the list of all criterion definition
419     */
420    public List<SearchServiceCriterionDefinition> getAllCriterionDefinitions(Form form)
421    {
422        List<SearchServiceCriterionDefinition> criteria = new ArrayList<>();
423        criteria.addAll(getFormCommonCriterionDefinitions(form));
424        criteria.addAll(getFormWorkflowCriterionDefinitions(form));
425        criteria.addAll(getFormQuestionCriterionDefinitions(form));
426        return criteria;
427    }
428    
429    /**
430     * Get all form entry columns for a given form
431     * @param form the given form
432     * @return the form entry columns
433     */
434    public List<FormEntryDashboardColumn> getFormEntryColumns(Form form)
435    {
436        List<FormEntryDashboardColumn> columns = new ArrayList<>();
437        
438        I18nizableText commonGroupLabel = new I18nizableText("plugin.forms", "PLUGINS_FORMS_SEARCH_GROUP_CRITERIA_COMMON");
439        I18nizableText workflowGroupLabel = new I18nizableText("plugin.forms", "PLUGINS_FORMS_SEARCH_GROUP_CRITERIA_WORKFLOW");
440        I18nizableText questionGroupLabel = new I18nizableText("plugin.forms", "PLUGINS_FORMS_SEARCH_GROUP_CRITERIA_QUESTION");
441        
442        // First add columns that are not related to the form entry data 
443        columns.add(new FormEntryDashboardColumn(LABEL_COLUMN_NAME, LABEL_COLUMN_NAME, commonGroupLabel, new I18nizableText("plugin.forms", "PLUGINS_FORMS_SERVICE_ADMIN_DASHBOARD_LABEL_HEADER"), "string", null, null));
444        columns.add(new FormEntryDashboardColumn(HISTORY_COLUMN_NAME, HISTORY_COLUMN_NAME, workflowGroupLabel, new I18nizableText("plugin.forms", "PLUGINS_FORMS_SERVICE_DASHBOARD_FORM_HISTORY_HEADER"), "string", null, null));
445        columns.add(new FormEntryDashboardColumn(ACTIONS_COLUMN_NAME, ACTIONS_COLUMN_NAME, workflowGroupLabel, new I18nizableText("plugin.forms", "PLUGINS_FORMS_SERVICE_DASHBOARD_FORM_ACTIONS_HEADER"), "string", null, null));
446    
447        // Then add all columns related to form entry data (questions and other model items)
448        for (FormEntryColumn column : _formEntriesSearchHelper.getFormEntryColumns(form).values())
449        {
450            FormQuestion question = column.question();
451            ModelItem modelItem = column.modelItem();
452            if (question != null)
453            {
454                columns.add(new FormEntryDashboardColumn(
455                    QUESTION_COLUMN_NAME_PREFIX + question.getNameForForm(), 
456                    question.getNameForForm(),
457                    questionGroupLabel,
458                    new I18nizableText(question.getTitle()),
459                    modelItem.getType().getId(),
460                    column.sortFieldName(),
461                    question
462                ));
463            }
464            else
465            {
466                I18nizableText groupLabel = modelItem.getName().equals(FormEntry.SYSTEM_PROPERTY_WORKFLOW_STEP)
467                        ? workflowGroupLabel
468                        : commonGroupLabel;
469                
470                columns.add(new FormEntryDashboardColumn(
471                    COLUMN_NAME_PREFIX + modelItem.getName(),
472                    modelItem.getName(),
473                    groupLabel,
474                    modelItem.getLabel(), 
475                    modelItem.getType().getId(),
476                    column.sortFieldName(),
477                    null
478                ));
479            }
480        }
481        
482        return columns;
483    }
484    
485    /**
486     * Gets the criterion definitions for a given form.
487     * @param formId The id of the form
488     * @return the criterion definitions as a map of criterion id to criterion properties
489     */
490    @Callable (rights = CONFIGURE_DASHBOARD_RIGHT_ID, rightContext = FormsDirectoryRightAssignmentContext.ID, paramIndex = 0)
491    public Map<String, Map<String, Object>> getFormCriterionDefinitions(String formId)
492    {
493        Map<String, Map<String, Object>> criteria = new LinkedHashMap<>();
494
495        I18nizableText commonGroupLabel = new I18nizableText("plugin.forms", "PLUGINS_FORMS_SEARCH_GROUP_CRITERIA_COMMON");
496        I18nizableText workflowGroupLabel = new I18nizableText("plugin.forms", "PLUGINS_FORMS_SEARCH_GROUP_CRITERIA_WORKFLOW");
497        I18nizableText questionGroupLabel = new I18nizableText("plugin.forms", "PLUGINS_FORMS_SEARCH_GROUP_CRITERIA_QUESTION");
498        
499        Form form = _resolver.resolveById(formId);
500
501        getFormCommonCriterionDefinitions(form).stream()
502            .forEach(criterionDefinition -> criteria.put(criterionDefinition.getName(), _criterionDefToJson(criterionDefinition, commonGroupLabel)));
503        
504        getFormWorkflowCriterionDefinitions(form).stream()
505            .forEach(criterionDefinition -> criteria.put(criterionDefinition.getName(), _criterionDefToJson(criterionDefinition, workflowGroupLabel)));
506        
507        getFormQuestionCriterionDefinitions(form).stream()
508            .forEach(criterionDefinition -> criteria.put(criterionDefinition.getName(), _criterionDefToJson(criterionDefinition, questionGroupLabel)));
509        
510        return criteria;
511    }
512
513    private Map<String, Object> _criterionDefToJson(SearchServiceCriterionDefinition criterionDefinition, I18nizableText groupLabel)
514    {
515        Map<String, Object> criterion = criterionDefinition.toJSON(DefinitionContext.newInstance());
516        criterion.put("groupLabel", groupLabel);
517        criterion.put("canBeRestricted", false); // For now we do not want to allow restricting the values of the criterion definitions in the admin dashboard
518        return criterion;
519    }
520    
521    /**
522     * Gets the column definitions for a given form and the saved search configuration if exists.
523     * @param formId The id of the form
524     * @return a map with "columns" (list of column definitions) and "savedConfiguration" (map with "criteria", "columns", "initialSortColumn", "initialSortOrder")
525     */
526    @Callable (rights = CONFIGURE_DASHBOARD_RIGHT_ID, rightContext = FormsDirectoryRightAssignmentContext.ID, paramIndex = 0)
527    public Map<String, Object> getFormSearchConfiguration(String formId)
528    {
529        Map<String, Object> searchConfiguration = new HashMap<>();
530        
531        List<Map<String, Object>> columns = new ArrayList<>();
532        Form form = _resolver.resolveById(formId);
533
534        getFormEntryColumns(form).stream()
535            .forEach(c -> columns.add(Map.of(
536                    "value", c.id(), 
537                    "label", c.label(),
538                    "group", c.group(),
539                    "isSortable", StringUtils.isNotBlank(c.sortFieldName()))
540            ));
541        
542        searchConfiguration.put("columns", columns);
543        
544        Map<String, Object> result = new HashMap<>();
545        result.put("criteria", form.getSearchCriteria());
546        result.put("columns", Arrays.asList(form.getSearchColumns()));
547        result.put("initialSortColumn", form.getSearchInitialSortColumn());
548        result.put("initialSortOrder", form.getSearchInitialSortOrder());
549
550        searchConfiguration.put("savedConfiguration", result);
551        
552        return searchConfiguration;
553    }
554    
555    /**
556     * Save the search configuration (criteria and columns) on a form.
557     * @param formId The id of the form
558     * @param criteriaJson The criteria as a JSON string
559     * @param columns The list of column names
560     * @param initialSortColumn The initial sort column name. Can be null or empty
561     * @param initialSortOrder The initial sort order (ASC or DESC). Can be null or empty
562     */
563    @Callable (rights = CONFIGURE_DASHBOARD_RIGHT_ID, rightContext = FormsDirectoryRightAssignmentContext.ID, paramIndex = 0)
564    public void saveSearchConfiguration(String formId, String criteriaJson, List<String> columns, String initialSortColumn, String initialSortOrder)
565    {
566        Form form = _resolver.resolveById(formId);
567        form.setSearchCriteria(criteriaJson);
568        form.setSearchColumns(columns.toArray(new String[0]));
569        form.setSearchInitialSortColumn(StringUtils.defaultString(initialSortColumn));
570        form.setSearchInitialSortOrder(StringUtils.defaultString(initialSortOrder));
571        form.saveChanges();
572    }
573    
574    /**
575     * Get the accessible forms from admin dashboard for current user
576     * ie, forms with at least one entry on which the current user can perform an action
577     * @param siteName the sitename
578     * @param actionableEntriesOnly <code>true</code> if the dashboard should display only entries on which the current user has at least one available action, <code>false</code> if it should display all entries on which the current user has read rights
579     * @return the accessible forms from admin dasboard for current user
580     */
581    public List<Form> getForms(String siteName, boolean actionableEntriesOnly)
582    {
583        try
584        {
585            List<FacetDefinition> facetDefinitions = List.of(new FacetDefinition(SolrFormEntryIndexer.FORM_ID, SolrFormEntryIndexer.FORM_ID));
586            SearchResults<FormEntry> searchFormEntries = _formEntryDAO.searchFormEntries(
587                    siteName, 
588                    null, 
589                    false, // do not filter on active entries
590                    actionableEntriesOnly, // if actionableEntriesOnly is true, we want only entries on which the current user has at least one action
591                    !actionableEntriesOnly, // if actionableEntriesOnly is false, we want only entries on which the current user has read rights
592                    null, 
593                    facetDefinitions, 
594                    List.of(), 
595                    0, 
596                    Integer.MAX_VALUE 
597            );
598            
599            return Optional.ofNullable(searchFormEntries)
600                    .map(entries -> entries.getFacetResults().get(SolrFormEntryIndexer.FORM_ID))
601                    .map(Map::keySet)
602                    .orElse(Set.of())
603                    .stream()
604                    .map(formId -> (Form) _resolver.resolveById(formId))
605                    .toList();
606        }
607        catch (Exception e)
608        {
609            getLogger().error("Error while getting forms to admin for site " + siteName, e);
610            return List.of();
611        }
612    }
613    
614    /**
615     * Get the default search criteria for a form as a JSON string.
616     * @param form the form
617     * @return the default search criteria for a form as a JSON string
618     */
619    public String getDefaultCriteriaAsJson(Form form)
620    {
621        List<Map<String, Object>> criteria = new ArrayList<>();
622        criteria.add(Map.of(
623            "type", "criterion",
624            "id", FormEntry.ATTRIBUTE_USER,
625            "op", "eq",
626            "mode", "USER_INPUT", 
627            "restrictedValues", ""
628        ));
629        
630        if (form.hasWorkflow())
631        {
632            criteria.add(Map.of(
633                "type", "criterion",
634                "id", FormEntry.SYSTEM_PROPERTY_WORKFLOW_STEP,
635                "op", "eq",
636                "mode", "USER_INPUT", 
637                "restrictedValues", ""
638            ));
639        }
640        
641        Map<String, Object> criteriaMap = Map.of(
642            "type", "AND",
643            "expressions", criteria
644        );
645        
646        return _json.convertObjectToJson(criteriaMap);
647    }
648    
649    /**
650     * Get the default columns to display for a form in the admin dashboard.
651     * @param form the form
652     * @return the default columns to display for a form in the admin dashboard
653     */
654    public String[] getDefaultColumns(Form form)
655    {
656        List<String> dataToDisplay = new ArrayList<>();
657        
658        dataToDisplay.add(COLUMN_NAME_PREFIX + FormEntry.ATTRIBUTE_ID);
659        dataToDisplay.add(LABEL_COLUMN_NAME);
660        dataToDisplay.add(COLUMN_NAME_PREFIX + FormEntry.ATTRIBUTE_USER);
661        dataToDisplay.add(COLUMN_NAME_PREFIX + FormEntry.ATTRIBUTE_SUBMIT_DATE);
662        if (form.hasWorkflow())
663        {
664            dataToDisplay.add(COLUMN_NAME_PREFIX + FormEntry.SYSTEM_PROPERTY_LAST_UPDATE);
665            dataToDisplay.add(COLUMN_NAME_PREFIX + FormEntry.SYSTEM_PROPERTY_WORKFLOW_STEP);
666            dataToDisplay.add(HISTORY_COLUMN_NAME);
667            dataToDisplay.add(ACTIONS_COLUMN_NAME);
668        }
669        
670        return dataToDisplay.toArray(new String[dataToDisplay.size()]);
671    }
672    
673    /**
674     * Update the dashboard configuration of a form when a question is renamed.
675     * @param form the form
676     * @param oldQuestionName the old name of the question
677     * @param newQuestionName the new name of the question
678     */
679    public void updateDashboardConfigOnQuestionRename(Form form, String oldQuestionName, String newQuestionName)
680    {
681        boolean hasChanges = false;
682        
683        String searchCriteria = form.getSearchCriteria();
684        if (Strings.CS.contains(searchCriteria, oldQuestionName))
685        {
686            searchCriteria = Strings.CS.replace(
687                searchCriteria, 
688                oldQuestionName, 
689                newQuestionName
690            );
691            form.setSearchCriteria(searchCriteria);
692            hasChanges = true;
693        }
694        
695        String[] searchColumns = form.getSearchColumns();
696        if (ArrayUtils.contains(searchColumns, QUESTION_COLUMN_NAME_PREFIX + oldQuestionName))
697        {
698            String[] newSearchColumns = Arrays.stream(searchColumns)
699                .map(c -> c.equals(QUESTION_COLUMN_NAME_PREFIX + oldQuestionName) ? QUESTION_COLUMN_NAME_PREFIX + newQuestionName : c)
700                .toArray(String[]::new);
701            form.setSearchColumns(newSearchColumns);
702            hasChanges = true;
703        }
704        
705        String searchInitialSortColumn = form.getSearchInitialSortColumn();
706        if (searchInitialSortColumn.equals(QUESTION_COLUMN_NAME_PREFIX + oldQuestionName))
707        {
708            form.setSearchInitialSortColumn(QUESTION_COLUMN_NAME_PREFIX + newQuestionName);
709            hasChanges = true;
710        }
711        
712        if (hasChanges)
713        {
714            form.saveChanges();
715        }
716    }
717    
718    /**
719     * Update the dashboard configuration of a form when a question is deleted.
720     * @param form the form
721     * @param questionName the name of the deleted question
722     */
723    public void updateDashboardConfigOnQuestionDelete(Form form, String questionName)
724    {
725        boolean hasChanges = false;
726        
727        String searchCriteria = form.getSearchCriteria();
728        if (Strings.CS.contains(searchCriteria, questionName))
729        {
730            // Update search criteria: recursively remove the criterion node matching the deleted question
731            String criterionId = questionName;
732            Map<String, Object> criteriaAsMap = _json.convertJsonToMap(searchCriteria);
733            Map<String, Object> updatedCriteriaMap = _removeCriterionFromTree(criteriaAsMap, criterionId);
734            form.setSearchCriteria(_json.convertObjectToJson(updatedCriteriaMap));
735            hasChanges = true;
736        }
737
738        // Update search columns: remove the column for the deleted question
739        String[] searchColumns = form.getSearchColumns();
740        if (ArrayUtils.contains(searchColumns, QUESTION_COLUMN_NAME_PREFIX + questionName))
741        {
742            String[] newSearchColumns = Arrays.stream(searchColumns)
743                .filter(c -> !c.equals(QUESTION_COLUMN_NAME_PREFIX + questionName))
744                .toArray(String[]::new);
745            form.setSearchColumns(newSearchColumns);
746            hasChanges = true;
747        }
748
749        // Update initial sort column: reset if it was pointing to the deleted question
750        String searchInitialSortColumn = form.getSearchInitialSortColumn();
751        if (searchInitialSortColumn.equals(QUESTION_COLUMN_NAME_PREFIX + questionName))
752        {
753            form.setSearchInitialSortColumn(StringUtils.EMPTY);
754            form.setSearchInitialSortOrder("ASC");
755            hasChanges = true;
756        }
757
758        if (hasChanges)
759        {
760            form.saveChanges();
761        }
762    }
763
764    /**
765     * Recursively traverse a criteria tree (Map) and remove any criterion node whose id matches the given criterion id.
766     * @param node the current node of the criteria tree
767     * @param criterionId the criterion id to remove
768     * @return the node with the criterion removed, or {@code null} if the node itself must be removed
769     */
770    @SuppressWarnings("unchecked")
771    private Map<String, Object> _removeCriterionFromTree(Map<String, Object> node, String criterionId)
772    {
773        String type = (String) node.get("type");
774        if ("criterion".equals(type))
775        {
776            // Leaf node: remove it if its id matches
777            return criterionId.equals(node.get("id")) ? null : node;
778        }
779        else
780        {
781            // AND / OR node: recursively filter the expressions list
782            List<Map<String, Object>> expressions = (List<Map<String, Object>>) node.get("expressions");
783            if (expressions != null)
784            {
785                List<Map<String, Object>> filteredExpressions = expressions.stream()
786                    .map(expr -> _removeCriterionFromTree(expr, criterionId))
787                    .filter(Objects::nonNull)
788                    .collect(Collectors.toList());
789
790                Map<String, Object> updatedNode = new HashMap<>(node);
791                updatedNode.put("expressions", filteredExpressions);
792                return updatedNode;
793            }
794            return node;
795        }
796    }
797    
798    /**
799     * Record representing a column for form entry display
800     * @param id the column id
801     * @param name the form entry model item name
802     * @param group the column group
803     * @param label the column label
804     * @param typeId the type of the data of the column
805     * @param sortFieldName the sort field name of the column
806     * @param question the question of the column. Can be null if the column is not related to a {@link FormQuestion}
807     */
808    public record FormEntryDashboardColumn(String id, String name, I18nizableText group, I18nizableText label, String typeId, String sortFieldName, FormQuestion question) { /* */ }
809    
810    /**
811     * A record grouping all parameters needed for searching form entries in the admin dashboard
812     * @param form the form
813     * @param criterionTree the criterion tree for form entry search
814     * @param userInputs the form search user inputs
815     * @param actionableEntriesOnly whether to filter only entries with at least one available action for the current user
816     * @param lang the form locale
817     */
818    public record DashboardAdminParameters(Form form, AbstractTreeNode<SearchServiceCriterion<?>> criterionTree, FormSearchUserInputs userInputs, boolean actionableEntriesOnly, String lang) { /*  */ }
819
820}