diff --git a/src/java/fr/paris/lutece/plugins/forms/web/admin/FormMultiviewConfigJspBean.java b/src/java/fr/paris/lutece/plugins/forms/web/admin/FormMultiviewConfigJspBean.java index 8ef654002..577f053db 100644 --- a/src/java/fr/paris/lutece/plugins/forms/web/admin/FormMultiviewConfigJspBean.java +++ b/src/java/fr/paris/lutece/plugins/forms/web/admin/FormMultiviewConfigJspBean.java @@ -232,7 +232,7 @@ public String modifyVisibleQuestions( HttpServletRequest request ) throws Access } /** - * Checks whether a question is considered filterable based on its entry type service + * Checks whether a question is considered filterable based on its entry type service * * @param entryTypeService * The entry type service to check diff --git a/src/java/fr/paris/lutece/plugins/forms/web/admin/MultiviewFormsJspBean.java b/src/java/fr/paris/lutece/plugins/forms/web/admin/MultiviewFormsJspBean.java index c0e92d8b4..d0f6dbe89 100644 --- a/src/java/fr/paris/lutece/plugins/forms/web/admin/MultiviewFormsJspBean.java +++ b/src/java/fr/paris/lutece/plugins/forms/web/admin/MultiviewFormsJspBean.java @@ -60,6 +60,10 @@ import fr.paris.lutece.plugins.forms.business.Form; import fr.paris.lutece.plugins.forms.business.FormHome; import fr.paris.lutece.plugins.forms.business.MultiviewConfig; +import fr.paris.lutece.plugins.forms.business.Question; +import fr.paris.lutece.plugins.forms.business.QuestionHome; +import fr.paris.lutece.plugins.forms.business.Step; +import fr.paris.lutece.plugins.forms.business.StepHome; import fr.paris.lutece.plugins.forms.business.action.GlobalFormsAction; import fr.paris.lutece.plugins.forms.business.action.GlobalFormsActionHome; import fr.paris.lutece.plugins.forms.business.form.FormItemSortConfig; @@ -72,6 +76,7 @@ import fr.paris.lutece.plugins.forms.export.ExportServiceManager; import fr.paris.lutece.plugins.forms.export.IFormatExport; import fr.paris.lutece.plugins.forms.service.FormPanelConfigIdService; +import fr.paris.lutece.plugins.forms.service.FormsResourceIdService; import fr.paris.lutece.plugins.forms.service.FormsPlugin; import fr.paris.lutece.plugins.forms.service.MultiviewFormService; import fr.paris.lutece.plugins.forms.util.FormsConstants; @@ -97,10 +102,14 @@ import fr.paris.lutece.portal.service.upload.MultipartItem; import fr.paris.lutece.portal.util.mvc.admin.annotations.Controller; import fr.paris.lutece.portal.util.mvc.commons.annotations.Action; +import fr.paris.lutece.portal.util.mvc.commons.annotations.ResponseBody; import fr.paris.lutece.portal.util.mvc.commons.annotations.View; import fr.paris.lutece.util.filesystem.FileSystemUtil; import fr.paris.lutece.util.html.AbstractPaginator; +import fr.paris.lutece.util.json.JsonResponse; +import fr.paris.lutece.util.json.JsonUtil; import fr.paris.lutece.util.url.UrlItem; +import org.apache.commons.lang3.math.NumberUtils; /** * Controller which manage the multiview of responses of all Forms @@ -116,6 +125,11 @@ public class MultiviewFormsJspBean extends AbstractJspBean // Actions private static final String ACTION_EXPORT_RESPONSES = "doExportResponses"; private static final String ACTION_SAVE_MULTIVIEW_CONFIG = "doSaveMultiviewConfig"; + private static final String ACTION_REORDER_COLUMNS = "doReorderMultiviewColumns"; + + // Json responses + private static final String RESPONSE_SUCCESS = "SUCCESS"; + private static final String RESPONSE_ERROR = "ERROR"; // Templates private static final String TEMPLATE_FORMS_MULTIVIEW = "admin/plugins/forms/multiview/forms_multiview.html"; @@ -693,9 +707,107 @@ protected static String getMultiviewBaseViewUrl( ) return "MultiviewForms.jsp?view=" + VIEW_MULTIVIEW_FORMS; } + /** + * Persists the multiview column order sent by the drag and drop reordering of the response table columns. Called through AJAX : the request holds one + * {@code multiview_column_order_} parameter per question backing a reordered column, whose value is the new order of the column. After + * persisting, the cached column list is rebuilt so the new order is reflected on the next reload of the page. + * + * @param request + * The Http request + * @return a JSON response ( SUCCESS or ERROR ) + */ + @Action( value = ACTION_REORDER_COLUMNS, securityTokenDisabled = true ) + @ResponseBody + public String doReorderMultiviewColumns( HttpServletRequest request ) + { + User user = (User) AdminUserService.getAdminUser( request ); + Map mapCheckedForms = new HashMap<>( ); + + try + { + for ( Map.Entry parameter : request.getParameterMap( ).entrySet( ) ) + { + String strParameterName = parameter.getKey( ); + String strPrefix = FormsConstants.PARAMETER_MULTIVIEW_ORDER + "_"; + if ( !strParameterName.startsWith( strPrefix ) ) + { + continue; + } + + int nIdQuestion = NumberUtils.toInt( strParameterName.substring( strPrefix.length( ) ), FormsConstants.DEFAULT_ID_VALUE ); + int nOrder = NumberUtils.toInt( request.getParameter( strParameterName ), FormsConstants.DEFAULT_ID_VALUE ); + + if ( nIdQuestion == FormsConstants.DEFAULT_ID_VALUE || nOrder == FormsConstants.DEFAULT_ID_VALUE ) + { + continue; + } + + Question question = QuestionHome.findByPrimaryKey( nIdQuestion ); + if ( question == null ) + { + continue; + } + + if ( !isAuthorizedToReorder( question, request, mapCheckedForms ) ) + { + throw new AccessDeniedException( StringUtils.EMPTY ); + } + + question.setMultiviewColumnOrder( nOrder ); + QuestionHome.update( question ); + } + } + catch( AccessDeniedException e ) + { + return JsonUtil.buildJsonResponse( new JsonResponse( RESPONSE_ERROR ) ); + } + + // Rebuild the cached column list so the new order is reflected on the next reload of the page, without resetting the active filters. The bean is + // session scoped and only rebuilds its column list on session loss / panel change, so we force the rebuild here using the currently selected form. + int nSelectedIdForm = NumberUtils.toInt( _strFormSelectedValue, FormsConstants.DEFAULT_ID_VALUE ); + Integer nIdForm = nSelectedIdForm != FormsConstants.DEFAULT_ID_VALUE ? nSelectedIdForm : null; + _listFormColumn = _formColumnFactory.buildFormColumnList( nIdForm, getLocale( ), user ); + _listFormColumnDisplay = FormDisplayFactory.createFormColumnDisplayList( _listFormColumn ); + + return JsonUtil.buildJsonResponse( new JsonResponse( RESPONSE_SUCCESS ) ); + } + + /** + * Checks that the current admin user is allowed to modify the params of the form owning the given question. The result is cached per form id for the + * duration of the request to avoid redundant RBAC checks. + * + * @param question + * The question whose owning form permission is checked + * @param request + * The Http request + * @param mapCheckedForms + * A cache of already checked form ids + * @return {@code true} if the user is authorized, {@code false} otherwise + */ + private boolean isAuthorizedToReorder( Question question, HttpServletRequest request, Map mapCheckedForms ) + { + Step step = StepHome.findByPrimaryKey( question.getIdStep( ) ); + if ( step == null ) + { + return false; + } + + return mapCheckedForms.computeIfAbsent( step.getIdForm( ), id -> { + try + { + checkUserPermission( Form.RESOURCE_TYPE, String.valueOf( id ), FormsResourceIdService.PERMISSION_MODIFY_PARAMS, request, null ); + return true; + } + catch( AccessDeniedException e ) + { + return false; + } + } ); + } + /** * Reload the form column list form the form filter list - * + * * @param listFormFilter * the form filter list */ diff --git a/src/java/fr/paris/lutece/plugins/forms/web/form/column/display/impl/FormColumnDisplayEntry.java b/src/java/fr/paris/lutece/plugins/forms/web/form/column/display/impl/FormColumnDisplayEntry.java index ee7ae0cc3..476f4f74e 100644 --- a/src/java/fr/paris/lutece/plugins/forms/web/form/column/display/impl/FormColumnDisplayEntry.java +++ b/src/java/fr/paris/lutece/plugins/forms/web/form/column/display/impl/FormColumnDisplayEntry.java @@ -69,6 +69,7 @@ public class FormColumnDisplayEntry extends AbstractFormColumnDisplay private static final String MARK_ENTRY_VALUES = "entry_values"; private static final String MARK_COLUMN_SORT_ATTRIBUTE = "column_sort_attribute"; private static final String MARK_SORT_URL = "sort_url"; + private static final String MARK_QUESTION_IDS = "question_ids"; /** * {@inheritDoc} @@ -87,6 +88,14 @@ public String buildFormColumnHeaderTemplate( String strSortUrl, Locale locale ) String columSort = column.getListEntryCode( ).stream( ).distinct( ).collect( Collectors.joining( "," ) ); String strAttributeSort = FormResponseSearchItem.FIELD_ENTRY_CODE_SUFFIX + columSort + FormResponseSearchItem.FIELD_RESPONSE_FIELD_ITER + "0"; + // Collect the id of every question backing this column so the client can persist the column order + String strQuestionIds = column.getListEntryCode( ).stream( ).distinct( ) + .flatMap( code -> QuestionHome.findByCode( code ).stream( ) ) + .map( q -> String.valueOf( q.getId( ) ) ) + .distinct( ) + .collect( Collectors.joining( "," ) ); + model.put( MARK_QUESTION_IDS, strQuestionIds ); + String strEntryCode = column.getListEntryCode( ).get( 0 ); Question question = QuestionHome.findByCode( strEntryCode ).get( 0 ); Entry entry = question.getEntry( ); diff --git a/webapp/WEB-INF/templates/admin/plugins/forms/forms_commons.html b/webapp/WEB-INF/templates/admin/plugins/forms/forms_commons.html index c1ae44848..e50ab13e2 100644 --- a/webapp/WEB-INF/templates/admin/plugins/forms/forms_commons.html +++ b/webapp/WEB-INF/templates/admin/plugins/forms/forms_commons.html @@ -713,11 +713,11 @@ }); -<#macro headerSort field title jsp_url attribute type='text' id='' hide=[]> +<#macro headerSort field title jsp_url attribute type='text' id='' hide=[] attr=''> <#-- Element visibility --> <#local displayTitleClass = displaySettings(hide,'table-cell') /> <#if jsp_url?contains("?")><#assign sort_url = jsp_url + "&sorted_attribute_name=" + attribute + "&asc_sort=" /><#else><#assign sort_url = jsp_url + "?sorted_attribute_name=" + attribute + "&asc_sort=" /> -<@th class='multiview-th bg-light ${displayTitleClass!}' id='sort${id!}_${attribute!}' params='data-field="${field}"' > +<@th class='multiview-th bg-light ${displayTitleClass!}' id='sort${id!}_${attribute!}' params='data-field="${field}" ${attr!}' > <@div class='d-flex align-items-center justify-content-between'> <@span class='text-truncate' params='data-bs-toggle="tooltip" title="${title}"'>${title} <@sort jsp_url=jsp_url attribute=attribute type=type /> diff --git a/webapp/WEB-INF/templates/admin/plugins/forms/multiview/column/header/form_column_entry_header.html b/webapp/WEB-INF/templates/admin/plugins/forms/multiview/column/header/form_column_entry_header.html index ad8960004..edda78d11 100644 --- a/webapp/WEB-INF/templates/admin/plugins/forms/multiview/column/header/form_column_entry_header.html +++ b/webapp/WEB-INF/templates/admin/plugins/forms/multiview/column/header/form_column_entry_header.html @@ -1 +1 @@ -<@headerSort field='entry_column_${entry_column_position}' title=column_title! jsp_url=sort_url attribute=column_sort_attribute /> \ No newline at end of file +<@headerSort field='entry_column_${entry_column_position}' title=column_title! jsp_url=sort_url attribute=column_sort_attribute attr='data-question-ids="${question_ids!}"' /> \ No newline at end of file diff --git a/webapp/themes/admin/shared/plugins/forms/css/forms.css b/webapp/themes/admin/shared/plugins/forms/css/forms.css index 480ff32aa..71d58d3c2 100644 --- a/webapp/themes/admin/shared/plugins/forms/css/forms.css +++ b/webapp/themes/admin/shared/plugins/forms/css/forms.css @@ -107,6 +107,15 @@ table.table-hover tbody tr:hover { max-width: 150px; } +/* Multiview - Drag and drop column reordering */ +.multiview-draggable-col.dragging { + opacity: 0.4; +} + +.multiview-draggable-col.drag-over { + border-left: 3px solid var(--tblr-primary); +} + .th-active { background-color: var(--tblr-primary); } diff --git a/webapp/themes/admin/shared/plugins/forms/js/multiview/forms-multiview.js b/webapp/themes/admin/shared/plugins/forms/js/multiview/forms-multiview.js index 3608f26e5..8ff594f2e 100644 --- a/webapp/themes/admin/shared/plugins/forms/js/multiview/forms-multiview.js +++ b/webapp/themes/admin/shared/plugins/forms/js/multiview/forms-multiview.js @@ -1,3 +1,105 @@ +/** + * Enable drag and drop reordering of the multiview response table columns. + * Only columns backed by questions (carrying a data-question-ids attribute) can be dragged. + * When the order changes it is persisted through an AJAX call to ManageFormMultiviewConfig.jsp, + * updating the multiview_column_order_XX value of each impacted question. + * + * @param {string} reorderUrl the JSP url handling the doReorderMultiviewColumns action + */ +function initMultiviewColumnDragAndDrop( reorderUrl ){ + const table = document.querySelector('#multi-form-list table'); + if ( !table || !table.tHead || !table.tHead.rows.length ) { + return; + } + + const headerRow = table.tHead.rows[0]; + let draggedIndex = null; + + function persistColumnOrder(){ + const params = new URLSearchParams(); + params.append('action_doReorderMultiviewColumns', '1'); + // The visible order of the columns becomes the new multiview_column_order value of each question + Array.from(headerRow.cells).forEach(function(th, position){ + const ids = th.getAttribute('data-question-ids'); + if ( ids ) { + ids.split(',').filter(Boolean).forEach(function(id){ + params.append('multiview_column_order_' + id, position); + }); + } + }); + + fetch(reorderUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, + body: params.toString() + }).catch(function(){ /* silently ignore : order is best effort and will be recomputed on reload */ }); + } + + function moveColumn( fromIndex, toIndex ){ + if ( fromIndex === toIndex ) { + return; + } + const rows = [ headerRow ].concat(Array.from(table.tBodies).flatMap(tb => Array.from(tb.rows))); + rows.forEach(function(row){ + if ( row.cells.length <= Math.max(fromIndex, toIndex) ) { + return; + } + const moved = row.cells[fromIndex]; + const reference = row.cells[toIndex]; + if ( fromIndex < toIndex ) { + reference.after(moved); + } else { + reference.before(moved); + } + }); + } + + Array.from(headerRow.cells).forEach(function(th){ + if ( !th.hasAttribute('data-question-ids') ) { + return; + } + th.setAttribute('draggable', 'true'); + th.classList.add('multiview-draggable-col'); + th.style.cursor = 'move'; + + th.addEventListener('dragstart', function(e){ + draggedIndex = th.cellIndex; + e.dataTransfer.effectAllowed = 'move'; + th.classList.add('dragging'); + }); + + th.addEventListener('dragend', function(){ + draggedIndex = null; + headerRow.querySelectorAll('th').forEach(function(cell){ + cell.classList.remove('dragging', 'drag-over'); + }); + }); + + th.addEventListener('dragover', function(e){ + if ( draggedIndex === null ) { + return; + } + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + th.classList.add('drag-over'); + }); + + th.addEventListener('dragleave', function(){ + th.classList.remove('drag-over'); + }); + + th.addEventListener('drop', function(e){ + e.preventDefault(); + th.classList.remove('drag-over'); + if ( draggedIndex === null || draggedIndex === th.cellIndex ) { + return; + } + moveColumn(draggedIndex, th.cellIndex); + persistColumnOrder(); + }); + }); +} + function redirectOnClick( element ){ const url = element.getAttribute("data-url"); if (url !== null) { @@ -176,6 +278,9 @@ document.addEventListener('DOMContentLoaded', function() { }); }); + // Enable drag and drop reordering of the response table columns + initMultiviewColumnDragAndDrop("jsp/admin/plugins/forms/MultiviewForms.jsp"); + // Add reset button to search text const searchedText = document.getElementById("searched_text"); if (searchedText) {