diff --git a/CHANGELOG.md b/CHANGELOG.md index aaf3f52ab7b..dace2fbf4cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ * [UI/Developer]: Removed old notification system hack and updated to use only Ant Design notifications. See [PR 1447](https://github.com/phac-nml/irida/pull/1447) * [UI]: Fixed bug where the `User` column was named `User Group` on the admin User Groups page. [See PR 1450](https://github.com/phac-nml/irida/pull/1450) * [Developer]: Replaced Apache OLTU with Nimbusds for performing OAuth2 authentication flow during syncing and Galaxy exporting. See [PR 1432](https://github.com/phac-nml/irida/pull/1432) +* [Developer/UI]: Performance enhancements to the metadata uploader. See [PR 1445](https://github.com/phac-nml/irida/pull/1445). ## [22.09.7] - 2022/01/24 * [UI]: Fixed bugs on NCBI Export page preventing the NCBI `submission.xml` file from being properly written. See [PR 1451](https://github.com/phac-nml/irida/pull/1451) diff --git a/doc/user/user/sample-metadata/images/loading.png b/doc/user/user/sample-metadata/images/loading.png new file mode 100644 index 00000000000..dd38ae352b4 Binary files /dev/null and b/doc/user/user/sample-metadata/images/loading.png differ diff --git a/doc/user/user/sample-metadata/images/upload-column-after.png b/doc/user/user/sample-metadata/images/upload-column-after.png new file mode 100644 index 00000000000..938c5f87a8f Binary files /dev/null and b/doc/user/user/sample-metadata/images/upload-column-after.png differ diff --git a/doc/user/user/sample-metadata/images/upload-column-before.png b/doc/user/user/sample-metadata/images/upload-column-before.png new file mode 100644 index 00000000000..2475c27b62e Binary files /dev/null and b/doc/user/user/sample-metadata/images/upload-column-before.png differ diff --git a/doc/user/user/sample-metadata/images/upload-column.png b/doc/user/user/sample-metadata/images/upload-column.png deleted file mode 100644 index 3ca4ff8b8fb..00000000000 Binary files a/doc/user/user/sample-metadata/images/upload-column.png and /dev/null differ diff --git a/doc/user/user/sample-metadata/images/upload-preview-errors.png b/doc/user/user/sample-metadata/images/upload-preview-errors.png index dd359cce0b0..9b2a2f748ad 100644 Binary files a/doc/user/user/sample-metadata/images/upload-preview-errors.png and b/doc/user/user/sample-metadata/images/upload-preview-errors.png differ diff --git a/doc/user/user/sample-metadata/images/upload-preview-success.png b/doc/user/user/sample-metadata/images/upload-preview-success.png index 6e2c4f1764e..df4c265657a 100644 Binary files a/doc/user/user/sample-metadata/images/upload-preview-success.png and b/doc/user/user/sample-metadata/images/upload-preview-success.png differ diff --git a/doc/user/user/sample-metadata/images/upload-preview.png b/doc/user/user/sample-metadata/images/upload-preview.png index 469c98cd7be..3b5227de0c8 100644 Binary files a/doc/user/user/sample-metadata/images/upload-preview.png and b/doc/user/user/sample-metadata/images/upload-preview.png differ diff --git a/doc/user/user/sample-metadata/images/upload-selection.png b/doc/user/user/sample-metadata/images/upload-selection.png index e0af432abd0..20bf4ae6db8 100644 Binary files a/doc/user/user/sample-metadata/images/upload-selection.png and b/doc/user/user/sample-metadata/images/upload-selection.png differ diff --git a/doc/user/user/sample-metadata/index.md b/doc/user/user/sample-metadata/index.md index 963729049cd..44e8edfb92c 100644 --- a/doc/user/user/sample-metadata/index.md +++ b/doc/user/user/sample-metadata/index.md @@ -37,23 +37,31 @@ Links to the upload page can be found: Any CSV or Excel spreadsheet containing metadata for samples in a project can be uploaded through the IRIDA web interface. One of the column in the table __must__ correspond to the sample name within the project. In this example spreadsheet, the `NLEP #` column is the sample name. -The first step is to select the CSV or Excel file containing the data. Either click on the square label `Click or drop Excel/CSV file containing metadata for samples in this project.` or drag and drop the file from your file browser. +The first step is to select the CSV or Excel file containing the data. Either click or drag the file into the drop zone from your file browser. ![Select spreadsheet](images/upload-selection.png) -After uploading a spreadsheet, the column corresponding to the sample name must be selected. After selecting the column header, press the `Preview the data` button. +After uploading a spreadsheet, you will be brought to the `Map Columns` step. The column corresponding to the sample name must be selected. -![Select name column.](images/upload-column.png) +![Select name column.](images/upload-column-before.png) -Rows that do not match an existing sample name are identified with the `New` tag. If selected, these samples will automatically be created. Rows that do match an existing sample name will be updated. Only select the rows that are to be uploaded and press the `Upload the data` button. +Once the sample name column is selected, a table will be displayed listing all the metadata fields. You can review the existing and target metadata field restrictions here. Click the `Review the data` button to continue. + +![Select name column.](images/upload-column-after.png) + +You may select the rows that are to be uploaded on the `Review Data` step. Rows that do not match an existing sample name are identified with the `New` tag. If selected, these samples will automatically be created. Rows that match an existing sample name will be updated. ![Preview Upload](images/upload-preview.png) -Rows that have an invalid sample name will be highlighted in red. These errors should be fixed within the spreadsheet and re-imported. +Rows that have an invalid sample name will be highlighted in red. These errors should be fixed within the spreadsheet and re-imported. Click the `Upload the data` button to continue. ![Upload Preview Errors](images/upload-preview-errors.png) -The complete page will be displayed on a successful upload. Clicking on the `Upload another file` button will redirect to the beginning of a new upload. +Progress will be displayed while uploading. Please be patient while uploading large data sets. Do not close the window or leave the page. + +![Upload Preview Errors](images/loading.png) + +On a successful upload, you will be brought to the `Complete` step. Clicking on the `Upload another file` button will redirect to the beginning of a new upload. ![Upload Preview Success](images/upload-preview-success.png) diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/model/sample/MetadataTemplateField.java b/src/main/java/ca/corefacility/bioinformatics/irida/model/sample/MetadataTemplateField.java index 1cbb08dc44b..4864b69bf5c 100644 --- a/src/main/java/ca/corefacility/bioinformatics/irida/model/sample/MetadataTemplateField.java +++ b/src/main/java/ca/corefacility/bioinformatics/irida/model/sample/MetadataTemplateField.java @@ -1,19 +1,22 @@ package ca.corefacility.bioinformatics.irida.model.sample; -import ca.corefacility.bioinformatics.irida.model.sample.metadata.MetadataEntry; -import org.hibernate.envers.Audited; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import java.util.List; +import java.util.Objects; import javax.persistence.*; import javax.validation.constraints.NotNull; -import java.util.List; -import java.util.Objects; + +import org.hibernate.envers.Audited; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import ca.corefacility.bioinformatics.irida.model.sample.metadata.MetadataEntry; /** * Describes an individual field in a {@link MetadataTemplate}. */ @Entity -@Table(name = "metadata_field") +@Table(name = "metadata_field", + uniqueConstraints = @UniqueConstraint(columnNames = { "label" }, name = "UK_METADATA_FIELD_LABEL")) @Audited @EntityListeners(AuditingEntityListener.class) public class MetadataTemplateField { diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/utilities/SampleMetadataStorage.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/utilities/SampleMetadataStorage.java deleted file mode 100644 index d3b1f8c8566..00000000000 --- a/src/main/java/ca/corefacility/bioinformatics/irida/ria/utilities/SampleMetadataStorage.java +++ /dev/null @@ -1,80 +0,0 @@ -package ca.corefacility.bioinformatics.irida.ria.utilities; - -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; - -/** - * Used to store information relating to sample metadata during upload. - */ -public class SampleMetadataStorage { - private String sampleNameColumn; - private List headers; - private List rows; - - public void setSampleNameColumn(String sampleColumnName) { - this.sampleNameColumn = sampleColumnName; - } - - public void setHeaders(List headers) { - this.headers = headers; - } - - public String getSampleNameColumn() { - return sampleNameColumn; - } - - public List getHeaders() { - return headers; - } - - public List getRows() { - return rows; - } - - /** - * Returns the row from storage given the sample name and column name - * - * @param sampleName the name of the sample - * @param sampleNameColumn the header name of the sample column - * @return the value associated with the key - */ - public SampleMetadataStorageRow getRow(String sampleName, String sampleNameColumn) { - return rows.stream() - .filter(row -> sampleName.equals(row.getEntryValue(sampleNameColumn))) - .findFirst() - .orElse(null); - } - - public List getFoundRows() { - return rows == null ? - Collections.emptyList() : - rows.stream() - .filter((r) -> r != null && r.getFoundSampleId() != null) - .collect(Collectors.toList()); - } - - public void setRows(List rows) { - this.rows = rows; - } - - /** - * remove all rows - */ - public void removeRows() { - this.rows = null; - } - - @Override - public boolean equals(Object o) { - if (this == o) - return true; - if (o == null || getClass() != o.getClass()) - return false; - SampleMetadataStorage that = (SampleMetadataStorage) o; - return Objects.equals(sampleNameColumn, that.sampleNameColumn) && Objects.equals(headers, that.headers) - && Objects.equals(rows, that.rows); - } - -} diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/utilities/SampleMetadataStorageRow.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/utilities/SampleMetadataStorageRow.java deleted file mode 100644 index 867ac1243b1..00000000000 --- a/src/main/java/ca/corefacility/bioinformatics/irida/ria/utilities/SampleMetadataStorageRow.java +++ /dev/null @@ -1,71 +0,0 @@ -package ca.corefacility.bioinformatics.irida.ria.utilities; - -import java.util.Map; -import java.util.Objects; - -/** - * Used to store information relating to sample metadata rows during upload. - */ -public class SampleMetadataStorageRow { - - private Map entry; - private Long foundSampleId; - private String error; - private Boolean isSaved; - - public SampleMetadataStorageRow(Map entry) { - this.entry = entry; - } - - public Map getEntry() { - return entry; - } - - /** - * Returns the associated value to which the given key is mapped - * - * @param key of the map - * @return the value associated with the key - */ - public String getEntryValue(String key) { - return entry.get(key); - } - - public void setEntry(Map entry) { - this.entry = entry; - } - - public Long getFoundSampleId() { - return foundSampleId; - } - - public void setFoundSampleId(Long foundSampleId) { - this.foundSampleId = foundSampleId; - } - - public String getError() { - return error; - } - - public void setError(String error) { - this.error = error; - } - - public Boolean isSaved() { - return isSaved; - } - - public void setSaved(Boolean saved) { - isSaved = saved; - } - - @Override - public boolean equals(Object o) { - if (this == o) - return true; - if (o == null || getClass() != o.getClass()) - return false; - SampleMetadataStorageRow that = (SampleMetadataStorageRow) o; - return Objects.equals(entry, that.entry) && Objects.equals(foundSampleId, that.foundSampleId); - } -} diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/CreateSampleRequest.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/CreateSampleRequest.java index 6247de8b23d..8830c2b1dce 100644 --- a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/CreateSampleRequest.java +++ b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/CreateSampleRequest.java @@ -2,6 +2,8 @@ import java.util.List; +import ca.corefacility.bioinformatics.irida.ria.web.ajax.projects.dto.MetadataEntryModel; + /** * UI Request to create a new sample */ @@ -9,7 +11,7 @@ public class CreateSampleRequest { private String name; private String organism; private String description; - private List metadata; + private List metadata; public CreateSampleRequest() { } @@ -19,7 +21,7 @@ public CreateSampleRequest(String name, String organism) { this.organism = organism; } - public CreateSampleRequest(String name, String organism, String description, List metadata) { + public CreateSampleRequest(String name, String organism, String description, List metadata) { this.name = name; this.organism = organism; this.description = description; @@ -50,11 +52,11 @@ public void setDescription(String description) { this.description = description; } - public List getMetadata() { + public List getMetadata() { return metadata; } - public void setMetadata(List metadata) { + public void setMetadata(List metadata) { this.metadata = metadata; } } diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/LockedSamplesResponse.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/LockedSamplesResponse.java new file mode 100644 index 00000000000..42d9615afc0 --- /dev/null +++ b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/LockedSamplesResponse.java @@ -0,0 +1,21 @@ +package ca.corefacility.bioinformatics.irida.ria.web.ajax.dto; + +import java.util.List; + +import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.ajax.AjaxResponse; + +/** + * Returns an ajax response with locked sample ids. + */ +public class LockedSamplesResponse extends AjaxResponse { + private List sampleIds; + + public LockedSamplesResponse(List sampleIds) { + this.sampleIds = sampleIds; + } + + public List getSampleIds() { + return sampleIds; + } +} + diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/SampleErrorResponse.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/SampleErrorResponse.java new file mode 100644 index 00000000000..eb9409959fb --- /dev/null +++ b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/SampleErrorResponse.java @@ -0,0 +1,37 @@ +package ca.corefacility.bioinformatics.irida.ria.web.ajax.dto; + +/** + * UI response to create/update a sample with error + */ +public class SampleErrorResponse { + private boolean error; + private String errorMessage; + + public SampleErrorResponse() { + } + + public SampleErrorResponse(boolean error) { + this.error = error; + } + + public SampleErrorResponse(boolean error, String errorMessage) { + this.error = error; + this.errorMessage = errorMessage; + } + + public boolean isError() { + return error; + } + + public void setError(boolean error) { + this.error = error; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } +} diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/SampleResponse.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/SampleResponse.java new file mode 100644 index 00000000000..8cea9cd5196 --- /dev/null +++ b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/SampleResponse.java @@ -0,0 +1,25 @@ +package ca.corefacility.bioinformatics.irida.ria.web.ajax.dto; + +/** + * UI response to create/update a sample with error and sample id + */ +public class SampleResponse extends SampleErrorResponse { + private Long sampleId; + + public SampleResponse(String errorMessage) { + super(true, errorMessage); + } + + public SampleResponse(Long sampleId) { + super(false); + this.sampleId = sampleId; + } + + public Long getSampleId() { + return sampleId; + } + + public void setSampleId(Long sampleId) { + this.sampleId = sampleId; + } +} diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/UpdateSampleRequest.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/UpdateSampleRequest.java index 793a60130e9..c193fd7cf10 100644 --- a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/UpdateSampleRequest.java +++ b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/UpdateSampleRequest.java @@ -2,11 +2,25 @@ import java.util.List; +import ca.corefacility.bioinformatics.irida.ria.web.ajax.projects.dto.MetadataEntryModel; + /** * UI Request to update an existing sample */ public class UpdateSampleRequest extends CreateSampleRequest { - public UpdateSampleRequest(String name, String organism, String description, List metadata) { + private Long sampleId; + + public UpdateSampleRequest(Long sampleID, String name, String organism, String description, + List metadata) { super(name, organism, description, metadata); + this.sampleId = sampleID; + } + + public Long getSampleId() { + return sampleId; + } + + public void setSampleId(Long sampleId) { + this.sampleId = sampleId; } } diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/ajax/AjaxMultipleResponse.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/ajax/AjaxMultipleResponse.java new file mode 100644 index 00000000000..71d8276226e --- /dev/null +++ b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/dto/ajax/AjaxMultipleResponse.java @@ -0,0 +1,19 @@ +package ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.ajax; + +import java.util.Map; + +/** + * AJAX response to return multiple responses to the client + */ +public class AjaxMultipleResponse extends AjaxResponse { + private Map responses; + + public AjaxMultipleResponse(Map responses) { + this.responses = responses; + } + + public Map getResponses() { + return responses; + } +} + diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/metadata/MetadataAjaxController.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/metadata/MetadataAjaxController.java index 62b689fe299..f43d9709fc9 100644 --- a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/metadata/MetadataAjaxController.java +++ b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/metadata/MetadataAjaxController.java @@ -47,13 +47,13 @@ public ResponseEntity> getProjectMetadataTemplates /** * Create a new metadata template within a project * - * @param template details about the template to create + * @param template details about the template to create * @param projectId identifier for a project * @return the newly created {@link MetadataTemplate} */ @PostMapping("/templates") - public ResponseEntity createNewMetadataTemplate( - @RequestBody MetadataTemplate template, @RequestParam Long projectId) { + public ResponseEntity createNewMetadataTemplate(@RequestBody MetadataTemplate template, + @RequestParam Long projectId) { return ResponseEntity.ok(service.createMetadataTemplate(template, projectId)); } @@ -61,7 +61,7 @@ public ResponseEntity createNewMetadataTemplate( * Updated the fields in a {@link MetadataTemplate} * * @param template the updated template to save - * @param locale Current users {@link Locale} + * @param locale Current users {@link Locale} * @return Message for UI to display about the result of the update. */ @PutMapping("/templates/{templateId}") @@ -89,8 +89,7 @@ public ResponseEntity deleteMetadataTemplate(@PathVariable Long te return ResponseEntity.ok( new AjaxSuccessResponse(service.deleteMetadataTemplate(templateId, projectId, locale))); } catch (Exception e) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new AjaxErrorResponse(e.getMessage())); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new AjaxErrorResponse(e.getMessage())); } } @@ -105,6 +104,25 @@ public List getMetadataFieldsForProject(@RequestParam Long return service.getMetadataFieldsForProject(projectId); } + /** + * Create project metadata fields with restrictions (no metadata entries) + * + * @param projectId Identifier for a project + * @param fields List of project metadata fields + * @param locale Current users {@link Locale} + * @return list of {@link MetadataTemplateField}s + */ + @PostMapping("/fields") + public ResponseEntity createMetadataFieldsForProject(@RequestParam Long projectId, + @RequestBody List fields, Locale locale) { + try { + return ResponseEntity.ok( + new AjaxSuccessResponse(service.createMetadataFieldsForProject(projectId, fields, locale))); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(new AjaxErrorResponse(e.getMessage())); + } + } + /** * Get all the metadata fields for a list of projects * @@ -132,8 +150,7 @@ public ResponseEntity setDefaultMetadataTemplate(@PathVariable Lon return ResponseEntity.ok( new AjaxSuccessResponse(service.setDefaultMetadataTemplate(templateId, projectId, locale))); } catch (Exception e) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new AjaxErrorResponse(e.getMessage())); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new AjaxErrorResponse(e.getMessage())); } } diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/metadata/dto/ProjectMetadataField.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/metadata/dto/ProjectMetadataField.java index 8683fe55c0c..8a5cf538ea8 100644 --- a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/metadata/dto/ProjectMetadataField.java +++ b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/metadata/dto/ProjectMetadataField.java @@ -3,9 +3,8 @@ import ca.corefacility.bioinformatics.irida.model.sample.MetadataTemplateField; /** - * A representation of a {@link MetadataTemplateField} specifically for a project. - * This class was required since metadata fields for a project need to include the restriction - * level specific for that project. + * A representation of a {@link MetadataTemplateField} specifically for a project. This class was required since + * metadata fields for a project need to include the restriction level specific for that project. */ public class ProjectMetadataField { private Long id; @@ -14,6 +13,10 @@ public class ProjectMetadataField { private String type; private String restriction; + //default constructor for serializing and deserializing the DTO + public ProjectMetadataField() { + } + public ProjectMetadataField(MetadataTemplateField field, String restriction) { this.id = field.getId(); this.fieldKey = field.getFieldKey(); @@ -61,4 +64,5 @@ public String getRestriction() { public void setRestriction(String restriction) { this.restriction = restriction; } + } diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/projects/ProjectSamplesAjaxController.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/projects/ProjectSamplesAjaxController.java index 838e5f85f6c..4b7d60889a1 100644 --- a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/projects/ProjectSamplesAjaxController.java +++ b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/projects/ProjectSamplesAjaxController.java @@ -1,10 +1,22 @@ package ca.corefacility.bioinformatics.irida.ria.web.ajax.projects; -import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.CreateSampleRequest; -import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.SampleFilesResponse; -import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.SampleNameValidationResponse; -import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.UpdateSampleRequest; +import java.io.IOException; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import javax.servlet.http.HttpServletResponse; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import ca.corefacility.bioinformatics.irida.model.sample.Sample; +import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.*; import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.ajax.AjaxErrorResponse; +import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.ajax.AjaxMultipleResponse; import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.ajax.AjaxResponse; import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.ajax.AjaxSuccessResponse; import ca.corefacility.bioinformatics.irida.ria.web.ajax.projects.dto.ValidateSampleNamesRequest; @@ -20,16 +32,6 @@ import ca.corefacility.bioinformatics.irida.ria.web.samples.dto.ShareSamplesRequest; import ca.corefacility.bioinformatics.irida.ria.web.services.UIProjectSampleService; import ca.corefacility.bioinformatics.irida.ria.web.services.UISampleService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; - -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.List; -import java.util.Locale; /** * AJAX Controller for handling asynchronous requests for project samples. @@ -62,31 +64,55 @@ public ResponseEntity validateNewSampleName(@Reque } /** - * Create a new sample within a project + * Create new samples within a project * - * @param request Details about the sample + * @param requests Details about the samples * @param projectId current project identifier - * @param locale current users locale - * @return result of creating the project + * @return result of creating the samples */ - @PostMapping("/add-sample") - public ResponseEntity createSampleInProject(@RequestBody CreateSampleRequest request, - @PathVariable long projectId, Locale locale) { - return uiProjectSampleService.createSample(request, projectId, locale); + @PostMapping("/create") + public ResponseEntity createSamplesInProject(@RequestBody CreateSampleRequest[] requests, + @PathVariable long projectId) { + Map responses = uiProjectSampleService.createSamples(requests, projectId); + long errorCount = responses.entrySet() + .stream() + .filter(response -> ((SampleResponse) response.getValue()).isError()) + .count(); + long successCount = responses.entrySet() + .stream() + .filter(response -> !((SampleResponse) response.getValue()).isError()) + .count(); + if (responses.size() == successCount) { + return ResponseEntity.status(HttpStatus.OK).body(new AjaxMultipleResponse(responses)); + } else if (responses.size() == errorCount) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(new AjaxMultipleResponse(responses)); + } + return ResponseEntity.status(HttpStatus.MULTI_STATUS).body(new AjaxMultipleResponse(responses)); } /** - * Update a sample within a project + * Update samples within a project * - * @param request Details about the sample - * @param sampleId sample identifier - * @param locale current users locale - * @return result of creating the project + * @param requests Details about the samples + * @return result of updating the samples */ - @PatchMapping("/add-sample/{sampleId}") - public ResponseEntity updateSampleInProject(@RequestBody UpdateSampleRequest request, - @PathVariable long sampleId, Locale locale) { - return uiProjectSampleService.updateSample(request, sampleId, locale); + @PatchMapping("/update") + public ResponseEntity updateSamplesInProject(@RequestBody UpdateSampleRequest[] requests) { + Map responses = uiProjectSampleService.updateSamples(requests); + long errorCount = responses.entrySet() + .stream() + .filter(response -> ((SampleErrorResponse) response.getValue()).isError()) + .count(); + long successCount = responses.entrySet() + .stream() + .filter(response -> !((SampleErrorResponse) response.getValue()).isError()) + .count(); + if (responses.size() == successCount) { + return ResponseEntity.status(HttpStatus.OK).body(new AjaxMultipleResponse(responses)); + } else if (responses.size() == errorCount) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(new AjaxMultipleResponse(responses)); + } + return ResponseEntity.status(HttpStatus.MULTI_STATUS).body(new AjaxMultipleResponse(responses)); } /** @@ -224,4 +250,15 @@ public ResponseEntity validateSampleNames(@PathVariable Long proje return ResponseEntity.ok(uiProjectSampleService.validateSampleNames(projectId, request)); } + /** + * Get a list of {@link Sample} ids that are locked in the given project + * + * @param projectId project identifier + * @return a boolean + */ + @GetMapping("/locked") + public ResponseEntity getLockedSamplesInProject(@PathVariable Long projectId) { + return ResponseEntity.ok(uiProjectSampleService.getLockedSamplesInProject(projectId)); + } + } diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/projects/dto/MetadataEntryModel.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/projects/dto/MetadataEntryModel.java new file mode 100644 index 00000000000..0dfa8d73d38 --- /dev/null +++ b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/ajax/projects/dto/MetadataEntryModel.java @@ -0,0 +1,33 @@ +package ca.corefacility.bioinformatics.irida.ria.web.ajax.projects.dto; + +/** + * Model for UI to represent a metadata entry. + */ +public class MetadataEntryModel { + + private String field; + + private String value; + + public MetadataEntryModel(String field, String value) { + this.field = field; + this.value = value; + } + + public String getField() { + return field; + } + + public void setField(String field) { + this.field = field; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + +} diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/errors/SavedMetadataException.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/errors/SavedMetadataException.java deleted file mode 100644 index 373b3b195ad..00000000000 --- a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/errors/SavedMetadataException.java +++ /dev/null @@ -1,19 +0,0 @@ -package ca.corefacility.bioinformatics.irida.ria.web.errors; - -import ca.corefacility.bioinformatics.irida.ria.utilities.SampleMetadataStorage; - -/** - * Returns the SampleMetadataStorage on error. - */ -public class SavedMetadataException extends Exception { - private SampleMetadataStorage storage; - - public SavedMetadataException(SampleMetadataStorage storage) { - super(); - this.storage = storage; - } - - public SampleMetadataStorage getStorage() { - return storage; - } -} diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/projects/dto/SavedMetadataErrorResponse.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/projects/dto/SavedMetadataErrorResponse.java deleted file mode 100644 index c291440f4ca..00000000000 --- a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/projects/dto/SavedMetadataErrorResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package ca.corefacility.bioinformatics.irida.ria.web.projects.dto; - -import ca.corefacility.bioinformatics.irida.ria.utilities.SampleMetadataStorage; -import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.ajax.AjaxResponse; - -/** - * Returns the SampleMetadataStorage on error. - */ -public class SavedMetadataErrorResponse extends AjaxResponse { - private SampleMetadataStorage storage; - - public SavedMetadataErrorResponse(SampleMetadataStorage storage) { - this.storage = storage; - } - - public SampleMetadataStorage getStorage() { - return storage; - } -} diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/projects/metadata/ProjectSampleMetadataAjaxController.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/projects/metadata/ProjectSampleMetadataAjaxController.java deleted file mode 100644 index 1a9a655a0b9..00000000000 --- a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/projects/metadata/ProjectSampleMetadataAjaxController.java +++ /dev/null @@ -1,138 +0,0 @@ -package ca.corefacility.bioinformatics.irida.ria.web.projects.metadata; - -import java.util.*; - -import javax.servlet.http.HttpSession; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -import ca.corefacility.bioinformatics.irida.model.project.Project; -import ca.corefacility.bioinformatics.irida.model.sample.Sample; -import ca.corefacility.bioinformatics.irida.model.sample.metadata.MetadataEntry; -import ca.corefacility.bioinformatics.irida.ria.utilities.SampleMetadataStorage; -import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.ajax.AjaxResponse; -import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.ajax.AjaxSuccessResponse; -import ca.corefacility.bioinformatics.irida.ria.web.errors.SavedMetadataException; -import ca.corefacility.bioinformatics.irida.ria.web.projects.dto.SavedMetadataErrorResponse; -import ca.corefacility.bioinformatics.irida.ria.web.services.UIMetadataImportService;; - -/** - * This class is designed to be used for bulk actions on {@link MetadataEntry} - * within a {@link Project}. - */ -@Controller -@RequestMapping("/ajax/projects/sample-metadata/upload") -public class ProjectSampleMetadataAjaxController { - private final UIMetadataImportService metadataImportService; - - @Autowired - public ProjectSampleMetadataAjaxController(UIMetadataImportService metadataImportService) { - this.metadataImportService = metadataImportService; - } - - /** - * Upload CSV or Excel file containing sample metadata and extract the - * headers. The file is stored in the session until the column that - * corresponds to a {@link Sample} identifier has been sent. - * - * @param session - * {@link HttpSession} - * @param projectId - * {@link Long} identifier for the current {@link Project} - * @param file - * {@link MultipartFile} The csv or excel file containing the - * metadata. - * @return {@link SampleMetadataStorage} which includes a {@link List} of - * headers and rows from the csv or excel file. - * @throws Exception - * if there is an error reading the file - */ - @PostMapping("/file") - @ResponseBody - public ResponseEntity createProjectSampleMetadata(HttpSession session, - @RequestParam Long projectId, @RequestParam("file") MultipartFile file) throws Exception { - return ResponseEntity.ok(metadataImportService.createProjectSampleMetadata(session, projectId, file)); - } - - /** - * Add the metadata to specific {@link Sample} based on the selected column - * to correspond to the {@link Sample} id. - * - * @param session - * {@link HttpSession}. - * @param projectId - * {@link Long} identifier for the current {@link Project}. - * @param sampleNameColumn - * {@link String} the header to used to represent the - * {@link Sample} identifier. - * @return a complete message. - */ - @PutMapping("/setSampleColumn") - @ResponseBody - public ResponseEntity setProjectSampleMetadataSampleId(HttpSession session, - @RequestParam Long projectId, @RequestParam String sampleNameColumn) { - return ResponseEntity.ok(new AjaxSuccessResponse( - metadataImportService.setProjectSampleMetadataSampleId(session, projectId, sampleNameColumn))); - } - - /** - * Save uploaded metadata from the session into IRIDA. - * - * @param locale - * {@link Locale} of the current user. - * @param session - * {@link HttpSession} - * @param projectId - * {@link Long} identifier for the current project - * @param sampleNames - * {@link List} of {@link String} sample names - * @return {@link String} message of how many samples were created and/or - * updated. - */ - @PostMapping("/save") - @ResponseBody - public ResponseEntity saveProjectSampleMetadata(Locale locale, HttpSession session, - @RequestParam Long projectId, @RequestParam List sampleNames) { - try { - return ResponseEntity.ok(new AjaxSuccessResponse( - metadataImportService.saveProjectSampleMetadata(locale, session, projectId, sampleNames))); - } catch (SavedMetadataException e) { - return ResponseEntity.status(HttpStatus.CONFLICT).body(new SavedMetadataErrorResponse(e.getStorage())); - } - } - - /** - * Clear any uploaded sample metadata stored into the session. - * - * @param session - * {@link HttpSession} - * @param projectId - * identifier for the {@link Project} currently uploaded metadata - * to. - */ - @DeleteMapping("/clear") - public void clearProjectSampleMetadata(HttpSession session, @RequestParam Long projectId) { - metadataImportService.clearProjectSampleMetadata(session, projectId); - } - - /** - * Get the currently stored metadata. - * - * @param session - * {@link HttpSession} - * @param projectId - * {@link Long} identifier for the current {@link Project} - * @return the currently stored {@link SampleMetadataStorage} - */ - @GetMapping("/getMetadata") - @ResponseBody - public ResponseEntity getProjectSampleMetadata(HttpSession session, - @RequestParam Long projectId) { - return ResponseEntity.ok(metadataImportService.getProjectSampleMetadata(session, projectId)); - } -} diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/services/UIMetadataFileImportService.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/services/UIMetadataFileImportService.java deleted file mode 100644 index c79b32134c8..00000000000 --- a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/services/UIMetadataFileImportService.java +++ /dev/null @@ -1,255 +0,0 @@ -package ca.corefacility.bioinformatics.irida.ria.web.services; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.*; - -import org.apache.commons.csv.CSVFormat; -import org.apache.commons.csv.CSVParser; -import org.apache.commons.csv.CSVRecord; -import org.apache.poi.hssf.usermodel.HSSFWorkbook; -import org.apache.poi.ss.usermodel.*; -import org.apache.poi.xssf.usermodel.XSSFWorkbook; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -import ca.corefacility.bioinformatics.irida.exceptions.EntityNotFoundException; -import ca.corefacility.bioinformatics.irida.exceptions.MetadataImportFileTypeNotSupportedError; -import ca.corefacility.bioinformatics.irida.model.project.Project; -import ca.corefacility.bioinformatics.irida.ria.utilities.SampleMetadataStorage; -import ca.corefacility.bioinformatics.irida.ria.utilities.SampleMetadataStorageRow; -import ca.corefacility.bioinformatics.irida.service.ProjectService; -import ca.corefacility.bioinformatics.irida.service.sample.SampleService; - -import com.google.common.base.Strings; - -/** - * UI service to handle parsing metadata files so they can be saved to the - * session. - */ -@Component -public class UIMetadataFileImportService { - - private static final Logger logger = LoggerFactory.getLogger(UIMetadataFileImportService.class); - - private final ProjectService projectService; - private final SampleService sampleService; - - @Autowired - public UIMetadataFileImportService(ProjectService projectService, SampleService sampleService) { - this.projectService = projectService; - this.sampleService = sampleService; - } - - /** - * Parse metadata from an csv file. - * - * @param projectId - * {@link Long} The project identifier. - * @param inputStream - * The inputStream of the csv file. - * @return {@link SampleMetadataStorage} contains the metadata from file. - * @throws IOException - * thrown if the extension does not exist. - */ - public SampleMetadataStorage parseCSV(Long projectId, InputStream inputStream) throws IOException { - SampleMetadataStorage storage = new SampleMetadataStorage(); - - CSVParser parser = CSVParser.parse(inputStream, StandardCharsets.UTF_8, - CSVFormat.RFC4180.withFirstRecordAsHeader().withTrim().withIgnoreEmptyLines()); - List rows = new ArrayList<>(); - - // save headers - Map headersSet = parser.getHeaderMap(); - List headersList = new ArrayList<>(headersSet.keySet()); - storage.setHeaders(headersList); - - // save data - for (CSVRecord row : parser) { - Map rowMap = new HashMap<>(); - for (String key : row.toMap().keySet()) { - String value = row.toMap().get(key); - rowMap.put(key, value); - } - rows.add(new SampleMetadataStorageRow(rowMap)); - } - storage.setRows(rows); - storage.setSampleNameColumn(findColumnName(projectId, rows)); - parser.close(); - - return storage; - } - - /** - * Parse metadata from an excel file. - * - * @param projectId - * {@link Long} The project identifier. - * @param inputStream - * The inputStream of the excel file. - * @param extension - * The extension of the excel file. - * @return {@link SampleMetadataStorage} contains the metadata from file. - * @throws IOException - * thrown if the extension does not exist. - */ - public SampleMetadataStorage parseExcel(Long projectId, InputStream inputStream, String extension) - throws IOException { - SampleMetadataStorage storage = new SampleMetadataStorage(); - Workbook workbook = null; - - // Check the type of workbook - switch (extension) { - case "xlsx": - workbook = new XSSFWorkbook(inputStream); - break; - case "xls": - workbook = new HSSFWorkbook(inputStream); - break; - default: - // Should never reach here as the uploader limits to .csv, .xlsx and - // .xlx files. - throw new MetadataImportFileTypeNotSupportedError(extension); - } - - // Only look at the first sheet in the workbook as this should be the - // file we want. - Sheet sheet = workbook.getSheetAt(0); - Iterator rowIterator = sheet.iterator(); - - List headers = getWorkbookHeaders(rowIterator.next()); - storage.setHeaders(headers); - - // Get the metadata out of the table. - List rows = new ArrayList<>(); - while (rowIterator.hasNext()) { - Map rowMap = new HashMap<>(); - Row row = rowIterator.next(); - Iterator cellIterator = row.cellIterator(); - while (cellIterator.hasNext()) { - Cell cell = cellIterator.next(); - - int columnIndex = cell.getColumnIndex(); - if (columnIndex < headers.size()) { - String header = headers.get(columnIndex); - - if (!Strings.isNullOrEmpty(header)) { - // Need to ignore empty headers. - if (cell.getCellType().equals(CellType.NUMERIC)) { - /* - * This is a special handler for number cells. It - * was requested that numbers keep their formatting - * from their excel files. E.g. 2.222222 with - * formatting for 2 decimal places will be saved as - * 2.22. - */ - DataFormatter formatter = new DataFormatter(); - String value = formatter.formatCellValue(cell); - rowMap.put(header, value); - } else { - rowMap.put(header, cell.getStringCellValue()); - } - } - } - } - rows.add(new SampleMetadataStorageRow(rowMap)); - } - storage.setRows(rows); - storage.setSampleNameColumn(findColumnName(projectId, rows)); - - if (extension.equals("xlsx")) { - workbook.close(); - } - - return storage; - } - - /** - * Extract the headers from an excel file. - * - * @param row - * {@link Row} First row from the excel file. - * @return {@link List} of {@link String} header values. - */ - private List getWorkbookHeaders(Row row) { - // We want to return a list of the table headers back to the UI. - List headers = new ArrayList<>(); - - // Get the column headers - Iterator headerIterator = row.cellIterator(); - while (headerIterator.hasNext()) { - Cell headerCell = headerIterator.next(); - CellType cellType = headerCell.getCellType(); - - String headerValue; - if (cellType.equals(CellType.STRING)) { - headerValue = headerCell.getStringCellValue().trim(); - } else { - headerValue = String.valueOf(headerCell.getNumericCellValue()).trim(); - } - - // Leave empty headers for now, we will remove those columns later. - headers.add(headerValue); - } - return headers; - } - - /** - * Find the sample name column, given the rows of a file. - * - * @param projectId - * {@link Long} The project identifier. - * @param rows - * {@link Row} The rows from the excel file. - * @return {@link String} column name. - */ - private String findColumnName(Long projectId, List rows) { - String columnName = null; - int col = 0; - int numRows = rows.size(); - - while (columnName == null && col < numRows) { - columnName = findColumnNameInRow(projectId, rows.get(col)); - col++; - } - - return columnName; - } - - /** - * Find the sample name column, given a row of a file. - * - * @param projectId - * {@link Long} The project identifier. - * @param row - * {@link Row} A row from the excel file. - * @return {@link String} column name. - */ - private String findColumnNameInRow(Long projectId, SampleMetadataStorageRow row) { - String columnName = null; - Project project = projectService.read(projectId); - Iterator> iterator = row.getEntry().entrySet().iterator(); - - while (iterator.hasNext() && columnName == null) { - String key = null; - String value = null; - try { - Map.Entry entry = iterator.next(); - key = entry.getKey(); - value = entry.getValue(); - - if (sampleService.getSampleBySampleName(project, value) != null) { - columnName = key; - } - } catch (EntityNotFoundException entityNotFoundException) { - logger.trace("Sample " + value + " in project " + project.getId() + " is not found.", - entityNotFoundException); - } - } - - return columnName; - } -} \ No newline at end of file diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/services/UIMetadataImportService.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/services/UIMetadataImportService.java deleted file mode 100644 index 6e7520fe6ce..00000000000 --- a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/services/UIMetadataImportService.java +++ /dev/null @@ -1,274 +0,0 @@ -package ca.corefacility.bioinformatics.irida.ria.web.services; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.util.*; - -import javax.servlet.http.HttpSession; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.MessageSource; -import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; - -import ca.corefacility.bioinformatics.irida.exceptions.EntityNotFoundException; -import ca.corefacility.bioinformatics.irida.exceptions.MetadataImportFileTypeNotSupportedError; -import ca.corefacility.bioinformatics.irida.model.project.Project; -import ca.corefacility.bioinformatics.irida.model.sample.MetadataTemplateField; -import ca.corefacility.bioinformatics.irida.model.sample.Sample; -import ca.corefacility.bioinformatics.irida.model.sample.metadata.MetadataEntry; -import ca.corefacility.bioinformatics.irida.ria.utilities.SampleMetadataStorage; -import ca.corefacility.bioinformatics.irida.ria.utilities.SampleMetadataStorageRow; -import ca.corefacility.bioinformatics.irida.ria.web.errors.SavedMetadataException; -import ca.corefacility.bioinformatics.irida.service.ProjectService; -import ca.corefacility.bioinformatics.irida.service.sample.MetadataTemplateService; -import ca.corefacility.bioinformatics.irida.service.sample.SampleService; - -import com.google.common.collect.ImmutableList; -import com.google.common.io.Files; - -/** - * UI service to handle importing metadata files, so they can be saved to the - * session. - */ -@Component -public class UIMetadataImportService { - - private static final Logger logger = LoggerFactory.getLogger(UIMetadataImportService.class); - private final MessageSource messageSource; - private final ProjectService projectService; - private final SampleService sampleService; - private final MetadataTemplateService metadataTemplateService; - private final UIMetadataFileImportService metadataFileImportService; - - @Autowired - public UIMetadataImportService(MessageSource messageSource, ProjectService projectService, - SampleService sampleService, MetadataTemplateService metadataTemplateService, - UIMetadataFileImportService metadataFileImportService) { - this.messageSource = messageSource; - this.projectService = projectService; - this.sampleService = sampleService; - this.metadataTemplateService = metadataTemplateService; - this.metadataFileImportService = metadataFileImportService; - } - - /** - * Upload CSV or Excel file containing sample metadata and extract the - * headers. The file is stored in the session until the column that - * corresponds to a {@link Sample} identifier has been sent. - * - * @param session - * {@link HttpSession} - * @param projectId - * {@link Long} identifier for the current {@link Project} - * @param file - * {@link MultipartFile} The csv or excel file containing the - * metadata. - * @return {@link Map} of headers and rows from the csv or excel file for - * the user to select the header corresponding the {@link Sample} - * identifier. - * @throws Exception - * if there is an error reading the file - */ - public SampleMetadataStorage createProjectSampleMetadata(HttpSession session, Long projectId, MultipartFile file) - throws Exception { - // We want to return a list of the table headers back to the UI. - SampleMetadataStorage storage = new SampleMetadataStorage(); - try (InputStream inputStream = file.getInputStream()) { - String filename = file.getOriginalFilename(); - String extension = Files.getFileExtension(filename); - - // Check the file type - switch (extension) { - case "csv": - storage = metadataFileImportService.parseCSV(projectId, inputStream); - break; - case "xlsx": - case "xls": - storage = metadataFileImportService.parseExcel(projectId, inputStream, extension); - break; - default: - // Should never reach here as the uploader limits to .csv, .xlsx - // and .xlx files. - throw new MetadataImportFileTypeNotSupportedError(extension); - } - - } catch (FileNotFoundException e) { - logger.debug("No file found for uploading an excel file of metadata."); - throw e; - } catch (IOException e) { - logger.error("Error opening file" + file.getOriginalFilename()); - throw e; - } - - session.setAttribute("pm-" + projectId, storage); - return storage; - } - - /** - * Add the metadata to specific {@link Sample} based on the selected column - * to correspond to the {@link Sample} id. - * - * @param session - * {@link HttpSession}. - * @param projectId - * {@link Long} identifier for the current {@link Project}. - * @param sampleNameColumn - * {@link String} the header to used to represent the - * {@link Sample} identifier. - * @return {@link String} containing a complete message. - */ - public String setProjectSampleMetadataSampleId(HttpSession session, Long projectId, String sampleNameColumn) { - // Attempt to get the metadata from the sessions - SampleMetadataStorage stored = (SampleMetadataStorage) session.getAttribute("pm-" + projectId); - - if (stored != null) { - stored.setSampleNameColumn(sampleNameColumn); - Project project = projectService.read(projectId); - List rows = stored.getRows(); - List updatedRows = new ArrayList<>(); - - // Get the metadata out of the table. - for (SampleMetadataStorageRow row : rows) { - try { - // If this throws an error than the sample does not exist. - Sample sample = sampleService.getSampleBySampleName(project, row.getEntryValue(sampleNameColumn)); - row.setFoundSampleId(sample.getId()); - } catch (EntityNotFoundException e) { - row.setFoundSampleId(null); - } - updatedRows.add(row); - } - stored.setRows(updatedRows); - } - - return "complete"; - } - - /** - * Save uploaded metadata - * - * @param locale - * {@link Locale} of the current user. - * @param session - * {@link HttpSession} - * @param projectId - * {@link Long} identifier for the current project - * @param sampleNames - * {@link List} of {@link String} sample names - * @return {@link String} that returns a message and potential errors. - * @throws SavedMetadataException - * if there is an error saving the metadata - */ - public String saveProjectSampleMetadata(Locale locale, HttpSession session, Long projectId, - List sampleNames) throws SavedMetadataException { - List DEFAULT_HEADERS = ImmutableList.of( - messageSource.getMessage("project.samples.table.sample-id", new Object[] {}, locale), - messageSource.getMessage("project.samples.table.id", new Object[] {}, locale), - messageSource.getMessage("project.samples.table.modified-date", new Object[] {}, locale), - messageSource.getMessage("project.samples.table.modified", new Object[] {}, locale), - messageSource.getMessage("project.samples.table.created-date", new Object[] {}, locale), - messageSource.getMessage("project.samples.table.created", new Object[] {}, locale), - messageSource.getMessage("project.samples.table.coverage", new Object[] {}, locale), - messageSource.getMessage("project.samples.table.project-id", new Object[] {}, locale)); - Project project = projectService.read(projectId); - SampleMetadataStorage stored = (SampleMetadataStorage) session.getAttribute("pm-" + projectId); - boolean hasErrors = false; - String message; - int samplesUpdatedCount = 0; - int samplesCreatedCount = 0; - - if (sampleNames != null) { - String sampleNameColumn = stored.getSampleNameColumn(); - - for (String sampleName : sampleNames) { - try { - Set metadataEntrySet = new HashSet<>(); - SampleMetadataStorageRow row = stored.getRow(sampleName, sampleNameColumn); - String name = row.getEntryValue(sampleNameColumn); - Sample sample = null; - - if (row.getFoundSampleId() != null) { - sample = sampleService.getSampleBySampleName(project, name); - samplesUpdatedCount++; - } else { - sample = new Sample(name); - projectService.addSampleToProject(project, sample, true); - samplesCreatedCount++; - } - - // Need to overwrite duplicate keys - for (Map.Entry entry : row.getEntry().entrySet()) { - // Make sure we are not saving non-metadata items. - if (!DEFAULT_HEADERS.contains(entry.getKey()) && !sampleNameColumn.contains(entry.getKey())) { - MetadataTemplateField key = metadataTemplateService - .readMetadataFieldByLabel(entry.getKey()); - - if (key == null) { - key = metadataTemplateService - .saveMetadataField(new MetadataTemplateField(entry.getKey(), "text")); - } - - metadataEntrySet.add(new MetadataEntry(entry.getValue(), "text", key)); - } - } - - // Save metadata back to the sample - sampleService.mergeSampleMetadata(sample, metadataEntrySet); - row.setSaved(true); - } catch (Exception e) { - SampleMetadataStorageRow row = stored.getRow(sampleName, sampleNameColumn); - row.setError(e.getMessage()); - row.setSaved(false); - hasErrors = true; - } - } - } - - if (hasErrors) { - throw new SavedMetadataException(stored); - } - - message = ((samplesUpdatedCount == 1) - ? messageSource.getMessage("server.metadataimport.results.save.success.single-updated", - new Object[] { samplesUpdatedCount }, locale) - : messageSource.getMessage("server.metadataimport.results.save.success.multiple-updated", - new Object[] { samplesUpdatedCount }, locale)); - message += (samplesCreatedCount == 1) - ? messageSource.getMessage("server.metadataimport.results.save.success.single-created", - new Object[] { samplesCreatedCount }, locale) - : messageSource.getMessage("server.metadataimport.results.save.success.multiple-created", - new Object[] { samplesCreatedCount }, locale); - - return message; - } - - /** - * Clear any uploaded sample metadata stored into the session. - * - * @param session - * {@link HttpSession} - * @param projectId - * identifier for the {@link Project} currently uploaded metadata - * to. - */ - public void clearProjectSampleMetadata(HttpSession session, Long projectId) { - session.removeAttribute("pm-" + projectId); - } - - /** - * Get the currently stored metadata. - * - * @param session - * {@link HttpSession} - * @param projectId - * {@link Long} identifier for the current {@link Project} - * @return the currently stored {@link SampleMetadataStorage} - */ - public SampleMetadataStorage getProjectSampleMetadata(HttpSession session, Long projectId) { - return (SampleMetadataStorage) session.getAttribute("pm-" + projectId); - } -} diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/services/UIMetadataService.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/services/UIMetadataService.java index ebac150569f..dcb4e885a82 100644 --- a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/services/UIMetadataService.java +++ b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/services/UIMetadataService.java @@ -11,7 +11,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.MessageSource; import org.springframework.stereotype.Component; @@ -57,15 +56,12 @@ public List getProjectMetadataTemplates(Long projectId) Project project = projectService.read(projectId); List templates = templateService.getMetadataTemplatesForProject(project); - return templates.stream() - .map(template -> { - List permittedFieldsForTemplate = templateService.getPermittedFieldsForTemplate( - template); - List fields = addRestrictionsToMetadataFields(project, - permittedFieldsForTemplate); - return new ProjectMetadataTemplate(template, fields); - }) - .collect(Collectors.toList()); + return templates.stream().map(template -> { + List permittedFieldsForTemplate = templateService.getPermittedFieldsForTemplate( + template); + List fields = addRestrictionsToMetadataFields(project, permittedFieldsForTemplate); + return new ProjectMetadataTemplate(template, fields); + }).collect(Collectors.toList()); } /** @@ -141,6 +137,32 @@ public List getMetadataFieldsForProject(Long projectId) { return addRestrictionsToMetadataFields(project, fields); } + /** + * Create project metadata fields with restrictions (no metadata entries) + * + * @param projectId Identifier for a {@link Project} + * @param fields List of project metadata fields + * @param locale Current users {@link Locale} + * @return result message + */ + @Transactional + public String createMetadataFieldsForProject(Long projectId, List fields, Locale locale) { + Project project = projectService.read(projectId); + for (ProjectMetadataField field : fields) { + String label = field.getLabel(); + MetadataTemplateField templateField; + MetadataTemplateField existingTemplateField = templateService.readMetadataFieldByLabel(label); + if (existingTemplateField != null) { + templateField = existingTemplateField; + } else { + templateField = templateService.saveMetadataField(new MetadataTemplateField(label, "text")); + } + ProjectMetadataRole role = ProjectMetadataRole.fromString(field.getRestriction()); + templateService.setMetadataRestriction(project, templateField, role); + } + return messageSource.getMessage("server.MetadataFieldsListManager.success", new Object[] {}, locale); + } + /** * Get all {@link MetadataTemplateField}s belonging to a list of {@link Project}s * @@ -154,13 +176,11 @@ public List getMetadataFieldsForProjects(List projec List fields = templateService.getPermittedFieldsForCurrentUser(project, false); projectMetadataFieldList = Stream.concat(projectMetadataFieldList.stream(), - addRestrictionsToMetadataFields(project, fields).stream()) - .collect(Collectors.toList()); + addRestrictionsToMetadataFields(project, fields).stream()).collect(Collectors.toList()); } // Sort in descending order by restriction and use distinct to get unique metadata template fields - projectMetadataFieldList.sort(Comparator.comparing(ProjectMetadataField::getRestriction) - .reversed()); + projectMetadataFieldList.sort(Comparator.comparing(ProjectMetadataField::getRestriction).reversed()); projectMetadataFieldList = projectMetadataFieldList.stream() .filter(distinctByKey(ProjectMetadataField::getLabel)) .collect(Collectors.toList()); @@ -194,7 +214,8 @@ public String updateMetadataProjectField(Long projectId, Long fieldId, ProjectMe Project project = projectService.read(projectId); MetadataTemplateField field = templateService.readMetadataField(fieldId); templateService.setMetadataRestriction(project, field, newRole); - return messageSource.getMessage("server.MetadataFieldsListManager.update", new Object[] { field.getLabel(), + return messageSource.getMessage("server.MetadataFieldsListManager.update", new Object[] { + field.getLabel(), messageSource.getMessage("metadataRole." + newRole.toString(), new Object[] {}, locale) }, locale); } @@ -233,9 +254,7 @@ public String setDefaultMetadataTemplate(Long templateId, Long projectId, Locale */ private List addRestrictionsToMetadataFields(Project project, List fields) { - return fields.stream() - .map(field -> createProjectMetadataField(project, field)) - .collect(Collectors.toList()); + return fields.stream().map(field -> createProjectMetadataField(project, field)).collect(Collectors.toList()); } /** @@ -252,7 +271,8 @@ public List getProjectMetadataRoles(Locale locale) { } /** - * Utility function to update a specific {@link MetadataTemplateField} with its security restrictions for a project. + * Utility function to update a specific {@link MetadataTemplateField} with its security restrictions for a + * project. * * @param project The {@link Project} the fields belong to * @param field the {@link MetadataTemplateField} to update @@ -261,15 +281,13 @@ public List getProjectMetadataRoles(Locale locale) { private ProjectMetadataField createProjectMetadataField(Project project, MetadataTemplateField field) { MetadataRestriction restriction = templateService.getMetadataRestrictionForFieldAndProject(project, field); //default to LEVEL_1 if no restriction is set - String level = restriction == null ? - ProjectMetadataRole.LEVEL_1.toString() : - restriction.getLevel() - .toString(); + String level = restriction == null ? ProjectMetadataRole.LEVEL_1.toString() : restriction.getLevel().toString(); return new ProjectMetadataField(field, level); } /** - * Predicate that maintains state about what it's seen previously, and that returns whether the given element was seen for the first time: + * Predicate that maintains state about what it's seen previously, and that returns whether the given element was + * seen for the first time: * * @param keyExtractor * @param diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/services/UIProjectSampleService.java b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/services/UIProjectSampleService.java index e8f009e24b0..5f803ce9302 100644 --- a/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/services/UIProjectSampleService.java +++ b/src/main/java/ca/corefacility/bioinformatics/irida/ria/web/services/UIProjectSampleService.java @@ -3,6 +3,8 @@ import java.util.*; import java.util.stream.Collectors; +import javax.validation.ConstraintViolationException; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.MessageSource; import org.springframework.http.HttpStatus; @@ -16,13 +18,8 @@ import ca.corefacility.bioinformatics.irida.model.sample.MetadataTemplateField; import ca.corefacility.bioinformatics.irida.model.sample.Sample; import ca.corefacility.bioinformatics.irida.model.sample.metadata.MetadataEntry; -import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.CreateSampleRequest; -import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.SampleNameValidationResponse; -import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.UpdateSampleRequest; -import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.ajax.AjaxCreateItemSuccessResponse; -import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.ajax.AjaxErrorResponse; -import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.ajax.AjaxResponse; -import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.ajax.AjaxUpdateItemSuccessResponse; +import ca.corefacility.bioinformatics.irida.ria.web.ajax.dto.*; +import ca.corefacility.bioinformatics.irida.ria.web.ajax.projects.dto.MetadataEntryModel; import ca.corefacility.bioinformatics.irida.ria.web.ajax.projects.dto.ValidateSampleNameModel; import ca.corefacility.bioinformatics.irida.ria.web.ajax.projects.dto.ValidateSampleNamesRequest; import ca.corefacility.bioinformatics.irida.ria.web.ajax.projects.dto.ValidateSampleNamesResponse; @@ -108,7 +105,6 @@ public ResponseEntity validateNewSampleName(String // Check to see if the sample name already exists. try { - Project project = projectService.read(projectId); sampleService.getSampleBySampleName(project, name); return ResponseEntity.status(HttpStatus.CONFLICT) @@ -121,69 +117,128 @@ public ResponseEntity validateNewSampleName(String } + /** + * Create new samples in a project + * + * @param requests Each {@link CreateSampleRequest} contains details about the sample to create + * @param projectId Identifier for the current project + * @return result of creating the sample + */ + public Map createSamples(CreateSampleRequest[] requests, Long projectId) { + Map responses = new HashMap<>(); + for (CreateSampleRequest request : requests) { + try { + Long sampleId = createSample(projectId, request); + SampleResponse response = new SampleResponse(sampleId); + responses.put(request.getName(), response); + } catch (Exception e) { + SampleResponse response = new SampleResponse(e.getMessage()); + responses.put(request.getName(), response); + } + } + return responses; + } + /** * Create a new sample in a project * - * @param request {@link CreateSampleRequest} details about the sample to create + * @param request {@link CreateSampleRequest} contains details about the sample to create * @param projectId Identifier for the current project - * @param locale Users current locale * @return result of creating the sample + * @throws EntityNotFoundException if the identifier does not exist in the database */ @Transactional - public ResponseEntity createSample(CreateSampleRequest request, Long projectId, Locale locale) { + public Long createSample(Long projectId, CreateSampleRequest request) throws EntityNotFoundException { Project project = projectService.read(projectId); - try { - Sample sample = new Sample(request.getName()); - if (!Strings.isNullOrEmpty(request.getOrganism())) { - sample.setOrganism(request.getOrganism()); - } - if (!Strings.isNullOrEmpty(request.getDescription())) { - sample.setDescription(request.getDescription()); - } - Join join = projectService.addSampleToProject(project, sample, true); - if (request.getMetadata() != null) { - Set metadataEntrySet = request.getMetadata().stream().map(entry -> { - MetadataTemplateField field = metadataTemplateService.saveMetadataField( - new MetadataTemplateField(entry.getField(), "text")); - return new MetadataEntry(entry.getValue(), "text", field); - }).collect(Collectors.toSet()); - sampleService.mergeSampleMetadata(sample, metadataEntrySet); + Sample sample = new Sample(request.getName()); + if (!Strings.isNullOrEmpty(request.getOrganism())) { + sample.setOrganism(request.getOrganism()); + } + if (!Strings.isNullOrEmpty(request.getDescription())) { + sample.setDescription(request.getDescription()); + } + Join join = projectService.addSampleToProjectWithoutEvent(project, sample, true); + if (request.getMetadata() != null) { + Set metadataEntrySet = createMetadata(request.getMetadata()); + sampleService.mergeSampleMetadata(sample, metadataEntrySet); + } + return join.getObject().getId(); + } + + /** + * Update samples in a project + * + * @param requests Each {@link UpdateSampleRequest} contains details about the sample to update + * @return result of creating the samples + */ + public Map updateSamples(UpdateSampleRequest[] requests) { + Map responses = new HashMap<>(); + for (UpdateSampleRequest request : requests) { + try { + updateSample(request); + SampleErrorResponse response = new SampleErrorResponse(false); + responses.put(request.getName(), response); + } catch (Exception e) { + SampleErrorResponse response = new SampleErrorResponse(true, e.getMessage()); + responses.put(request.getName(), response); } - return ResponseEntity.ok(new AjaxCreateItemSuccessResponse(join.getObject().getId())); - } catch (EntityNotFoundException e) { - return ResponseEntity.ok(new AjaxErrorResponse( - messageSource.getMessage("server.AddSample.error.exists", new Object[] {}, locale))); } + return responses; } /** * Update a sample in a project * - * @param request {@link UpdateSampleRequest} details about the sample to update - * @param sampleId Identifier for the sample - * @param locale Users current locale + * @param request {@link UpdateSampleRequest} contains details about the sample to update * @return result of creating the sample + * @throws EntityNotFoundException if the identifier does not exist in the database + * @throws ConstraintViolationException if the entity being updated contains constraint violations */ @Transactional - public ResponseEntity updateSample(UpdateSampleRequest request, Long sampleId, Locale locale) { - try { - Sample sample = sampleService.read(sampleId); - sample.setSampleName(request.getName()); + public Sample updateSample(UpdateSampleRequest request) + throws EntityNotFoundException, ConstraintViolationException { + Long sampleId = request.getSampleId(); + Sample sample = sampleService.read(sampleId); + sample.setSampleName(request.getName()); + if (!Strings.isNullOrEmpty(request.getOrganism())) { sample.setOrganism(request.getOrganism()); + } + if (request.getDescription() != null) { sample.setDescription(request.getDescription()); - if (request.getMetadata() != null) { - Set metadataEntrySet = request.getMetadata().stream().map(entry -> { - MetadataTemplateField field = metadataTemplateService.saveMetadataField( - new MetadataTemplateField(entry.getField(), "text")); - return new MetadataEntry(entry.getValue(), "text", field); - }).collect(Collectors.toSet()); - sampleService.updateSampleMetadata(sample, metadataEntrySet); - } - sampleService.update(sample); - return ResponseEntity.ok(new AjaxUpdateItemSuccessResponse( - messageSource.getMessage("server.AddSample.success", null, locale))); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.CONFLICT).body(new AjaxErrorResponse(e.getMessage())); } + if (request.getMetadata() != null) { + Set metadataEntrySet = createMetadata(request.getMetadata()); + sampleService.updateSampleMetadata(sample, metadataEntrySet); + } + return sampleService.update(sample); + + } + + /** + * Get a list of {@link Sample} ids that are locked in the given project + * + * @param projectId project identifier + * @return result of creating the sample + */ + public LockedSamplesResponse getLockedSamplesInProject(Long projectId) { + Project project = projectService.read(projectId); + List lockedSampleIds = sampleService.getLockedSamplesInProject(project); + return new LockedSamplesResponse(lockedSampleIds); } + + /** + * Creates a metadata entry set for a sample, assuming the metadata field and restriction exist + * + * @param metadataFields list of {@link MetadataEntryModel}s + * @return metadata entry set + */ + private Set createMetadata(List metadataFields) { + Set metadataEntrySet = metadataFields.stream().map(entry -> { + String label = entry.getField(); + MetadataTemplateField field = metadataTemplateService.readMetadataFieldByLabel(label); + return new MetadataEntry(entry.getValue(), "text", field); + }).collect(Collectors.toSet()); + return metadataEntrySet; + } + } diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/service/ProjectService.java b/src/main/java/ca/corefacility/bioinformatics/irida/service/ProjectService.java index 0175f10d737..345dd7b9861 100644 --- a/src/main/java/ca/corefacility/bioinformatics/irida/service/ProjectService.java +++ b/src/main/java/ca/corefacility/bioinformatics/irida/service/ProjectService.java @@ -137,6 +137,17 @@ public Join updateUserGroupProjectMetadataRole(Project proje */ public Join addSampleToProject(Project project, Sample sample, boolean owner); + /** + * Add the specified {@link Sample} to the {@link Project} without creating an event. + * + * @param project the {@link Project} to add the {@link Sample} to. + * @param sample the {@link Sample} to add to the {@link Project}. If the {@link Sample} has not previously been + * persisted, the service will persist the {@link Sample}. + * @param owner Whether the project will have modification access for this sample + * @return a reference to the relationship resource created between the two entities. + */ + public Join addSampleToProjectWithoutEvent(Project project, Sample sample, boolean owner); + /** * Move a {@link Sample} from one {@link Project} to another * diff --git a/src/main/java/ca/corefacility/bioinformatics/irida/service/impl/ProjectServiceImpl.java b/src/main/java/ca/corefacility/bioinformatics/irida/service/impl/ProjectServiceImpl.java index 5e0b5297cca..2156bef347f 100644 --- a/src/main/java/ca/corefacility/bioinformatics/irida/service/impl/ProjectServiceImpl.java +++ b/src/main/java/ca/corefacility/bioinformatics/irida/service/impl/ProjectServiceImpl.java @@ -410,8 +410,9 @@ public ProjectSampleJoin addSampleToProject(Project project, Sample sample, bool // Check to ensure a sample with this sample name doesn't exist in this // project already if (sampleRepository.getSampleBySampleName(project, sample.getSampleName()) != null) { - throw new ExistingSampleNameException("Sample with the name '" + sample.getSampleName() - + "' already exists in project " + project.getId(), sample); + throw new ExistingSampleNameException( + "Sample with the name '" + sample.getSampleName() + "' already exists in project " + + project.getId(), sample); } // the sample hasn't been persisted before, persist it before calling @@ -437,13 +438,24 @@ public ProjectSampleJoin addSampleToProject(Project project, Sample sample, bool } } + /** + * {@inheritDoc} + */ + @Override + @Transactional + @PreAuthorize("hasAnyRole('ROLE_ADMIN', 'ROLE_SEQUENCER') or (hasPermission(#project, 'isProjectOwner'))") + public ProjectSampleJoin addSampleToProjectWithoutEvent(Project project, Sample sample, boolean owner) { + return addSampleToProject(project, sample, owner); + } + /** * {@inheritDoc} */ @Override @Transactional @LaunchesProjectEvent(SampleAddedProjectEvent.class) - @PreAuthorize("hasRole('ROLE_ADMIN') or ( hasPermission(#source, 'isProjectOwner') and hasPermission(#destination, 'isProjectOwner'))") + @PreAuthorize( + "hasRole('ROLE_ADMIN') or ( hasPermission(#source, 'isProjectOwner') and hasPermission(#destination, 'isProjectOwner'))") public ProjectSampleJoin moveSampleBetweenProjects(Project source, Project destination, Sample sample) { //read the existing ProjectSampleJoin to see if we're the owner ProjectSampleJoin projectSampleJoin = psjRepository.readSampleForProject(source, sample); @@ -607,15 +619,16 @@ public Collection getUserGroupProjectJoins(User user, Proj * {@inheritDoc} */ @Override - @PreAuthorize("hasRole('ROLE_ADMIN') or hasPermission(#subject,'isProjectOwner') and hasPermission(#relatedProject,'canReadProject')") + @PreAuthorize( + "hasRole('ROLE_ADMIN') or hasPermission(#subject,'isProjectOwner') and hasPermission(#relatedProject,'canReadProject')") public RelatedProjectJoin addRelatedProject(Project subject, Project relatedProject) { if (subject.equals(relatedProject)) { throw new IllegalArgumentException("Project cannot be related to itself"); } try { - RelatedProjectJoin relation = relatedProjectRepository - .save(new RelatedProjectJoin(subject, relatedProject)); + RelatedProjectJoin relation = relatedProjectRepository.save( + new RelatedProjectJoin(subject, relatedProject)); return relation; } catch (DataIntegrityViolationException e) { throw new EntityExistsException( @@ -691,8 +704,8 @@ public Join addReferenceFileToProject(Project project, R @Override @PreAuthorize("hasRole('ROLE_ADMIN') or hasPermission(#project, 'isProjectOwner')") public void removeReferenceFileFromProject(Project project, ReferenceFile file) { - List> referenceFilesForProject = prfjRepository - .findReferenceFilesForProject(project); + List> referenceFilesForProject = prfjRepository.findReferenceFilesForProject( + project); Join specificJoin = null; for (Join join : referenceFilesForProject) { if (join.getObject().equals(file)) { @@ -703,8 +716,9 @@ public void removeReferenceFileFromProject(Project project, ReferenceFile file) if (specificJoin != null) { prfjRepository.delete((ProjectReferenceFileJoin) specificJoin); } else { - throw new EntityNotFoundException("Cannot find a join for project [" + project.getName() - + "] and reference file [" + file.getLabel() + "]."); + throw new EntityNotFoundException( + "Cannot find a join for project [" + project.getName() + "] and reference file [" + file.getLabel() + + "]."); } } @@ -848,8 +862,8 @@ public Project createProjectWithSamples(Project project, List sampleIds, b @PostFilter("hasPermission(filterObject, 'canReadProject')") @Override public List getProjectsUsedInAnalysisSubmission(AnalysisSubmission submission) { - Set findSequencingObjectsForAnalysisSubmission = sequencingObjectRepository - .findSequencingObjectsForAnalysisSubmission(submission); + Set findSequencingObjectsForAnalysisSubmission = sequencingObjectRepository.findSequencingObjectsForAnalysisSubmission( + submission); // get available projects Set projectsInAnalysis = getProjectsForSequencingObjects(findSequencingObjectsForAnalysisSubmission); @@ -890,7 +904,7 @@ private static final Sort getOrDefaultSort(Sort sort) { * @param projectRole The {@link ProjectRole} to search for. * @param user The user to search * @return a {@link Specification} to search for {@link Project} where the specified {@link User} has a certain - * {@link ProjectRole}. + * {@link ProjectRole}. */ private static final Specification getProjectJoinsWithRole(User user, ProjectRole projectRole) { return new Specification() { diff --git a/src/main/resources/ca/corefacility/bioinformatics/irida/database/changesets/unreleased/all-changes.xml b/src/main/resources/ca/corefacility/bioinformatics/irida/database/changesets/unreleased/all-changes.xml index 61d30a2d3fa..ea58a9e4165 100644 --- a/src/main/resources/ca/corefacility/bioinformatics/irida/database/changesets/unreleased/all-changes.xml +++ b/src/main/resources/ca/corefacility/bioinformatics/irida/database/changesets/unreleased/all-changes.xml @@ -1,4 +1,6 @@ \ No newline at end of file + http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd"> + + \ No newline at end of file diff --git a/src/main/resources/ca/corefacility/bioinformatics/irida/database/changesets/unreleased/metadata-field-add-label-constraint.xml b/src/main/resources/ca/corefacility/bioinformatics/irida/database/changesets/unreleased/metadata-field-add-label-constraint.xml new file mode 100644 index 00000000000..0b2f2be52ba --- /dev/null +++ b/src/main/resources/ca/corefacility/bioinformatics/irida/database/changesets/unreleased/metadata-field-add-label-constraint.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index d3c3a621a6c..1500058aeea 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -1158,6 +1158,7 @@ MetadataFieldsListMember.empty=No metadata has been added to this project. MetadataFieldsListManager.restrictions=Restrictions MetadataFieldsListManager.restrictions-help=Each Metadata Field can be set to only be viewed by certain project roles. Manager's will be able to view all fields while the Collaborators will only be able to view the fields that are selected for that role. A Collaborator with a metadata role of LEVEL 1 will only be able to view metadata fields set to LEVEL 1. Whereas a Collaborator with metadata role of LEVEL 2 will be able to view metadata fields set to LEVEL 1 or LEVEL 2. server.MetadataFieldsListManager.update=Role for {0} updated to {1} +server.MetadataFieldsListManager.success=Metadata project fields successfully created. MetadataField.label=Label MetadataField.type=Type @@ -1984,32 +1985,46 @@ server.metadataimport.results.save.success.multiple-created=\ and {0} new sample SampleMetadataImportWizard.title=Sample Metadata Uploader SampleMetadataImportWizard.intro=A tool you can use to upload excel or csv files containing metadata for samples in this project. -SampleMetadataImportSteps.step1=Upload File -SampleMetadataImportSteps.step2=Map Headers +SampleMetadataImportSteps.step1=Select File +SampleMetadataImportSteps.step2=Map Columns SampleMetadataImportSteps.step3=Review Data SampleMetadataImportSteps.step4=Complete -SampleMetadataImportUploadFile.warning=Metadata uploaded will overwrite duplicate metadata on the sample. -SampleMetadataImportUploadFile.dropzone=Click or drop Excel/CSV file containing metadata for samples in this project. -SampleMetadataImportUploadFile.success={0} was uploaded successfully. -SampleMetadataImportUploadFile.error={0} failed to upload. - -SampleMetadataImportMapHeaders.description=Select which column maps to the sample name. -SampleMetadataImportMapHeaders.button.back=Upload a new file -SampleMetadataImportMapHeaders.button.next=Preview the data +SampleMetadataImportSelectFile.warning=Metadata uploaded will overwrite duplicate metadata on the sample. +SampleMetadataImportSelectFile.dropzone=Click or drop Excel/CSV file containing metadata for samples in this project. +SampleMetadataImportSelectFile.success=File '{0}' was read successfully. +SampleMetadataImportSelectFile.error=File '{0}' failed to upload. +SampleMetadataImportSelectFile.alert.valid.title=Validation Error +SampleMetadataImportSelectFile.alert.valid.description.preface=The uploaded file has the following error(s): +SampleMetadataImportSelectFile.alert.valid.description.duplicate=Duplicate header names +SampleMetadataImportSelectFile.alert.valid.description.empty=Empty header names +SampleMetadataImportSelectFile.alert.valid.description.postface=Please correct the error(s) in the file and then re-upload. + +SampleMetadataImportMapColumns.form.sampleNameColumn=Select Sample Name Column +SampleMetadataImportMapColumns.form.metadataColumns=Review Parsed Metadata Columns +SampleMetadataImportMapColumns.button.back=Select a new file +SampleMetadataImportMapColumns.button.next=Review the data +SampleMetadataImportMapColumns.table.empty=Waiting on the selection of the sample name column. +SampleMetadataImportMapColumns.table.header=Header +SampleMetadataImportMapColumns.table.existingRestriction=Existing Restriction +SampleMetadataImportMapColumns.table.targetRestriction=Target Restriction SampleMetadataImportReview.description=Review the metadata to be uploaded. +SampleMetadataImportReview.loading=Please wait until the upload completes. Closing this window or leaving this page will terminate the upload. SampleMetadataImportReview.button.back=Select a different column SampleMetadataImportReview.button.next=Upload the data SampleMetadataImportReview.tab.found=Samples to be updated SampleMetadataImportReview.tab.missing=Samples to be created SampleMetadataImportReview.table.filter.new=New SampleMetadataImportReview.table.filter.existing=Existing -SampleMetadataImportReview.alert.title=Validation Error -SampleMetadataImportReview.alert.description=Please correct the following errors within the file and re-upload. The sample name must meet the following criteria: -SampleMetadataImportReview.alert.rule1=cannot be empty -SampleMetadataImportReview.alert.rule2=minimum 3 characters long -SampleMetadataImportReview.alert.rule3=contain only alphanumeric characters and '-', '_' +SampleMetadataImportReview.alert.valid.title=Validation Error +SampleMetadataImportReview.alert.valid.description=Please correct the following errors within the file and re-upload. The sample name must meet the following criteria: +SampleMetadataImportReview.alert.valid.rule1=cannot be empty +SampleMetadataImportReview.alert.valid.rule2=minimum 3 characters long +SampleMetadataImportReview.alert.valid.rule3=contain only alphanumeric characters and '-', '_' +SampleMetadataImportReview.alert.locked.description.popover.content={0} sample metadata +SampleMetadataImportReview.alert.locked.description=\ cannot be imported, because these samples are locked in the current project. +SampleMetadataImportReview.notification.partialError=There are {0} rows that were unable to be saved. Please review the errors in the table. SampleMetadataImportComplete.result.title=The sample metadata imported successfully! SampleMetadataImportComplete.button.upload=Upload another file @@ -2297,7 +2312,6 @@ AddSample.submit=Create Sample server.AddSample.error.length=Sample name must have at least 4 characters. server.AddSample.error.special.characters=Sample name cannot contain any spaces or special characters. server.AddSample.error.exists=A sample by this name already exists in this project. -server.AddSample.success=success # ========================================================================================== # # CART EMPTY COMPONENT # diff --git a/src/main/webapp/resources/js/apis/metadata/field.js b/src/main/webapp/resources/js/apis/metadata/field.js deleted file mode 100644 index 30835b5d820..00000000000 --- a/src/main/webapp/resources/js/apis/metadata/field.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Class responsible for ajax call for project sample metadata fields. - */ -import axios from "axios"; -import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; -import { addKeysToList } from "../../utilities/http-utilities"; -import { setBaseUrl } from "../../utilities/url-utilities"; - -const BASE_URL = setBaseUrl(`/ajax/metadata/fields`); - -/** - * Redux API for metadata fields. - * @type {Api<(args: (string | FetchArgs), api: BaseQueryApi, extraOptions: {}) => MaybePromise>, {getMetadataFieldsForProject: *}, string, string, typeof coreModuleName> | Api<(args: (string | FetchArgs), api: BaseQueryApi, extraOptions: {}) => MaybePromise>, {getMetadataFieldsForProject: *}, string, string, typeof coreModuleName | typeof reactHooksModuleName>} - */ -export const fieldsApi = createApi({ - reducerPath: `fieldsApi`, - baseQuery: fetchBaseQuery({ baseUrl: BASE_URL }), - tagTypes: ["MetadataFields"], - endpoints: (build) => ({ - /* - Get the metadata fields for a specific project. - */ - getMetadataFieldsForProject: build.query({ - query: (projectId) => ({ - url: "", - params: { projectId }, - }), - provides: (result) => [ - ...result.map(({ id }) => ({ type: "MetadataFields", id })), - { type: "MetadataFields", id: "LIST" }, - ], - transformResponse(response) { - return addKeysToList(response, "field", "id"); - }, - }), - updateProjectMetadataFieldRestriction: build.mutation({ - query: ({ projectId, fieldId, projectRole }) => ({ - url: `/restrictions`, - method: `PATCH`, - params: { projectId, fieldId, projectRole }, - }), - invalidatesTags: [{ type: "MetadataFields", id: "LIST" }], - }), - }), -}); - -export const { - useGetMetadataFieldsForProjectQuery, - useUpdateProjectMetadataFieldRestrictionMutation, -} = fieldsApi; - -/** - * Get a list of field restrictions - * @returns {Promise} - */ -export async function getMetadataRestrictions() { - try { - const { data } = await axios.get(`${BASE_URL}/restrictions`); - return data; - } catch (e) { - return Promise.reject(e.response.data.message); - } -} - -/** - * Get a list of metadata fields for the list of projects - * @returns {Promise} - */ -export async function getAllMetadataFieldsForProjects({ projectIds }) { - try { - const { data } = await axios.get( - `${BASE_URL}/projects?projectIds=${projectIds}` - ); - return addKeysToList(data, "field", "id"); - } catch (e) { - return Promise.reject(e.response.data.message); - } -} diff --git a/src/main/webapp/resources/js/apis/metadata/field.ts b/src/main/webapp/resources/js/apis/metadata/field.ts new file mode 100644 index 00000000000..b15c5cd7037 --- /dev/null +++ b/src/main/webapp/resources/js/apis/metadata/field.ts @@ -0,0 +1,133 @@ +/** + * Class responsible for ajax call for project sample metadata fields. + */ +import axios from "axios"; +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; +import { addKeysToList } from "../../utilities/http-utilities"; +import { setBaseUrl } from "../../utilities/url-utilities"; +import { MetadataField } from "../../types/irida"; +import { RestrictionListItem } from "../../utilities/restriction-utilities"; + +const BASE_URL = setBaseUrl(`/ajax/metadata/fields`); + +/** + * Redux API for metadata fields. + */ +export const fieldsApi = createApi({ + reducerPath: `fieldsApi`, + baseQuery: fetchBaseQuery({ baseUrl: BASE_URL }), + tagTypes: ["MetadataFields"], + endpoints: (build) => ({ + /* + Get the metadata fields for a specific project. + */ + getMetadataFieldsForProject: build.query({ + query: (projectId) => ({ + url: "", + params: { projectId }, + }), + providesTags: (result) => [ + ...result.map(({ id }: { id: number }) => ({ + type: "MetadataFields", + id, + })), + { type: "MetadataFields", id: "LIST" }, + ], + transformResponse(response: MetadataField[]) { + return addKeysToList(response, "field", "id"); + }, + }), + updateProjectMetadataFieldRestriction: build.mutation({ + query: ({ projectId, fieldId, projectRole }) => ({ + url: `/restrictions`, + method: `PATCH`, + params: { projectId, fieldId, projectRole }, + }), + invalidatesTags: [{ type: "MetadataFields", id: "LIST" }], + }), + }), +}); + +export const { + useGetMetadataFieldsForProjectQuery, + useUpdateProjectMetadataFieldRestrictionMutation, +} = fieldsApi; + +/** + * Get a list of fields for the project + */ +export async function getMetadataFieldsForProject(projectId: string) { + try { + const { data } = await axios.get( + `${BASE_URL}?projectId=${projectId}` + ); + return data; + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response) { + return Promise.reject(error.response.data.message); + } else { + return Promise.reject(error.message); + } + } else { + return Promise.reject("An unexpected error occurred"); + } + } +} + +/** + * Get a list of field restrictions + */ +export async function getMetadataRestrictions() { + try { + const { data } = await axios.get( + `${BASE_URL}/restrictions` + ); + return data; + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response) { + return Promise.reject(error.response.data.message); + } else { + return Promise.reject(error.message); + } + } else { + return Promise.reject("An unexpected error occurred"); + } + } +} + +/** + * Get a list of metadata fields for the list of projects + */ +export async function getAllMetadataFieldsForProjects(projectIds: string[]) { + try { + const { data } = await axios.get( + `${BASE_URL}/projects?projectIds=${projectIds}` + ); + return addKeysToList(data, "field", "id"); + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response) { + return Promise.reject(error.response.data.message); + } else { + return Promise.reject(error.message); + } + } else { + return Promise.reject("An unexpected error occurred"); + } + } +} + +/* + * Create metadata fields for a specific project + */ +export async function createMetadataFieldsForProject({ + projectId, + body, +}: { + projectId: string; + body: MetadataField[]; +}) { + return await axios.post(`${BASE_URL}?projectId=${projectId}`, body); +} diff --git a/src/main/webapp/resources/js/apis/metadata/metadata-import.js b/src/main/webapp/resources/js/apis/metadata/metadata-import.js deleted file mode 100644 index ecce5537aa9..00000000000 --- a/src/main/webapp/resources/js/apis/metadata/metadata-import.js +++ /dev/null @@ -1,80 +0,0 @@ -import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; -import { setBaseUrl } from "../../utilities/url-utilities"; -import { validateSampleName } from "./sample-utils"; - -const BASE_URL = setBaseUrl(`ajax/projects/sample-metadata/upload`); - -/** - * Redux API for Sample Metadata - */ -export const metadataImportApi = createApi({ - reducerPath: `metadataImportApi`, - baseQuery: fetchBaseQuery({ baseUrl: BASE_URL }), - tagTypes: ["MetadataImport"], - endpoints: (build) => ({ - getProjectSampleMetadata: build.query({ - query: (projectId) => ({ - url: `/getMetadata`, - params: { - projectId, - }, - }), - /** - Transforming the response to include if the row has a valid sample name and a unique row key for rendering the ant design table. - */ - transformResponse(response) { - const transformed = { - ...response, - rows: response.rows.map((row, index) => ({ - ...row, - rowKey: `row-${index}`, - isSampleNameValid: validateSampleName( - row.entry[response.sampleNameColumn] - ), - })), - }; - return transformed; - }, - providesTags: ["MetadataImport"], - }), - clearProjectSampleMetadata: build.mutation({ - query: (projectId) => ({ - url: `/clear`, - method: "DELETE", - params: { - projectId, - }, - }), - invalidatesTags: ["MetadataImport"], - }), - setColumnProjectSampleMetadata: build.mutation({ - query: ({ projectId, sampleNameColumn }) => ({ - url: `/setSampleColumn`, - method: "PUT", - params: { - projectId, - sampleNameColumn, - }, - }), - invalidatesTags: ["MetadataImport"], - }), - saveProjectSampleMetadata: build.mutation({ - query: ({ projectId, sampleNames }) => ({ - url: `/save`, - method: "POST", - params: { - projectId, - sampleNames, - }, - }), - invalidatesTags: ["MetadataImport"], - }), - }), -}); - -export const { - useGetProjectSampleMetadataQuery, - useClearProjectSampleMetadataMutation, - useSetColumnProjectSampleMetadataMutation, - useSaveProjectSampleMetadataMutation, -} = metadataImportApi; diff --git a/src/main/webapp/resources/js/apis/metadata/sample-utils.js b/src/main/webapp/resources/js/apis/metadata/sample-utils.js deleted file mode 100644 index 0e4a7970e6d..00000000000 --- a/src/main/webapp/resources/js/apis/metadata/sample-utils.js +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Utility file for samples - */ - -export const sampleNameRegex = new RegExp("^[A-Za-z0-9-_]{3,}$"); - -/** - * Checks is the sample name is valid - * @param {string} sampleName - * @returns {boolean} - */ -export function validateSampleName(sampleName) { - return sampleNameRegex.test(sampleName); - } \ No newline at end of file diff --git a/src/main/webapp/resources/js/apis/metadata/sample-utils.ts b/src/main/webapp/resources/js/apis/metadata/sample-utils.ts new file mode 100644 index 00000000000..c90e28b2bb8 --- /dev/null +++ b/src/main/webapp/resources/js/apis/metadata/sample-utils.ts @@ -0,0 +1,18 @@ +/* + * Utility file for samples + */ + +export const sampleNameRegex = new RegExp("^[A-Za-z0-9-_]{3,}$"); + +/** + * Checks is the sample name is valid + * @param sampleName the name of the sample + * @returns if the sample name is valid + */ +export function validateSampleName(sampleName: string): boolean { + if (sampleName) { + return sampleNameRegex.test(sampleName); + } else { + return false; + } +} diff --git a/src/main/webapp/resources/js/apis/projects/samples.ts b/src/main/webapp/resources/js/apis/projects/samples.ts index 3f67533d268..535c655abba 100644 --- a/src/main/webapp/resources/js/apis/projects/samples.ts +++ b/src/main/webapp/resources/js/apis/projects/samples.ts @@ -9,12 +9,76 @@ import { } from "../../types/ajax-response"; import { getProjectIdFromUrl, setBaseUrl } from "../../utilities/url-utilities"; import { get, post } from "../requests"; +import axios from "axios"; export interface SequencingFiles { singles: SingleEndSequenceFile[]; pairs: PairedEndSequenceFile[]; } +export interface ValidateSampleNameModel { + ids?: number[]; + name: string; +} + +export interface ValidateSamplesResponse { + samples: ValidateSampleNameModel[]; +} + +export interface LockedSamplesResponse { + sampleIds: number[]; +} + +export interface SamplesResponse { + responses: Record; +} + +export interface SampleItemErrorResponse { + error: boolean; + errorMessage: string; +} + +export interface MetadataItem { + [field: string]: string; + rowKey: string; +} + +export interface FieldUpdate { + field: string; + value: string; +} + +export interface UpdateSampleItem extends CreateSampleItem { + sampleId: number; +} + +export interface CreateSampleItem { + name: string; + organism?: string; + description?: string; + metadata: FieldUpdate[]; +} + +export interface UpdateSamplesRequest { + projectId: string; + body: UpdateSampleItem[]; +} + +export interface CreateSamplesRequest { + projectId: string; + body: CreateSampleItem[]; +} + +export interface ValidateSampleNamesRequest { + samples: ValidateSampleNameModel[]; + associatedProjectIds?: number[]; +} + +export type CreateUpdateSamples = (params: { + projectId: string; + body: Array | Array; +}) => Promise; + const PROJECT_ID = getProjectIdFromUrl(); const URL = setBaseUrl(`/ajax/projects`); @@ -74,6 +138,75 @@ export const { useShareSamplesWithProjectMutation, } = samplesApi; +export async function validateSamples({ + projectId, + body, +}: { + projectId: string; + body: ValidateSampleNamesRequest; +}): Promise { + const response = await axios.post( + `${URL}/${projectId}/samples/validate`, + body + ); + return response.data; +} + +export async function getLockedSamples({ + projectId, +}: { + projectId: string; +}): Promise { + const response = await axios.get(`${URL}/${projectId}/samples/locked`); + return response.data; +} + +export const createSamples: CreateUpdateSamples = async ({ + projectId, + body, +}) => { + try { + const { data } = await axios.post( + `${URL}/${projectId}/samples/create`, + body + ); + return Promise.resolve(data); + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response) { + return Promise.resolve(error.response.data); + } else { + return Promise.reject(error.message); + } + } else { + return Promise.reject("An unexpected error occurred"); + } + } +}; + +export const updateSamples: CreateUpdateSamples = async ({ + projectId, + body, +}) => { + try { + const { data } = await axios.patch( + `${URL}/${projectId}/samples/update`, + body + ); + return Promise.resolve(data); + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response) { + return Promise.resolve(error.response.data); + } else { + return Promise.reject(error.message); + } + } else { + return Promise.reject("An unexpected error occurred"); + } + } +}; + /** * Server side validation of a new sample name. * @param name - sample name to validate @@ -88,25 +221,6 @@ export async function validateSampleName(name: string) { return response.json(); } -/** - * Create a new sample within a project - * @param name - name of the new sample - * @param organism - name of the organism (optional) - * @returns {Promise} - */ -export async function createNewSample({ - name, - organism, -}: { - name: string; - organism: string; -}) { - return post(`${URL}/${PROJECT_ID}/samples/add-sample`, { - name: name.trim(), - organism, - }); -} - /** * Share or move samples with another project. * @param currentId - current projectId diff --git a/src/main/webapp/resources/js/components/Buttons/AddMemberButton.jsx b/src/main/webapp/resources/js/components/Buttons/AddMemberButton.tsx similarity index 82% rename from src/main/webapp/resources/js/components/Buttons/AddMemberButton.jsx rename to src/main/webapp/resources/js/components/Buttons/AddMemberButton.tsx index 2d888be7e44..d38bfffd98e 100644 --- a/src/main/webapp/resources/js/components/Buttons/AddMemberButton.jsx +++ b/src/main/webapp/resources/js/components/Buttons/AddMemberButton.tsx @@ -12,22 +12,36 @@ import { useMetadataRoles } from "../../contexts/metadata-roles-context"; import { useProjectRoles } from "../../contexts/project-roles-context"; import { useDebounce, useResetFormOnCloseModal } from "../../hooks"; import { SPACE_XS } from "../../styles/spacing"; +import { MetadataRoles } from "../samples/components/EditMetadata"; +import { User } from "../../types/irida"; const { Option } = Select; const { Text } = Typography; +export interface AddMemberButtonProps { + label: string; + modalTitle: string; + addMemberFn: (p: { + metadataRole: string; + projectRole: string; + id: number | undefined; + }) => Promise; + getAvailableMembersFn: (debouncedQuery: string) => Promise; + addMemberSuccessFn: () => void; +} + export function AddMemberButton({ label, modalTitle, - addMemberFn = () => {}, - getAvailableMembersFn = () => {}, - addMemberSuccessFn = () => {}, -}) { + addMemberFn, + getAvailableMembersFn, + addMemberSuccessFn, +}: AddMemberButtonProps): JSX.Element { /* Required a reference to the user select input so that focus can be set to it when the window opens. */ - const userRef = useRef(); + const userRef = useRef(null); const { roles: projectRoles } = useProjectRoles(); const { roles: metadataRoles } = useMetadataRoles(); @@ -40,7 +54,7 @@ export function AddMemberButton({ /* The identifier for the currently selected user from the user input */ - const [userId, setUserId] = useState(); + const [userId, setUserId] = useState(); /* The value of the currently selected role from the role input @@ -63,7 +77,7 @@ export function AddMemberButton({ List of users to display to the user to select from. Values returned from server from the debouncedQuery search. */ - const [results, setResults] = useState([]); + const [results, setResults] = useState([]); /* Ant Design form @@ -98,13 +112,13 @@ export function AddMemberButton({ const addMember = () => { addMemberFn({ id: userId, projectRole, metadataRole }) - .then((message) => { + .then((message: string) => { addMemberSuccessFn(); notification.success({ message }); form.resetFields(); setVisible(false); }) - .catch((message) => notification.error({ message })); + .catch((message: string) => notification.error({ message })); }; /* @@ -172,8 +186,12 @@ export function AddMemberButton({ } }} > - {projectRoles.map((role) => ( - + {projectRoles.map((role: MetadataRoles) => ( + {role.label} ))} @@ -188,7 +206,7 @@ export function AddMemberButton({ onChange={(e) => setMetadataRole(e.target.value)} disabled={projectRole === "PROJECT_OWNER"} > - {metadataRoles.map((role) => ( + {metadataRoles.map((role: MetadataRoles) => ( {role.label} diff --git a/src/main/webapp/resources/js/components/alerts/ErrorAlert.jsx b/src/main/webapp/resources/js/components/alerts/ErrorAlert.tsx similarity index 55% rename from src/main/webapp/resources/js/components/alerts/ErrorAlert.jsx rename to src/main/webapp/resources/js/components/alerts/ErrorAlert.tsx index 83671ddacc7..4bd7dee8264 100644 --- a/src/main/webapp/resources/js/components/alerts/ErrorAlert.jsx +++ b/src/main/webapp/resources/js/components/alerts/ErrorAlert.tsx @@ -4,16 +4,18 @@ import React from "react"; import { Alert } from "antd"; +import { AlertProps } from "antd/lib/alert"; /** * Stateless UI component for displaying an [antd error Alert]{@link https://ant.design/components/alert/} * - * @param {string} message - Text to display in alert - * @param {string} description - Optional description - * @param {object} props - any other props that are passed - * @returns {Element} - Returns an antd error 'Alert' component + * @returns {JSX.Element} - Returns an antd error 'Alert' component */ -export function ErrorAlert({ message, description, ...props }) { +export function ErrorAlert({ + message, + description, + ...props +}: AlertProps): JSX.Element { return ( ); -} \ No newline at end of file +} diff --git a/src/main/webapp/resources/js/components/ant.design/forms/BlockRadioInput.jsx b/src/main/webapp/resources/js/components/ant.design/forms/BlockRadioInput.tsx similarity index 76% rename from src/main/webapp/resources/js/components/ant.design/forms/BlockRadioInput.jsx rename to src/main/webapp/resources/js/components/ant.design/forms/BlockRadioInput.tsx index f863c9f4bcf..baac02d1443 100644 --- a/src/main/webapp/resources/js/components/ant.design/forms/BlockRadioInput.jsx +++ b/src/main/webapp/resources/js/components/ant.design/forms/BlockRadioInput.tsx @@ -19,6 +19,11 @@ const RadioItem = styled.button` } `; +interface BlockRadioInputProps + extends React.ButtonHTMLAttributes { + children?: React.ReactNode; +} + /** * React component to Render a Ant Design Radio button in block. * @@ -27,6 +32,9 @@ const RadioItem = styled.button` * @returns {JSX.Element} * @constructor */ -export function BlockRadioInput({ children, ...props }) { +export function BlockRadioInput({ + children, + ...props +}: BlockRadioInputProps): JSX.Element { return {children}; } diff --git a/src/main/webapp/resources/js/components/files/DragUpload.tsx b/src/main/webapp/resources/js/components/files/DragUpload.tsx index 69105cf4ff4..b84797c5915 100644 --- a/src/main/webapp/resources/js/components/files/DragUpload.tsx +++ b/src/main/webapp/resources/js/components/files/DragUpload.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Upload } from "antd"; import { IconFileUpload } from "../icons/Icons"; import { SPACE_SM, SPACE_XS } from "../../styles/spacing"; -import { FileUpload } from "../../apis/samples/samples"; +import { DraggerProps } from "antd/lib/upload"; const { Dragger } = Upload; @@ -10,26 +10,14 @@ export interface Dictionary { [Key: string]: T; } -export interface UploadOptions { - multiple: boolean; - showUploadList: boolean | Dictionary; - accept: string; - progress?: Dictionary; - beforeUpload?: (file: FileUpload, fileList: FileUpload[]) => boolean; - customRequest?: () => void; - action?: string; - onChange?: () => void; -} - export interface DragUploadProps { - uploadText: string; - uploadHint: string; - options: UploadOptions; - props: Dictionary; + uploadText: string | React.ReactElement; + uploadHint: string | React.ReactElement; + options: DraggerProps; + props?: Dictionary; } /** * React component for rendering the drag and drop upload functionality. - * @param {object} - upload options as well as text/hint for drag and drop * @returns {*} * @constructor */ diff --git a/src/main/webapp/resources/js/components/metadata/TargetMetadataRestriction.jsx b/src/main/webapp/resources/js/components/metadata/TargetMetadataRestriction.tsx similarity index 64% rename from src/main/webapp/resources/js/components/metadata/TargetMetadataRestriction.jsx rename to src/main/webapp/resources/js/components/metadata/TargetMetadataRestriction.tsx index c5e54efa9a9..1da0015b329 100644 --- a/src/main/webapp/resources/js/components/metadata/TargetMetadataRestriction.jsx +++ b/src/main/webapp/resources/js/components/metadata/TargetMetadataRestriction.tsx @@ -1,37 +1,55 @@ -import { Form, Popover, Select, Space, Tag, Tooltip, Typography } from "antd"; +import { Form, Popover, Radio, Space, Tag, Tooltip, Typography } from "antd"; import React from "react"; import { useDispatch } from "react-redux"; import { IconInfoCircle, IconWarningOutlined } from "../icons/Icons"; import { blue6, red6 } from "../../styles/colors"; -import { getColourForRestriction } from "../../utilities/restriction-utilities"; +import { + getColourForRestriction, + getRestrictionLabel, + Restriction, + RestrictionListItem, +} from "../../utilities/restriction-utilities"; import { updateNewProjectMetadataRestriction } from "../../pages/projects/create/newProjectSlice"; import { updateMetadataRestriction } from "../../pages/projects/share/shareSlice"; +import { FormItemProps } from "antd/lib/form/FormItem"; + +export interface Field { + difference: number; + target: string; + restriction: Restriction; +} + +interface TargetMetadataRestrictionProps { + field: Field; + restrictions: RestrictionListItem[]; + newProject: boolean; +} /** * React component to allow the user to select the level of restiction for a * metadata field in the destination project. * - * @param {object} field - field to get the value on + * @param field - field to get the value on * project, if it is not in the target project it get the current restriction. - * @param {array} restrictions - list of available restrictions - * @param {function} onChange - change handler - * @param {boolean} newProject - if a new project is being created or not + * @param restrictions - list of available restrictions + * @param onChange - change handler + * @param newProject - if a new project is being created or not * @returns {JSX.Element} * @constructor */ export function TargetMetadataRestriction({ - field = {}, + field, restrictions = [], newProject = false, -}) { +}: TargetMetadataRestrictionProps): JSX.Element { const dispatch = useDispatch(); - const [feedback, setFeedback] = React.useState({ + const [feedback, setFeedback] = React.useState({ hasFeedback: false, validateStatus: "", }); - const [tooltipVisible, setTooltipVisible] = React.useState(false); + const [tooltipVisible, setTooltipVisible] = React.useState(false); React.useEffect(() => { /* @@ -47,14 +65,7 @@ export function TargetMetadataRestriction({ } }, [field.difference]); - function getRestrictionLabel(value) { - const restriction = restrictions?.find( - (restriction) => restriction.value === value - ); - return restriction?.label; - } - - const onChange = (field, value) => { + const onChange = (field: Field, value: string) => { if (newProject) { dispatch(updateNewProjectMetadataRestriction({ field, value })); } else { @@ -67,7 +78,7 @@ export function TargetMetadataRestriction({ return ( - {getRestrictionLabel(field.restriction)} + {getRestrictionLabel(restrictions, field.restriction)} - {getRestrictionLabel(field.restriction)} + {getRestrictionLabel(restrictions, field.restriction)} - + ); diff --git a/src/main/webapp/resources/js/components/project-members/ProjectMembersTable.jsx b/src/main/webapp/resources/js/components/project-members/ProjectMembersTable.jsx index 115ce5eeeee..798337f0a71 100644 --- a/src/main/webapp/resources/js/components/project-members/ProjectMembersTable.jsx +++ b/src/main/webapp/resources/js/components/project-members/ProjectMembersTable.jsx @@ -16,6 +16,7 @@ import { setBaseUrl } from "../../utilities/url-utilities"; import { PagedTable, PagedTableContext } from "../ant.design/PagedTable"; import { AddMemberButton, RemoveTableItemButton } from "../Buttons"; import { RoleSelect } from "../roles/RoleSelect"; +import { stringSorter } from "../../utilities/table-utilities"; /** * React component to display a table of project users. @@ -29,10 +30,8 @@ export function ProjectMembersTable({ projectId }) { const { identifier: userId } = useSelector((state) => state.user); const { roles: projectRoles, getRoleFromKey } = useProjectRoles(); - const { - roles: metadataRoles, - getRoleFromKey: getMetadataRoleFromKey, - } = useMetadataRoles(); + const { roles: metadataRoles, getRoleFromKey: getMetadataRoleFromKey } = + useMetadataRoles(); React.useEffect(() => { dispatch(getCurrentUserDetails()); @@ -48,25 +47,29 @@ export function ProjectMembersTable({ projectId }) { updateTable(); } - const updateProjectRole = ({ id }) => (projectRole) => { - return updateUserRoleOnProject({ projectId, id, projectRole }).then( - (message) => { + const updateProjectRole = + ({ id }) => + (projectRole) => { + return updateUserRoleOnProject({ projectId, id, projectRole }).then( + (message) => { + updateTable(); + return message; + } + ); + }; + + const updateMetadataRole = + ({ id }) => + (metadataRole) => { + return updateUserMetadataRoleOnProject({ + projectId, + id, + metadataRole, + }).then((message) => { updateTable(); return message; - } - ); - }; - - const updateMetadataRole = ({ id }) => (metadataRole) => { - return updateUserMetadataRoleOnProject({ - projectId, - id, - metadataRole, - }).then((message) => { - updateTable(); - return message; - }); - }; + }); + }; const columns = [ { @@ -75,6 +78,8 @@ export function ProjectMembersTable({ projectId }) { render(text, item) { return {text}; }, + sorter: stringSorter("name"), + defaultSortOrder: "ascend", }, { title: i18n("ProjectMembersTable.projectRole"), diff --git a/src/main/webapp/resources/js/components/samples/components/EditMetadata.tsx b/src/main/webapp/resources/js/components/samples/components/EditMetadata.tsx index 96775605071..446b9ad3090 100644 --- a/src/main/webapp/resources/js/components/samples/components/EditMetadata.tsx +++ b/src/main/webapp/resources/js/components/samples/components/EditMetadata.tsx @@ -1,12 +1,4 @@ -import { - Form, - Input, - Modal, - notification, - Popover, - Select, - Typography, -} from "antd"; +import { Form, Input, Modal, notification, Radio, Typography } from "antd"; import React from "react"; import { useAppDispatch, useAppSelector } from "../../../hooks/useState"; import { useUpdateSampleMetadataMutation } from "../../../apis/samples/samples"; @@ -135,17 +127,19 @@ export function EditMetadata() { label={i18n("SampleMetadata.modal.restriction")} initialValue={restriction ? restriction : "LEVEL_1"} > - + diff --git a/src/main/webapp/resources/js/pages/UserGroupsPage/components/UserGroupMembersTable.jsx b/src/main/webapp/resources/js/pages/UserGroupsPage/components/UserGroupMembersTable.jsx index 5dc9c6f82ff..4d4a85a4e29 100644 --- a/src/main/webapp/resources/js/pages/UserGroupsPage/components/UserGroupMembersTable.jsx +++ b/src/main/webapp/resources/js/pages/UserGroupsPage/components/UserGroupMembersTable.jsx @@ -9,17 +9,11 @@ import { import { RemoveTableItemButton } from "../../../components/Buttons"; import { GroupRole } from "../../../components/roles/GroupRole"; import { SPACE_XS } from "../../../styles/spacing"; -import { - formatInternationalizedDateTime -} from "../../../utilities/date-utilities"; +import { formatInternationalizedDateTime } from "../../../utilities/date-utilities"; import { stringSorter } from "../../../utilities/table-utilities"; import { setBaseUrl } from "../../../utilities/url-utilities"; -import { - AddUserToGroupButton -} from "../../admin/components/user-groups/AddUserToGroupButton"; -import { - getPaginationOptions -} from "../../../utilities/antdesign-table-utilities"; +import { AddUserToGroupButton } from "../../admin/components/user-groups/AddUserToGroupButton"; +import { getPaginationOptions } from "../../../utilities/antdesign-table-utilities"; /** * Custom sorter for the name column since this is NOT paged server side. @@ -56,6 +50,7 @@ export default function UserGroupMembersTable({ ); }, + defaultSortOrder: "ascend", }, { title: i18n("UserGroupMembersTable.role"), diff --git a/src/main/webapp/resources/js/pages/projects/create/CreateProjectLayout.jsx b/src/main/webapp/resources/js/pages/projects/create/CreateProjectLayout.jsx index 32d7500000f..fcf2f95d75e 100644 --- a/src/main/webapp/resources/js/pages/projects/create/CreateProjectLayout.jsx +++ b/src/main/webapp/resources/js/pages/projects/create/CreateProjectLayout.jsx @@ -40,16 +40,6 @@ export function CreateProjectLayout({ children }) { }, ]; - const validateMessages = { - required: i18n("CreateProjectDetails.required"), - string: { - min: i18n("CreateProjectDetails.length"), - }, - types: { - url: i18n("CreateProjectDetails.url"), - }, - }; - /** * Once the form is filled out, this is the submit to server call. * After a successful call, the user will be redirected to the new project. @@ -138,7 +128,7 @@ export function CreateProjectLayout({ children }) { } onCancel={onCancel} - width={720} + width={900} title={i18n("CreateProject.title")} > diff --git a/src/main/webapp/resources/js/pages/projects/create/CreateProjectMetadataRestrictions.jsx b/src/main/webapp/resources/js/pages/projects/create/CreateProjectMetadataRestrictions.jsx index 8cea0aa453e..64742662be8 100644 --- a/src/main/webapp/resources/js/pages/projects/create/CreateProjectMetadataRestrictions.jsx +++ b/src/main/webapp/resources/js/pages/projects/create/CreateProjectMetadataRestrictions.jsx @@ -14,11 +14,10 @@ import { setNewProjectMetadataRestrictions } from "./newProjectSlice"; /** * Component to render metadata restrictions for samples that are in the cart (if any). * User can update the new project restrictions as required - * @param {Object} form - Ant Design form API * @returns {JSX.Element} * @constructor */ -export function CreateProjectMetadataRestrictions({ form }) { +export function CreateProjectMetadataRestrictions() { const dispatch = useDispatch(); /** @@ -39,7 +38,7 @@ export function CreateProjectMetadataRestrictions({ form }) { React.useEffect(() => { if (samples?.length) { let projectIds = samples.map((s) => s.projectId); - getAllMetadataFieldsForProjects({ projectIds }).then((data) => { + getAllMetadataFieldsForProjects(projectIds).then((data) => { setSourceFields(data); dispatch( setNewProjectMetadataRestrictions( @@ -55,7 +54,7 @@ export function CreateProjectMetadataRestrictions({ form }) { getMetadataRestrictions().then((data) => { setRestrictions(data); }); - }, [samples]); + }, [dispatch, samples]); const columns = [ { @@ -110,6 +109,7 @@ export function CreateProjectMetadataRestrictions({ form }) { dataSource={metadataRestrictions} scroll={{ y: 300 }} pagination={false} + tableLayout="auto" /> ) : ( - - {i18n("SampleMetadataImportComplete.button.upload")} - - } - /> - - ); -} diff --git a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportComplete.tsx b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportComplete.tsx new file mode 100644 index 00000000000..5e8f3c7166d --- /dev/null +++ b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportComplete.tsx @@ -0,0 +1,97 @@ +import React, { useMemo } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { Button, Result } from "antd"; +import { SampleMetadataImportWizard } from "./SampleMetadataImportWizard"; +import { MetadataItem } from "../../../../apis/projects/samples"; +import { ImportState, useImportSelector } from "../redux/store"; +import { NavigateFunction } from "react-router/dist/lib/hooks"; + +/** + * React component that displays Step #4 of the Sample Metadata Uploader. + * This page is where the user receives confirmation that the metadata was uploaded successfully. + * @returns {*} + * @constructor + */ +export function SampleMetadataImportComplete(): JSX.Element { + const { + sampleNameColumn, + metadata, + metadataValidateDetails, + metadataSaveDetails, + } = useImportSelector((state: ImportState) => state.importReducer); + + const filteredSamples = React.useCallback( + (metadataItem: MetadataItem, isSampleFound: boolean) => { + return ( + metadataSaveDetails[metadataItem[sampleNameColumn]]?.saved === true && + !metadataValidateDetails[metadataItem[sampleNameColumn]].locked && + (isSampleFound + ? metadataValidateDetails[metadataItem[sampleNameColumn]] + .foundSampleId + : !metadataValidateDetails[metadataItem[sampleNameColumn]] + .foundSampleId) + ); + }, + [metadataSaveDetails, metadataValidateDetails, sampleNameColumn] + ); + + const samplesUpdatedCount = useMemo( + () => + metadata.filter((metadataItem: MetadataItem) => + filteredSamples(metadataItem, true) + ).length, + [filteredSamples, metadata] + ); + + const samplesCreatedCount = useMemo( + () => + metadata.filter((metadataItem: MetadataItem) => + filteredSamples(metadataItem, false) + ).length, + [filteredSamples, metadata] + ); + + let stats = + samplesUpdatedCount == 1 + ? i18n( + "server.metadataimport.results.save.success.single-updated", + samplesUpdatedCount + ) + : i18n( + "server.metadataimport.results.save.success.multiple-updated", + samplesUpdatedCount + ); + stats += + samplesCreatedCount == 1 + ? i18n( + "server.metadataimport.results.save.success.single-created", + samplesCreatedCount + ) + : i18n( + "server.metadataimport.results.save.success.multiple-created", + samplesCreatedCount + ); + + const { projectId } = useParams<{ projectId: string }>(); + const navigate: NavigateFunction = useNavigate(); + + return ( + + + navigate(`/${projectId}/sample-metadata/upload/file`) + } + > + {i18n("SampleMetadataImportComplete.button.upload")} + + } + /> + + ); +} diff --git a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportMapColumns.tsx b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportMapColumns.tsx new file mode 100644 index 00000000000..32d04dde8ec --- /dev/null +++ b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportMapColumns.tsx @@ -0,0 +1,209 @@ +import React, { useMemo } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Button, + Empty, + Radio, + Select, + Space, + Table, + Tag, + Typography, +} from "antd"; +import { SampleMetadataImportWizard } from "./SampleMetadataImportWizard"; +import { + IconArrowLeft, + IconArrowRight, +} from "../../../../components/icons/Icons"; +import { + MetadataHeaderItem, + setSampleNameColumn, + updateHeaders, +} from "../redux/importReducer"; +import { NavigateFunction } from "react-router/dist/lib/hooks"; +import { + ImportDispatch, + ImportState, + useImportDispatch, + useImportSelector, +} from "../redux/store"; +import { getMetadataRestrictions } from "../../../../apis/metadata/field"; +import { + getColourForRestriction, + getRestrictionLabel, + Restriction, + RestrictionListItem, +} from "../../../../utilities/restriction-utilities"; + +/** + * React component that displays Step #2 of the Sample Metadata Uploader. + * This page is where the user selects the sample name column. + * @returns {*} + * @constructor + */ +export function SampleMetadataImportMapColumns(): JSX.Element { + const { projectId } = useParams<{ projectId: string }>(); + const navigate: NavigateFunction = useNavigate(); + const [loading, setLoading] = React.useState(false); + const [restrictions, setRestrictions] = React.useState( + [] + ); + const { headers, sampleNameColumn } = useImportSelector( + (state: ImportState) => state.importReducer + ); + const [updatedSampleNameColumn, setUpdatedSampleNameColumn] = + React.useState(sampleNameColumn); + const dispatch: ImportDispatch = useImportDispatch(); + + const updatedHeaders = useMemo(() => [...headers], [headers]); + + React.useEffect(() => { + getMetadataRestrictions().then((data) => { + setRestrictions(data); + }); + }, []); + + const onSubmit = async () => { + if (projectId && updatedSampleNameColumn) { + setLoading(true); + await dispatch( + setSampleNameColumn({ projectId, updatedSampleNameColumn }) + ); + await dispatch(updateHeaders(updatedHeaders)); + navigate(`/${projectId}/sample-metadata/upload/review`); + } + }; + + const onSampleNameColumnChange = (value: string) => { + setUpdatedSampleNameColumn(value); + }; + + const onRestrictionChange = ( + item: MetadataHeaderItem, + value: Restriction + ) => { + const index = updatedHeaders.findIndex( + (header) => header.rowKey === item.rowKey + ); + if (index !== -1) { + const updatedHeadersItem = { ...updatedHeaders[index] }; + updatedHeadersItem.targetRestriction = value; + updatedHeaders[index] = updatedHeadersItem; + } + }; + + const dataSource = useMemo( + () => + updatedSampleNameColumn + ? updatedHeaders.filter( + (updatedHeader) => updatedHeader.name !== updatedSampleNameColumn + ) + : undefined, + [updatedSampleNameColumn, updatedHeaders] + ); + + const columns = [ + { + title: i18n("SampleMetadataImportMapColumns.table.header"), + dataIndex: "name", + }, + { + title: i18n("SampleMetadataImportMapColumns.table.existingRestriction"), + dataIndex: "existingRestriction", + render(id: number, item: MetadataHeaderItem) { + if (item.existingRestriction) { + return ( + + {getRestrictionLabel(restrictions, item.existingRestriction)} + + ); + } else { + return undefined; + } + }, + }, + { + title: i18n("SampleMetadataImportMapColumns.table.targetRestriction"), + dataIndex: "restriction", + render(id: number, item: MetadataHeaderItem) { + return ( + + onRestrictionChange({ ...item }, value) + } + optionType="button" + /> + ); + }, + }, + ]; + + return ( + + + + + {i18n("SampleMetadataImportMapColumns.form.sampleNameColumn")} + + + + + + {i18n("SampleMetadataImportMapColumns.form.metadataColumns")} + + row.rowKey} + columns={columns} + dataSource={dataSource} + pagination={false} + scroll={{ y: 600 }} + locale={{ + emptyText: ( + + ), + }} + /> + + +
+ + +
+ + ); +} diff --git a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportMapHeaders.jsx b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportMapHeaders.jsx deleted file mode 100644 index c5dba445c76..00000000000 --- a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportMapHeaders.jsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from "react"; -import { useSelector } from "react-redux"; -import { useNavigate, useParams } from "react-router-dom"; -import { Button, Radio, Typography } from "antd"; -import { SampleMetadataImportWizard } from "./SampleMetadataImportWizard"; -import { BlockRadioInput } from "../../../../components/ant.design/forms/BlockRadioInput"; -import { useSetColumnProjectSampleMetadataMutation } from "../../../../apis/metadata/metadata-import"; -import { - IconArrowLeft, - IconArrowRight, -} from "../../../../components/icons/Icons"; - -const { Text } = Typography; - -/** - * React component that displays Step #2 of the Sample Metadata Uploader. - * This page is where the user selects the sample name column. - * @returns {*} - * @constructor - */ -export function SampleMetadataImportMapHeaders() { - const { projectId } = useParams(); - const navigate = useNavigate(); - const [column, setColumn] = React.useState(); - const { headers, sampleNameColumn } = useSelector((state) => state.reducer); - const [updateColumn] = useSetColumnProjectSampleMetadataMutation(); - - React.useEffect(() => { - setColumn(sampleNameColumn ? sampleNameColumn : headers[0]); - }, []); - - const onSubmit = () => { - updateColumn({ projectId, sampleNameColumn: column }) - .unwrap() - .then((payload) => { - navigate(`/${projectId}/sample-metadata/upload/review`); - }); - }; - - return ( - - {i18n("SampleMetadataImportMapHeaders.description")} - setColumn(e.target.value)} - > - {headers.map((header, index) => ( - - - {header} - - - ))} - -
- - -
-
- ); -} diff --git a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportReview.jsx b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportReview.jsx deleted file mode 100644 index 5a32dad168c..00000000000 --- a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportReview.jsx +++ /dev/null @@ -1,232 +0,0 @@ -import React from "react"; -import { useNavigate, useParams } from "react-router-dom"; -import { Alert, Button, Table, Tag, Tooltip, Typography } from "antd"; -import { SampleMetadataImportWizard } from "./SampleMetadataImportWizard"; -import { - useGetProjectSampleMetadataQuery, - useSaveProjectSampleMetadataMutation, -} from "../../../../apis/metadata/metadata-import"; -import { - IconArrowLeft, - IconArrowRight, - IconExclamationCircle, -} from "../../../../components/icons/Icons"; -import { red1, red2, red5 } from "../../../../styles/colors"; -import styled from "styled-components"; - -const { Paragraph, Text } = Typography; - -const MetadataTable = styled(Table)` - tr.row-error > td { - background-color: ${red1}; - } - tr.row-error:hover > td { - background-color: ${red2}; - } - tr.row-error > td.ant-table-cell-fix-left { - background-color: ${red1}; - } - tr.row-error:hover > td.ant-table-cell-fix-left { - background-color: ${red2}; - } - tr.row-error > td.ant-table-cell-fix-right { - background-color: ${red1}; - } - tr.row-error:hover > td.ant-table-cell-fix-right { - background-color: ${red2}; - } -`; - -/** - * React component that displays Step #3 of the Sample Metadata Uploader. - * This page is where the user reviews the metadata to be uploaded. - * @returns {*} - * @constructor - */ -export function SampleMetadataImportReview() { - const { projectId } = useParams(); - const navigate = useNavigate(); - const [columns, setColumns] = React.useState([]); - const [selected, setSelected] = React.useState([]); - const [valid, setValid] = React.useState(true); - const { data = {}, isFetching, isSuccess } = useGetProjectSampleMetadataQuery( - projectId - ); - const [saveMetadata] = useSaveProjectSampleMetadataMutation(); - - const tagColumn = { - title: "", - dataIndex: "tags", - className: "t-metadata-uploader-new-column", - fixed: "left", - width: 70, - render: (text, item) => { - if (!item.foundSampleId) - return ( - - {i18n("SampleMetadataImportReview.table.filter.new")} - - ); - }, - filters: [ - { - text: i18n("SampleMetadataImportReview.table.filter.new"), - value: "new", - }, - { - text: i18n("SampleMetadataImportReview.table.filter.existing"), - value: "existing", - }, - ], - onFilter: (value, record) => - value === "new" ? !record.foundSampleId : record.foundSampleId, - }; - - const rowSelection = { - fixed: true, - selectedRowKeys: selected, - onChange: (selectedRowKeys) => { - setSelected(selectedRowKeys); - }, - getCheckboxProps: (record) => ({ - disabled: !( - record.isSampleNameValid && - (record.saved === null || record.saved === true) - ), - }), - }; - - React.useEffect(() => { - if (isSuccess) { - setValid(!data.rows.some((row) => row.isSampleNameValid === false)); - - const index = data.headers.findIndex( - (item) => item === data.sampleNameColumn - ); - - const headers = [...data.headers]; - - const sample = headers.splice(index, 1)[0]; - - const sampleColumn = { - title: sample, - dataIndex: sample, - fixed: "left", - width: 100, - render(text, item) { - return { - props: { - style: { background: item.isSampleNameValid ? null : red1 }, - }, - children: item.entry[sample], - }; - }, - }; - - const savedColumn = { - dataIndex: "saved", - fixed: "left", - width: 10, - render: (text, item) => { - if (item.saved === false) - return ( - - - - ); - }, - }; - - const otherColumns = headers.map((header) => ({ - title: header, - dataIndex: header, - render: (text, item) => item.entry[header], - })); - - const updatedColumns = [ - savedColumn, - sampleColumn, - tagColumn, - ...otherColumns, - ]; - - setColumns(updatedColumns); - setSelected( - data.rows.map((row) => { - if ( - row.isSampleNameValid && - (row.saved === null || row.saved === true) - ) - return row.rowKey; - }) - ); - } - }, [data, isSuccess]); - - const save = () => { - const sampleNames = data.rows - .filter((row) => selected.includes(row.rowKey)) - .map((row) => row.entry[data.sampleNameColumn]); - saveMetadata({ projectId, sampleNames }) - .unwrap() - .then((payload) => { - navigate(`/${projectId}/sample-metadata/upload/complete`, { - state: { statusMessage: payload.message }, - }); - }); - }; - - return ( - - {i18n("SampleMetadataImportReview.description")} - {!valid && ( - - {i18n("SampleMetadataImportReview.alert.description")} -
    -
  • {i18n("SampleMetadataImportReview.alert.rule1")}
  • -
  • {i18n("SampleMetadataImportReview.alert.rule2")}
  • -
  • {i18n("SampleMetadataImportReview.alert.rule3")}
  • -
- - } - type="error" - showIcon - /> - )} - row.rowKey} - loading={isFetching} - rowClassName={(record, index) => - record.saved === false ? "row-error" : null - } - rowSelection={rowSelection} - columns={columns} - dataSource={data.rows} - scroll={{ x: "max-content", y: 600 }} - pagination={false} - /> - -
- - -
-
- ); -} diff --git a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportReview.tsx b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportReview.tsx new file mode 100644 index 00000000000..1d253d0cda4 --- /dev/null +++ b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportReview.tsx @@ -0,0 +1,349 @@ +import React from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Alert, + Button, + List, + notification, + Popover, + Progress, + Table, + TableProps, + Tag, + Tooltip, + Typography, +} from "antd"; +import { SampleMetadataImportWizard } from "./SampleMetadataImportWizard"; +import { + IconArrowLeft, + IconArrowRight, + IconExclamationCircle, +} from "../../../../components/icons/Icons"; +import styled from "styled-components"; +import { saveMetadata } from "../redux/importReducer"; +import { getPaginationOptions } from "../../../../utilities/antdesign-table-utilities"; +import { NavigateFunction } from "react-router/dist/lib/hooks"; +import { + ImportDispatch, + ImportState, + useImportDispatch, + useImportSelector, +} from "../redux/store"; +import { MetadataItem } from "../../../../apis/projects/samples"; +import { ColumnsType, ColumnType } from "antd/es/table"; +import { TableRowSelection } from "antd/lib/table/interface"; +import { ErrorAlert } from "../../../../../js/components/alerts/ErrorAlert"; + +const { Paragraph, Text } = Typography; + +const MetadataTable = styled(Table)` + tr.row-error > td { + background-color: var(--red-1); + } + tr.row-error:hover > td { + background-color: var(--red-2); + } + tr.row-error > td.ant-table-cell-fix-left { + background-color: var(--red-1); + } + tr.row-error:hover > td.ant-table-cell-fix-left { + background-color: var(--red-2); + } + tr.row-error > td.ant-table-cell-fix-right { + background-color: var(--red-1); + } + tr.row-error:hover > td.ant-table-cell-fix-right { + background-color: var(--red-2); + } +`; + +/** + * React component that displays Step #3 of the Sample Metadata Uploader. + * This page is where the user reviews the metadata to be uploaded. + * @returns {*} + * @constructor + */ +export function SampleMetadataImportReview(): JSX.Element { + const { projectId } = useParams<{ projectId: string }>(); + const navigate: NavigateFunction = useNavigate(); + const [columns, setColumns] = React.useState>([]); + const [selected, setSelected] = React.useState([]); + const [progress, setProgress] = React.useState(0); + const [loading, setLoading] = React.useState(false); + const { + headers, + sampleNameColumn, + metadata, + metadataValidateDetails, + metadataSaveDetails, + percentComplete, + } = useImportSelector((state: ImportState) => state.importReducer); + const dispatch: ImportDispatch = useImportDispatch(); + + const rowSelection: TableRowSelection = { + fixed: true, + selectedRowKeys: selected, + onChange: (selectedRowKeys) => { + setSelected(selectedRowKeys); + }, + getCheckboxProps: (record: MetadataItem) => ({ + disabled: !( + metadataValidateDetails[record[sampleNameColumn]].isSampleNameValid || + metadataSaveDetails[record[sampleNameColumn]]?.saved === true + ), + }), + }; + + React.useEffect(() => { + setProgress(percentComplete); + }, [percentComplete]); + + React.useEffect(() => { + const sampleColumn: ColumnType = { + title: sampleNameColumn, + dataIndex: sampleNameColumn, + fixed: "left", + width: 100, + onCell: (item) => { + return { + style: { + background: metadataValidateDetails[item[sampleNameColumn]] + .isSampleNameValid + ? undefined + : `var(--red-1)`, + }, + }; + }, + }; + + const savedColumn: ColumnType = { + dataIndex: "saved", + fixed: "left", + width: 10, + render: (text, item) => { + if (metadataSaveDetails[item[sampleNameColumn]]?.saved === false) + return ( + + + + ); + return text; + }, + }; + + const tagColumn: ColumnType = { + title: "", + dataIndex: "tags", + className: "t-metadata-uploader-new-column", + fixed: "left", + width: 70, + render: (text, item) => { + if (!metadataValidateDetails[item[sampleNameColumn]].foundSampleId) + return ( + + {i18n("SampleMetadataImportReview.table.filter.new")} + + ); + return text; + }, + filters: [ + { + text: i18n("SampleMetadataImportReview.table.filter.new"), + value: "new", + }, + { + text: i18n("SampleMetadataImportReview.table.filter.existing"), + value: "existing", + }, + ], + onFilter: (value, record) => + value === "new" + ? metadataValidateDetails[record[sampleNameColumn]].foundSampleId !== + undefined + : metadataValidateDetails[record[sampleNameColumn]].foundSampleId === + undefined, + }; + + const otherColumns: ColumnsType = headers + .filter((header) => header.name !== sampleNameColumn) + .map((header) => ({ + title: header.name, + dataIndex: header.name, + })); + + const updatedColumns: ColumnsType = [ + savedColumn, + sampleColumn, + tagColumn, + ...otherColumns, + ]; + + setColumns(updatedColumns); + setSelected( + metadata + .filter( + (row) => + metadataValidateDetails[row[sampleNameColumn]].isSampleNameValid || + metadataSaveDetails[row[sampleNameColumn]]?.saved === true + ) + .map((row): string => row.rowKey) + ); + }, [ + headers, + metadata, + metadataSaveDetails, + metadataValidateDetails, + sampleNameColumn, + ]); + + const save = async () => { + setLoading(true); + const selectedMetadataKeys = metadata + .filter((metadataItem) => selected.includes(metadataItem.rowKey)) + .map((metadataItem) => metadataItem.rowKey); + + if (projectId) { + await dispatch(saveMetadata({ projectId, selectedMetadataKeys })) + .unwrap() + .then(({ metadataSaveDetails }) => { + const errorCount = Object.entries(metadataSaveDetails).filter( + ([, metadataSaveDetailsItem]) => metadataSaveDetailsItem.error + ).length; + if (errorCount === 0) { + navigate(`/${projectId}/sample-metadata/upload/complete`); + } else { + setLoading(false); + notification.error({ + message: i18n( + "SampleMetadataImportReview.notification.partialError", + errorCount + ), + }); + } + }) + .catch((payload) => { + setLoading(false); + notification.error({ + message: payload, + className: "t-metadata-uploader-review-error", + }); + }); + } + }; + + const isValid = !metadata.some( + (row) => !metadataValidateDetails[row[sampleNameColumn]].isSampleNameValid + ); + + const lockedSampleMetadata = metadata.filter( + (metadataItem) => + metadataValidateDetails[metadataItem[sampleNameColumn]].locked + ); + + return ( + + {i18n("SampleMetadataImportReview.description")} + {!isValid && ( + + {i18n("SampleMetadataImportReview.alert.valid.description")} +
    +
  • {i18n("SampleMetadataImportReview.alert.valid.rule1")}
  • +
  • {i18n("SampleMetadataImportReview.alert.valid.rule2")}
  • +
  • {i18n("SampleMetadataImportReview.alert.valid.rule3")}
  • +
+ + } + /> + )} + {lockedSampleMetadata.length > 0 && ( + + + ( + {metadataItem[sampleNameColumn]} + )} + /> + + } + > + + {i18n( + "SampleMetadataImportReview.alert.locked.description.popover.content", + lockedSampleMetadata.length + )} + + + {i18n("SampleMetadataImportReview.alert.locked.description")} + + } + type="warning" + showIcon + /> + )} + ) => JSX.Element> + className="t-metadata-uploader-review-table" + rowKey={(row) => row.rowKey} + rowClassName={(record) => + metadataSaveDetails[record[sampleNameColumn]]?.saved === false + ? "row-error" + : "" + } + rowSelection={rowSelection} + columns={columns} + dataSource={metadata.filter( + (metadataItem) => + !metadataValidateDetails[metadataItem[sampleNameColumn]].locked + )} + pagination={getPaginationOptions(metadata.length)} + loading={{ + indicator: , + spinning: loading, + tip: ( + + {i18n("SampleMetadataImportReview.loading")} + + ), + }} + /> +
+ + +
+
+ ); +} diff --git a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportSelectFile.tsx b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportSelectFile.tsx new file mode 100644 index 00000000000..7bddb9b3a00 --- /dev/null +++ b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportSelectFile.tsx @@ -0,0 +1,181 @@ +import React from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { notification, Spin, StepsProps, Typography } from "antd"; +import { DragUpload } from "../../../../components/files/DragUpload"; +import { SampleMetadataImportWizard } from "./SampleMetadataImportWizard"; +import { ImportDispatch, useImportDispatch } from "../redux/store"; +import { NavigateFunction } from "react-router/dist/lib/hooks"; +import { RcFile } from "antd/lib/upload/interface"; +import { setHeaders, setMetadata, setProjectId } from "../redux/importReducer"; +import * as XLSX from "xlsx"; +import { ErrorAlert } from "../../../../components/alerts/ErrorAlert"; +import { SPACE_XS } from "../../../../styles/spacing"; +import { MetadataItem } from "../../../../apis/projects/samples"; + +const { Text } = Typography; + +/** + * React component that displays Step #1 of the Sample Metadata Uploader. + * This page is where the user selects the file to be uploaded. + * @returns {*} + * @constructor + */ +export function SampleMetadataImportSelectFile(): JSX.Element { + const { projectId } = useParams<{ projectId: string }>(); + const navigate: NavigateFunction = useNavigate(); + const dispatch: ImportDispatch = useImportDispatch(); + const [status, setStatus] = React.useState("process"); + const [loading, setLoading] = React.useState(false); + const [validationErrors, setValidationErrors] = React.useState([]); + + React.useEffect(() => { + if (projectId != null) { + dispatch(setProjectId(projectId)); + } + }, [dispatch, projectId]); + + const readFileContents = (file: Blob) => { + const reader = new FileReader(); + + return new Promise((resolve, reject) => { + reader.onerror = () => { + reader.abort(); + reject("Problem reading the file."); + }; + + reader.onload = () => { + resolve(reader.result); + }; + + reader.readAsBinaryString(file); + }); + }; + + const options = { + multiple: false, + showUploadList: false, + accept: ".xls,.xlsx,.csv", + customRequest: () => { + navigate(`/${projectId}/sample-metadata/upload/columns`); + }, + beforeUpload: async (file: RcFile) => { + try { + setLoading(true); + const data = await readFileContents(file); + const workbook: XLSX.WorkBook = XLSX.read(data, { + type: "binary", + raw: true, + }); + const { SheetNames } = workbook; + const [firstSheet] = SheetNames; + const rows = XLSX.utils.sheet_to_json(workbook.Sheets[firstSheet], { + rawNumbers: false, + header: 1, + }); + const cleanRows: string[][] = JSON.parse( + JSON.stringify(rows).replace(/"\s+|\s+"/g, '"') + ); + const headers = cleanRows.shift(); + const duplicateHeaders = headers?.filter( + (header, index, headers) => headers.indexOf(header) !== index + ); + const emptyHeaders = headers?.filter((header) => header === null); + const errors: string[] = []; + + if ( + headers === undefined || + headers.length === 0 || + (emptyHeaders && emptyHeaders.length > 0) || + (duplicateHeaders && duplicateHeaders.length > 0) + ) { + if ( + headers === undefined || + headers.length === 0 || + (emptyHeaders && emptyHeaders.length > 0) + ) { + errors.push( + i18n( + "SampleMetadataImportSelectFile.alert.valid.description.empty" + ) + ); + } + if (duplicateHeaders && duplicateHeaders.length > 0) { + errors.push( + i18n( + "SampleMetadataImportSelectFile.alert.valid.description.duplicate" + ) + ); + } + setValidationErrors(errors); + setLoading(false); + return false; + } else { + const output: MetadataItem[] = cleanRows.map((row, rowIndex) => { + const metadataItem: MetadataItem = { + rowKey: `metadata-uploader-row-${rowIndex}`, + }; + row.forEach((item, itemIndex) => { + if (item) metadataItem[headers[itemIndex]] = item; + }); + return metadataItem; + }); + await dispatch(setHeaders({ headers: headers })); + await dispatch(setMetadata(output)); + notification.success({ + message: i18n("SampleMetadataImportSelectFile.success", file.name), + }); + return true; + } + } catch (error) { + setLoading(false); + setStatus("error"); + notification.error({ + message: i18n("SampleMetadataImportSelectFile.error", file.name), + }); + return false; + } + }, + }; + + return ( + + + {validationErrors.length > 0 && ( + + + {i18n( + "SampleMetadataImportSelectFile.alert.valid.description.preface" + )} + +
    + {validationErrors.map((error, index) => ( +
  • + {error} +
  • + ))} +
+ + {i18n( + "SampleMetadataImportSelectFile.alert.valid.description.postface" + )} + + + } + /> + )} + {i18n("SampleMetadataImportSelectFile.warning")} + } + options={options} + props={{ className: "t-metadata-uploader-dropzone" }} + /> +
+
+ ); +} diff --git a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportSteps.jsx b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportSteps.tsx similarity index 54% rename from src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportSteps.jsx rename to src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportSteps.tsx index c5c5bd16781..827de93fdf9 100644 --- a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportSteps.jsx +++ b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportSteps.tsx @@ -1,18 +1,23 @@ import React from "react"; -import { Steps } from "antd"; +import { Steps, StepsProps } from "antd"; const { Step } = Steps; /** * React component that displays the steps for the Sample Metadata Uploader. - * @prop {number} currentStep - the current step, starting with zero - * @prop {string} currentStatus - the status of the current step + * @prop current - the current step, starting with zero + * @prop status - the status of the current step + * @prop percent - the progress percentage of the current step * @returns {*} * @constructor */ -export function SampleMetadataImportSteps({ currentStep, currentStatus }) { +export function SampleMetadataImportSteps({ + current, + status, + percent, +}: StepsProps): JSX.Element { return ( - + diff --git a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportUploadFile.jsx b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportUploadFile.jsx deleted file mode 100644 index b0441f9889f..00000000000 --- a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportUploadFile.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from "react"; -import { useDispatch } from "react-redux"; -import { useNavigate, useParams } from "react-router-dom"; -import { setHeaders } from "../services/importReducer"; -import { notification, Typography } from "antd"; -import { DragUpload } from "../../../../components/files/DragUpload"; -import { setBaseUrl } from "../../../../utilities/url-utilities"; -import { SampleMetadataImportWizard } from "./SampleMetadataImportWizard"; -import { useClearProjectSampleMetadataMutation } from "../../../../apis/metadata/metadata-import"; - -const { Text } = Typography; - -/** - * React component that displays Step #1 of the Sample Metadata Uploader. - * This page is where the user selects the file to be uploaded. - * @returns {*} - * @constructor - */ -export function SampleMetadataImportUploadFile() { - const { projectId } = useParams(); - const navigate = useNavigate(); - const dispatch = useDispatch(); - const [status, setStatus] = React.useState("process"); - const [clearStorage] = useClearProjectSampleMetadataMutation(); - - React.useEffect(() => { - clearStorage(projectId); - }, [clearStorage, projectId]); - - const options = { - multiple: false, - showUploadList: false, - accept: [".xls", ".xlsx", ".csv"], - action: setBaseUrl( - `/ajax/projects/sample-metadata/upload/file?projectId=${projectId}` - ), - onChange(info) { - const { status } = info.file; - if (status === "done") { - notification.success({ - message: i18n( - "SampleMetadataImportUploadFile.success", - info.file.name - ), - }); - dispatch( - setHeaders( - info.file.response.headers, - info.file.response.sampleNameColumn - ) - ); - navigate(`/${projectId}/sample-metadata/upload/headers`); - } else if (status === "error") { - setStatus("error"); - notification.error({ - message: i18n("SampleMetadataImportUploadFile.error", info.file.name), - }); - } - }, - }; - - return ( - - {i18n("SampleMetadataImportUploadFile.warning")} - } - options={options} - props={{ className: "t-metadata-uploader-dropzone" }} - /> - - ); -} diff --git a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportWizard.jsx b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportWizard.jsx deleted file mode 100644 index 3e77513b8a5..00000000000 --- a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportWizard.jsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from "react"; -import { useParams } from "react-router-dom"; -import { PageHeader, Space } from "antd"; -import { SampleMetadataImportSteps } from "./SampleMetadataImportSteps"; -import { setBaseUrl } from "../../../../utilities/url-utilities"; - -/** - * React component that displays the Sample Metadata Uploader Wizard wrapper. - * @prop {number} currentStep - the current step, starting with zero - * @prop {string} currentStatus - the status of the current step - * @prop {any} children - the status of the current step - * @returns {*} - * @constructor - */ -export function SampleMetadataImportWizard({ - currentStep, - currentStatus, - children, -}) { - const { projectId } = useParams(); - - return ( - - - (window.location.href = setBaseUrl(`projects/${projectId}/linelist`)) - } - /> - - {children} - - ); -} diff --git a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportWizard.tsx b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportWizard.tsx new file mode 100644 index 00000000000..c266e3a4698 --- /dev/null +++ b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/components/SampleMetadataImportWizard.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { useParams } from "react-router-dom"; +import { PageHeader, Space, StepsProps } from "antd"; +import { SampleMetadataImportSteps } from "./SampleMetadataImportSteps"; +import { setBaseUrl } from "../../../../utilities/url-utilities"; + +interface SampleMetadataImportWizardProps { + current: StepsProps["current"]; + status?: StepsProps["status"]; + percent?: StepsProps["percent"]; + children: React.ReactNode; +} + +/** + * React component that displays the Sample Metadata Uploader Wizard wrapper. + * @prop current - the current step, starting with zero + * @prop status - the status of the current step + * @prop percent - the progress percentage of the current step + * @prop children - the status of the current step + * @returns {*} + * @constructor + */ +export function SampleMetadataImportWizard({ + current, + status, + percent, + children, +}: SampleMetadataImportWizardProps): JSX.Element { + const { projectId } = useParams<{ projectId: string }>(); + + return ( + + (window.location.href = setBaseUrl(`projects/${projectId}/linelist`)) + } + > + + + {children} + + + ); +} diff --git a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/index.js b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/index.js deleted file mode 100644 index bdae61a979b..00000000000 --- a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/index.js +++ /dev/null @@ -1,41 +0,0 @@ -import React from "react"; -import { BrowserRouter, Route, Routes } from "react-router-dom"; -import { Provider } from "react-redux"; -import { render } from "react-dom"; -import { SampleMetadataImportComplete } from "./components/SampleMetadataImportComplete"; -import { SampleMetadataImportMapHeaders } from "./components/SampleMetadataImportMapHeaders"; -import { SampleMetadataImportReview } from "./components/SampleMetadataImportReview"; -import { SampleMetadataImportUploadFile } from "./components/SampleMetadataImportUploadFile"; -import { setBaseUrl } from "../../../utilities/url-utilities"; -import store from "./store"; - -/* -Router for sample metadata importer. -For more information on the browser router see: https://reactrouter.com/web/api/BrowserRouter - */ - -render( - - - - } - /> - } - /> - } - /> - } - /> - - - , - document.querySelector("#samples-metadata-import-root") -); diff --git a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/index.tsx b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/index.tsx new file mode 100644 index 00000000000..ff98cdc21ba --- /dev/null +++ b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/index.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { Provider } from "react-redux"; +import { render } from "react-dom"; +import { SampleMetadataImportComplete } from "./components/SampleMetadataImportComplete"; +import { SampleMetadataImportMapColumns } from "./components/SampleMetadataImportMapColumns"; +import { SampleMetadataImportReview } from "./components/SampleMetadataImportReview"; +import { SampleMetadataImportSelectFile } from "./components/SampleMetadataImportSelectFile"; +import { setBaseUrl } from "../../../utilities/url-utilities"; +import store from "./redux/store"; + +/** + * The Metadata Importer Page + */ +function SampleMetadataImport(): JSX.Element { + return ( + + + + } + /> + } + /> + } + /> + } + /> + + + + ); +} + +render( + , + document.querySelector("#samples-metadata-import-root") +); diff --git a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/redux/import-utilities.ts b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/redux/import-utilities.ts new file mode 100644 index 00000000000..b312d97d048 --- /dev/null +++ b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/redux/import-utilities.ts @@ -0,0 +1,73 @@ +import { + CreateSampleItem, + CreateUpdateSamples, + MetadataItem, + UpdateSampleItem, +} from "../../../../apis/projects/samples"; +import { + MetadataHeaderItem, + MetadataSaveDetailsItem, + updatePercentComplete, +} from "./importReducer"; +import { ImportDispatch } from "./store"; +import { chunkArray } from "../../../../utilities/array-utilities"; + +/** + * @fileoverview utilities to help with metadata importer + */ +export function generatePromiseList( + sampleList: CreateSampleItem[] | UpdateSampleItem[], + sampleFunction: CreateUpdateSamples, + projectId: string, + totalCount: number, + metadataSaveDetails: Record, + dispatch: ImportDispatch +) { + const newMetadataSaveDetails = { ...metadataSaveDetails }; + const promiseList: Promise[] = []; + const chunkedSampleList = chunkArray(sampleList); + + chunkedSampleList.forEach((chunk) => { + promiseList.push( + sampleFunction({ projectId, body: chunk }).then(({ responses }) => { + dispatch( + updatePercentComplete( + Math.round((Object.keys(responses).length / totalCount) * 100) + ) + ); + Object.keys(responses).forEach((key) => { + const { error, errorMessage } = responses[key]; + newMetadataSaveDetails[key] = { + saved: !error, + error: errorMessage, + }; + }); + }) + ); + }); + + return { promiseList, newMetadataSaveDetails }; +} + +/** + * Create a list of metadata fields and their values for each sample. + * @param sampleNameColumn - the name of the header that represents the sample name column + * @param headers - a list of the table headers + * @param metadataItem - the data of a row in the table representing the metadata of a sample + */ +export function createMetadataFields( + sampleNameColumn: string, + headers: MetadataHeaderItem[], + metadataItem: MetadataItem +) { + return Object.entries(metadataItem) + .filter( + ([key]) => + headers.map((header) => header.name).includes(key) && + key !== sampleNameColumn + ) + .map(([key, value]) => ({ + field: key, + value, + })); +} diff --git a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/redux/importReducer.ts b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/redux/importReducer.ts new file mode 100644 index 00000000000..b5f0f089151 --- /dev/null +++ b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/redux/importReducer.ts @@ -0,0 +1,353 @@ +import { + createAction, + createAsyncThunk, + createReducer, +} from "@reduxjs/toolkit"; +import { validateSampleName } from "../../../../apis/metadata/sample-utils"; +import { + CreateSampleItem, + createSamples, + getLockedSamples, + LockedSamplesResponse, + MetadataItem, + UpdateSampleItem, + updateSamples, + ValidateSampleNameModel, + validateSamples, + ValidateSamplesResponse, +} from "../../../../apis/projects/samples"; +import { ImportDispatch, ImportState } from "./store"; +import { + createMetadataFieldsForProject, + getMetadataFieldsForProject, +} from "../../../../apis/metadata/field"; +import { Restriction } from "../../../../utilities/restriction-utilities"; +import { createMetadataFields, generatePromiseList } from "./import-utilities"; +import { MetadataField } from "../../../../types/irida"; + +export interface MetadataHeaderItem { + name: string; + existingRestriction: Restriction | undefined; + targetRestriction: Restriction; + rowKey: string; +} + +export interface MetadataValidateDetailsItem { + isSampleNameValid: boolean; + foundSampleId?: number; + locked: boolean; +} + +export interface MetadataSaveDetailsItem { + saved: boolean; + error?: string; +} + +export interface SaveMetadataResponse { + metadataSaveDetails: Record; +} + +export interface SetSampleNameColumnResponse { + sampleNameColumn: string; + metadataValidateDetails: Record; +} + +export interface InitialState { + projectId: string; + sampleNameColumn: string; + headers: MetadataHeaderItem[]; + metadata: MetadataItem[]; + metadataValidateDetails: Record; + metadataSaveDetails: Record; + percentComplete: number; +} + +const initialState: InitialState = { + projectId: "", + sampleNameColumn: "", + headers: [], + metadata: [], + metadataValidateDetails: {}, + metadataSaveDetails: {}, + percentComplete: 0, +}; + +/* +Redux async thunk for saving the metadata to samples. +For more information on redux async thunks see: https://redux-toolkit.js.org/api/createAsyncThunk + */ +export const saveMetadata = createAsyncThunk< + SaveMetadataResponse, + { projectId: string; selectedMetadataKeys: string[] }, + { dispatch: ImportDispatch; state: ImportState; rejectValue: string } +>( + `importReducer/saveMetadata`, + async ( + { projectId, selectedMetadataKeys }, + { dispatch, getState, rejectWithValue } + ) => { + const state: ImportState = getState(); + const { + sampleNameColumn, + headers, + metadata, + metadataValidateDetails, + metadataSaveDetails, + } = state.importReducer; + + try { + //save header details (metadata field & restriction) + //if failure display error notification on page + await createMetadataFieldsForProject({ + projectId, + body: headers + .filter((header) => header.name !== sampleNameColumn) + .map((header) => ({ + label: header.name, + restriction: header.targetRestriction, + })), + }).catch((error) => { + throw new Error(error.response.data.error); + }); + + //save selected metadata entry rows + //during a partial failure only save data that has not already been saved + const selectedSampleList = metadata.filter((metadataItem) => { + const name: string = metadataItem[sampleNameColumn]; + return ( + selectedMetadataKeys.includes(metadataItem.rowKey) && + metadataSaveDetails[name]?.saved !== true + ); + }); + + const createSampleList: CreateSampleItem[] = []; + const updateSampleList: UpdateSampleItem[] = []; + + selectedSampleList.forEach((metadataItem) => { + const name = metadataItem[sampleNameColumn]; + const sampleId = metadataValidateDetails[name].foundSampleId; + const metadata = createMetadataFields( + sampleNameColumn, + headers, + metadataItem + ); + + if (sampleId) { + updateSampleList.push({ name, sampleId, metadata }); + } else { + createSampleList.push({ name, metadata }); + } + }); + + const { + promiseList: createPromiseList, + newMetadataSaveDetails: createMetadataSaveDetails, + } = generatePromiseList( + createSampleList, + createSamples, + projectId, + selectedSampleList.length, + metadataSaveDetails, + dispatch + ); + await Promise.all(createPromiseList); + + const { + promiseList: updatePromiseList, + newMetadataSaveDetails: updateMetadataSaveDetails, + } = generatePromiseList( + updateSampleList, + updateSamples, + projectId, + selectedSampleList.length, + createMetadataSaveDetails, + dispatch + ); + await Promise.all(updatePromiseList); + return { metadataSaveDetails: updateMetadataSaveDetails }; + } catch (error) { + let message; + if (error instanceof Error) { + ({ message } = error); + } else { + message = String(error); + } + return rejectWithValue(message); + } + } +); + +/* +Redux async thunk for setting the sample name column and enriching the metadata. +For more information on redux async thunks see: https://redux-toolkit.js.org/api/createAsyncThunk +*/ +export const setSampleNameColumn = createAsyncThunk< + SetSampleNameColumnResponse, + { projectId: string; updatedSampleNameColumn: string }, + { state: ImportState } +>( + `importReducer/setSampleNameColumn`, + async ({ projectId, updatedSampleNameColumn }, { getState }) => { + const state: ImportState = getState(); + const { metadata } = state.importReducer; + const metadataValidateDetails: Record = + {}; + const samples: ValidateSampleNameModel[] = metadata + .filter((row) => row[updatedSampleNameColumn]) + .map((row) => ({ + name: row[updatedSampleNameColumn], + })); + const validatedSamples: ValidateSamplesResponse = await validateSamples({ + projectId: projectId, + body: { + samples: samples, + }, + }); + const lockedSamples: LockedSamplesResponse = await getLockedSamples({ + projectId, + }); + for (const metadataItem of metadata) { + const sampleName: string = metadataItem[updatedSampleNameColumn]; + const foundValidatedSamples = validatedSamples.samples.find( + (sample) => sampleName === sample.name + ); + const foundSampleId = foundValidatedSamples?.ids?.at(0); + const foundLockedSamples = lockedSamples.sampleIds.find( + (sampleId) => sampleId === foundSampleId + ); + metadataValidateDetails[sampleName] = { + isSampleNameValid: validateSampleName(sampleName), + foundSampleId: foundSampleId, + locked: !!foundLockedSamples, + }; + } + + return { + sampleNameColumn: updatedSampleNameColumn, + metadataValidateDetails, + }; + } +); + +/* +Redux async thunk for setting the metadata headers. +For more information on redux async thunks see: https://redux-toolkit.js.org/api/createAsyncThunk +*/ +export const setHeaders = createAsyncThunk< + { headers: MetadataHeaderItem[] }, + { headers: string[] }, + { state: ImportState } +>(`importReducer/setHeaders`, async ({ headers }, { getState }) => { + const state: ImportState = getState(); + const { projectId } = state.importReducer; + const response: MetadataField[] = await getMetadataFieldsForProject( + projectId + ); + const updatedHeaders = headers.map((header, index) => { + const metadataField = response.find( + (metadataField) => metadataField.label === header + ); + return { + name: header, + existingRestriction: metadataField?.restriction, + targetRestriction: metadataField?.restriction + ? metadataField.restriction + : "LEVEL_1", + rowKey: `metadata-uploader-header-row-${index}`, + }; + }); + return { headers: updatedHeaders }; +}); + +/* +Redux action for updating the metadata headers. +For more information on redux actions see: https://redux-toolkit.js.org/api/createAction + */ +export const updateHeaders = createAction( + `importReducer/updateHeaders`, + (headers: MetadataHeaderItem[]) => ({ + payload: { headers }, + }) +); + +/* +Redux action for setting the projectId. +For more information on redux actions see: https://redux-toolkit.js.org/api/createAction + */ +export const setProjectId = createAction( + `importReducer/setProjectID`, + (projectId: string) => ({ + payload: { projectId }, + }) +); + +/* +Redux action for setting the project metadata. +For more information on redux actions see: https://redux-toolkit.js.org/api/createAction + */ +export const setMetadata = createAction( + `importReducer/setMetadata`, + (metadata: MetadataItem[]) => ({ + payload: { metadata }, + }) +); + +/* +Redux action for setting the project metadata save details. +For more information on redux actions see: https://redux-toolkit.js.org/api/createAction + */ +export const setMetadataSaveDetails = createAction( + `importReducer/setMetadataSaveDetails`, + (metadataSaveDetails: Record) => ({ + payload: { metadataSaveDetails }, + }) +); + +/* +Redux action for updating the progress bar. +For more information on redux actions see: https://redux-toolkit.js.org/api/createAction + */ +export const updatePercentComplete = createAction( + `importReducer/updatePercentComplete`, + (amount: number) => ({ + payload: { amount }, + }) +); + +/* +Redux reducer for project metadata. +For more information on redux reducers see: https://redux-toolkit.js.org/api/createReducer + */ +export const importReducer = createReducer(initialState, (builder) => { + builder.addCase(updateHeaders, (state, action) => { + state.headers = action.payload.headers; + }); + builder.addCase(setProjectId, (state, action) => { + state.projectId = action.payload.projectId; + state.sampleNameColumn = ""; + state.headers = []; + state.metadata = []; + state.metadataValidateDetails = {}; + state.metadataSaveDetails = {}; + state.percentComplete = 0; + }); + builder.addCase(setMetadata, (state, action) => { + state.metadata = action.payload.metadata; + }); + builder.addCase(setMetadataSaveDetails, (state, action) => { + state.metadataSaveDetails = action.payload.metadataSaveDetails; + }); + builder.addCase(setHeaders.fulfilled, (state, action) => { + state.headers = action.payload.headers; + }); + builder.addCase(setSampleNameColumn.fulfilled, (state, action) => { + state.sampleNameColumn = action.payload.sampleNameColumn; + state.metadataValidateDetails = action.payload.metadataValidateDetails; + }); + builder.addCase(saveMetadata.fulfilled, (state, action) => { + state.metadataSaveDetails = action.payload.metadataSaveDetails; + }); + builder.addCase(updatePercentComplete, (state, action) => { + state.percentComplete = state.percentComplete + action.payload.amount; + }); +}); diff --git a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/redux/store.ts b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/redux/store.ts new file mode 100644 index 00000000000..62a4d0f47d1 --- /dev/null +++ b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/redux/store.ts @@ -0,0 +1,56 @@ +import { + Action, + configureStore, + Dispatch, + MiddlewareAPI, +} from "@reduxjs/toolkit"; +import { importReducer } from "./importReducer"; +import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; +import { getProjectIdFromUrl } from "../../../../utilities/url-utilities"; + +const projectId = getProjectIdFromUrl(); +const storageKey = "metadataImport-" + projectId; + +const storeState = (store: MiddlewareAPI) => { + return (next: Dispatch) => (action: Action) => { + const result = next(action); + const expirationDate = new Date(new Date().getTime() + 6 * 60 * 60 * 1000); + const storage = { + state: store.getState(), + expiry: expirationDate.toISOString(), + }; + sessionStorage.setItem(storageKey, JSON.stringify(storage)); + return result; + }; +}; + +const retrieveState = () => { + const stringData = sessionStorage.getItem(storageKey); + if (stringData !== null) { + const result = JSON.parse(stringData); + const expiry = new Date(result.expiry); + if (expiry > new Date()) { + return result.state; + } + } +}; + +/* +Redux Store for sample metadata importer. +For more information on redux stores see: https://redux.js.org/tutorials/fundamentals/part-4-store + */ +const store = configureStore({ + reducer: { + importReducer, + }, + preloadedState: retrieveState(), + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(storeState), +}); + +export type ImportState = ReturnType; +export type ImportDispatch = typeof store.dispatch; +export const useImportDispatch: () => ImportDispatch = useDispatch; +export const useImportSelector: TypedUseSelectorHook = useSelector; + +export default store; diff --git a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/services/importReducer.js b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/services/importReducer.js deleted file mode 100644 index adf67a06ca9..00000000000 --- a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/services/importReducer.js +++ /dev/null @@ -1,28 +0,0 @@ -import { createReducer, createAction } from "@reduxjs/toolkit"; - -const initialState = {} - -/* -Redux action for project metadata. -For more information on redux actions see: https://redux-toolkit.js.org/api/createAction - */ -export const setHeaders = createAction( - `rootReducers/setHeaders`, - (headers, sampleNameColumn) => ({ - payload: { headers, sampleNameColumn } - }) -); - -/* -Redux reducer for project metadata. -For more information on redux reducers see: https://redux-toolkit.js.org/api/createReducer - */ -export const importReducer = createReducer( - initialState, - (builder) => { - builder.addCase(setHeaders, (state, action) => { - state.headers = action.payload.headers; - state.sampleNameColumn = action.payload.sampleNameColumn; - }); - } -); \ No newline at end of file diff --git a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/store.js b/src/main/webapp/resources/js/pages/projects/samples-metadata-import/store.js deleted file mode 100644 index 19267b16241..00000000000 --- a/src/main/webapp/resources/js/pages/projects/samples-metadata-import/store.js +++ /dev/null @@ -1,16 +0,0 @@ -import { configureStore } from "@reduxjs/toolkit"; -import { metadataImportApi } from "../../../apis/metadata/metadata-import"; -import { importReducer } from "./services/importReducer"; - -/* -Redux Store for sample metadata importer. -For more information on redux stores see: https://redux.js.org/tutorials/fundamentals/part-4-store - */ -export default configureStore({ - reducer: { - reducer: importReducer, - [metadataImportApi.reducerPath]: metadataImportApi.reducer, - }, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware().concat(metadataImportApi.middleware), -}); diff --git a/src/main/webapp/resources/js/pages/projects/samples/components/CreateNewSample.jsx b/src/main/webapp/resources/js/pages/projects/samples/components/CreateNewSample.jsx index 5e60a48f584..6e305709715 100644 --- a/src/main/webapp/resources/js/pages/projects/samples/components/CreateNewSample.jsx +++ b/src/main/webapp/resources/js/pages/projects/samples/components/CreateNewSample.jsx @@ -1,7 +1,7 @@ import React from "react"; import { AutoComplete, Form, Input, Modal } from "antd"; import { - createNewSample, + createSamples, validateSampleName, } from "../../../../apis/projects/samples"; import searchOntology from "../../../../apis/ontology/taxonomy/query"; @@ -11,7 +11,12 @@ import searchOntology from "../../../../apis/ontology/taxonomy/query"; * @returns {JSX.Element} * @constructor */ -export default function CreateNewSample({ visible, onCreate, onCancel }) { +export default function CreateNewSample({ + visible, + projectId, + onCreate, + onCancel, +}) { const [form] = Form.useForm(); const nameRef = React.useRef(); const [organisms, setOrganisms] = React.useState([]); @@ -72,7 +77,10 @@ export default function CreateNewSample({ visible, onCreate, onCancel }) { const createSample = () => { form.validateFields().then((values) => { - createNewSample(values).then(() => { + createSamples({ + projectId, + body: [values], + }).then(() => { form.resetFields(); onCreate(); }); @@ -96,7 +104,7 @@ export default function CreateNewSample({ visible, onCreate, onCancel }) { name="name" label={i18n("AddSample.name")} rules={[ - ({}) => ({ + () => ({ validator(_, value) { return validateName(value); }, diff --git a/src/main/webapp/resources/js/pages/projects/samples/components/SampleIcons.jsx b/src/main/webapp/resources/js/pages/projects/samples/components/SampleIcons.tsx similarity index 77% rename from src/main/webapp/resources/js/pages/projects/samples/components/SampleIcons.jsx rename to src/main/webapp/resources/js/pages/projects/samples/components/SampleIcons.tsx index 52eb3c254f2..335e5e658d2 100644 --- a/src/main/webapp/resources/js/pages/projects/samples/components/SampleIcons.jsx +++ b/src/main/webapp/resources/js/pages/projects/samples/components/SampleIcons.tsx @@ -3,14 +3,21 @@ import { LockTwoTone } from "@ant-design/icons"; import { Popover, Space } from "antd"; import { red6 } from "../../../../styles/colors"; +interface Sample { + owner: boolean; +} + +export interface SampleIconsProps { + sample: Sample; +} + /** * React component to render any icons onto the sample listing table that * give extra information about the sample. - * @param {object} sample * @returns {JSX.Element} * @constructor */ -export default function SampleIcons({ sample }) { +export default function SampleIcons({ sample }: SampleIconsProps): JSX.Element { return ( {!sample.owner && ( diff --git a/src/main/webapp/resources/js/pages/projects/samples/components/SamplesMenu.jsx b/src/main/webapp/resources/js/pages/projects/samples/components/SamplesMenu.jsx index a3a1a2f8dfc..904ad959c65 100644 --- a/src/main/webapp/resources/js/pages/projects/samples/components/SamplesMenu.jsx +++ b/src/main/webapp/resources/js/pages/projects/samples/components/SamplesMenu.jsx @@ -355,6 +355,7 @@ export default function SamplesMenu() { }> setCreateSampleVisible(false)} onCreate={onCreate} /> diff --git a/src/main/webapp/resources/js/pages/projects/settings/components/metadata/MetadataFieldsListManager.jsx b/src/main/webapp/resources/js/pages/projects/settings/components/metadata/MetadataFieldsListManager.jsx index 5d1b41163bd..fd2d7434880 100644 --- a/src/main/webapp/resources/js/pages/projects/settings/components/metadata/MetadataFieldsListManager.jsx +++ b/src/main/webapp/resources/js/pages/projects/settings/components/metadata/MetadataFieldsListManager.jsx @@ -1,4 +1,4 @@ -import { Button, Empty, notification, Select, Space, Table } from "antd"; +import { Button, Empty, notification, Radio, Space, Table } from "antd"; import React from "react"; import { getMetadataRestrictions, @@ -24,9 +24,8 @@ export default function MetadataFieldsListManager() { isLoading, refetch: refetchFields, } = useGetMetadataFieldsForProjectQuery(projectId); - const [ - updateProjectMetadataFieldRestriction, - ] = useUpdateProjectMetadataFieldRestrictionMutation(); + const [updateProjectMetadataFieldRestriction] = + useUpdateProjectMetadataFieldRestrictionMutation(); const [{ selected, selectedItems }, { setSelected }] = useTableSelect(fields); @@ -75,13 +74,25 @@ export default function MetadataFieldsListManager() { key: "restriction", render(restriction, field) { return ( -