diff --git a/application-commons/src/main/java/life/qbic/application/commons/FileSizeFormatter.java b/application-commons/src/main/java/life/qbic/application/commons/FileSizeFormatter.java index 954f1b1082..ff7d0b694e 100644 --- a/application-commons/src/main/java/life/qbic/application/commons/FileSizeFormatter.java +++ b/application-commons/src/main/java/life/qbic/application/commons/FileSizeFormatter.java @@ -11,9 +11,10 @@ */ public class FileSizeFormatter { - private static final long KILOBYTE = 1_000L; - private static final long MEGABYTE = 1_000_000L; - private static final long GIGABYTE = 1_000_000_000L; + private static final long base = 1024; + private static final long KILOBYTE = base; + private static final long MEGABYTE = base * KILOBYTE; + private static final long GIGABYTE = base * MEGABYTE; private FileSizeFormatter() { // Utility class - no instantiation @@ -43,4 +44,4 @@ public static String formatBytes(long bytes) { } return String.format(Locale.US, "%.2f GB", bytes / (double) GIGABYTE); } -} \ No newline at end of file +} diff --git a/datamanager-app/frontend/themes/datamanager/components/dialog.css b/datamanager-app/frontend/themes/datamanager/components/dialog.css index 408fa8c844..b1d842368a 100644 --- a/datamanager-app/frontend/themes/datamanager/components/dialog.css +++ b/datamanager-app/frontend/themes/datamanager/components/dialog.css @@ -494,6 +494,16 @@ Since we want to remove the spacing between the cancel and confirm button we rep width: 100%; } +.upload-component .restrictions:has(:only-child) { + justify-content: flex-end; +} + +.upload-component .upload-component-error { + font-size: smaller; + color: var(--lumo-error-color); + padding: var(--lumo-space-xs); +} + .measurement-upload-dialog .upload-items-display .uploaded-items-section .section-title { color: var(--lumo-secondary-text-color); font-size: var(--lumo-font-size-s); @@ -514,8 +524,7 @@ Since we want to remove the spacing between the cancel and confirm button we rep } .measurement-upload-dialog .upload-items-display .uploaded-measurement-items .measurement-item, -.register-samples-dialog .uploaded-items-section .uploaded-item, -.edit-samples-dialog .uploaded-items-section .uploaded-item { +.uploaded-items-section .uploaded-item { font-size: var(--lumo-font-size-s); display: flex; padding-top: var(--lumo-space-m); diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/configuration/UploadConfiguration.java b/datamanager-app/src/main/java/life/qbic/datamanager/configuration/UploadConfiguration.java new file mode 100644 index 0000000000..39af219100 --- /dev/null +++ b/datamanager-app/src/main/java/life/qbic/datamanager/configuration/UploadConfiguration.java @@ -0,0 +1,28 @@ +package life.qbic.datamanager.configuration; + +import java.util.Objects; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.unit.DataSize; + +@Configuration +public class UploadConfiguration { + + private final DataSize maxInMemoryThreshold; + private final DataSize maxFileSize; + + public UploadConfiguration( + @Value("${qbic.upload.in-memory-limit}") DataSize maxInMemoryThreshold, + @Value("${qbic.upload.max-file-size}") DataSize maxFileSize) { + this.maxInMemoryThreshold = Objects.requireNonNull(maxInMemoryThreshold); + this.maxFileSize = Objects.requireNonNull(maxFileSize); + } + + public DataSize maxInMemoryThreshold() { + return maxInMemoryThreshold; + } + + public DataSize maxFileSize() { + return maxFileSize; + } +} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/download/DownloadProvider.java b/datamanager-app/src/main/java/life/qbic/datamanager/download/DownloadProvider.java deleted file mode 100644 index 0b3aa1d62c..0000000000 --- a/datamanager-app/src/main/java/life/qbic/datamanager/download/DownloadProvider.java +++ /dev/null @@ -1,45 +0,0 @@ -package life.qbic.datamanager.download; - -import com.vaadin.flow.component.UI; -import com.vaadin.flow.component.html.Anchor; -import com.vaadin.flow.server.StreamResource; -import java.io.ByteArrayInputStream; -import life.qbic.application.commons.ApplicationException; - -/** - * The DownloadProvider class extends the Anchor class and provides functionality for triggering - * any kind of download based on a provided DownloadContentProvider - */ -public class DownloadProvider extends Anchor { - - private final transient DownloadContentProvider downloadContentProvider; - - public DownloadProvider(DownloadContentProvider downloadContentProvider) { - super("_blank", "Download"); - this.downloadContentProvider = downloadContentProvider; - /* - * Using setVisisble(false), vaadin prevents any client side actions. - * This prevents us from using JavaScript to click the link, which is the only option - * for using anchors now. - * Thus, we prevent the display of the link with `display: none`. - * The link is still on the page but invisible. - */ - getStyle().set("display", "none"); - - setTarget("_blank"); - getElement().setAttribute("download", true); - } - - public void trigger() { - byte[] content = downloadContentProvider.getContent(); - if(content.length > 0) { - UI ui = getUI().orElseThrow(() -> new ApplicationException( - "Download component triggered but not attached to any UI.")); - StreamResource resource = new StreamResource(downloadContentProvider.getFileName(), - () -> new ByteArrayInputStream(content)); - this.setHref(resource); - ui.getPage().executeJs("$0.click()", this.getElement()); - } - } - -} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/files/export/download/ByteArrayDownloadStreamProvider.java b/datamanager-app/src/main/java/life/qbic/datamanager/files/export/download/ByteArrayDownloadStreamProvider.java index d23edf297e..67903f6eb6 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/files/export/download/ByteArrayDownloadStreamProvider.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/files/export/download/ByteArrayDownloadStreamProvider.java @@ -11,4 +11,9 @@ public interface ByteArrayDownloadStreamProvider extends DownloadStreamProvider default InputStream getStream() { return new ByteArrayInputStream(getBytes()); } + + @Override + default String getContentType() { + return "application/octet-stream"; + } } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/files/export/download/DownloadStreamProvider.java b/datamanager-app/src/main/java/life/qbic/datamanager/files/export/download/DownloadStreamProvider.java index 4c305186d5..4f00701934 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/files/export/download/DownloadStreamProvider.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/files/export/download/DownloadStreamProvider.java @@ -1,6 +1,7 @@ package life.qbic.datamanager.files.export.download; import java.io.InputStream; +import java.util.Optional; public interface DownloadStreamProvider { @@ -8,4 +9,7 @@ public interface DownloadStreamProvider { InputStream getStream(); + String getContentType(); + + Optional contentLength(); } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/files/export/download/WorkbookDownloadStreamProvider.java b/datamanager-app/src/main/java/life/qbic/datamanager/files/export/download/WorkbookDownloadStreamProvider.java index 1d7b0287d4..e35a6298cf 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/files/export/download/WorkbookDownloadStreamProvider.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/files/export/download/WorkbookDownloadStreamProvider.java @@ -19,4 +19,8 @@ default InputStream getStream() { throw new RuntimeException(e); } } + + default String getContentType() { + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + } } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/security/SecurityConfiguration.java b/datamanager-app/src/main/java/life/qbic/datamanager/security/SecurityConfiguration.java index a778706e49..ff26d568ea 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/security/SecurityConfiguration.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/security/SecurityConfiguration.java @@ -1,10 +1,10 @@ package life.qbic.datamanager.security; +import static com.vaadin.flow.spring.security.VaadinSecurityConfigurer.vaadin; import static java.util.Objects.requireNonNull; import com.vaadin.flow.spring.security.VaadinAwareSecurityContextHolderStrategyConfiguration; import com.vaadin.flow.spring.security.VaadinDefaultRequestCache; -import com.vaadin.flow.spring.security.VaadinWebSecurity; import life.qbic.datamanager.views.login.LoginLayout; import life.qbic.identity.application.security.QBiCPasswordEncoder; import life.qbic.identity.application.user.IdentityService; @@ -16,13 +16,13 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @EnableWebSecurity @Configuration @Import(VaadinAwareSecurityContextHolderStrategyConfiguration.class) -public class SecurityConfiguration extends VaadinWebSecurity { +public class SecurityConfiguration { final VaadinDefaultRequestCache defaultRequestCache; private final IdentityService identityService; @@ -44,6 +44,30 @@ public SecurityConfiguration( this.identityService = requireNonNull(identityService); } + @Bean + public SecurityFilterChain vaadinSecurityFilterChain(HttpSecurity http) throws Exception { + http.requestCache(c -> c.requestCache(defaultRequestCache)); + + http.authorizeHttpRequests(v -> v + .requestMatchers( + "/oauth2/authorization/orcid", + "/oauth2/code/**", + "/link/**", + "/images/*.png") + .permitAll() + ); + + http.oauth2Login(oauth2 -> oauth2. + loginPage("/login").permitAll() + .defaultSuccessUrl("/") + .successHandler(authenticationSuccessHandler()) + .failureUrl("/login?errorOauth2=true&error")); + + http.with(vaadin(), vaadin -> vaadin + .loginView(LoginLayout.class, contextPath + "/login?logout=true")); + return http.build(); + } + @Bean public PasswordEncoder passwordEncoder() { return new QBiCPasswordEncoder(); @@ -56,34 +80,4 @@ private AuthenticationSuccessHandler authenticationSuccessHandler() { storedRequestAwareOidcAuthenticationSuccessHandler.setRequestCache(defaultRequestCache); return storedRequestAwareOidcAuthenticationSuccessHandler; } - - @Override - protected void configure(HttpSecurity http) throws Exception { - // Use Vaadin’s request cache (so redirects back to the original Flow route work) - http.requestCache(c -> c.requestCache(defaultRequestCache)); - - http.authorizeHttpRequests(v -> v - .requestMatchers(VaadinWebSecurity.getDefaultWebSecurityIgnoreMatcher()).permitAll() - .requestMatchers( - new AntPathRequestMatcher("/oauth2/authorization/orcid"), - new AntPathRequestMatcher("/oauth2/code/**"), - new AntPathRequestMatcher("/link/**"), - new AntPathRequestMatcher("/images/*.png")) - .permitAll() - ); - - http.oauth2Login(oAuth2Login -> { - oAuth2Login.loginPage("/login").permitAll(); - oAuth2Login.defaultSuccessUrl("/"); - oAuth2Login.successHandler( - authenticationSuccessHandler()); - oAuth2Login.failureUrl("/login?errorOauth2=true&error"); - }); - - // Let Vaadin register its filters/matchers - super.configure(http); - - // Set the login view - setLoginView(http, LoginLayout.class, contextPath + "/login?logout=true"); - } } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/LandingPageTitleAndLogo.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/LandingPageTitleAndLogo.java index b98019643e..9a9e95dc45 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/LandingPageTitleAndLogo.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/LandingPageTitleAndLogo.java @@ -4,7 +4,8 @@ import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Image; import com.vaadin.flow.component.html.Span; -import com.vaadin.flow.server.StreamResource; +import com.vaadin.flow.server.streams.DownloadHandler; +import com.vaadin.flow.server.streams.DownloadResponse; /** * Landing Page Title and Logo @@ -33,9 +34,11 @@ public LandingPageTitleAndLogo() { } private Image getUTLogo() { - StreamResource utResource = new StreamResource("university_tuebingen_logo.svg", - () -> getClass().getClassLoader().getResourceAsStream(UT_LOGO_PATH)); - Image utLogo = new Image(utResource, "university_tuebingen_logo"); + DownloadHandler downloadHandler = DownloadHandler.fromInputStream(event -> + new DownloadResponse(getClass().getClassLoader().getResourceAsStream(UT_LOGO_PATH), + "university_tuebingen_logo.svg", + "image/svg+xml", -1)); + Image utLogo = new Image(downloadHandler, "university_tuebingen_logo"); utLogo.addClassName("ut-logo"); return utLogo; } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/account/UserAvatar.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/account/UserAvatar.java index d53363beaa..f8a72ba5eb 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/account/UserAvatar.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/account/UserAvatar.java @@ -2,7 +2,8 @@ import com.vaadin.flow.component.avatar.Avatar; import com.vaadin.flow.component.avatar.AvatarGroup; -import com.vaadin.flow.server.StreamResource; +import com.vaadin.flow.server.streams.DownloadHandler; +import com.vaadin.flow.server.streams.DownloadResponse; import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import life.qbic.datamanager.views.identicon.IdenticonGenerator; @@ -19,20 +20,26 @@ public UserAvatar() { } public void setUserId(String userId) { - setImageResource(getImageResource(userId)); + setImageHandler(getDownloadHandler(userId)); } - private static StreamResource getImageResource(String name) { - return new StreamResource("user-identicon.svg", - () -> new ByteArrayInputStream(IdenticonGenerator.generateIdenticonSVG(name).getBytes( - StandardCharsets.UTF_8))); + private static DownloadHandler getDownloadHandler(String userid) { + return DownloadHandler.fromInputStream(event -> + { + byte[] imageBytes = IdenticonGenerator.generateIdenticonSVG(userid).getBytes( + StandardCharsets.UTF_8); + return new DownloadResponse( + new ByteArrayInputStream(imageBytes), + "user-identicon.svg", null, imageBytes.length); + }); } public static class UserAvatarGroupItem extends AvatarGroup.AvatarGroupItem { public UserAvatarGroupItem(String userName, String userId) { + //new logic -> first more important; image then name -> image; name then image -> name is shown + setImageHandler(UserAvatar.getDownloadHandler(userId)); super.setName(userName); - setImageResource(UserAvatar.getImageResource(userId)); } } } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/demo/ComponentDemo.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/demo/ComponentDemo.java index b888499daf..7b78f2af40 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/demo/ComponentDemo.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/demo/ComponentDemo.java @@ -27,6 +27,7 @@ import java.util.Objects; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import life.qbic.datamanager.configuration.UploadConfiguration; import life.qbic.datamanager.views.StringBean; import life.qbic.datamanager.views.general.Card; import life.qbic.datamanager.views.general.DetailBox; @@ -42,7 +43,6 @@ import life.qbic.datamanager.views.general.dialog.stepper.StepperDialog; import life.qbic.datamanager.views.general.dialog.stepper.StepperDialogFooter; import life.qbic.datamanager.views.general.dialog.stepper.StepperDisplay; -import life.qbic.datamanager.views.general.grid.Filter; import life.qbic.datamanager.views.general.grid.PredicateFilter; import life.qbic.datamanager.views.general.grid.component.FilterGrid; import life.qbic.datamanager.views.general.grid.component.FilterGridConfigurations; @@ -51,6 +51,8 @@ import life.qbic.datamanager.views.general.grid.component.FilterGridTabSheet.TabAction; import life.qbic.datamanager.views.general.grid.component.GridConfiguration.FilterTester; import life.qbic.datamanager.views.general.icon.IconFactory; +import life.qbic.datamanager.views.general.upload.ContentUploadComponent; +import life.qbic.datamanager.views.general.upload.UploadedFilesChangeListener.FileEntry; import life.qbic.datamanager.views.notifications.MessageSourceNotificationFactory; import life.qbic.datamanager.views.projects.project.experiments.experiment.components.experimentalvariable.ExperimentalVariablesInput; import life.qbic.datamanager.views.projects.project.info.SimpleParagraph; @@ -83,13 +85,17 @@ public class ComponentDemo extends Div { public static final String NORMAL_BODY_TEXT = "normal-body-text"; Div title = new Div("Data Manager - Component Demo"); private final MessageSourceNotificationFactory messageFactory; + private final UploadConfiguration uploadConfiguration; @Autowired - public ComponentDemo(MessageSourceNotificationFactory messageSourceNotificationFactory) { + public ComponentDemo(MessageSourceNotificationFactory messageSourceNotificationFactory, + UploadConfiguration uploadConfiguration) { this.messageFactory = Objects.requireNonNull(messageSourceNotificationFactory); + this.uploadConfiguration = Objects.requireNonNull(uploadConfiguration); title.addClassName("heading-1"); addClassNames("padding-horizontal-07", "padding-vertical-04"); add(title); + add(uploadShowcase()); add(headingShowcase()); add(colorShowCase()); add(clickableShowCase()); @@ -104,6 +110,20 @@ public ComponentDemo(MessageSourceNotificationFactory messageSourceNotificationF add(filterGridShowCase()); } + private @org.jspecify.annotations.NonNull ContentUploadComponent uploadShowcase() { + ContentUploadComponent contentUploadComponent = new ContentUploadComponent(uploadConfiguration); + + contentUploadComponent.addChangeListener(event -> { + var files = event.changedFiles(); + var changeType = event.changeType(); + for (FileEntry file : files) { + System.out.println("file = " + file); + System.out.println("changeType = " + changeType); + } + }); + return contentUploadComponent; + } + private record SimplePersonFilter(String term) implements PredicateFilter { @Override @@ -185,7 +205,7 @@ private Div filterGridShowCase() { var contactInMemoryConfiguration = FilterGridConfigurations.inMemory( examples, - nameContainsFilterTerm::test + nameContainsFilterTerm ); var inMemoryPersonGrid = FilterGrid.create( @@ -201,9 +221,9 @@ private Div filterGridShowCase() { tabSheet.addTab(filterTab); tabSheet.addTab(filterTabContacts); tabSheet.addTab(filterTabInMemoryPerson); - TabAction, ComponentDemo.Person> featureAction = tab -> log.info( + TabAction, Person> featureAction = tab -> log.info( "feature for tab: " + tab); - TabAction, ComponentDemo.Person> primaryAction = tab -> log.info( + TabAction, Person> primaryAction = tab -> log.info( "primary action for tab: " + tab); tabSheet.addFeatureAction(filterTab, featureAction); @@ -217,21 +237,6 @@ private Div filterGridShowCase() { return new Div(tabSheet); } - static class ExampleFilter implements Filter { - - private String term; - - ExampleFilter(String term) { - this.term = term; - } - - @Override - public Optional searchTerm() { - return Optional.ofNullable(this.term); - } - - } - static List examples = new ArrayList<>(); diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/AllowedFileExtension.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/AllowedFileExtension.java new file mode 100644 index 0000000000..5851edf48b --- /dev/null +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/AllowedFileExtension.java @@ -0,0 +1,43 @@ +package life.qbic.datamanager.views.general; + +/** + * Allowed File Extension + * + *

Enumeration of all allowed File extensions in the context of the Quality Control upload. + * Additionally provides the MIME_type and a short description as outlined by the mozilla docs ... + *

+ */ +public enum AllowedFileExtension { + + EXCEL(".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Microsoft Excel (OpenXML)"), + PDF(".pdf", "application/pdf", + "Adobe Portable Document Format (PDF)"), + WORD( + ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "Microsoft Word (OpenXML)"); + + private final String extension; + private final String mimeType; + private final String description; + + + AllowedFileExtension(String extension, String mimeType, String description) { + this.extension = extension; + this.mimeType = mimeType; + this.description = description; + } + + public String extension() { + return extension; + } + + public String mimetype() { + return mimeType; + } + + public String description() { + return description; + } +} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/OboIdFormatter.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/OboIdFormatter.java index e2a1f38201..9a6f9b9230 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/OboIdFormatter.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/OboIdFormatter.java @@ -12,7 +12,7 @@ public class OboIdFormatter { public static String render(OntologyTerm term) { - return enforceColonSeparator(term.getOboId()); + return enforceColonSeparator(term.oboId().toString()); } public static String render(String oboId) { diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/OntologyComponent.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/OntologyComponent.java index 21699c6603..a60501ec1a 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/OntologyComponent.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/OntologyComponent.java @@ -12,7 +12,7 @@ public OntologyComponent(OntologyTerm contentDTO) { Span ontologyLabel = new Span(contentDTO.getLabel()); ontologyLabel.addClassName("bold"); /* Ontology terms are delimited by a column, the underscore is only used in the web environment*/ - String ontologyNameContent = contentDTO.getOboId().replace("_", ":"); + String ontologyNameContent = contentDTO.oboId().toString(); Span ontologyName = new Span(ontologyNameContent); /*Clicking the Link should open the origin page in a new tab*/ ontologyName.addClassName("ontology-name"); diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/download/DownloadComponent.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/download/DownloadComponent.java index 06f4887d2d..2c60602bb8 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/download/DownloadComponent.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/download/DownloadComponent.java @@ -2,7 +2,8 @@ import com.vaadin.flow.component.UI; import com.vaadin.flow.component.html.Anchor; -import com.vaadin.flow.server.StreamResource; +import com.vaadin.flow.server.streams.DownloadHandler; +import com.vaadin.flow.server.streams.DownloadResponse; import life.qbic.application.commons.ApplicationException; import life.qbic.datamanager.files.export.download.DownloadStreamProvider; @@ -25,9 +26,11 @@ public DownloadComponent() { public synchronized void trigger(DownloadStreamProvider downloadStreamProvider) { UI ui = getUI().orElseThrow(() -> new ApplicationException( this.getClass().getSimpleName() + "component triggered but not attached to any UI.")); - StreamResource resource = new StreamResource(downloadStreamProvider.getFilename(), - downloadStreamProvider::getStream); - this.setHref(resource); + DownloadHandler downloadHandler = DownloadHandler.fromInputStream( + event -> new DownloadResponse(downloadStreamProvider.getStream(), + downloadStreamProvider.getFilename(), downloadStreamProvider.getContentType(), + downloadStreamProvider.contentLength().orElse(-1L))); + this.setHref(downloadHandler); ui.getPage().executeJs("$0.click()", this.getElement()); } } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/oidc/OidcLogo.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/oidc/OidcLogo.java index 6f4dd28b77..0a43952b50 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/oidc/OidcLogo.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/oidc/OidcLogo.java @@ -1,8 +1,7 @@ package life.qbic.datamanager.views.general.oidc; import com.vaadin.flow.component.html.Image; -import com.vaadin.flow.server.AbstractStreamResource; -import com.vaadin.flow.server.StreamResource; +import com.vaadin.flow.server.streams.DownloadHandler; /** * OidcLogo shown within the data manager application. @@ -17,13 +16,24 @@ public OidcLogo(OidcType oidcType) { setSrc(getLogoResource()); } - private AbstractStreamResource getLogoResource() { + private DownloadHandler getLogoResource() { String oidcLogoSrc = oidcType.getLogoPath(); - //Image source cannot contain a "/" so we look for the actual file name independent in which folder path it is contained. - if (oidcLogoSrc.contains("/")) { - oidcLogoSrc = oidcType.getLogoPath().substring(oidcType.getLogoPath().lastIndexOf('/') + 1); - } - return new StreamResource(oidcLogoSrc, - () -> getClass().getClassLoader().getResourceAsStream(oidcType.getLogoPath())); +// //Image source cannot contain a "/" so we look for the actual file name independent in which folder path it is contained. +// if (oidcLogoSrc.contains("/")) { +// oidcLogoSrc = oidcType.getLogoPath().substring(oidcType.getLogoPath().lastIndexOf('/') + 1); +// } +// https://docs.oracle.com/javase/8/docs/technotes/guides/lang/resources.html + return DownloadHandler.forClassResource(getClass(), oidcLogoSrc); + +// event -> +// new DownloadResponse( +// getClass().getClassLoader().getResourceAsStream(oidcType.getLogoPath()), +// "orcid_logo.svg", +// "image/svg+xml", +// -1L +// )); + +// oidcLogoSrc, +// () -> getClass().getClassLoader().getResourceAsStream(oidcType.getLogoPath())); } } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/oidc/OidcType.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/oidc/OidcType.java index f837d534db..ef8a74b488 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/oidc/OidcType.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/oidc/OidcType.java @@ -5,8 +5,8 @@ * corresponding oidc a user has provided */ public enum OidcType { - ORCID("https://orcid.org", "login/orcid_logo.svg", "https://orcid.org/", "Orcid"), - ORCID_SANDBOX("https://sandbox.orcid.org", "login/orcid_logo.svg", "https://sandbox.orcid.org/", + ORCID("https://orcid.org", "/login/orcid_logo.svg", "https://orcid.org/", "Orcid"), + ORCID_SANDBOX("https://sandbox.orcid.org", "/login/orcid_logo.svg", "https://sandbox.orcid.org/", "OrcId"); private final String issuer; diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/upload/ContentUploadComponent.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/upload/ContentUploadComponent.java new file mode 100644 index 0000000000..1b17167ce6 --- /dev/null +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/upload/ContentUploadComponent.java @@ -0,0 +1,532 @@ +package life.qbic.datamanager.views.general.upload; + + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.upload.FileRejectedEvent; +import com.vaadin.flow.component.upload.FileRemovedEvent; +import com.vaadin.flow.component.upload.Upload; +import com.vaadin.flow.component.upload.UploadI18N; +import com.vaadin.flow.component.upload.UploadI18N.Error; +import com.vaadin.flow.server.streams.UploadHandler; +import com.vaadin.flow.server.streams.UploadMetadata; +import com.vaadin.flow.shared.Registration; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.Serializable; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import life.qbic.application.commons.FileSizeFormatter; +import life.qbic.datamanager.configuration.UploadConfiguration; +import life.qbic.datamanager.views.general.upload.ContentUploadComponent.FileData.InMemory; +import life.qbic.datamanager.views.general.upload.ContentUploadComponent.FileData.OnDisk; +import life.qbic.datamanager.views.general.upload.UploadedFilesChangeListener.ChangeType; +import life.qbic.datamanager.views.general.upload.UploadedFilesChangeListener.FileEntry; +import life.qbic.datamanager.views.general.upload.UploadedFilesChangeListener.UploadedFilesChangeEvent; +import life.qbic.logging.api.Logger; +import life.qbic.logging.service.LoggerFactory; +import org.jspecify.annotations.NonNull; +import org.springframework.util.unit.DataSize; + +/** + * A file upload component that supports single and multi-file uploads with automatic in-memory + * and on-disk storage management. + * + *

Files uploaded within the configured {@code maxInMemoryBytes} threshold are stored in memory; + * larger files are temporarily written to disk and cleaned up automatically on component detachment + * or when files are removed. + * + *

The component exposes change events via {@link UploadedFilesChangeListener}, allowing consumers + * to react to files being added or removed. It also provides methods to retrieve uploaded data + * ({@link #getContent(String)}) and metadata ({@link #getMetadata(String)}) after upload completion. + * + *

Error display: File rejection errors (too big, wrong type, too many files) are displayed + * inline below the upload area. {@link UnspecificFailedEvent} is fired for network-level failures. + * + *

Thread safety: The internal file data store is backed by a synchronized map. + * File removal and data storage operations are thread-safe. + * + * @author QBiC + * @see UploadedFilesChangeListener + * @see UploadedFilesChangeListener.FileEntry + */ +public class ContentUploadComponent extends Div { + + private static final Logger log = LoggerFactory.logger(ContentUploadComponent.class); + private final long maxInMemoryBytes; + private final Map fileDataStore = Collections.synchronizedMap( + new LinkedHashMap<>()); //linked hash map to keep insertion order + private final Upload upload; + private final Div restrictionsArea = new Div(); + private final Div errorArea = new Div(); + + + /** + * Holds metadata for an uploaded file, including file name, MIME type, and content length. + * + * @param fileName the name of the uploaded file + * @param mimeType the MIME content type of the file + * @param size the content length in bytes + */ + public record UploadedMetadata(String fileName, String mimeType, long size) { + + + } + /** + * Represents the storage format for uploaded file data. + * + *

Files within the configured in-memory threshold are stored as in-memory byte arrays; + * larger files are written to temporary disk storage. The sealed hierarchy ensures safe + * pattern matching for consumers. + */ + sealed interface FileData permits InMemory, + OnDisk { + + /** + * In-memory file data storing the metadata and raw byte content. + * + *

Used for files whose size is at or below the configured {@code maxInMemoryBytes} threshold. + * Implements {@link Serializable} for potential clustering/session replication support. + */ + record InMemory(UploadMetadata metadata, byte[] data) + implements FileData, Serializable { + + @Override + public boolean equals(Object o) { + //need to overwrite as array field is present + if (o instanceof InMemory(UploadMetadata otherMetadata, byte[] otherData)) { + return Arrays.equals(data, otherData) && Objects.equals(metadata, otherMetadata); + } + return false; + } + + @Override + public int hashCode() { + //need to overwrite as array field is present + int result = Arrays.hashCode(data); + result = 31 * result + Objects.hashCode(metadata); + return result; + } + + } + /** + * On-disk file data storing the metadata and a path to a temporary file. + * + *

Used for files whose size exceeds the configured {@code maxInMemoryBytes} threshold. + * The temporary file is automatically deleted when the component is detached or the file is removed. + * Implements {@link Serializable} for potential clustering/session replication support. + */ + record OnDisk(UploadMetadata metadata, Path path) implements + FileData, Serializable { + + + } + UploadMetadata metadata(); + + } + + /** + * Creates a new upload component with the given upload configuration. + * + *

Configures file size thresholds, upload constraints (max files, accepted file types), + * internationalized error messages, and file rejection/removed handlers. + * + *

Cleanup of temporary on-disk files is handled automatically via a detach listener. + * + * @param uploadConfiguration configuration for max in-memory threshold and file size limits + * @throws NullPointerException if uploadConfiguration is null + */ + public ContentUploadComponent(UploadConfiguration uploadConfiguration) { + //ensure cleanup after detachment of component + addDetachListener(event -> deletePendingFiles()); + addClassNames("upload-component"); + maxInMemoryBytes = uploadConfiguration.maxInMemoryThreshold().toBytes(); + + restrictionsArea.addClassNames("restrictions", "extra-small-body-text", "color-secondary"); + + errorArea.addClassNames("upload-component-error"); + errorArea.setVisible(false); + + upload = new Upload(); + + Error errorTranslation = new Error(); + errorTranslation.setFileIsTooBig( + "The provided file is too big. Please try again with a smaller file."); + errorTranslation.setTooManyFiles( + "Too many files uploaded. Please try uploading less files."); + errorTranslation.setIncorrectFileType( + "Incorrect file type detected. Please ensure your file is intact and supported." + ); + UploadI18N uploadI18N = new UploadI18N(); + uploadI18N.setError(errorTranslation); + upload.setI18n(uploadI18N); + + UploadHandler handler = event -> { + //TODO in vaadin25.1 the upload event has a reject method. use that instead. + try { + long fileSize = event.getFileSize(); + //decide how to handle the upload based on filesize + UploadHandler delegateHandler = fileSize <= maxInMemoryBytes + ? UploadHandler.inMemory(this::process) + : UploadHandler.toTempFile(this::process); + delegateHandler.handleUploadRequest(event); + } catch (IOException e) { + log.warn("Unexpected upload failure: " + e.getMessage()); + fireEvent(new UnspecificFailedEvent(this, false, e)); + } + }; + upload.setUploadHandler(handler); + + upload.addFileRemovedListener(event -> removeFile(event.getFileName())); + upload.addAllFinishedListener(event -> { + errorArea.setVisible(false); + errorArea.removeAll(); + }); + /** + * FIXME: + * Please be advised that when even 1 file out of all files is uploaded, no rejection messages are shown. + * This needs to be fixed in vaadin 25.1 where the upload handler/ upload event got necessary methods + */ + upload.addFileRejectedListener(event -> + { + log.warn("Failed to upload " + event.getFileName() + + " Reason: " + event.getErrorMessage()); + Span fileNameSpan = new Span(event.getFileName()); + fileNameSpan.addClassNames("bold"); + Span errorMessage = new Span(event.getErrorMessage()); + errorArea.add(new Div(fileNameSpan, new Span(" rejected because: "), + errorMessage)); + errorArea.setVisible(true); + }); + + if (!uploadConfiguration.maxFileSize().isNegative()) { + setMaxFileSize(uploadConfiguration.maxFileSize()); + } + add(errorArea, upload, restrictionsArea); + } + + /** + * Sets the maximum allowed file size for uploads, expressed as a {@link DataSize}. + * + *

Calling this method with a negative or zero value removes the file size restriction. + * Updates the restrictions display area automatically. + * + * @param maxFileSize maximum upload size; zero or negative removes the limit + * @throws IllegalArgumentException if maxFileSize is negative (pre-condition check) + */ + public void setMaxFileSize(@NonNull DataSize maxFileSize) { + if (maxFileSize.toBytes() > Integer.MAX_VALUE) { + throw new IllegalArgumentException( + "Maximum file size exceeds Integer.MAX_VALUE bytes and cannot be applied"); + } + setMaxFileSize(Math.toIntExact(maxFileSize.toBytes())); + } + + /** + * Sets the maximum allowed file size for uploads, expressed in bytes. + * + *

Calling this method with a value less than or equal to zero removes the file size restriction. + * Updates the restrictions display area automatically. + * + * @param maxFileSize maximum upload size in bytes; zero or negative removes the limit + */ + public void setMaxFileSize(int maxFileSize) { + if (maxFileSize <= 0) { + upload.getElement().removeProperty("maxFileSize"); + } else { + upload.setMaxFileSize(maxFileSize); + } + updateRestrictionsDisplay(); + } + + /** + * Sets internationalization (I18N) labels for the upload component. + * + *

Configures error messages for file rejection scenarios such as oversized files, + * incorrect file types, and exceeding the maximum file count. + * + * @param uploadI18N internationalization configuration containing error labels + */ + public void setI18n(UploadI18N uploadI18N) { + upload.setI18n(uploadI18N); + } + + /** + * Sets the list of accepted file types by MIME type and/or extension. + * + *

Accepts file types such as mime type strings ({@code "application/pdf"}) and/or file + * extensions ({@code ".pdf"}). File type filtering is performed on the client side. + * Server-side validation should still be performed by the consumer after upload. + * + * @param acceptedFileTypes array of accepted file type strings (MIME types or extensions) + * @see Upload#setAcceptedFileTypes(String...) + */ + public void setAcceptedFileTypes(String... acceptedFileTypes) { + upload.setAcceptedFileTypes(acceptedFileTypes); + } + + /** + * Sets the maximum number of files that may be uploaded in a single batch. + * + *

Exceeding this limit results in a {@link FileRejectedEvent} for additional files. + * + * @param maxFiles maximum number of files allowed in one batch upload + */ + public void setMaxFiles(int maxFiles) { + upload.setMaxFiles(maxFiles); + } + + private FileEntry maptoFileEntry(FileData file) { + + return new FileEntry(file.metadata().fileName(), + file.metadata().contentType(), + file.metadata().contentLength()); + } + + /** + * Updates the restrictions display area to show the current maximum file size. + * + *

Displays the file size restriction text when {@link #getMaxFileSize()} returns a positive value, + * and hides the area when no restriction is set. + */ + protected void updateRestrictionsDisplay() { + if (getMaxFileSize() > 0) { + restrictionsArea.removeAll(); + Div restriction = new Div( + "Maximum file size is " + FileSizeFormatter.formatBytes(getMaxFileSize())); + restriction.addClassNames("restriction"); + restrictionsArea.add(restriction); + restrictionsArea.setVisible(true); + } else { + restrictionsArea.removeAll(); + restrictionsArea.setVisible(false); + } + } + private synchronized void deletePendingFiles() { + fileDataStore.values().forEach( + it -> { + if (it instanceof OnDisk onDisk) { + deleteFileFromDisk(onDisk); + } + } + ); + } + + private synchronized void removeFile(String fileName) { + assert fileDataStore.containsKey( + fileName) : "The removed file was uploaded before"; + if (fileDataStore.containsKey(fileName)) { + FileData trackedFile = fileDataStore.get(fileName); + if (trackedFile instanceof OnDisk onDisk) { + deleteFileFromDisk(onDisk); + } + fileDataStore.remove(fileName); + fireRemoveChange(List.of(trackedFile)); + } + } + + private static void deleteFileFromDisk(OnDisk onDisk) { + try { + Files.deleteIfExists(onDisk.path()); + } catch (IOException e) { + log.warn("Temporarily uploaded file %s was not deleted. Reason:%s".formatted( + onDisk.path().toAbsolutePath().toString(), e.getMessage())); + } + } + + private void fireAddedChange(List files) { + fireEvent(new UploadedFilesChangeEvent(this, + fileDataStore.values().stream().map(this::maptoFileEntry).toList(), + files.stream().map(this::maptoFileEntry).toList(), + ChangeType.FILE_ADDED)); + } + + private void fireRemoveChange(List files) { + fireEvent(new UploadedFilesChangeEvent(this, + fileDataStore.values().stream().map(this::maptoFileEntry).toList(), + files.stream().map(this::maptoFileEntry).toList(), + ChangeType.FILE_REMOVED)); + } + + private void process(UploadMetadata metadata, File file) { + setData(new OnDisk(metadata, file.toPath())); + } + + private void process(UploadMetadata metadata, byte[] data) { + setData(new InMemory(metadata, data)); + } + + private synchronized void setData(FileData fileData) { + String fileName = fileData.metadata().fileName(); + fileDataStore.put(fileName, fileData); + fireAddedChange(List.of(fileData)); + } + + + + protected boolean isEmpty() { + return fileDataStore.isEmpty(); + } + + /** + * Lists the names of all uploaded files. The ordering of names is not guaranteed to be + * preserved. + * + * @return a list of names of uploaded files. + */ + public List getFileNames() { + var keys = fileDataStore.keySet(); + return keys.stream() + .toList(); + } + + /** + * Retrieves metadata for the specified uploaded file. + * + * @param fileName the name of the uploaded file + * @return an {@link Optional} containing the file metadata if found, or empty otherwise + */ + public Optional getMetadata(String fileName) { + if (!fileDataStore.containsKey(fileName)) { + return Optional.empty(); + } + FileData entry = fileDataStore.get(fileName); + return switch (entry) { + case InMemory inMemory -> Optional.of(new UploadedMetadata(inMemory.metadata().fileName(), + inMemory.metadata().contentType(), inMemory.metadata().contentLength())); + case OnDisk onDisk -> Optional.of( + new UploadedMetadata(onDisk.metadata().fileName(), onDisk.metadata().contentType(), + onDisk.metadata().contentLength())); + }; + } + + /** + * Retrieves an input stream for the content of the specified uploaded file. + * + *

For in-memory files, a {@link ByteArrayInputStream} is returned. For on-disk files, + * a {@link FileInputStream} is opened pointing to the temporary file. Callers must close + * the returned stream to avoid resource leaks. + * + * @param fileName the name of the uploaded file + * @return an {@link Optional} containing the input stream of the file's content, or empty if + * the file was not uploaded or a {@link FileNotFoundException} occurred + */ + public Optional getContent(String fileName) { + if (isEmpty()) { + return Optional.empty(); + } + if (!fileDataStore.containsKey(fileName)) { + return Optional.empty(); + } + FileData fileData = fileDataStore.get(fileName); + return switch (fileData) { + case InMemory inMemory -> Optional.of(new ByteArrayInputStream(inMemory.data())); + case OnDisk onDisk -> { + InputStream inputStream = null; + try { + inputStream = new FileInputStream(onDisk.path().toFile()); + } catch (FileNotFoundException e) { + log.warn("File %s was not found but was expected to be there. Reason:%s".formatted( + onDisk.path().toAbsolutePath().toString(), e.getMessage())); + } + yield Optional.ofNullable(inputStream); + } + }; + } + + /** + * Retrieves the configured maximum file size in bytes. + * + * @return the maximum file size; returns -1 if no limit is set + */ + public long getMaxFileSize() { + return upload.getMaxFileSize(); + } + + /** + * Retrieves the configured maximum number of files per upload batch. + * + * @return the maximum number of files allowed; returns -1 if unlimited + */ + public int getMaxFiles() { + return upload.getMaxFiles(); + } + + /** + * Registers a listener for file addition and removal change events. + * + * @param listener the change listener to register + * @return a {@link Registration} that can be used to remove the listener + */ + public Registration addChangeListener(UploadedFilesChangeListener listener) { + return addListener(UploadedFilesChangeEvent.class, listener); + } + + /** + * Registers a listener for unspecific upload failures (e.g., network errors). + * + * @param listener the failure event listener to register + * @return a {@link Registration} that can be used to remove the listener + */ + public Registration addUnspecificFailureListener( + ComponentEventListener listener) { + return addListener(UnspecificFailedEvent.class, listener); + } + + /** + * Registers a listener for file rejection events triggered when files violate upload constraints. + * + *

{@inheritDoc} + */ + public Registration addFileRejectedListener( + ComponentEventListener listener) { + return upload.addFileRejectedListener(listener); + } + + /** + * Registers a listener for file removal events triggered when the user removes a file from the upload queue. + * + *

{@inheritDoc} + */ + public Registration addFileRemovedListener( + ComponentEventListener listener) { + return upload.addFileRemovedListener(listener); + } + + public static class UnspecificFailedEvent extends ComponentEvent { + + private final Exception cause; + + /** + * Creates a new event using the given source and indicator whether the event originated from + * the client side or the server side. + * + * @param source the source component + * @param fromClient true if the event originated from the client + * side, false otherwise + */ + public UnspecificFailedEvent(ContentUploadComponent source, boolean fromClient, + Exception cause) { + super(source, fromClient); + this.cause = cause; + } + + public Exception getCause() { + return cause; + } + } + +} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/upload/UploadContentValidator.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/upload/UploadContentValidator.java new file mode 100644 index 0000000000..77561799ca --- /dev/null +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/upload/UploadContentValidator.java @@ -0,0 +1,26 @@ +package life.qbic.datamanager.views.general.upload; + +import java.io.InputStream; +import java.util.Optional; + +/** + * Strategy interface for validating the content of an uploaded file. + *

+ * Implementations inspect the file stream and return an empty {@link Optional} to indicate + * that the content is acceptable, or an error message describing the problem. + *

+ * Implementations must not close the provided input stream. + */ +public interface UploadContentValidator { + + /** + * Validates the content of an uploaded file. + * + * @param fileName the original name of the uploaded file (may be used to determine the expected + * format or type) + * @param content an {@link InputStream} providing the file content to validate + * @return {@link Optional#empty()} if the content is valid, or an {@link Optional} containing a + * human-readable error message describing why validation failed + */ + Optional validate(String fileName, InputStream content); +} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/upload/UploadedFilesChangeListener.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/upload/UploadedFilesChangeListener.java new file mode 100644 index 0000000000..f50f166868 --- /dev/null +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/upload/UploadedFilesChangeListener.java @@ -0,0 +1,72 @@ +package life.qbic.datamanager.views.general.upload; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import java.io.Serializable; +import java.util.List; +import life.qbic.datamanager.views.general.upload.UploadedFilesChangeListener.UploadedFilesChangeEvent; + +/** + * Interface for components that display uploaded files and their validation status. + *

+ */ +public interface UploadedFilesChangeListener extends + ComponentEventListener { + + + class UploadedFilesChangeEvent extends ComponentEvent { + + private final List files; + private final List changedFiles; + private final ChangeType changeType; + + + /** + * Creates a new event using the given source and indicator whether the event originated from + * the client side or the server side. + * + * @param source the source component + */ + public UploadedFilesChangeEvent(ContentUploadComponent source, List files, + List changedFiles, ChangeType changeType) { + super(source, false); + this.files = files.stream().toList(); + this.changedFiles = changedFiles.stream().toList(); + this.changeType = changeType; + } + + public List files() { + return files; + } + + public List changedFiles() { + return changedFiles; + } + + public ChangeType changeType() { + return changeType; + } + } + + /** + * Describes what kind of change triggered the firing of the event. + */ + enum ChangeType { + /** A new file was uploaded. */ + FILE_ADDED, + /** An uploaded file was removed by the user. */ + FILE_REMOVED + } + + /** + * Represents a single uploaded file and its current validation state. + * + * @param fileName the original file name + * @param mimeType the MIME type of the file + * @param size the file size in bytes + */ + record FileEntry(String fileName, String mimeType, long size) implements + Serializable { + + } +} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/create/OntologyComboboxFactory.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/create/OntologyComboboxFactory.java index cbc8431c58..c49cc2b771 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/create/OntologyComboboxFactory.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/create/OntologyComboboxFactory.java @@ -5,13 +5,11 @@ import com.vaadin.flow.component.combobox.MultiSelectComboBox; import com.vaadin.flow.data.provider.CallbackDataProvider.FetchCallback; import com.vaadin.flow.data.renderer.ComponentRenderer; -import java.util.List; import java.util.Objects; import life.qbic.datamanager.views.general.OntologyComponent; import life.qbic.datamanager.views.projects.project.experiments.OntologyFilterConnector; import life.qbic.projectmanagement.application.ontology.SpeciesLookupService; import life.qbic.projectmanagement.application.ontology.TerminologyService; -import life.qbic.projectmanagement.domain.model.Ontology; import life.qbic.projectmanagement.domain.model.OntologyTerm; /** @@ -81,9 +79,8 @@ private static MultiSelectComboBox newBox() { } private static String ontologyItemFormatted(OntologyTerm ontologyTerm) { - String ontologyLinkName = ontologyTerm.getOboId().replace("_", ":"); + String ontologyLinkName = ontologyTerm.oboId().toString(); return String.format("%s (%s)", ontologyTerm.getLabel(), ontologyLinkName); } } - diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectInformationMain.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectInformationMain.java index 2c4f9510e4..0ca774f729 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectInformationMain.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectInformationMain.java @@ -14,8 +14,10 @@ import jakarta.annotation.security.PermitAll; import java.io.Serial; import java.util.List; +import java.util.Optional; import life.qbic.application.commons.ApplicationException; import life.qbic.application.commons.Result; +import life.qbic.datamanager.configuration.UploadConfiguration; import life.qbic.datamanager.files.export.download.ByteArrayDownloadStreamProvider; import life.qbic.datamanager.security.UserPermissions; import life.qbic.datamanager.views.Context; @@ -41,8 +43,8 @@ import life.qbic.datamanager.views.projects.project.info.QualityControlListComponent.QualityControl; import life.qbic.datamanager.views.projects.purchase.PurchaseItemDeletionConfirmationNotification; import life.qbic.datamanager.views.projects.purchase.UploadPurchaseDialog; -import life.qbic.datamanager.views.projects.qualityControl.QCItemDeletionConfirmationNotification; -import life.qbic.datamanager.views.projects.qualityControl.UploadQualityControlDialog; +import life.qbic.datamanager.views.projects.quality_control.QCItemDeletionConfirmationNotification; +import life.qbic.datamanager.views.projects.quality_control.UploadQualityControlDialog; import life.qbic.logging.api.Logger; import life.qbic.projectmanagement.application.AddExperimentToProjectService; import life.qbic.projectmanagement.application.experiment.ExperimentInformationService; @@ -94,6 +96,7 @@ public class ProjectInformationMain extends Main implements BeforeEnterObserver private final transient MessageSourceNotificationFactory messageSourceNotificationFactory; private final transient TerminologyService terminologyService; private Context context; + private final UploadConfiguration uploadConfiguration; public ProjectInformationMain(@Autowired ProjectSummaryComponent projectSummaryComponent, @Autowired ExperimentListComponent experimentListComponent, @@ -105,7 +108,8 @@ public ProjectInformationMain(@Autowired ProjectSummaryComponent projectSummaryC @Autowired QualityControlService qualityControlService, @Autowired TerminologyService terminologyService, CancelConfirmationDialogFactory cancelConfirmationDialogFactory, - MessageSourceNotificationFactory messageSourceNotificationFactory) { + MessageSourceNotificationFactory messageSourceNotificationFactory, + UploadConfiguration uploadConfiguration) { this.projectSummaryComponent = requireNonNull(projectSummaryComponent); this.experimentListComponent = requireNonNull(experimentListComponent, "experimentListComponent must not be null"); @@ -124,6 +128,7 @@ public ProjectInformationMain(@Autowired ProjectSummaryComponent projectSummaryC "messageSourceNotificationFactory must not be null"); this.cancelConfirmationDialogFactory = requireNonNull(cancelConfirmationDialogFactory, "cancelConfirmationDialogFactory must not be null"); + this.uploadConfiguration = requireNonNull(uploadConfiguration); downloadComponent = new DownloadComponent(); offerListComponent = getConfiguredOfferList(); @@ -194,6 +199,11 @@ public byte[] getBytes() { public String getFilename() { return offer.getFileName(); } + + @Override + public Optional contentLength() { + return Optional.of((long) offer.fileContent().length); + } }); } @@ -214,7 +224,7 @@ private void onDeleteOfferClicked(DeleteOfferClickEvent deleteOfferClickEvent) { private void onUploadOfferClicked(UploadOfferClickEvent uploadOfferClickEvent, ProjectPurchaseService projectPurchaseService, String projectId) { - UploadPurchaseDialog dialog = new UploadPurchaseDialog(); + UploadPurchaseDialog dialog = new UploadPurchaseDialog(uploadConfiguration); dialog.addConfirmListener(confirmEvent -> { List offerDTOs = confirmEvent.getSource().purchaseItems().stream() .map(it -> new OfferDTO(it.signed(), it.fileName(), it.content())) @@ -249,6 +259,11 @@ public String getFilename() { return qualityControlUpload.getFileName(); } + @Override + public Optional contentLength() { + return Optional.of((long) qualityControlUpload.fileContent().length); + } + @Override public byte[] getBytes() { return qualityControlUpload.fileContent(); @@ -272,7 +287,7 @@ private void onDeleteQualityControlClicked(DeleteQualityControlEvent deleteQuali private void onUploadQualityControlClicked() { UploadQualityControlDialog dialog = new UploadQualityControlDialog( - context.projectId().orElseThrow(), experimentInformationService); + context.projectId().orElseThrow(), experimentInformationService, uploadConfiguration); dialog.addConfirmListener(confirmEvent -> { List qualityControlReports = confirmEvent.getSource() .qualityControlItems() diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectSummaryComponent.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectSummaryComponent.java index 430ad69df3..17a6d3e832 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectSummaryComponent.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectSummaryComponent.java @@ -84,7 +84,6 @@ import life.qbic.datamanager.views.projects.ProjectInformation; import life.qbic.datamanager.views.projects.edit.EditContactsComponent; import life.qbic.datamanager.views.projects.edit.ProjectDesignForm; -import life.qbic.datamanager.views.projects.project.experiments.experiment.ExperimentDetailsComponent; import life.qbic.datamanager.views.strategy.scope.ReadScopeStrategy; import life.qbic.datamanager.views.strategy.scope.UserScopeStrategy; import life.qbic.datamanager.views.strategy.scope.WriteScopeStrategy; @@ -113,8 +112,6 @@ import life.qbic.projectmanagement.application.api.AsyncProjectService.RequestFailedException; import life.qbic.projectmanagement.application.api.AsyncProjectService.UnknownRequestException; import life.qbic.projectmanagement.application.contact.PersonLookupService; -import life.qbic.projectmanagement.application.experiment.ExperimentInformationService; -import life.qbic.projectmanagement.domain.model.experiment.Experiment; import life.qbic.projectmanagement.domain.model.project.Contact; import life.qbic.projectmanagement.domain.model.project.Contact.OidcInformation; import life.qbic.projectmanagement.domain.model.project.Project; @@ -148,7 +145,6 @@ public class ProjectSummaryComponent extends PageArea { public static final String SAVE_BUTTON_TEXT = "Save"; private static final Logger log = logger(ProjectSummaryComponent.class); private final transient ProjectInformationService projectInformationService; - private final transient ExperimentInformationService experimentInformationService; private final transient PersonLookupService personLookupService; private final transient UserPermissions userPermissions; private final transient MessageSourceNotificationFactory notificationFactory; @@ -162,7 +158,6 @@ public class ProjectSummaryComponent extends PageArea { private final RequestCache requestCache; private final MessageSourceNotificationFactory messageSourceNotificationFactory; private final Map pendingTaskToasts = new HashMap<>(); - private final ExperimentDetailsComponent experimentDetailsComponent; private Toast taskInProgressToast; private Context context; private transient List scopes; @@ -172,14 +167,12 @@ public class ProjectSummaryComponent extends PageArea { @Autowired public ProjectSummaryComponent(ProjectInformationService projectInformationService, - ExperimentInformationService experimentInformationService, PersonLookupService personLookupService, UserPermissions userPermissions, MessageSourceNotificationFactory notificationFactory, AsyncProjectService asyncProjectService, RequestCache requestCache, - MessageSourceNotificationFactory messageSourceNotificationFactory, - ExperimentDetailsComponent experimentDetailsComponent) { + MessageSourceNotificationFactory messageSourceNotificationFactory) { this.projectInformationService = Objects.requireNonNull(projectInformationService); this.personLookupService = requireNonNull(personLookupService, "person lookup service must not be null"); @@ -190,7 +183,6 @@ public ProjectSummaryComponent(ProjectInformationService projectInformationServi this.projectContactsSection = new SectionBuilder().build(); this.userPermissions = Objects.requireNonNull(userPermissions); this.notificationFactory = Objects.requireNonNull(notificationFactory); - this.experimentInformationService = experimentInformationService; this.asyncProjectService = Objects.requireNonNull(asyncProjectService); this.requestCache = Objects.requireNonNull(requestCache); this.downloadComponent = new DownloadComponent(); @@ -204,7 +196,6 @@ public ProjectSummaryComponent(ProjectInformationService projectInformationServi add(projectContactsSection); add(downloadComponent); this.messageSourceNotificationFactory = messageSourceNotificationFactory; - this.experimentDetailsComponent = experimentDetailsComponent; } private static ProjectInformation convertToInfo(Project project) { @@ -234,9 +225,9 @@ private static life.qbic.datamanager.views.general.contact.Contact convert(Conta contact.oidcInformation().map(OidcInformation::oidcIssuer).orElse(null)); } - private static Button createButtonWithListener(String label, + private static Button createButtonWithListener( ComponentEventListener> listener) { - var button = new Button(label); + var button = new Button("Edit"); button.addClickListener(listener); return button; } @@ -294,8 +285,7 @@ public void setContext(Context context) { .orElseThrow(() -> new ApplicationException("No project with given ID found")); var fullProject = projectInformationService.find(projectId) .orElseThrow(() -> new ApplicationException("No project found")); - var experiments = experimentInformationService.findAllForProject(projectId); - setContent(projectOverview, fullProject, experiments); + setContent(projectOverview, fullProject); this.scopes = loadScope(userPermissions::editProject, projectId, projectDesignSection, fundingInformationSection, projectContactsSection); this.scopes.forEach(UserScopeStrategy::execute); @@ -323,7 +313,7 @@ private void loadExperimentInfo(String projectId) { var species = new HashSet(); var specimen = new HashSet(); var analytes = new HashSet(); - experiments.stream().forEach(experiment -> { + experiments.forEach(experiment -> { species.addAll(experiment.species()); specimen.addAll(experiment.specimen()); analytes.addAll(experiment.analytes()); @@ -385,8 +375,7 @@ private void reloadProjectContactsSection(Project project) { buildProjectContactsInfoSection(project); } - private void setContent(ProjectOverview projectOverview, Project fullProject, - List experiments) { + private void setContent(ProjectOverview projectOverview, Project fullProject) { Objects.requireNonNull(projectOverview); buildHeaderSection(projectOverview); buildDesignSection(projectOverview, fullProject); @@ -426,7 +415,7 @@ private void handleSubmission(String projectId, ProjectContact manager, private void buildProjectContactsInfoSection(Project project) { // set up the edit button, that opens the dialog for editing contacts - var editButton = createButtonWithListener("Edit", listener -> { + var editButton = createButtonWithListener(listener -> { var editContacts = new EditContactsComponent(convertToInfo(project), Utility.tryToLoadFromPrincipal().orElse(null), personLookupService); AppDialog dialog = AppDialog.medium(); @@ -516,7 +505,7 @@ private Div renderContactInfo(Contact contact) { private void buildFundingInformationSection(Project fullProject, ProjectInformation projectInformation) { - var editButton = createButtonWithListener("Edit", listener -> { + var editButton = createButtonWithListener(listener -> { var dialog = AppDialog.small(); DialogHeader.with(dialog, "Edit funding information"); DialogFooter.with(dialog, CANCEL_BUTTON_TEXT, SAVE_BUTTON_TEXT); @@ -612,7 +601,7 @@ private void buildDesignSection(ProjectOverview projectInformation, Project proj editButton.addClickListener(listener -> { var form = new ProjectDesignForm(); form.setContent(convertToInfo(project)); - AppDialog dialog = createEditDesignDialog(project.getId().value(), form, form); + AppDialog dialog = createEditDesignDialog(form, form); dialog.registerCancelAction(dialog::close); dialog.registerConfirmAction(() -> { dialog.close(); @@ -837,18 +826,17 @@ private void handleSuccess(ProjectUpdateResponse response) { */ private void handleRequestFailed(RequestFailedException error) { log.error("request failed", error); - getUI().ifPresent(ui -> ui.access(() -> { - requestCache.get(error.getRequestId()).ifPresentOrElse(request -> { - // do sth with the cache - var toast = notificationFactory.toast("project.updated.error.retry", - new String[]{}, getLocale()); - toast.open(); - }, () -> { - var toast = notificationFactory.toast("project.updated.error", - new String[]{}, getLocale()); - toast.open(); - }); - })); + getUI().ifPresent( + ui -> ui.access(() -> requestCache.get(error.getRequestId()).ifPresentOrElse(request -> { + // do sth with the cache + var toast = notificationFactory.toast("project.updated.error.retry", + new String[]{}, getLocale()); + toast.open(); + }, () -> { + var toast = notificationFactory.toast("project.updated.error", + new String[]{}, getLocale()); + toast.open(); + }))); } /* @@ -876,7 +864,7 @@ private void handleAccessDenied(AccessDeniedException e) { })); } - private AppDialog createEditDesignDialog(String projectId, Component body, UserInput input) { + private AppDialog createEditDesignDialog(Component body, UserInput input) { var appDialog = AppDialog.medium(); DialogHeader.with(appDialog, "Edit Project Design"); DialogFooter.with(appDialog, CANCEL_BUTTON_TEXT, SAVE_BUTTON_TEXT); @@ -933,6 +921,18 @@ public String getFilename() { public InputStream getStream() { return forSummary(context.projectId().orElseThrow()); } + + @Override + public String getContentType() { + return "application/zip"; + } + + @Override + public Optional contentLength() { + return Optional.empty(); + } + + }); } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementDetailsComponent.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementDetailsComponent.java index e3380d5c64..1712320097 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementDetailsComponent.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementDetailsComponent.java @@ -17,7 +17,7 @@ import com.vaadin.flow.component.icon.VaadinIcon; import com.vaadin.flow.data.provider.CallbackDataProvider.CountCallback; import com.vaadin.flow.data.provider.CallbackDataProvider.FetchCallback; -import com.vaadin.flow.server.StreamResource; +import com.vaadin.flow.server.streams.DownloadHandler; import com.vaadin.flow.shared.Registration; import com.vaadin.flow.spring.data.VaadinSpringDataHelpers; import java.io.Serializable; @@ -60,9 +60,6 @@ */ public class MeasurementDetailsComponent extends PageArea implements Serializable { - private static final StreamResource ROR_ICON_RESOURCE = new StreamResource("ROR_logo.svg", - () -> MeasurementDetailsComponent.class.getClassLoader() - .getResourceAsStream("icons/ROR_logo.svg")); private static final NumberFormat INJECTION_VOLUME_FORMAT = new DecimalFormat("#.##"); private static final DateTimeFormat MEASUREMENT_REGISTRATION_DATE_TIME_FORMAT = DateTimeFormat.ISO_LOCAL_DATE_TIME_WHITESPACE_SEPARATED; @@ -881,7 +878,9 @@ private static Span renderMsDevice(String label, String oboId, String iri) { } private static Anchor renderOrganisation(String label, String iri) { - SvgIcon svgIcon = new SvgIcon(ROR_ICON_RESOURCE); + DownloadHandler downloadHandler = + DownloadHandler.forClassResource(MeasurementDetailsComponent.class, "/icons/ROR_logo.svg"); + SvgIcon svgIcon = new SvgIcon(downloadHandler); svgIcon.addClassName("organisation-icon"); Span organisationLabel = new Span(label); Anchor organisationAnchor = new Anchor(iri, organisationLabel, svgIcon); diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementMain.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementMain.java index 7a554dd5cd..8d6385c2b7 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementMain.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementMain.java @@ -18,12 +18,13 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Consumer; import java.util.function.IntConsumer; import life.qbic.application.commons.ApplicationException; import life.qbic.application.commons.FileNameFormatter; +import life.qbic.datamanager.configuration.UploadConfiguration; import life.qbic.datamanager.files.export.download.DownloadStreamProvider; import life.qbic.datamanager.files.export.download.WorkbookDownloadStreamProvider; import life.qbic.datamanager.files.parsing.converters.ConverterRegistry; @@ -53,16 +54,16 @@ import life.qbic.projectmanagement.application.api.AsyncProjectService.MeasurementRegistrationInformationPxP; import life.qbic.projectmanagement.application.api.AsyncProjectService.MeasurementRegistrationRequest; import life.qbic.projectmanagement.application.api.AsyncProjectService.MeasurementRegistrationRequestBody; +import life.qbic.projectmanagement.application.api.AsyncProjectService.MeasurementUpdateInformationIP; import life.qbic.projectmanagement.application.api.AsyncProjectService.MeasurementUpdateInformationNGS; import life.qbic.projectmanagement.application.api.AsyncProjectService.MeasurementUpdateInformationPxP; -import life.qbic.projectmanagement.application.api.AsyncProjectService.MeasurementUpdateInformationIP; import life.qbic.projectmanagement.application.api.AsyncProjectService.MeasurementUpdateRequest; import life.qbic.projectmanagement.application.api.AsyncProjectService.MeasurementUpdateRequestBody; import life.qbic.projectmanagement.application.api.AsyncProjectService.ValidationRequestBody; import life.qbic.projectmanagement.application.api.fair.DigitalObject; +import life.qbic.projectmanagement.application.measurement.IpMeasurementLookup; import life.qbic.projectmanagement.application.measurement.MeasurementService; import life.qbic.projectmanagement.application.measurement.MeasurementService.MeasurementDeletionException; -import life.qbic.projectmanagement.application.measurement.IpMeasurementLookup; import life.qbic.projectmanagement.application.measurement.NgsMeasurementLookup; import life.qbic.projectmanagement.application.measurement.PxpMeasurementLookup; import life.qbic.projectmanagement.application.measurement.validation.MeasurementValidationService; @@ -77,6 +78,7 @@ import org.apache.poi.ss.usermodel.Workbook; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; import reactor.core.publisher.Flux; @@ -115,6 +117,7 @@ public class MeasurementMain extends Main implements BeforeEnterObserver { private final UiHandle uiHandle = new UiHandle(); + private UploadConfiguration uploadConfiguration; static class ProjectContext { @@ -146,7 +149,8 @@ public MeasurementMain( MessageSourceNotificationFactory messageSourceNotificationFactory, NgsMeasurementLookup ngsMeasurementLookup, PxpMeasurementLookup pxpMeasurementLookup, - IpMeasurementLookup ipMeasurementLookup) { + IpMeasurementLookup ipMeasurementLookup, + UploadConfiguration uploadConfiguration) { Objects.requireNonNull(measurementService); Objects.requireNonNull(measurementValidationService); Objects.requireNonNull(asyncProjectService); @@ -159,6 +163,7 @@ public MeasurementMain( DownloadComponent measurementTemplateDownload = new DownloadComponent(); this.registerSamplesDisclaimer = createNoSamplesRegisteredDisclaimer(); this.noMeasurementDisclaimer = createNoMeasurementDisclaimer(); + this.uploadConfiguration = Objects.requireNonNull(uploadConfiguration); initContent(); addClassName("measurement"); @@ -235,7 +240,7 @@ private AppDialog ngsEditDialog(List selectedMeasurementIds) { var upload = new MeasurementUpload(asyncService, context, ConverterRegistry.converterFor( - MeasurementUpdateInformationNGS.class), messageFactory); + MeasurementUpdateInformationNGS.class), messageFactory, uploadConfiguration); var uploadComponent = new MeasurementUpdateComponent(templateDownload, upload); DialogBody.with(dialog, uploadComponent, uploadComponent); dialog.registerCancelAction(dialog::close); @@ -264,7 +269,7 @@ private AppDialog pxpEditDialog(List selectedMeasurementIds) { var upload = new MeasurementUpload(asyncService, context, ConverterRegistry.converterFor( - MeasurementUpdateInformationPxP.class), messageFactory); + MeasurementUpdateInformationPxP.class), messageFactory, uploadConfiguration); var uploadComponent = new MeasurementUpdateComponent(templateDownload, upload); DialogBody.with(dialog, uploadComponent, uploadComponent); dialog.registerCancelAction(dialog::close); @@ -293,7 +298,8 @@ private AppDialog ipEditDialog(List selectedMeasurementIds) { var upload = new MeasurementUpload(asyncService, context, ConverterRegistry.converterFor( - MeasurementUpdateInformationIP.class), messageFactory); + MeasurementUpdateInformationIP.class), messageFactory, + uploadConfiguration); var uploadComponent = new MeasurementUpdateComponent(templateDownload, upload); DialogBody.with(dialog, uploadComponent, uploadComponent); dialog.registerCancelAction(dialog::close); @@ -379,7 +385,7 @@ private void handleDeletionError(MeasurementDeletionException error) { case FAILED -> "Deletion failed. Please try again."; case DATA_ATTACHED -> "Data is attached to one or more measurements."; }; - showErrorNotification("Deletion failed", errorMessage); + showErrorNotification(errorMessage); } private void handleDeletionSuccessNgs(int count) { @@ -472,6 +478,16 @@ public String getFilename() { public InputStream getStream() { return digitalObject.content(); } + + @Override + public String getContentType() { + return MimeTypeUtils.APPLICATION_OCTET_STREAM_VALUE; + } + + @Override + public Optional contentLength() { + return Optional.empty(); + } }; downloadComponent.trigger(downloadStreamProvider); } @@ -521,7 +537,7 @@ private void openRegistrationDialog() { var registrationMeasurementUpload = new MeasurementUpload(asyncService, context, ConverterRegistry.converterFor(MeasurementRegistrationInformationNGS.class), - messageSourceNotificationFactory); + messageSourceNotificationFactory, uploadConfiguration); var templateComponent = new MeasurementTemplateSelectionComponent( Map.ofEntries( Map.entry(MeasurementTemplateSelectionComponent.Domain.Genomics, @@ -533,6 +549,11 @@ public String getFilename() { "xlsx"); } + @Override + public Optional contentLength() { + return Optional.empty(); + } + @Override public Workbook getWorkbook() { return NGSWorkbooks.createRegistrationWorkbook(); @@ -547,6 +568,11 @@ public String getFilename() { "xlsx"); } + @Override + public Optional contentLength() { + return Optional.empty(); + } + @Override public Workbook getWorkbook() { return ProteomicsWorkbooks.createRegistrationWorkbook(); @@ -561,6 +587,11 @@ public String getFilename() { 1, "xlsx"); } + @Override + public Optional contentLength() { + return Optional.empty(); + } + @Override public Workbook getWorkbook() { return IPWorkbooks.createRegistrationWorkbook(); @@ -798,7 +829,7 @@ private static List mergeByPoolNGS( } private void processResults(int numberOfSuccesses, int numberOfRequests, - Consumer onSuccess, IntConsumer onFailure) { + IntConsumer onSuccess, IntConsumer onFailure) { if (numberOfSuccesses > 0 && numberOfSuccesses == numberOfRequests) { // Only successful registrations onSuccess.accept(numberOfSuccesses); @@ -856,8 +887,8 @@ private void routeToSampleCreation(ComponentEvent componentEvent) { } } - private void showErrorNotification(String title, String description) { - ErrorMessage errorMessage = new ErrorMessage(title, description); + private void showErrorNotification(String description) { + ErrorMessage errorMessage = new ErrorMessage("Deletion failed", description); StyledNotification notification = new StyledNotification(errorMessage); notification.open(); } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementPresenter.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementPresenter.java deleted file mode 100644 index e98ec1e946..0000000000 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementPresenter.java +++ /dev/null @@ -1,127 +0,0 @@ -package life.qbic.datamanager.views.projects.project.measurements; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import life.qbic.projectmanagement.application.sample.SampleInformationService; -import life.qbic.projectmanagement.domain.model.measurement.NGSIndex; -import life.qbic.projectmanagement.domain.model.measurement.NGSMeasurement; -import life.qbic.projectmanagement.domain.model.measurement.NGSSpecificMeasurementMetadata; -import life.qbic.projectmanagement.domain.model.measurement.ProteomicsMeasurement; -import life.qbic.projectmanagement.domain.model.measurement.ProteomicsSpecificMeasurementMetadata; -import life.qbic.projectmanagement.domain.model.sample.SampleId; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -/** - * Measurement presenter - *

- * Some helper methods to render {@link ProteomicsMeasurement} information. - * - * @since 1.0.0 - */ -@Component -public class MeasurementPresenter { - - private final SampleInformationService sampleInformationService; - - public MeasurementPresenter(@Autowired SampleInformationService sampleInformationService) { - this.sampleInformationService = Objects.requireNonNull(sampleInformationService); - } - - private static ProteomicsMeasurementEntry convertProteomicsMeasurement( - ProteomicsMeasurement measurement, - SampleInformation sampleInfo, - ProteomicsSpecificMeasurementMetadata specificMeasurementMetadata) { - return new ProteomicsMeasurementEntry(measurement.measurementCode().value(), - sampleInfo, - measurement.technicalReplicateName().orElse(""), - measurement.organisation().IRI(), - measurement.organisation().label(), - measurement.msDevice().getOboId().replace("_", ":"), - measurement.msDevice().getLabel(), - measurement.samplePoolGroup().orElse(""), - measurement.facility(), - specificMeasurementMetadata.fractionName(), - measurement.digestionEnzyme(), - measurement.digestionMethod(), - measurement.enrichmentMethod(), - String.valueOf(measurement.injectionVolume()), - measurement.lcColumn(), - measurement.lcmsMethod(), - measurement.labelType(), - specificMeasurementMetadata.label(), - specificMeasurementMetadata.comment().orElse("")); - } - - private static NGSMeasurementEntry convertNGSMeasurement(NGSMeasurement measurement, - SampleInformation sampleInfo, NGSSpecificMeasurementMetadata specificMeasurementMetadata) { - return new NGSMeasurementEntry(measurement.measurementCode().value(), - sampleInfo, measurement.organisation().IRI(), measurement.organisation().label(), - measurement.instrument().getOboId().replace("_", ":"), - measurement.instrument().getLabel(), - measurement.samplePoolGroup().orElse(""), measurement.facility(), - measurement.sequencingReadType(), - measurement.libraryKit().orElse(""), measurement.flowCell().orElse(""), - measurement.sequencingRunProtocol().orElse(""), - specificMeasurementMetadata.index().orElse(new NGSIndex("", "")).indexI7(), - specificMeasurementMetadata.index().orElse(new NGSIndex("", "")).indexI5(), - specificMeasurementMetadata.comment().orElse("")); - } - - public List expandProteomicsPools( - ProteomicsMeasurement proteomicsMeasurement) { - List expandedEntries = new ArrayList<>(); - for (SampleId sampleId : proteomicsMeasurement.measuredSamples()) { - var sampleInfo = sampleInformationService.findSample(sampleId) - .map(sample -> new SampleInformation(sample.sampleCode().code(), sample.label())) - .orElse(new SampleInformation("", "")); - var specificMetadata = proteomicsMeasurement.specificMetadata().stream() - .filter(metadata -> metadata.measuredSample().equals(sampleId)).findFirst(); - if (specificMetadata.isPresent()) { - expandedEntries.add( - convertProteomicsMeasurement(proteomicsMeasurement, sampleInfo, specificMetadata.get())); - } else { - expandedEntries.add(convertProteomicsMeasurement(proteomicsMeasurement, sampleInfo)); - } - - } - return expandedEntries; - } - - private ProteomicsMeasurementEntry convertProteomicsMeasurement( - ProteomicsMeasurement measurement, SampleInformation sampleInfo) { - return new ProteomicsMeasurementEntry(measurement.measurementCode().value(), - sampleInfo, - measurement.technicalReplicateName().orElse(""), - measurement.organisation().IRI(), - measurement.organisation().label(), - measurement.msDevice().getOboId().replace("_", ":"), - measurement.msDevice().getLabel(), - measurement.samplePoolGroup().orElse(""), - measurement.facility(), - "", - measurement.digestionEnzyme(), - measurement.digestionMethod(), - measurement.enrichmentMethod(), - String.valueOf(measurement.injectionVolume()), - measurement.lcColumn(), - measurement.lcmsMethod(), - measurement.labelType(), - "", - ""); - } - - public List expandNGSPools(NGSMeasurement ngsMeasurement) { - List expandedEntries = new ArrayList<>(); - for (NGSSpecificMeasurementMetadata specificMeasurementMetadata : ngsMeasurement.specificMeasurementMetadata()) { - var sampleInfo = sampleInformationService.findSample( - specificMeasurementMetadata.measuredSample()) - .map(sample -> new SampleInformation(sample.sampleCode().code(), - sample.label())).orElse(new SampleInformation("", "")); - expandedEntries.add( - convertNGSMeasurement(ngsMeasurement, sampleInfo, specificMeasurementMetadata)); - } - return expandedEntries; - } -} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementTemplateComponent.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementTemplateComponent.java index 144e9ea92a..4ef5838645 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementTemplateComponent.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementTemplateComponent.java @@ -6,6 +6,7 @@ import com.vaadin.flow.component.html.Div; import java.io.InputStream; import java.time.LocalDate; +import java.util.Optional; import java.util.function.Supplier; import life.qbic.application.commons.FileNameFormatter; import life.qbic.datamanager.files.export.download.DownloadStreamProvider; @@ -13,6 +14,7 @@ import life.qbic.datamanager.views.notifications.MessageSourceNotificationFactory; import life.qbic.datamanager.views.notifications.Toast; import life.qbic.projectmanagement.application.api.fair.DigitalObject; +import org.springframework.util.MimeTypeUtils; import reactor.core.publisher.Mono; /** @@ -63,16 +65,14 @@ public MeasurementTemplateComponent( var failureToast = messageFactory.toast("task.failed", new Object[]{"Template generation"}, getLocale()); var inProgressToast = messageFactory.pendingTaskToast("measurement.preparing-download", MessageSourceNotificationFactory.EMPTY_PARAMETERS, getLocale()); - var downloadButton = new Button(buttonText, e -> { - templateMono - .doOnSubscribe(ignored -> openToast(inProgressToast)) - .doOnSuccess(this::triggerDownload).doOnTerminate(() -> closeToast(inProgressToast)) - .doOnError(throwable -> { - closeToast(inProgressToast); - openToast(failureToast); - }) - .subscribe(); - }); + var downloadButton = new Button(buttonText, e -> templateMono + .doOnSubscribe(ignored -> openToast(inProgressToast)) + .doOnSuccess(this::triggerDownload).doOnTerminate(() -> closeToast(inProgressToast)) + .doOnError(throwable -> { + closeToast(inProgressToast); + openToast(failureToast); + }) + .subscribe()); var buttonElement = new Div(downloadButton); add(descriptionElement, buttonElement, downloadComponent); @@ -87,20 +87,29 @@ private void openToast(Toast toast) { } private void triggerDownload(DigitalObject digitalObject) { - getUI().ifPresent(ui -> ui.access(() -> { - downloadComponent.trigger(new DownloadStreamProvider() { - @Override - public String getFilename() { - var projectId = projectIdSupplier.get(); - return FileNameFormatter.formatWithTimestampedSimple(LocalDate.now(), projectId, "measurements", "xlsx"); - } + getUI().ifPresent(ui -> ui.access(() -> downloadComponent.trigger(new DownloadStreamProvider() { + @Override + public String getFilename() { + var projectId = projectIdSupplier.get(); + return FileNameFormatter.formatWithTimestampedSimple(LocalDate.now(), projectId, + "measurements", "xlsx"); + } - @Override - public InputStream getStream() { - return digitalObject.content(); - } - }); - })); + @Override + public InputStream getStream() { + return digitalObject.content(); + } + + @Override + public String getContentType() { + return MimeTypeUtils.APPLICATION_OCTET_STREAM_VALUE; + } + + @Override + public Optional contentLength() { + return Optional.empty(); + } + }))); } } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementUpdateComponent.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementUpdateComponent.java index f04fe127d3..f1356f302e 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementUpdateComponent.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/MeasurementUpdateComponent.java @@ -17,14 +17,12 @@ public class MeasurementUpdateComponent extends Div implements UserInput { - private final MeasurementTemplateComponent templateComponent; private final MeasurementUpload measurementUpload; public MeasurementUpdateComponent( MeasurementTemplateComponent templateComponent, MeasurementUpload measurementUpload ) { - this.templateComponent = Objects.requireNonNull(templateComponent); this.measurementUpload = Objects.requireNonNull(measurementUpload); var templateSection = DialogSection.with("Download Metadata", templateComponent); diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/registration/MeasurementUpload.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/registration/MeasurementUpload.java index d819271e97..bc83c3e294 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/registration/MeasurementUpload.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/measurements/registration/MeasurementUpload.java @@ -2,17 +2,14 @@ import static java.util.Objects.requireNonNull; +import com.vaadin.flow.component.Component; import com.vaadin.flow.component.Focusable; import com.vaadin.flow.component.Text; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.VaadinIcon; -import com.vaadin.flow.component.upload.FailedEvent; import com.vaadin.flow.component.upload.FileRejectedEvent; -import com.vaadin.flow.component.upload.FileRemovedEvent; -import com.vaadin.flow.component.upload.SucceededEvent; -import com.vaadin.flow.component.upload.Upload; import java.io.InputStream; import java.io.Serial; import java.time.Duration; @@ -28,6 +25,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import java.util.stream.Collectors; +import life.qbic.datamanager.configuration.UploadConfiguration; import life.qbic.datamanager.files.parsing.ParsingResult; import life.qbic.datamanager.files.parsing.converters.MetadataConverterV2; import life.qbic.datamanager.files.parsing.tsv.TSVParser; @@ -38,7 +36,8 @@ import life.qbic.datamanager.views.general.dialog.DialogSection; import life.qbic.datamanager.views.general.dialog.InputValidation; import life.qbic.datamanager.views.general.dialog.UserInput; -import life.qbic.datamanager.views.general.upload.EditableMultiFileMemoryBuffer; +import life.qbic.datamanager.views.general.upload.ContentUploadComponent; +import life.qbic.datamanager.views.general.upload.UploadedFilesChangeListener.FileEntry; import life.qbic.datamanager.views.notifications.MessageSourceNotificationFactory; import life.qbic.datamanager.views.projects.project.measurements.processor.ProcessorRegistry; import life.qbic.projectmanagement.application.ValidationResult; @@ -52,6 +51,8 @@ import life.qbic.projectmanagement.application.api.AsyncProjectService.ValidationRequest; import life.qbic.projectmanagement.application.api.AsyncProjectService.ValidationRequestBody; import life.qbic.projectmanagement.application.api.AsyncProjectService.ValidationResponse; +import org.jspecify.annotations.NonNull; +import org.springframework.util.unit.DataSize; import reactor.core.publisher.Flux; /** @@ -79,7 +80,6 @@ public class MeasurementUpload extends Div implements UserInput { private final MessageSourceNotificationFactory notificationFactory; private final UiHandle uiHandle = new UiHandle(); // keeps a reference to the current UI - private final EditableMultiFileMemoryBuffer uploadBuffer; private final List measurementFileItems; private enum AcceptedFileType { @@ -107,7 +107,8 @@ public MeasurementUpload( AsyncProjectService service, Context context, MetadataConverterV2 converter, - MessageSourceNotificationFactory notificationFactory) { + MessageSourceNotificationFactory notificationFactory, + UploadConfiguration uploadConfiguration) { isValidationInProgress = new AtomicBoolean(false); // Initial parameter validation of injected objects this.service = requireNonNull(service); @@ -116,17 +117,30 @@ public MeasurementUpload( this.notificationFactory = requireNonNull(notificationFactory); // Initial parameter instantiation of local objects this.measurementFileItems = new ArrayList<>(); - this.uploadBuffer = new EditableMultiFileMemoryBuffer(); this.validationProgress = new Div(); // Set up the upload section - var upload = new Upload(uploadBuffer); + var upload = new ContentUploadComponent(requireNonNull(uploadConfiguration)); upload.setAcceptedFileTypes(AcceptedFileType.allMimeTypes().toArray(String[]::new)); - upload.setMaxFileSize(MAX_FILE_SIZE_BYTES); - upload.addSucceededListener(this::onUploadSucceeded); - upload.addFileRejectedListener(this::onFileRejected); - upload.addFailedListener(this::onUploadFailed); - upload.addFileRemovedListener(this::onFileRemoved); + upload.setMaxFileSize(DataSize.ofBytes(MAX_FILE_SIZE_BYTES)); + upload.addChangeListener(event -> + { + switch (event.changeType()) { + case FILE_ADDED -> { + for (FileEntry changedFile : event.changedFiles()) { + onUploadSucceeded(changedFile.fileName(), changedFile.mimeType(), + upload.getContent(changedFile.fileName()).orElseThrow()); + } + } + case FILE_REMOVED -> { + for (FileEntry changedFile : event.changedFiles()) { + removeFile(changedFile.fileName()); + } + } + } + } + ); + upload.addFileRejectedListener(rejectedEvent -> displayError()); this.uploadedItemsDisplay = new UploadedItemsDisplay(upload); // Create the different sections @@ -170,7 +184,6 @@ private void refresh() { } private void removeFile(String fileName) { - uploadBuffer.remove(fileName); measurementFileItems.removeIf( measurementFileItem -> measurementFileItem.fileName().equals(fileName)); uploadedItemsDisplay.removeFileFromDisplay(fileName); @@ -178,14 +191,6 @@ private void removeFile(String fileName) { refresh(); } - private void onFileRemoved(FileRemovedEvent fileRemovedEvent) { - removeFile(fileRemovedEvent.getFileName()); - } - - private void onUploadFailed(FailedEvent failedEvent) { - displayError(); - } - private void displayUnsupportedFileType() { var toast = notificationFactory.toast("measurement.upload.failed.unsupported-file-type", new Object[]{}, getLocale()); @@ -198,18 +203,17 @@ private void displayError() { toast.open(); } - private void onUploadSucceeded(SucceededEvent succeededEvent) { - var fileName = succeededEvent.getFileName(); - Optional knownFileType = AcceptedFileType.forMimeType( - succeededEvent.getMIMEType()); + private void onUploadSucceeded(String fileName, String mimeType, InputStream inputStream) { + Optional knownFileType = AcceptedFileType.forMimeType( + mimeType); if (knownFileType.isEmpty()) { displayUnsupportedFileType(); return; } var parsingResult = switch (knownFileType.get()) { - case EXCEL -> parseXLSX(uploadBuffer.inputStream(fileName).orElseThrow()); - case TSV -> parseTSV(uploadBuffer.inputStream(fileName).orElseThrow()); + case EXCEL -> parseXLSX(inputStream); + case TSV -> parseTSV(inputStream); }; var result = converter.convert(parsingResult); @@ -233,35 +237,41 @@ private static List process( if (validationRequest.isEmpty()) { return validationRequest; } - var firstEntry = requireNonNull(validationRequest.get(0)); - switch (firstEntry) { - case MeasurementRegistrationInformationNGS ignored: { + if (validationRequest.stream() + .map(Object::getClass) + .distinct().count() > 1) { + // not all the same class + throw new RuntimeException("Not all validation requests are of the same class"); + } + var firstEntry = requireNonNull(validationRequest.getFirst()); + return switch (firstEntry) { + case MeasurementRegistrationInformationNGS ignored -> { var processor = ProcessorRegistry.processorFor(MeasurementRegistrationInformationNGS.class); - return processor.process((List) validationRequest); + yield processor.process((List) validationRequest); } - case MeasurementRegistrationInformationPxP ignored: { + case MeasurementRegistrationInformationPxP ignored -> { var processor = ProcessorRegistry.processorFor(MeasurementRegistrationInformationPxP.class); - return processor.process((List) validationRequest); + yield processor.process((List) validationRequest); } - case MeasurementRegistrationInformationIP ignored: { + case MeasurementRegistrationInformationIP ignored -> { var processor = ProcessorRegistry.processorFor(MeasurementRegistrationInformationIP.class); - return processor.process((List) validationRequest); + yield processor.process((List) validationRequest); } - case MeasurementUpdateInformationPxP ignored: { + case MeasurementUpdateInformationPxP ignored -> { var processor = ProcessorRegistry.processorFor(MeasurementUpdateInformationPxP.class); - return processor.process((List) validationRequest); + yield processor.process((List) validationRequest); } - case MeasurementUpdateInformationNGS ignored: { + case MeasurementUpdateInformationNGS ignored -> { var processor = ProcessorRegistry.processorFor(MeasurementUpdateInformationNGS.class); - return processor.process((List) validationRequest); + yield processor.process((List) validationRequest); } - case MeasurementUpdateInformationIP ignored: { + case MeasurementUpdateInformationIP ignored -> { var processor = ProcessorRegistry.processorFor(MeasurementUpdateInformationIP.class); - return processor.process((List) validationRequest); + yield processor.process((List) validationRequest); } - default: + default -> throw new IllegalStateException("Unknown validation request: " + validationRequest); - } + }; } private void runValidation(ArrayList requests, int itemsToValidate, @@ -273,10 +283,8 @@ private void runValidation(ArrayList requests, int itemsToVal service.validate(Flux.fromIterable(requests)) .doOnNext(ignored -> counter.incrementAndGet()) .bufferTimeout(20, Duration.ofMillis(200)) - .doOnNext(item -> uiHandle.onUiAndPush(() -> { - setValidationProgressText( - "Processed " + counter.get() + " requests from " + itemsToValidate); - })) + .doOnNext(item -> uiHandle.onUiAndPush(() -> setValidationProgressText( + "Processed " + counter.get() + " requests from " + itemsToValidate))) .flatMap(Flux::fromIterable) .collectList() .doOnSuccess(it -> uiHandle.onUi(this::validationFinished)) @@ -367,6 +375,7 @@ private ParsingResult parseTSV(InputStream inputStream) { } @Override + @NonNull public InputValidation validate() { var inputWithFailureSearch = measurementFileItems.stream() .map(MeasurementFileItem::measurementValidationReport) @@ -389,7 +398,7 @@ private static class UploadedItemsDisplay extends Div { private final Div uploadedItemsDisplays; private final DialogSection uploadedFilesSection; - public UploadedItemsDisplay(Upload upload) { + public UploadedItemsDisplay(Component uploadComponent) { var saveYourFileInfo = new InfoBox().setInfoText( "When uploading a tab-separated file, please save your Excel file as UTF-16 Unicode Text (*.txt) before uploading.") @@ -403,7 +412,7 @@ public UploadedItemsDisplay(Upload upload) { "Maximum file size: %s MB".formatted(MAX_FILE_SIZE_BYTES / Math.pow(1024, 2)))); this.uploadSection = new Div(); - uploadSection.add(saveYourFileInfo, upload, restrictions); + uploadSection.add(saveYourFileInfo, uploadComponent, restrictions); uploadSection.addClassNames("upload-section", "flex-vertical", "gap-03"); uploadedItemsDisplays = new Div(); diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/rawdata/RawDataDetailsComponent.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/rawdata/RawDataDetailsComponent.java index 1901527fc8..601b001e1d 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/rawdata/RawDataDetailsComponent.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/rawdata/RawDataDetailsComponent.java @@ -51,6 +51,7 @@ import life.qbic.projectmanagement.application.api.AsyncProjectService.SortDirection; import life.qbic.projectmanagement.application.api.AsyncProjectService.SortOrder; import org.springframework.lang.NonNull; +import org.springframework.util.MimeTypeUtils; /** * Raw Data Details Component @@ -104,13 +105,15 @@ public RawDataDetailsComponent( final FilterGridTabSheet filterTabSheet = new FilterGridTabSheet(); filterTabSheet.removeAllTabs(); - if (asyncProjectService.countRawDataNgs(projectId, experimentId, - new RawDatasetFilter("", List.of())).block(MAX_BLOCKING_DURATION) > 0) { + Integer ngsDataCount = asyncProjectService.countRawDataNgs(projectId, experimentId, + new RawDatasetFilter("", List.of())).block(MAX_BLOCKING_DURATION); + if (ngsDataCount != null && ngsDataCount > 0) { var filterGridNgs = createNgsFilterGrid(createNgsRawDataGrid(), projectId, experimentId); addNgsTab(filterTabSheet, 0, "Genomics", filterGridNgs); } - if (asyncProjectService.countRawDataPxp(projectId, experimentId, - new RawDatasetFilter("", List.of())).block(MAX_BLOCKING_DURATION) > 0) { + Integer pxpDataCount = asyncProjectService.countRawDataPxp(projectId, experimentId, + new RawDatasetFilter("", List.of())).block(MAX_BLOCKING_DURATION); + if (pxpDataCount != null && pxpDataCount > 0) { var filterGridPxp = createPxpFilterGrid(createPxpRawDataGrid(), projectId, experimentId); addPxpTab(filterTabSheet, 1, "Proteomics", filterGridPxp); } @@ -214,6 +217,16 @@ public InputStream getStream() { return new BufferedInputStream( new ByteArrayInputStream(file.getBytes(StandardCharsets.UTF_8))); } + + @Override + public String getContentType() { + return MimeTypeUtils.TEXT_PLAIN_VALUE; + } + + @Override + public Optional contentLength() { + return Optional.of((long) file.getBytes(StandardCharsets.UTF_8).length); + } }; } @@ -542,7 +555,7 @@ public RawDataFilter(@NonNull String searchTerm) { } public Optional searchTerm() { - return Optional.ofNullable(searchTerm); + return Optional.of(searchTerm); } } } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleDetailsComponent.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleDetailsComponent.java index 38e8f9c659..7fc1717ebf 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleDetailsComponent.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleDetailsComponent.java @@ -243,6 +243,16 @@ public String getFilename() { public InputStream getStream() { return digitalObject.content(); } + + @Override + public String getContentType() { + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + } + + @Override + public Optional contentLength() { + return Optional.empty(); + } }); })); } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleInformationMain.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleInformationMain.java index e294efd34e..edda101784 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleInformationMain.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/samples/SampleInformationMain.java @@ -5,31 +5,21 @@ import com.vaadin.flow.component.Component; import com.vaadin.flow.component.ComponentEvent; import com.vaadin.flow.component.UI; -import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Span; -import com.vaadin.flow.component.icon.VaadinIcon; -import com.vaadin.flow.component.textfield.TextField; -import com.vaadin.flow.data.value.ValueChangeMode; import com.vaadin.flow.router.BeforeEnterEvent; import com.vaadin.flow.router.BeforeEnterObserver; import com.vaadin.flow.router.Route; import com.vaadin.flow.spring.annotation.SpringComponent; import com.vaadin.flow.spring.annotation.UIScope; import jakarta.annotation.security.PermitAll; -import java.io.IOException; import java.io.Serial; -import java.time.LocalDate; import java.util.ArrayList; -import java.util.HashMap; -import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; import life.qbic.application.commons.ApplicationException; -import life.qbic.application.commons.FileNameFormatter; import life.qbic.datamanager.ClientDetailsProvider; -import life.qbic.datamanager.files.export.download.ByteArrayDownloadStreamProvider; +import life.qbic.datamanager.configuration.UploadConfiguration; import life.qbic.datamanager.views.AppRoutes.ProjectRoutes; import life.qbic.datamanager.views.Context; import life.qbic.datamanager.views.general.Disclaimer; @@ -38,7 +28,6 @@ import life.qbic.datamanager.views.general.download.DownloadComponent; import life.qbic.datamanager.views.notifications.CancelConfirmationDialogFactory; import life.qbic.datamanager.views.notifications.MessageSourceNotificationFactory; -import life.qbic.datamanager.views.notifications.Toast; import life.qbic.datamanager.views.projects.project.experiments.ExperimentMainLayout; import life.qbic.datamanager.views.projects.project.samples.BatchDetailsComponent.DeleteBatchEvent; import life.qbic.datamanager.views.projects.project.samples.BatchDetailsComponent.EditBatchEvent; @@ -50,13 +39,10 @@ import life.qbic.projectmanagement.application.ProjectInformationService; import life.qbic.projectmanagement.application.ProjectOverview; import life.qbic.projectmanagement.application.api.AsyncProjectService; -import life.qbic.projectmanagement.application.api.AsyncProjectService.AccessDeniedException; import life.qbic.projectmanagement.application.api.AsyncProjectService.ProjectCode; -import life.qbic.projectmanagement.application.api.fair.DigitalObject; import life.qbic.projectmanagement.application.batch.BatchInformationService; import life.qbic.projectmanagement.application.confounding.ConfoundingVariableService.ExperimentReference; import life.qbic.projectmanagement.application.experiment.ExperimentInformationService; -import life.qbic.projectmanagement.application.sample.SampleInformationService; import life.qbic.projectmanagement.application.sample.SampleRegistrationServiceV2; import life.qbic.projectmanagement.application.sample.SampleValidationService; import life.qbic.projectmanagement.domain.model.batch.BatchId; @@ -65,9 +51,7 @@ import life.qbic.projectmanagement.domain.model.project.Project; import life.qbic.projectmanagement.domain.model.project.ProjectId; import life.qbic.projectmanagement.domain.model.sample.Sample; -import org.reactivestreams.Subscription; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.util.MimeType; /** * Sample Information Main Component @@ -83,15 +67,13 @@ @PermitAll public class SampleInformationMain extends Main implements BeforeEnterObserver { - public static final String PROJECT_ID_ROUTE_PARAMETER = "projectId"; - public static final String EXPERIMENT_ID_ROUTE_PARAMETER = "experimentId"; - private static final MimeType OPEN_XML = MimeType.valueOf( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); @Serial private static final long serialVersionUID = 3778218989387044758L; + + public static final String PROJECT_ID_ROUTE_PARAMETER = "projectId"; + public static final String EXPERIMENT_ID_ROUTE_PARAMETER = "experimentId"; private static final Logger log = LoggerFactory.logger(SampleInformationMain.class); private final transient ExperimentInformationService experimentInformationService; - private final transient SampleInformationService sampleInformationService; private final transient DeletionService deletionService; private transient Component sampleDetailsComponent; private final BatchDetailsComponent batchDetailsComponent; @@ -105,16 +87,12 @@ public class SampleInformationMain extends Main implements BeforeEnterObserver { private final transient SampleValidationService sampleValidationService; private final transient SampleRegistrationServiceV2 sampleRegistrationServiceV2; private final transient AsyncProjectService asyncProjectService; + private final transient UploadConfiguration uploadConfiguration; private final MessageSourceNotificationFactory messageFactory; - private final HashMap pendingTaskToasts = new HashMap<>(); - private final BatchInformationService batchInformationService; - private final ClientDetailsProvider clientDetailsProvider; - private Toast pendingTask; private transient Context context; public SampleInformationMain(@Autowired ExperimentInformationService experimentInformationService, @Autowired DeletionService deletionService, - @Autowired SampleInformationService sampleInformationService, @Autowired AsyncProjectService asyncProjectService, ProjectInformationService projectInformationService, CancelConfirmationDialogFactory cancelConfirmationDialogFactory, @@ -123,13 +101,12 @@ public SampleInformationMain(@Autowired ExperimentInformationService experimentI SampleRegistrationServiceV2 sampleRegistrationServiceV2, MessageSourceNotificationFactory messageSourceNotificationFactory, BatchInformationService batchInformationService, - ClientDetailsProvider clientDetailsProvider) { + ClientDetailsProvider clientDetailsProvider, + UploadConfiguration uploadConfiguration) { this.downloadComponent = new DownloadComponent(); - + this.uploadConfiguration = uploadConfiguration; this.experimentInformationService = requireNonNull(experimentInformationService, "ExperimentInformationService cannot be null"); - this.sampleInformationService = requireNonNull(sampleInformationService, - "SampleInformationService cannot be null"); this.deletionService = requireNonNull(deletionService, "DeletionService cannot be null"); this.sampleDetailsComponent = new Div(); @@ -169,8 +146,6 @@ public SampleInformationMain(@Autowired ExperimentInformationService experimentI System.identityHashCode(sampleDetailsComponent))); add(downloadComponent); this.messageFactory = messageSourceNotificationFactory; - this.batchInformationService = batchInformationService; - this.clientDetailsProvider = clientDetailsProvider; } private static boolean noExperimentGroupsInExperiment(Experiment experiment) { @@ -186,102 +161,6 @@ private void initContent() { content.addClassName("sample-main-content"); } - private void downloadSampleMetadata() { - var projectId = context.projectId().orElseThrow(); - var experimentId = context.experimentId().orElseThrow(); - var projectCode = projectInformationService.findOverview(projectId) - .map(ProjectOverview::projectCode).orElseThrow(); - var experimentName = experimentInformationService.find(projectId.value(), experimentId) - .map(Experiment::getName).orElseThrow(); - - var requestId = UUID.randomUUID().toString(); - - asyncProjectService.sampleInformationTemplate(projectId.value(), experimentId.value(), - OPEN_XML) - .doOnSubscribe(showInProgress(requestId, "preparing sample information")) - .doOnSuccess(resource -> handleSuccess(projectCode, experimentName).accept(resource)) - .doOnError(this::handleError) - .doFinally(signalType -> clearPendingTask(requestId)) - .subscribe(); - } - - private Consumer handleSuccess(String projectCode, String experimentName) { - return resource -> getUI().ifPresent(ui -> ui.access(() -> { - messageFactory.toast("task.finished", new Object[]{"Preparation of sample information"}, - getLocale()).open(); - triggerDownload(resource, - FileNameFormatter.formatWithTimestampedContext(LocalDate.now(), projectCode, - experimentName, - "sample information", - "xlsx")); - })); - } - - private Consumer showInProgress(String requestId, - String preparingSampleInformation) { - return subscriber -> getUI().ifPresent(ui -> ui.access(() -> { - pendingTask = messageFactory.pendingTaskToast("task.in-progress", - new Object[]{preparingSampleInformation}, getLocale()); - pendingTask.open(); - addToast(requestId, pendingTask); - })); - } - - private void addToast(String requestId, Toast pendingTask) { - if (pendingTaskToasts.containsKey(requestId)) { - return; - } - pendingTaskToasts.put(requestId, pendingTask); - } - - private void clearPendingTask(String requestId) { - getUI().ifPresent(ui -> ui.access(() -> { - if (pendingTaskToasts.containsKey(requestId)) { - var toast = pendingTaskToasts.get(requestId); - toast.close(); - pendingTaskToasts.remove(requestId); - } - })); - } - - private void handleError(Throwable throwable) { - switch (throwable) { - case AccessDeniedException ignored: - handleAccessDeniedError(); - return; - default: - handleUnexpectedError(throwable); - } - } - - private void handleUnexpectedError(Throwable throwable) { - throw new ApplicationException("We are sorry, an unexpected error occurred.", throwable); - } - - private void handleAccessDeniedError() { - getUI().ifPresent(ui -> ui.access( - () -> messageFactory.toast("access.denied.message", new Object[]{}, getLocale()).open())); - } - - private void triggerDownload(DigitalObject resource, String filename) { - getUI().ifPresent( - ui -> ui.access(() -> downloadComponent.trigger(new ByteArrayDownloadStreamProvider() { - @Override - public byte[] getBytes() { - try (var content = resource.content()) { - return content.readAllBytes(); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - @Override - public String getFilename() { - return filename; - } - }))); - } - private void onRegisterBatchClicked() { ProjectId projectId = context.projectId().orElseThrow(); ExperimentId experimentId = context.experimentId().orElseThrow(); @@ -296,7 +175,8 @@ private void onRegisterBatchClicked() { .orElseThrow(); RegisterSampleBatchDialog registerSampleBatchDialog = new RegisterSampleBatchDialog( asyncProjectService, messageFactory, experimentId.value(), - projectId.value(), projectOverview.projectCode()); + projectId.value(), projectOverview.projectCode(), + uploadConfiguration); UI ui = UI.getCurrent(); registerSampleBatchDialog.addConfirmListener(event -> { var sampleMetadata = new ArrayList<>(event.validatedSampleMetadata()); @@ -405,13 +285,6 @@ private void displayRegistrationSuccess(String batchLabel) { } - private void displayUpdateFailure() { - notificationFactory.dialog("sample-batch.update.failure", - MessageSourceNotificationFactory.EMPTY_PARAMETERS, - getLocale()) - .open(); - } - private void onEditBatchClicked(EditBatchEvent editBatchEvent) { ProjectId projectId = context.projectId().orElseThrow(); ExperimentId experimentId = context.experimentId().orElseThrow(); @@ -427,9 +300,12 @@ private void onEditBatchClicked(EditBatchEvent editBatchEvent) { BatchId batchId = editBatchEvent.batchPreview().batchId(); String batchLabel = editBatchEvent.batchPreview().batchLabel(); var editSampleBatchDialog = new EditSampleBatchDialog( - sampleValidationService, asyncProjectService, messageFactory, batchId, batchLabel, + asyncProjectService, messageFactory, batchId, batchLabel, experimentId.value(), - projectId.value(), projectOverview.projectCode()); + projectId.value(), + projectOverview.projectCode(), + sampleValidationService, + uploadConfiguration); UI ui = UI.getCurrent(); editSampleBatchDialog.addConfirmListener(event -> { var sampleMetadata = new ArrayList<>(event.validatedSampleMetadata()); diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/samples/registration/batch/EditSampleBatchDialog.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/samples/registration/batch/EditSampleBatchDialog.java index f35ab59db0..71c32d0091 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/samples/registration/batch/EditSampleBatchDialog.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/project/samples/registration/batch/EditSampleBatchDialog.java @@ -14,18 +14,20 @@ import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.shared.Registration; import java.io.IOException; +import java.io.InputStream; import java.time.LocalDate; import java.util.ArrayList; -import java.util.Collections; +import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; -import java.util.function.Function; -import java.util.stream.Collectors; import life.qbic.application.commons.ApplicationException; import life.qbic.application.commons.FileNameFormatter; +import life.qbic.datamanager.configuration.UploadConfiguration; import life.qbic.datamanager.files.export.download.ByteArrayDownloadStreamProvider; import life.qbic.datamanager.files.parsing.MetadataParser.ParsingException; import life.qbic.datamanager.files.parsing.ParsingResult; @@ -34,14 +36,14 @@ import life.qbic.datamanager.files.parsing.xlsx.XLSXParser; import life.qbic.datamanager.views.general.WizardDialogWindow; import life.qbic.datamanager.views.general.download.DownloadComponent; -import life.qbic.datamanager.views.general.upload.UploadWithDisplay; -import life.qbic.datamanager.views.general.upload.UploadWithDisplay.FileType; -import life.qbic.datamanager.views.general.upload.UploadWithDisplay.SucceededEvent; -import life.qbic.datamanager.views.general.upload.UploadWithDisplay.UploadedData; +import life.qbic.datamanager.views.general.upload.ContentUploadComponent; +import life.qbic.datamanager.views.general.upload.UploadedFilesChangeListener.FileEntry; import life.qbic.datamanager.views.notifications.MessageSourceNotificationFactory; +import life.qbic.datamanager.views.projects.project.samples.registration.batch.SampleUploadDisplay.InProgressDisplay; +import life.qbic.datamanager.views.projects.project.samples.registration.batch.SampleUploadDisplay.InvalidUploadDisplay; +import life.qbic.datamanager.views.projects.project.samples.registration.batch.SampleUploadDisplay.ValidUploadDisplay; import life.qbic.logging.api.Logger; import life.qbic.logging.service.LoggerFactory; -import life.qbic.projectmanagement.application.ValidationResult; import life.qbic.projectmanagement.application.ValidationResultWithPayload; import life.qbic.projectmanagement.application.api.AsyncProjectService; import life.qbic.projectmanagement.application.api.AsyncProjectService.AccessDeniedException; @@ -49,6 +51,8 @@ import life.qbic.projectmanagement.application.sample.SampleMetadata; import life.qbic.projectmanagement.application.sample.SampleValidationService; import life.qbic.projectmanagement.domain.model.batch.BatchId; +import org.jspecify.annotations.Nullable; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.util.MimeType; /** @@ -72,7 +76,8 @@ public class EditSampleBatchDialog extends WizardDialogWindow { private static final String SECONDARY_CSS = "secondary"; private static final Logger log = LoggerFactory.logger(EditSampleBatchDialog.class); private static final int MAX_FILE_SIZE = 25 * 1024 * 1024; - private final transient List validatedSampleMetadata; + private final SampleValidationService sampleValidationService; + private final transient Map> validatedSampleMetadata; private final TextField batchNameField; private final Div initialView; private final Div inProgressView; @@ -80,16 +85,23 @@ public class EditSampleBatchDialog extends WizardDialogWindow { private final Div succeededView; private final MessageSourceNotificationFactory messageFactory; private final DownloadComponent downloadComponent; + private final ContentUploadComponent contentUploadComponent; - public EditSampleBatchDialog(SampleValidationService sampleValidationService, - AsyncProjectService service, MessageSourceNotificationFactory messageFactory, + + public EditSampleBatchDialog(AsyncProjectService service, + MessageSourceNotificationFactory messageFactory, BatchId batchId, String batchName, String experimentId, String projectId, - String projectCode) { + String projectCode, + SampleValidationService sampleValidationService, + UploadConfiguration uploadConfiguration) { + this.messageFactory = Objects.requireNonNull(messageFactory); + this.sampleValidationService = sampleValidationService; this.downloadComponent = new DownloadComponent(); + this.contentUploadComponent = new ContentUploadComponent(uploadConfiguration); setHeaderTitle("Edit Sample Batch"); setConfirmButtonLabel("Edit Batch"); @@ -115,27 +127,27 @@ public EditSampleBatchDialog(SampleValidationService sampleValidationService, projectId, projectCode); setHeaderTitle("Edit Sample Batch"); - validatedSampleMetadata = new ArrayList<>(); + validatedSampleMetadata = new HashMap<>(); - UploadWithDisplay uploadWithDisplay = new UploadWithDisplay( - MAX_FILE_SIZE, new FileType[]{ - new FileType(".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") - }); - uploadWithDisplay.addUnspecificFailureListener( + contentUploadComponent.setMaxFiles(1); + contentUploadComponent.setAcceptedFileTypes( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + contentUploadComponent.addUnspecificFailureListener( uploadFailed -> - /* display of the error is handled by the uploadWithDisplay component. However we do need to log with the context*/ + /* display of the error is handled by the uploadWithDisplay component. However, we do need to log with the context*/ log.error( "Upload failed for project(" + projectId + ") experiment(" + experimentId + ")", uploadFailed.getCause())); - uploadWithDisplay.addSuccessListener( - uploadSucceeded -> onUploadSucceeded(sampleValidationService, experimentId, projectId, - uploadSucceeded) - ); - uploadWithDisplay.addRemovedListener(it -> setValidatedSampleMetadata(List.of())); + + var uploadDisplay = new SampleUploadDisplay(); + Registration controllerRegistration = new SampleEditUploadDisplayController(projectId, + experimentId) + .control(uploadDisplay, contentUploadComponent); + uploadDisplay.addDetachListener(it -> controllerRegistration.remove()); Span uploadTheSampleDataTitle = new Span("Upload the sample data"); uploadTheSampleDataTitle.addClassName("section-title"); - Div uploadSection = new Div(uploadTheSampleDataTitle, uploadWithDisplay); + Div uploadSection = new Div(uploadTheSampleDataTitle, contentUploadComponent, uploadDisplay); uploadSection.addClassName("upload-section"); uploadSection.addClassName("section-with-title"); initialView.add(batchNameField, downloadMetadataSection, uploadSection); @@ -146,114 +158,15 @@ public EditSampleBatchDialog(SampleValidationService sampleValidationService, add(initialView, inProgressView, failedView, succeededView, downloadComponent); } - private static InvalidUploadDisplay invalidDisplay(String fileName, - List validationResults) { - List failureReasons = validationResults.stream() - .flatMap(res -> res.failures().stream()).toList(); - return new InvalidUploadDisplay(fileName, failureReasons); - } - - private void onUploadSucceeded(SampleValidationService sampleValidationService, - String experimentId, String projectId, SucceededEvent uploadSucceeded) { - UploadWithDisplay component = uploadSucceeded.getSource(); - UI ui = component.getUI().orElseThrow(); - UploadedData uploadedData = component.getUploadedData().orElseThrow(); - - InProgressDisplay uploadProgressDisplay = new InProgressDisplay(uploadedData.fileName()); - component.setDisplay(uploadProgressDisplay); - - List sampleInformationForExistingSamples; - try { - sampleInformationForExistingSamples = extractSampleInformationForExistingSamples( - uploadedData); - } catch (ParsingException e) { - RuntimeException runtimeException = new RuntimeException( - "Parsing failed.", e); - log.error("Could not complete validation. " + e.getMessage(), runtimeException); - InvalidUploadDisplay invalidUploadDisplay = new InvalidUploadDisplay( - uploadedData.fileName(), - "Could not complete validation. " + e.getMessage()); - ui.access(() -> component.setDisplay(invalidUploadDisplay)); - throw runtimeException; - } - - List>> validations = new ArrayList<>(); - for (SampleInformationForExistingSample sampleInformationForExistingSample : sampleInformationForExistingSamples) { - CompletableFuture> validation = sampleValidationService.validateExistingSampleAsync( - sampleInformationForExistingSample.sampleCode(), - sampleInformationForExistingSample.sampleName(), - sampleInformationForExistingSample.biologicalReplicate(), - sampleInformationForExistingSample.condition(), - sampleInformationForExistingSample.species(), - sampleInformationForExistingSample.specimen(), - sampleInformationForExistingSample.analyte(), - sampleInformationForExistingSample.analysisMethod(), - sampleInformationForExistingSample.comment(), - sampleInformationForExistingSample.confoundingVariables(), - experimentId, - projectId - ).orTimeout(1, TimeUnit.MINUTES); - validations.add(validation); - } - var validationTasks = CompletableFuture - //allOf makes sure exceptional state is transferred to outer completable future. - .allOf(validations.toArray(new CompletableFuture[0])) - .thenApply(v -> validations.stream() - .map(CompletableFuture::join) - .toList()) - .orTimeout(5, TimeUnit.MINUTES); - - validationTasks - .thenAccept(validationResults -> { - - List> failedValidations = validationResults.stream() - .filter(validation -> validation.validationResult().containsFailures()) - .toList(); - List> succeededValidations = validationResults.stream() - .filter(validation -> validation.validationResult().allPassed()) - .toList(); - - if (!failedValidations.isEmpty()) { - ui.access(() -> component.setDisplay(invalidDisplay(uploadedData.fileName(), - failedValidations.stream().map(ValidationResultWithPayload::validationResult) - .toList()))); - setValidatedSampleMetadata(List.of()); - return; - } - if (!succeededValidations.isEmpty()) { - ui.access(() -> component - .setDisplay(new ValidUploadDisplay(uploadedData.fileName(), - succeededValidations.size()))); - setValidatedSampleMetadata( - succeededValidations.stream().map(ValidationResultWithPayload::payload).toList()); - } - }) - .exceptionally(e -> { - RuntimeException runtimeException = new RuntimeException( - "At least one validation task could not complete.", e); - log.error("Could not complete validation. Please try again.", runtimeException); - InvalidUploadDisplay invalidUploadDisplay = new InvalidUploadDisplay( - uploadedData.fileName(), - "Could not complete validation. Please try again."); - ui.access(() -> component.setDisplay(invalidUploadDisplay)); - throw runtimeException; - } - ); - } - - private void setValidatedSampleMetadata(List metadata) { - this.validatedSampleMetadata.clear(); - this.validatedSampleMetadata.addAll(metadata); - } - private List extractSampleInformationForExistingSamples( - UploadedData uploadedData) { - ParsingResult parsingResult = XLSXParser.create().parse(uploadedData.inputStream()); + InputStream inputStream) { + ParsingResult parsingResult = XLSXParser.create().parse(inputStream); return new SampleInformationExtractor() .extractInformationForExistingSamples(parsingResult); } + private Div setupDownloadMetadataSection(AsyncProjectService service, String batchId, String experimentId, @@ -318,6 +231,11 @@ public byte[] getBytes() { public String getFilename() { return filename; } + + @Override + public Optional contentLength() { + return Optional.empty(); + } }))); } @@ -349,7 +267,9 @@ protected void onConfirmClicked(ClickEvent