View Javadoc
1   package fr.ifremer.reefdb.ui.swing.content.observation.shared;
2   
3   /*-
4    * #%L
5    * Reef DB :: UI
6    * $Id:$
7    * $HeadURL:$
8    * %%
9    * Copyright (C) 2014 - 2019 Ifremer
10   * %%
11   * This program is free software: you can redistribute it and/or modify
12   * it under the terms of the GNU Affero General Public License as published by
13   * the Free Software Foundation, either version 3 of the License, or
14   * (at your option) any later version.
15   *
16   * This program is distributed in the hope that it will be useful,
17   * but WITHOUT ANY WARRANTY; without even the implied warranty of
18   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
19   * GNU General Public License for more details.
20   *
21   * You should have received a copy of the GNU Affero General Public License
22   * along with this program.  If not, see <http://www.gnu.org/licenses/>.
23   * #L%
24   */
25  
26  import com.google.common.base.Joiner;
27  import com.google.common.collect.ArrayListMultimap;
28  import com.google.common.collect.ImmutableList;
29  import com.google.common.collect.Maps;
30  import com.google.common.collect.Multimap;
31  import fr.ifremer.quadrige3.core.dao.technical.Assert;
32  import fr.ifremer.quadrige3.core.dao.technical.factorization.pmfm.AllowedQualitativeValuesMap;
33  import fr.ifremer.quadrige3.ui.core.dto.DirtyAware;
34  import fr.ifremer.quadrige3.ui.swing.table.editor.ExtendedComboBoxCellEditor;
35  import fr.ifremer.reefdb.decorator.DecoratorService;
36  import fr.ifremer.reefdb.dto.ErrorDTO;
37  import fr.ifremer.reefdb.dto.ReefDbBeanFactory;
38  import fr.ifremer.reefdb.dto.ReefDbBeans;
39  import fr.ifremer.reefdb.dto.configuration.control.ControlRuleDTO;
40  import fr.ifremer.reefdb.dto.configuration.control.PreconditionRuleDTO;
41  import fr.ifremer.reefdb.dto.configuration.control.RuleGroupDTO;
42  import fr.ifremer.reefdb.dto.configuration.control.RulePmfmDTO;
43  import fr.ifremer.reefdb.dto.data.measurement.MeasurementAware;
44  import fr.ifremer.reefdb.dto.data.measurement.MeasurementDTO;
45  import fr.ifremer.reefdb.dto.enums.ControlElementValues;
46  import fr.ifremer.reefdb.dto.enums.ControlFeatureMeasurementValues;
47  import fr.ifremer.reefdb.dto.enums.FilterTypeValues;
48  import fr.ifremer.reefdb.dto.referential.DepartmentDTO;
49  import fr.ifremer.reefdb.dto.referential.TaxonDTO;
50  import fr.ifremer.reefdb.dto.referential.TaxonGroupDTO;
51  import fr.ifremer.reefdb.dto.referential.pmfm.PmfmDTO;
52  import fr.ifremer.reefdb.dto.referential.pmfm.QualitativeValueDTO;
53  import fr.ifremer.reefdb.service.ReefDbTechnicalException;
54  import fr.ifremer.reefdb.ui.swing.content.observation.operation.measurement.grouped.OperationMeasurementsGroupedRowModel;
55  import fr.ifremer.reefdb.ui.swing.util.ReefDbUI;
56  import fr.ifremer.reefdb.ui.swing.util.table.AbstractReefDbTableUIHandler;
57  import fr.ifremer.reefdb.ui.swing.util.table.PmfmTableColumn;
58  import fr.ifremer.reefdb.ui.swing.util.table.ReefDbColumnIdentifier;
59  import fr.ifremer.reefdb.ui.swing.util.table.ReefDbPmfmColumnIdentifier;
60  import org.apache.commons.collections4.CollectionUtils;
61  import org.apache.commons.lang3.StringUtils;
62  import org.apache.commons.logging.Log;
63  import org.apache.commons.logging.LogFactory;
64  
65  import javax.swing.JComponent;
66  import javax.swing.JDialog;
67  import javax.swing.JScrollPane;
68  import javax.swing.SwingUtilities;
69  import java.awt.Dimension;
70  import java.awt.Insets;
71  import java.util.*;
72  import java.util.concurrent.atomic.AtomicInteger;
73  import java.util.stream.Collectors;
74  
75  import static org.nuiton.i18n.I18n.t;
76  
77  /**
78   * @author peck7 on 08/01/2019.
79   */
80  public abstract class AbstractMeasurementsGroupedTableUIHandler<
81      R extends AbstractMeasurementsGroupedRowModel<MeasurementDTO, R>,
82      M extends AbstractMeasurementsGroupedTableUIModel<MeasurementDTO, R, M>,
83      UI extends ReefDbUI<M, ?>>
84      extends AbstractReefDbTableUIHandler<R, M, UI> {
85  
86      private static final Log LOG = LogFactory.getLog(AbstractMeasurementsGroupedTableUIHandler.class);
87  
88      // editor for taxon group column
89      protected ExtendedComboBoxCellEditor<TaxonGroupDTO> taxonGroupCellEditor;
90      // editor for taxon column
91      protected ExtendedComboBoxCellEditor<TaxonDTO> taxonCellEditor;
92      // editor for analyst column
93      protected ExtendedComboBoxCellEditor<DepartmentDTO> departmentCellEditor;
94  
95      public AbstractMeasurementsGroupedTableUIHandler(String... properties) {
96          super(properties);
97      }
98  
99      @Override
100     protected String[] getRowPropertiesToIgnore() {
101         return new String[]{
102             AbstractMeasurementsGroupedRowModel.PROPERTY_INPUT_TAXON_ID,
103             AbstractMeasurementsGroupedRowModel.PROPERTY_INPUT_TAXON_NAME
104         };
105     }
106 
107     @Override
108     public void beforeInit(UI ui) {
109         super.beforeInit(ui);
110         ui.setContextValue(createNewModel());
111     }
112 
113     protected abstract M createNewModel();
114 
115     protected abstract void initTable();
116 
117     @Override
118     public void afterInit(UI ui) {
119 
120         initUI(ui);
121 
122         createTaxonGroupCellEditor();
123         createTaxonCellEditor();
124         createDepartmentCellEditor();
125 
126         initTable();
127 
128         initListeners();
129     }
130 
131     @Override
132     public abstract AbstractMeasurementsGroupedTableModel<R> getTableModel();
133 
134     private void createTaxonCellEditor() {
135 
136 //        taxonCellEditor = newExtendedComboBoxCellEditor(null, SurveyMeasurementsGroupedTableModel.TAXON, false);
137         taxonCellEditor = newExtendedComboBoxCellEditor(null, TaxonDTO.class, DecoratorService.WITH_CITATION_AND_REFERENT, false);
138 
139         taxonCellEditor.setAction("unfilter-taxon", "reefdb.common.unfilter.taxon", e -> {
140             // unfilter taxons
141             updateTaxonCellEditor(getModel().getSingleSelectedRow(), true);
142         });
143 
144     }
145 
146     private void updateTaxonCellEditor(R row, boolean forceNoFilter) {
147 
148         // Mantis #0027041 forceNoFilter==true remove link between TaxonGroup and Taxon, but keep context filter
149         taxonCellEditor.getCombo().setActionEnabled(!forceNoFilter /*&& getContext().getDataContext().isContextFiltered(FilterTypeValues.TAXON)*/);
150 
151 //        List<TaxonDTO> taxons = getContext().getObservationService().getAvailableTaxons(row == null ? null : row.getTaxonGroup(), forceNoFilter);
152         List<TaxonDTO> taxons = getModel().getObservationUIHandler().getAvailableTaxons(row == null || forceNoFilter ? null : row.getTaxonGroup(), false);
153         taxonCellEditor.getCombo().setData(taxons);
154 
155         // Mantis #0028101 : don't affect taxon in row, even there is no corresponding
156 //        if (row != null) {
157 //            if (taxons.isEmpty() && row.getTaxon() != null) {
158 //                row.setTaxon(null);
159 //            } else if (taxons.size() == 1) {
160 //                row.setTaxon(taxons.get(0));
161 //            }
162 //        }
163     }
164 
165     private void createTaxonGroupCellEditor() {
166 
167         taxonGroupCellEditor = newExtendedComboBoxCellEditor(null, TaxonGroupDTO.class, false);
168 
169         taxonGroupCellEditor.setAction("unfilter-taxon", "reefdb.common.unfilter.taxon", e -> {
170             // unfilter taxon groups
171             updateTaxonGroupCellEditor(getModel().getSingleSelectedRow(), true);
172         });
173 
174     }
175 
176     private void updateTaxonGroupCellEditor(R row, boolean forceNoFilter) {
177 
178         // Mantis #0027041 forceNoFilter==true remove link between TaxonGroup and Taxon, but keep context filter
179         taxonGroupCellEditor.getCombo().setActionEnabled(!forceNoFilter /*&& getContext().getDataContext().isContextFiltered(FilterTypeValues.TAXON_GROUP)*/);
180 
181 //        List<TaxonGroupDTO> taxonGroups = getContext().getObservationService().getAvailableTaxonGroups(row == null ? null : row.getTaxon(), forceNoFilter);
182         List<TaxonGroupDTO> taxonGroups = getModel().getObservationUIHandler().getAvailableTaxonGroups(row == null || forceNoFilter ? null : row.getTaxon(), false);
183 
184         taxonGroupCellEditor.getCombo().setData(taxonGroups);
185 
186         // Mantis #0028101 : don't affect taxon group in row, even there is no corresponding
187 //        if (row != null) {
188 //            if (taxonGroups.isEmpty() && row.getTaxonGroup() != null) {
189 //                row.setTaxonGroup(null);
190 //            } else if (taxonGroups.size() == 1) {
191 //                row.setTaxonGroup(taxonGroups.get(0));
192 //            }
193 //        }
194     }
195 
196     private void createDepartmentCellEditor() {
197 
198         departmentCellEditor = newExtendedComboBoxCellEditor(null, DepartmentDTO.class, false);
199 
200         departmentCellEditor.setAction("unfilter", "reefdb.common.unfilter", e -> {
201             if (!askBefore(t("reefdb.common.unfilter"), t("reefdb.common.unfilter.confirmation"))) {
202                 return;
203             }
204             // unfilter location
205             updateDepartmentCellEditor(true);
206         });
207 
208     }
209 
210     private void updateDepartmentCellEditor(boolean forceNoFilter) {
211 
212         departmentCellEditor.getCombo().setActionEnabled(!forceNoFilter
213             && getContext().getDataContext().isContextFiltered(FilterTypeValues.DEPARTMENT));
214 
215         departmentCellEditor.getCombo().setData(getContext().getObservationService().getAvailableDepartments(forceNoFilter));
216     }
217 
218     protected void resetCellEditors() {
219 
220         updateTaxonGroupCellEditor(null, false);
221         updateTaxonCellEditor(null, false);
222         updateDepartmentCellEditor(false);
223     }
224 
225     protected void initListeners() {
226 
227         getModel().addPropertyChangeListener(evt -> {
228 
229             switch (evt.getPropertyName()) {
230 
231                 case AbstractMeasurementsGroupedTableUIModel.PROPERTY_SURVEY:
232 
233                     loadMeasurements();
234                     break;
235 
236                 case AbstractMeasurementsGroupedTableUIModel.EVENT_INDIVIDUAL_MEASUREMENTS_LOADED:
237 
238                     detectPreconditionedPmfms();
239                     detectGroupedIdentifiers();
240                     break;
241 
242                 case AbstractMeasurementsGroupedTableUIModel.PROPERTY_MEASUREMENT_FILTER:
243 
244                     filterMeasurements();
245                     break;
246 
247                 case AbstractMeasurementsGroupedTableUIModel.PROPERTY_SINGLE_ROW_SELECTED:
248 
249                     if (getModel().getSingleSelectedRow() != null && !getModel().getSingleSelectedRow().isEditable()) {
250                         return;
251                     }
252 
253                     // update taxonCellEditor
254                     updateTaxonCellEditor(getModel().getSingleSelectedRow(), false);
255                     updateTaxonGroupCellEditor(getModel().getSingleSelectedRow(), false);
256                     updateDepartmentCellEditor(false);
257                     updatePmfmCellEditors(getModel().getSingleSelectedRow(), null, false);
258                     break;
259 
260             }
261         });
262     }
263 
264     protected abstract void filterMeasurements();
265 
266     /**
267      * Load measurements
268      */
269     private void loadMeasurements() {
270 
271         SwingUtilities.invokeLater(() -> {
272 
273             // Uninstall save state listener
274             uninstallSaveTableStateListener();
275 
276             // reset (and prepare combo boxes)
277             resetCellEditors();
278 
279             // Build dynamic columns
280             // TODO manage column position depending on criteria (parametrized list or specific attribute)
281             // maybe split pmfms list in 2 separated list or move afterward
282             ReefDbColumnIdentifier<R> insertPosition = getTableModel().getPmfmInsertPosition();
283             addPmfmColumns(
284                 getModel().getPmfms(),
285                 AbstractMeasurementsGroupedRowModel.PROPERTY_INDIVIDUAL_PMFMS,
286                 DecoratorService.NAME_WITH_UNIT,
287                 insertPosition);
288 
289             boolean notEmpty = CollectionUtils.isNotEmpty(getModel().getPmfms());
290 
291             // Build rows
292             getModel().setRows(buildRows(!getModel().getSurvey().isEditable()));
293 
294             recomputeRowsValidState();
295 
296             // Apply measurement filter
297             filterMeasurements();
298 
299             // restore table from swing session
300             restoreTableState();
301 
302             // hide analyst if no pmfm
303 //            forceColumnVisibleAtLastPosition(AbstractMeasurementsGroupedTableModel.ANALYST, notEmpty);
304             // Don't force position (Mantis #49537)
305             forceColumnVisible(AbstractMeasurementsGroupedTableModel.ANALYST, notEmpty);
306 
307             // set columns with errors visible (Mantis #40752)
308             ensureColumnsWithErrorAreVisible(getModel().getRows());
309 
310             // Install save state listener
311             installSaveTableStateListener();
312 
313             getModel().fireMeasurementsLoaded();
314         });
315 
316     }
317 
318     protected List<R> buildRows(boolean readOnly) {
319 
320         List<R> rows = new ArrayList<>();
321 
322         List<? extends MeasurementAware> measurementAwareBeans = getMeasurementAwareModels();
323 
324         for (MeasurementAware bean : measurementAwareBeans) {
325 
326             List<MeasurementDTO> measurements = bean.getIndividualMeasurements().stream()
327                 // sort by individual id
328                 .sorted(Comparator.comparingInt(MeasurementDTO::getIndividualId))
329                 .collect(Collectors.toList());
330 
331             R row = null;
332             for (final MeasurementDTO measurement : measurements) {
333                 // check previous row
334                 if (row != null) {
335 
336                     // if individual id differs = new row
337                     if (!row.getIndividualId().equals(measurement.getIndividualId())) {
338                         row = null;
339                     }
340 
341                     // if taxon group or taxon differs, should update current row (see Mantis #0026647)
342                     else if (!Objects.equals(row.getTaxonGroup(), measurement.getTaxonGroup())
343                         || !Objects.equals(row.getTaxon(), measurement.getTaxon())
344                         || !Objects.equals(row.getInputTaxonId(), measurement.getInputTaxonId())
345                         || !Objects.equals(row.getInputTaxonName(), measurement.getInputTaxonName())) {
346 
347                         // update taxon group and taxon if previously empty
348                         if (row.getTaxonGroup() == null) {
349                             row.setTaxonGroup(measurement.getTaxonGroup());
350                         } else if (measurement.getTaxonGroup() != null && !row.getTaxonGroup().equals(measurement.getTaxonGroup())) {
351                             // the taxon group in measurement differs
352                             LOG.error(String.format("taxon group in measurement (id=%s) differs with taxon group in previous measurements with same individual id (=%s) !",
353                                 measurement.getId(), measurement.getIndividualId()));
354                         }
355                         if (row.getTaxon() == null) {
356                             row.setTaxon(measurement.getTaxon());
357                         } else if (measurement.getTaxon() != null && !row.getTaxon().equals(measurement.getTaxon())) {
358                             // the taxon in measurement differs
359                             LOG.error(String.format("taxon in measurement (id=%s) differs with taxon in previous measurements with same individual id (=%s) !",
360                                 measurement.getId(), measurement.getIndividualId()));
361                         }
362                         if (row.getInputTaxonId() == null) {
363                             row.setInputTaxonId(measurement.getInputTaxonId());
364                         } else if (measurement.getInputTaxonId() != null && !row.getInputTaxonId().equals(measurement.getInputTaxonId())) {
365                             // the input taxon Id in measurement differs
366                             LOG.error(String.format("input taxon id in measurement (id=%s) differs with input taxon id in previous measurements with same individual id (=%s) !",
367                                 measurement.getId(), measurement.getIndividualId()));
368                         }
369                         if (StringUtils.isBlank(row.getInputTaxonName())) {
370                             row.setInputTaxonName(measurement.getInputTaxonName());
371                         } else if (measurement.getInputTaxonName() != null && !row.getInputTaxonName().equals(measurement.getInputTaxonName())) {
372                             // the input taxon name in measurement differs
373                             LOG.error(String.format("input taxon name in measurement (id=%s) differs with input taxon name in previous measurements with same individual id (=%s) !",
374                                 measurement.getId(), measurement.getIndividualId()));
375                         }
376                     }
377                 }
378 
379                 // build a new row
380                 if (row == null) {
381                     row = createNewRow(readOnly, bean);
382                     row.setIndividualPmfms(new ArrayList<>(getModel().getPmfms()));
383                     row.setIndividualId(measurement.getIndividualId());
384                     row.setTaxonGroup(measurement.getTaxonGroup());
385                     row.setTaxon(measurement.getTaxon());
386                     row.setInputTaxonId(measurement.getInputTaxonId());
387                     row.setInputTaxonName(measurement.getInputTaxonName());
388                     row.setAnalyst(measurement.getAnalyst());
389                     // Mantis #26724 or #29130: don't take only one comment but a concatenation of all measurements comments
390 //                    row.setComment(measurement.getComment());
391 
392                     row.setValid(true);
393 
394                     rows.add(row);
395 
396                     // add errors on new row if samplingOperations have some
397                     List<ErrorDTO> errors = ReefDbBeans.filterCollection(bean.getErrors(),
398                         input -> input != null
399                             && ControlElementValues.MEASUREMENT.equals(input.getControlElementCode())
400                             && Objects.equals(input.getIndividualId(), measurement.getIndividualId())
401                     );
402                     ReefDbBeans.addUniqueErrors(row, errors);
403                 }
404 
405                 // add measurement on current row
406                 row.getIndividualMeasurements().add(measurement);
407 
408                 // add errors on row if measurement have some
409                 ReefDbBeans.addUniqueErrors(row, measurement.getErrors());
410 
411             }
412 
413         }
414 
415         // Mantis #26724 or #29130: don't take only one comment but a concatenation of all measurements comments
416         for (R row : rows) {
417 
418             // join and affect
419             row.setComment(ReefDbBeans.getUnifiedCommentFromIndividualMeasurements(row));
420         }
421 
422         return rows;
423     }
424 
425     protected abstract List<? extends MeasurementAware> getMeasurementAwareModels();
426 
427     protected abstract MeasurementAware getMeasurementAwareModelForRow(R row);
428 
429     protected abstract R createNewRow(boolean readOnly, MeasurementAware parentBean);
430 
431     @Override
432     protected void onRowsAdded(List<R> addedRows) {
433         super.onRowsAdded(addedRows);
434 
435         if (addedRows.size() == 1) {
436             R newRow = addedRows.get(0);
437 
438             // If newRow has no pmfm and no measurement (a real new row)
439             if (CollectionUtils.isEmpty(newRow.getIndividualPmfms()) && CollectionUtils.isEmpty(newRow.getIndividualMeasurements())) {
440 
441                 // Affect individual pmfms from parent model
442                 newRow.setIndividualPmfms(new ArrayList<>(getModel().getPmfms()));
443 
444                 // Create empty measurements
445                 ReefDbBeans.createEmptyMeasurements(newRow);
446 
447                 // Set default value if filters are set
448                 initAddedRow(newRow);
449             }
450 
451             // set default analyst from pmfm strategies (Mantis #42619)
452             setDefaultAnalyst(newRow);
453 
454             // reset the cell editors
455             resetCellEditors();
456 
457             getModel().setModify(true);
458 
459             // Ajouter le focus sur la cellule de la ligne cree
460             setFocusOnCell(newRow);
461 
462         } else {
463 
464             // set default analyst from pmfm strategies for all new rows (ie. from grid initialization) (Mantis #49331)
465             setDefaultAnalyst(addedRows);
466         }
467     }
468 
469     protected void setDefaultAnalyst(R row) {
470         setDefaultAnalyst(Collections.singleton(row));
471     }
472 
473     protected void setDefaultAnalyst(Collection<R> rows) {
474 
475         if (CollectionUtils.isEmpty(rows))
476             return;
477 
478         DepartmentDTO analyst = getContext().getProgramStrategyService().getAnalysisDepartmentOfAppliedStrategyBySurvey(
479             getModel().getSurvey(),
480             getModel().getPmfms()
481         );
482 
483         rows.forEach(row -> row.setAnalyst(analyst));
484     }
485 
486     protected void initAddedRow(R row) {
487         if (getModel().getTaxonGroupFilter() != null) {
488             row.setTaxonGroup(getModel().getTaxonGroupFilter());
489         }
490         if (getModel().getTaxonFilter() != null) {
491             row.setTaxon(getModel().getTaxonFilter());
492         }
493     }
494 
495     @Override
496     protected void onRowModified(int rowIndex, R row, String propertyName, Integer propertyIndex, Object oldValue, Object newValue) {
497 
498         // if a value of individual pmfm changes
499         if (AbstractMeasurementsGroupedRowModel.PROPERTY_INDIVIDUAL_PMFMS.equals(propertyName)) {
500 
501             // no need to tell the table is modified if no pmfms value change
502             if (oldValue == newValue) return;
503 
504             // if a value has been changed, update other editor with preconditions
505             if (!getModel().isAdjusting())
506                 updatePmfmCellEditors(row, propertyIndex, newValue != null);
507         }
508 
509         // update taxon when taxon group is updated
510         if (AbstractMeasurementsGroupedRowModel.PROPERTY_TAXON_GROUP.equals(propertyName)) {
511             // filter taxon
512             updateTaxonCellEditor(row, false);
513 
514             // reset measurementId if no more taxon group or new taxon group
515             if ((newValue == null ^ oldValue == null) && row.getTaxon() == null) {
516 
517                 resetIndividualMeasurementIds(row);
518             }
519         }
520 
521         if (AbstractMeasurementsGroupedRowModel.PROPERTY_TAXON.equals(propertyName)) {
522             // filter taxon
523             updateTaxonGroupCellEditor(row, false);
524 
525             // reset measurementId if no more taxon or new taxon
526             if ((newValue == null ^ oldValue == null) && row.getTaxonGroup() == null) {
527 
528                 resetIndividualMeasurementIds(row);
529             }
530 
531             // update input taxon
532             TaxonDTO taxon = (TaxonDTO) newValue;
533             row.setInputTaxonId(taxon != null ? taxon.getId() : null);
534             row.setInputTaxonName(taxon != null ? taxon.getName() : null);
535         }
536 
537         // fire modify event
538         super.onRowModified(rowIndex, row, propertyName, propertyIndex, oldValue, newValue);
539 
540         // if value has changed, process save to bean
541         if (oldValue != newValue) {
542 
543             // save modifications ot the row to bean
544 //            saveMeasurementsInModel(row);
545 
546             // also recompute valid state on all rows
547             recomputeRowsValidState();
548         }
549 
550     }
551 
552     protected void resetIndividualMeasurementIds(R row) {
553         if (row != null && CollectionUtils.isNotEmpty(row.getIndividualMeasurements())) {
554             row.getIndividualMeasurements().forEach(measurement -> measurement.setId(null));
555         }
556     }
557 
558     protected void setDirty(MeasurementAware bean) {
559         if (bean instanceof DirtyAware) ((DirtyAware) bean).setDirty(true);
560     }
561 
562     protected void updateMeasurementFromRow(MeasurementDTO measurement, R row) {
563         measurement.setIndividualId(row.getIndividualId());
564         measurement.setTaxonGroup(row.getTaxonGroup());
565         measurement.setTaxon(row.getTaxon());
566         measurement.setInputTaxonId(row.getInputTaxonId());
567         measurement.setInputTaxonName(row.getInputTaxonName());
568         measurement.setComment(row.getComment());
569         measurement.setAnalyst(row.getAnalyst());
570     }
571 
572     public void removeIndividualMeasurements() {
573 
574         if (getModel().getSelectedRows().isEmpty()) {
575             LOG.warn("Can't remove rows: no row selected");
576             return;
577         }
578 
579         if (askBeforeDelete(t("reefdb.action.delete.survey.measurement.titre"), t("reefdb.action.delete.survey.measurement.message"))) {
580 
581             // Get distinct models to update after rows removed
582             Collection<MeasurementAware> modelsToUpdate = getModel().getSelectedRows().stream()
583                 .map(this::getMeasurementAwareModelForRow)
584                 .collect(Collectors.toSet());
585 
586             // Remove from table
587             getModel().deleteSelectedRows();
588 
589             modelsToUpdate.forEach(this::setDirty);
590             getModel().setModify(true);
591             recomputeRowsValidState();
592         }
593 
594     }
595 
596     public void duplicateSelectedRow() {
597 
598         if (getModel().getSelectedRows().size() == 1 && getModel().getSingleSelectedRow() != null) {
599 
600             R rowToDuplicate = getModel().getSingleSelectedRow();
601             MeasurementAware bean = getMeasurementAwareModelForRow(rowToDuplicate);
602             R row = createNewRow(false, bean);
603             row.setTaxonGroup(rowToDuplicate.getTaxonGroup());
604             row.setTaxon(rowToDuplicate.getTaxon());
605             row.setInputTaxonId(rowToDuplicate.getInputTaxonId());
606             row.setInputTaxonName(rowToDuplicate.getInputTaxonName());
607             row.setComment(rowToDuplicate.getComment());
608             row.setIndividualPmfms(rowToDuplicate.getIndividualPmfms());
609             // add also analyst (MAntis #45054)
610             row.setAnalyst(rowToDuplicate.getAnalyst());
611             // duplicate measurements
612             row.setIndividualMeasurements(ReefDbBeans.duplicate(rowToDuplicate.getIndividualMeasurements()));
613 
614             // Add duplicate measurement to table
615             getModel().insertRowAfterSelected(row);
616 
617             setDirty(bean);
618             recomputeRowsValidState();
619             getModel().setModify(true);
620             setFocusOnCell(row);
621         }
622     }
623 
624     /* validation section */
625 
626     /**
627      * Call this method only before save to perform perfect duplicate check
628      * It can be too long if many rows (see Mantis #50040)
629      */
630     @Override
631     public void recomputeRowsValidState() {
632 
633         // recompute individual row valid state
634         super.recomputeRowsValidState();
635 
636         // and check perfect duplicates
637         hasNoTaxonPerfectDuplicates();
638     }
639 
640     @Override
641     protected boolean isRowValid(R row) {
642         boolean valid = super.isRowValid(row);
643 
644         if (!valid && !row.isMandatoryValid()) {
645             // check invalid mandatory errors
646             new ArrayList<>(row.getInvalidMandatoryIdentifiers()).forEach(invalidIdentifier -> {
647                 if (row.getMultipleValuesOnIdentifier().contains(invalidIdentifier)) {
648                     // if this identifier has multiple value, remove error
649                     row.getErrors().removeIf(error -> error.getPropertyName().size() == 1 && error.getPropertyName().contains(invalidIdentifier.getPropertyName()));
650                     row.getInvalidMandatoryIdentifiers().remove(invalidIdentifier);
651                 }
652             });
653             valid = row.isMandatoryValid();
654         }
655 
656         boolean noUnicityDuplicates = hasNoUnicityDuplicates(row);
657         boolean noPreconditionErrors = hasNoPreconditionErrors(row);
658         boolean noGroupedRuleErrors = hasNoGroupedRuleErrors(row);
659         boolean hasAnalyst = hasAnalyst(row);
660 
661         return valid && noUnicityDuplicates && noPreconditionErrors && noGroupedRuleErrors && hasAnalyst;
662     }
663 
664     protected boolean hasAnalyst(R row) {
665         if (!row.getMultipleValuesOnIdentifier().contains(AbstractMeasurementsGroupedTableModel.ANALYST)
666             && row.getAnalyst() == null && row.getIndividualMeasurements().stream().anyMatch(this::isMeasurementNotEmpty)) {
667             ReefDbBeans.addError(row,
668                 t("reefdb.validator.error.analyst.required"),
669                 AbstractMeasurementsGroupedRowModel.PROPERTY_ANALYST);
670             return false;
671         }
672         return true;
673     }
674 
675     protected boolean isMeasurementNotEmpty(MeasurementDTO measurement) {
676         return !ReefDbBeans.isMeasurementEmpty(measurement);
677     }
678 
679     /**
680      * Check and set invalid rows which are perfect duplicates (Mantis #50040)
681      */
682     protected void hasNoTaxonPerfectDuplicates() {
683 
684         if (getModel().getRowCount() < 2) {
685             // no need to check duplicates
686             return;
687         }
688 
689         Map<String, List<R>> duplicatedMap = getModel().getRows().stream().collect(Collectors.groupingBy(r -> r.rowWithMeasurementsHashCode()));
690         duplicatedMap.entrySet().stream().filter(entry -> entry.getValue().size() > 1)
691             .forEach(entry -> entry.getValue().forEach(duplicatedRow -> {
692                 ReefDbBeans.addWarning(duplicatedRow,
693                     t("reefdb.measurement.grouped.duplicates"), duplicatedRow.getDefaultProperties().toArray(new String[0]));
694                 ReefDbBeans.getPmfmIdsOfNonEmptyIndividualMeasurements(duplicatedRow).forEach(pmfmId -> ReefDbBeans.addWarning(duplicatedRow,
695                     t("reefdb.measurement.grouped.duplicates"),
696                     pmfmId,
697                     AbstractMeasurementsGroupedRowModel.PROPERTY_INDIVIDUAL_PMFMS));
698             }));
699 
700     }
701 
702     protected boolean hasNoUnicityDuplicates(R row) {
703 
704         // TODO: LP 08/01/2020: try to use same algo as hasNoTaxonPerfectDuplicates but it's a bit more difficult due to valid state to return on each row
705         // use rowWithMeasurementsHashCode(Collection<PmfmDTO> filterPmfms)
706 
707         if (getModel().getRowCount() < 2
708             || (row.getTaxonGroup() == null && row.getTaxon() == null)
709             || CollectionUtils.isEmpty(getModel().getUniquePmfms())) {
710             // no need to check duplicates
711             return true;
712         }
713 
714         for (R otherRow : getModel().getRows()) {
715             if (otherRow == row || (otherRow.getTaxonGroup() == null && otherRow.getTaxon() == null)) continue;
716 
717             if (row.isSameRow(otherRow)
718                 // TODO : ce bug n'a pas été trouvé !
719 //                    && row.getIndividualMeasurements().size() == otherRow.getIndividualMeasurements().size()
720             ) {
721                 // if rows are equals, check measurement values with unique pmfms
722                 for (PmfmDTO uniquePmfm : getModel().getUniquePmfms()) {
723 
724                     // find the measurement with this pmfm in the row
725                     MeasurementDTO measurement = ReefDbBeans.getIndividualMeasurementByPmfmId(row, uniquePmfm.getId());
726 
727                     if (measurement == null) {
728                         continue;
729                     }
730 
731                     // find the measurement with this pmfm in the other row
732                     MeasurementDTO otherMeasurement = ReefDbBeans.getIndividualMeasurementByPmfmId(otherRow, uniquePmfm.getId());
733 
734                     if (otherMeasurement == null) {
735                         continue;
736                     }
737 
738                     if ((measurement.getPmfm().getParameter().isQualitative() && Objects.equals(measurement.getQualitativeValue(), otherMeasurement.getQualitativeValue()))
739                         || (!measurement.getPmfm().getParameter().isQualitative() && Objects.equals(measurement.getNumericalValue(), otherMeasurement.getNumericalValue()))) {
740 
741                         // duplicate value found
742                         List<String> properties = row.getDefaultProperties();
743                         properties.add(AbstractMeasurementsGroupedRowModel.PROPERTY_INDIVIDUAL_PMFMS);
744                         ReefDbBeans.addError(row,
745                             t("reefdb.measurement.grouped.duplicates.taxonUnique", decorate(uniquePmfm, DecoratorService.NAME_WITH_UNIT)),
746                             uniquePmfm.getId(), properties.toArray(new String[0]));
747                         return false;
748 
749                     }
750                 }
751             }
752         }
753 
754         return true;
755     }
756 
757     protected boolean hasNoPreconditionErrors(R row) {
758         if (row.getPreconditionErrors().isEmpty()) return true;
759 
760         row.getErrors().addAll(row.getPreconditionErrors());
761         return true;
762     }
763 
764     /* grouped rules related methods */
765 
766     private void detectGroupedIdentifiers() {
767 
768         if (CollectionUtils.isEmpty(getModel().getSurvey().getGroupedRules())) return;
769 
770         for (ControlRuleDTO groupedRule : getModel().getSurvey().getGroupedRules()) {
771             boolean allIdentifiersFound = true;
772             List<Map.Entry<RuleGroupDTO, ? extends ReefDbColumnIdentifier<R>>> identifierByGroup = new ArrayList<>();
773             for (RuleGroupDTO group : groupedRule.getGroups()) {
774                 ControlRuleDTO rule = group.getRule();
775                 if (ControlElementValues.MEASUREMENT.equals(rule.getControlElement())) {
776 
777                     if (ControlFeatureMeasurementValues.TAXON_GROUP.equals(rule.getControlFeature())) {
778                         ReefDbColumnIdentifier<R> identifier = findColumnIdentifierByPropertyName(AbstractMeasurementsGroupedRowModel.PROPERTY_TAXON_GROUP);
779                         if (identifier != null) {
780                             identifierByGroup.add(Maps.immutableEntry(group, identifier));
781                         } else {
782                             allIdentifiersFound = false;
783                             break;
784                         }
785                     } else if (ControlFeatureMeasurementValues.TAXON.equals(rule.getControlFeature())) {
786                         ReefDbColumnIdentifier<R> identifier = findColumnIdentifierByPropertyName(AbstractMeasurementsGroupedRowModel.PROPERTY_TAXON);
787                         if (identifier != null) {
788                             identifierByGroup.add(Maps.immutableEntry(group, identifier));
789                         } else {
790                             allIdentifiersFound = false;
791                             break;
792                         }
793                     } else if (ControlFeatureMeasurementValues.PMFM.equals(rule.getControlFeature())) {
794                         for (PmfmDTO pmfm : rule.getRulePmfms().stream().map(RulePmfmDTO::getPmfm).collect(Collectors.toList())) {
795                             PmfmTableColumn column = findPmfmColumnByPmfmId(getModel().getPmfmColumns(), pmfm.getId());
796                             if (column != null) {
797                                 identifierByGroup.add(Maps.immutableEntry(group, column.getPmfmIdentifier()));
798                             } else {
799                                 allIdentifiersFound = false;
800                                 break;
801                             }
802                         }
803                     }
804                 }
805             }
806             if (allIdentifiersFound) {
807                 getModel().getIdentifiersByGroupedRuleMap().putAll(groupedRule, identifierByGroup);
808             }
809         }
810     }
811 
812     protected boolean hasNoGroupedRuleErrors(R row) {
813         if (getModel().getIdentifiersByGroupedRuleMap().isEmpty()) return true;
814         boolean result = true;
815 
816         for (ControlRuleDTO groupedRule : getModel().getIdentifiersByGroupedRuleMap().keySet()) {
817 
818             boolean groupResult = false;
819             for (Map.Entry<RuleGroupDTO, ? extends ReefDbColumnIdentifier<R>> identifierByGroup : getModel().getIdentifiersByGroupedRuleMap().get(groupedRule)) {
820                 RuleGroupDTO group = identifierByGroup.getKey();
821                 ReefDbColumnIdentifier<R> identifier = identifierByGroup.getValue();
822 
823                 if (row.getMultipleValuesOnIdentifier().contains(identifier)
824                     || (identifier instanceof ReefDbPmfmColumnIdentifier && row.getMultipleValuesOnPmfmIds().contains(((ReefDbPmfmColumnIdentifier) identifier).getPmfmId()))
825                 ) {
826                     groupResult = true;
827                     break;
828                 }
829 
830                 // For now, assume logical operator is always OR
831                 Assert.isTrue(group.isIsOr());
832                 groupResult |= getContext().getControlRuleService().controlUniqueObject(group.getRule(), identifier.getValue(row));
833             }
834 
835             if (!groupResult) {
836                 // build error message
837                 String message;
838                 List<String> columnNames = new ArrayList<>();
839                 Set<String> propertyNames = new HashSet<>();
840                 Set<String> pmfmPropertyNames = new HashSet<>();
841                 Set<Integer> pmfmIds = new HashSet<>();
842                 for (RuleGroupDTO group : groupedRule.getGroups()) {
843                     if (ReefDbBeans.isPmfmMandatory(group.getRule())) {
844                         // iterate pmfms
845                         for (PmfmDTO pmfm : group.getRule().getRulePmfms().stream().map(RulePmfmDTO::getPmfm).collect(Collectors.toList())) {
846                             ReefDbPmfmColumnIdentifier<R> pmfmIdentifier = findPmfmColumnByPmfmId(getModel().getPmfmColumns(), pmfm.getId()).getPmfmIdentifier();
847                             columnNames.add(pmfmIdentifier.getHeaderLabel());
848                             pmfmPropertyNames.add(pmfmIdentifier.getPropertyName());
849                             pmfmIds.add(pmfm.getId());
850                         }
851                     } else {
852                         ReefDbColumnIdentifier<R> identifier = getModel().getIdentifierByGroup(group);
853                         columnNames.add(t(identifier.getHeaderI18nKey()));
854                         propertyNames.add(identifier.getPropertyName());
855                     }
856                 }
857                 if (StringUtils.isNotBlank(groupedRule.getMessage())) {
858                     message = groupedRule.getMessage();
859                 } else {
860                     message = t("reefdb.measurement.grouped.invalidGroupedRule", Joiner.on(',').join(columnNames));
861                 }
862 
863                 // add error on normal column
864                 ReefDbBeans.addError(row, message, propertyNames.toArray(new String[0]));
865                 // add error on pmfm column
866                 if (!pmfmIds.isEmpty())
867                     for (Integer pmfmId : pmfmIds)
868                         ReefDbBeans.addError(row, message, pmfmId, pmfmPropertyNames.toArray(new String[0]));
869             }
870 
871             // if a group result is false, the result is false (= has error)
872             result &= groupResult;
873 
874         }
875 
876         return result;
877     }
878 
879     /* Preconditions related methods */
880 
881     private void detectPreconditionedPmfms() {
882 
883         if (CollectionUtils.isEmpty(getModel().getSurvey().getPreconditionedRules())) return;
884 
885         for (ControlRuleDTO preconditionedRule : getModel().getSurvey().getPreconditionedRules()) {
886 
887             for (PreconditionRuleDTO precondition : preconditionedRule.getPreconditions()) {
888 
889                 int basePmfmId = precondition.getBaseRule().getRulePmfms(0).getPmfm().getId();
890                 int usedPmfmId = precondition.getUsedRule().getRulePmfms(0).getPmfm().getId();
891 
892                 getModel().addPreconditionRuleByPmfmId(basePmfmId, precondition);
893                 if (precondition.isBidirectional())
894                     getModel().addPreconditionRuleByPmfmId(usedPmfmId, precondition);
895             }
896         }
897     }
898 
899     /**
900      * Update all editors on measurements if they have preconditions
901      *
902      * @param row               the actual selected row
903      * @param firstPmfmId       the first pmfm Id to treat (can be null)
904      * @param resetValueAllowed allow or not the target value to be reset
905      */
906     private void updatePmfmCellEditors(R row, Integer firstPmfmId, boolean resetValueAllowed) {
907 
908         if (row == null || CollectionUtils.isEmpty(row.getIndividualPmfms())) return;
909 
910         // Build list of pmfms to process (if firstPmfmId is specified, it will be threat first)
911         List<PmfmDTO> allPmfms;
912         if (firstPmfmId == null) {
913             allPmfms = row.getIndividualPmfms();
914         } else {
915             allPmfms = new ArrayList<>(row.getIndividualPmfms());
916             PmfmDTO firstPmfm = ReefDbBeans.findById(allPmfms, firstPmfmId);
917             if (firstPmfm != null) {
918                 allPmfms.remove(firstPmfm);
919                 // add this pmfm at first position
920                 allPmfms.add(0, firstPmfm);
921             }
922         }
923 
924         // clear previous allowed values map
925         row.getAllowedQualitativeValuesMap().clear();
926         row.getPreconditionErrors().clear();
927         List<Integer> allPmfmIds = allPmfms.stream().map(PmfmDTO::getId).collect(Collectors.toList());
928 
929         for (PmfmDTO pmfm : allPmfms) {
930 
931             // if there is a preconditioned rule for this pmfm
932             if (getModel().isPmfmIdHasPreconditions(pmfm.getId())) {
933 
934                 // get the measurement with this pmfm
935                 MeasurementDTO measurement = ReefDbBeans.getIndividualMeasurementByPmfmId(row, pmfm.getId());
936                 if (measurement == null) {
937                     // create empty measurement if needed
938                     measurement = ReefDbBeanFactory.newMeasurementDTO();
939                     measurement.setPmfm(pmfm);
940                     row.getIndividualMeasurements().add(measurement);
941                 }
942 
943                 // Process allowed qualitative values
944                 getContext().getRuleListService().buildAllowedValuesByPmfmId(
945                     measurement,
946                     allPmfmIds,
947                     getModel().getPreconditionRulesByPmfmId(pmfm.getId()),
948                     row.getAllowedQualitativeValuesMap());
949 
950                 // update combos of affected pmfms
951                 for (Integer targetPmfmId : row.getAllowedQualitativeValuesMap().getTargetIds()) {
952                     updateQualitativePmfmCellEditors(row, targetPmfmId, resetValueAllowed);
953                 }
954 
955             }
956         }
957 
958         // then update all pmfm editors a last time
959         for (PmfmDTO pmfm : allPmfms) {
960             if (pmfm.getParameter().isQualitative()) {
961                 updateQualitativePmfmCellEditors(row, pmfm.getId(), resetValueAllowed);
962             }
963         }
964 
965     }
966 
967     @SuppressWarnings("unchecked")
968     private void updateQualitativePmfmCellEditors(R row, int targetPmfmId, boolean resetValueAllowed) {
969 
970         PmfmDTO targetPmfm = ReefDbBeans.findById(row.getIndividualPmfms(), targetPmfmId);
971         PmfmTableColumn targetPmfmColumn = findPmfmColumnByPmfmId(getModel().getPmfmColumns(), targetPmfmId);
972 
973         // cellEditor must be a ExtendedComboBoxCellEditor
974         if (!(targetPmfmColumn.getCellEditor() instanceof ExtendedComboBoxCellEditor)) return; // simply skip
975         ExtendedComboBoxCellEditor<QualitativeValueDTO> comboBoxCellEditor = (ExtendedComboBoxCellEditor<QualitativeValueDTO>) targetPmfmColumn.getCellEditor();
976 
977         AllowedQualitativeValuesMap.AllowedValues allowedTargetIds = new AllowedQualitativeValuesMap.AllowedValues();
978 
979         Collection<Integer> sourcePmfmIds = row.getAllowedQualitativeValuesMap().getExistingSourcePmfmIds(targetPmfmId);
980         Integer lastNumericalSourcePmfmId = null;
981         for (Integer sourcePmfmId : sourcePmfmIds) {
982 
983             // get existing measurement for this source pmfm
984             MeasurementDTO sourceMeasurement = ReefDbBeans.getIndividualMeasurementByPmfmId(row, sourcePmfmId);
985             boolean sourceIsNumerical = false;
986 
987             if (sourceMeasurement != null) {
988                 AllowedQualitativeValuesMap.AllowedValues allowedValues = null;
989                 if (sourceMeasurement.getQualitativeValue() != null) {
990                     allowedValues = row.getAllowedQualitativeValuesMap().getAllowedValues(targetPmfmId, sourcePmfmId, sourceMeasurement.getQualitativeValue().getId());
991                 } else if (sourceMeasurement.getNumericalValue() != null) {
992                     allowedValues = row.getAllowedQualitativeValuesMap().getAllowedValues(targetPmfmId, sourcePmfmId, sourceMeasurement.getNumericalValue());
993                     sourceIsNumerical = true;
994                 }
995                 if (allowedValues != null) {
996                     allowedTargetIds.addOrRetain(allowedValues);
997                     if (sourceIsNumerical) lastNumericalSourcePmfmId = sourcePmfmId;
998                 }
999             }
1000         }
1001 
1002         // Compute allowed values
1003         List<QualitativeValueDTO> allowedQualitativeValues = ReefDbBeans.filterCollection(targetPmfm.getQualitativeValues(),
1004             qualitativeValue -> allowedTargetIds.isAllowed(qualitativeValue.getId()));
1005         comboBoxCellEditor.getCombo().setData(allowedQualitativeValues);
1006 
1007         // fix the target value
1008         QualitativeValueDTO actualValue = (QualitativeValueDTO) targetPmfmColumn.getPmfmIdentifier().getValue(row);
1009         if (resetValueAllowed) {
1010             if (allowedQualitativeValues.size() == 1) {
1011                 if (actualValue == null) {
1012                     // force a 1:1 relation (only the first time)
1013                     getModel().setAdjusting(true);
1014                     targetPmfmColumn.getPmfmIdentifier().setValue(row, allowedQualitativeValues.get(0));
1015                     getModel().setAdjusting(false);
1016                 }
1017             } else {
1018                 if (actualValue != null && !allowedQualitativeValues.contains(actualValue) && lastNumericalSourcePmfmId == null) {
1019                     // reset a bad 1:n relation
1020                     getModel().setAdjusting(true);
1021                     targetPmfmColumn.getPmfmIdentifier().setValue(row, null);
1022                     getModel().setAdjusting(false);
1023                 }
1024             }
1025         }
1026 
1027         // check integrity
1028         if (!allowedQualitativeValues.isEmpty() && actualValue != null && !allowedQualitativeValues.contains(actualValue) && lastNumericalSourcePmfmId != null) {
1029             PmfmDTO sourcePmfm = getContext().getReferentialService().getPmfm(lastNumericalSourcePmfmId);
1030             ErrorDTO error = ReefDbBeanFactory.newErrorDTO();
1031             error.setError(true);
1032             error.setMessage(t("reefdb.measurement.grouped.incoherentQualitativeAndNumericalValues",
1033                 decorate(targetPmfm, DecoratorService.NAME_WITH_UNIT), decorate(sourcePmfm, DecoratorService.NAME_WITH_UNIT)));
1034             error.setPropertyName(ImmutableList.of(AbstractMeasurementsGroupedRowModel.PROPERTY_INDIVIDUAL_PMFMS));
1035             error.setPmfmId(lastNumericalSourcePmfmId);
1036             ReefDbBeans.addUniqueErrors(row.getPreconditionErrors(), ImmutableList.of(error));
1037             recomputeRowValidState(row);
1038         }
1039     }
1040 
1041     /**
1042      * Open the multiline edit dialog (Mantis #49615)
1043      *
1044      * @param dialog the dialog to open
1045      */
1046     protected void editSelectedMeasurements(JDialog dialog) {
1047         // save current table state to be able to restore it in dialog's table
1048         saveTableState();
1049 
1050         Assert.isInstanceOf(ReefDbUI.class, dialog);
1051         ReefDbUI<?, ?> multiEditUI = (ReefDbUI<?, ?>) dialog;
1052         Assert.isInstanceOf(AbstractMeasurementsMultiEditUIModel.class, multiEditUI.getModel());
1053         AbstractMeasurementsMultiEditUIModel<MeasurementDTO, R, ?> multiEditModel = (AbstractMeasurementsMultiEditUIModel<MeasurementDTO, R, ?>) multiEditUI.getModel();
1054         multiEditModel.setObservationHandler(getModel().getObservationUIHandler());
1055         multiEditModel.setRowsToEdit(getModel().getSelectedRows());
1056         multiEditModel.setPmfms(getModel().getPmfms());
1057         multiEditModel.setSurvey(getModel().getSurvey());
1058 
1059         // get parent component for dialog centering
1060         JComponent parent = getUI().getParentContainer(getTable(), JScrollPane.class);
1061         Insets borderInsets = parent.getBorder().getBorderInsets(parent);
1062         openDialog(dialog, new Dimension(parent.getWidth() + borderInsets.left + borderInsets.right, 160), true, parent);
1063 
1064         // if dialog is valid
1065         if (multiEditModel.isValid()) {
1066 
1067             // affect all non multiple column values to selected rows
1068             R multiEditRow = multiEditModel.getRows().get(0);
1069 
1070             for (R row : getModel().getSelectedRows()) {
1071 
1072                 // affect column values
1073                 for (ReefDbColumnIdentifier<R> identifier : multiEditModel.getIdentifiersToCheck()) {
1074                     // be sure that sampling operation value can not be changed
1075                     if (identifier.getPropertyName().equals(OperationMeasurementsGroupedRowModel.PROPERTY_SAMPLING_OPERATION))
1076                         continue;
1077 
1078                     // get values
1079                     Object multiValue = identifier.getValue(multiEditRow);
1080                     Object currentValue = identifier.getValue(row);
1081                     boolean isMultiple = multiEditRow.getMultipleValuesOnIdentifier().contains(identifier);
1082                     // affect value if a multiple value has been replaced, or modification has been made
1083                     if ((!isMultiple || multiValue != null) && !Objects.equals(multiValue, currentValue)) {
1084                         identifier.setValue(row, multiValue);
1085                         // special case for INPUT_TAXON_NAME
1086                         if (identifier.getPropertyName().equals(AbstractMeasurementsGroupedRowModel.PROPERTY_INPUT_TAXON_NAME)) {
1087                             // affect also INPUT_TAXON_ID
1088                             row.setInputTaxonId(multiEditRow.getInputTaxonId());
1089                         }
1090                     }
1091                 }
1092 
1093                 // for measurement columns
1094                 for (PmfmDTO pmfm : getModel().getPmfms()) {
1095                     MeasurementDTO multiMeasurement = ReefDbBeans.getIndividualMeasurementByPmfmId(multiEditRow, pmfm.getId());
1096                     if (multiMeasurement == null) {
1097                         // can happened if user pass on on a cel without setting a value
1098                         multiMeasurement = ReefDbBeanFactory.newMeasurementDTO();
1099                     }
1100                     boolean isMultiple = multiEditRow.getMultipleValuesOnPmfmIds().contains(pmfm.getId());
1101                     // affect value if a multiple value has been replaced, or modification has been made
1102                     if (!isMultiple || !ReefDbBeans.isMeasurementEmpty(multiMeasurement)) {
1103                         MeasurementDTO measurement = ReefDbBeans.getIndividualMeasurementByPmfmId(row, pmfm.getId());
1104                         if (measurement == null) {
1105                             // create and add the measurement if not exists
1106                             measurement = ReefDbBeanFactory.newMeasurementDTO();
1107                             measurement.setPmfm(pmfm);
1108                             measurement.setIndividualId(row.getIndividualId());
1109                             row.getIndividualMeasurements().add(measurement);
1110                         }
1111                         // affect value (either numeric or qualitative)
1112                         if (!ReefDbBeans.measurementValuesEquals(multiMeasurement, measurement)) {
1113                             measurement.setNumericalValue(multiMeasurement.getNumericalValue());
1114                             measurement.setQualitativeValue(multiMeasurement.getQualitativeValue());
1115                         }
1116                     }
1117                 }
1118 
1119             }
1120 
1121             // done
1122             getModel().setModify(true);
1123             getTable().repaint();
1124         }
1125     }
1126 
1127     /**
1128      * Save grouped measurements on parent model(s)
1129      * This replace inline save (see Mantis #51725)
1130      */
1131     public void save() {
1132 
1133         // Get all existing measurements in beans
1134         List<? extends MeasurementAware> beansToSave = getMeasurementAwareModels();
1135         List<MeasurementDTO> existingMeasurements = beansToSave.stream()
1136             .flatMap(bean -> bean.getIndividualMeasurements().stream())
1137             .collect(Collectors.toList());
1138 
1139         // get the minimum negative measurement id
1140         AtomicInteger minNegativeId = new AtomicInteger(
1141             Math.min(
1142                 0,
1143                 existingMeasurements.stream()
1144                     .filter(measurement -> measurement != null && measurement.getId() != null)
1145                     .mapToInt(MeasurementDTO::getId)
1146                     .min()
1147                     .orElse(0)
1148             )
1149         );
1150 
1151         Multimap<MeasurementAware, R> rowsByBean = ArrayListMultimap.create();
1152 
1153         // Get ordered rows by bean
1154         for (int i = 0; i < getTable().getRowCount(); i++) {
1155             R row = getTableModel().getEntry(getTable().convertRowIndexToModel(i));
1156             MeasurementAware bean = getMeasurementAwareModelForRow(row);
1157             if (bean == null) {
1158                 throw new ReefDbTechnicalException("The parent bean is null for a grouped measurements row");
1159             }
1160             rowsByBean.put(bean, row);
1161         }
1162 
1163         rowsByBean.keySet().forEach(bean -> {
1164 
1165             AtomicInteger individualId = new AtomicInteger();
1166             List<MeasurementDTO> beanMeasurements = new ArrayList<>();
1167 
1168             // Iterate over each row
1169             rowsByBean.get(bean).forEach(row -> {
1170 
1171                 // Test is this row is to save or not
1172                 if (isRowToSave(row)) {
1173 
1174                     // Affect row individual ids according this order
1175                     row.setIndividualId(individualId.incrementAndGet());
1176 
1177                     // Iterate over row's measurements
1178                     row.getIndividualMeasurements().forEach(measurement -> {
1179                         if (measurement.getId() == null) {
1180                             // this measurement was not in bean, so add it with next negative id
1181                             measurement.setId(minNegativeId.decrementAndGet());
1182                         }
1183                         // update this measurement
1184                         updateMeasurementFromRow(measurement, row);
1185                         // Add to new list
1186                         beanMeasurements.add(measurement);
1187                     });
1188 
1189                 }
1190             });
1191 
1192             // Affect new list of measurements
1193             bean.getIndividualMeasurements().clear();
1194             bean.getIndividualMeasurements().addAll(beanMeasurements);
1195 
1196             setDirty(bean);
1197             // Remove this bean from the list
1198             beansToSave.remove(bean);
1199         });
1200 
1201         // Clean remaining beans
1202         beansToSave.forEach(bean -> {
1203             // If a bean still in this map, it means there is no row of this bean, so remove all
1204             bean.getIndividualMeasurements().clear();
1205             setDirty(bean);
1206         });
1207     }
1208 
1209     /**
1210      * Determine if this row is to be saved
1211      *
1212      * @param row to test
1213      * @return true if to save (default)
1214      */
1215     protected boolean isRowToSave(R row) {
1216         return
1217             // The row must exists
1218             row != null
1219                 // and contains at least 1 non empty individual measurement or a taxon group or a taxon
1220                 && (row.hasTaxonInformation() || row.hasNonEmptyMeasurements());
1221     }
1222 }