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}