diff --git a/application-commons/src/main/java/life/qbic/application/commons/time/DateTimeFormat.java b/application-commons/src/main/java/life/qbic/application/commons/time/DateTimeFormat.java index b965bb4d1d..1365dfb4fd 100644 --- a/application-commons/src/main/java/life/qbic/application/commons/time/DateTimeFormat.java +++ b/application-commons/src/main/java/life/qbic/application/commons/time/DateTimeFormat.java @@ -33,6 +33,13 @@ public enum DateTimeFormat { * Format: {@code , }} */ SIMPLE_DATE, + + /** + * A more compact date representation. E.g. {@code 11 Feb 2026} + * Format: {@code } + */ + SIMPLE_DATE_SHORT, + /** * A simple to read, textual date-time representation. E.g. * {@code Wednesday, 11 February 2026 11:12:10} @@ -78,6 +85,7 @@ public static DateTimeFormatter asJavaFormatter(@NonNull DateTimeFormat format, case ISO_LOCAL_DATE_TIME_WHITESPACE_SEPARATED -> DateTimeFormatter.ofPattern( "yyyy-MM-dd HH:mm").withZone(zoneId); case SIMPLE_DATE -> DateTimeFormatter.ofPattern("EEEE, dd LLLL yyyy").withZone(zoneId); + case SIMPLE_DATE_SHORT -> DateTimeFormatter.ofPattern("d MMM yyyy").withZone(zoneId); case SIMPLE_DATE_TIME -> DateTimeFormatter.ofPattern("EEEE, dd LLLL yyyy HH:mm:ss").withZone( zoneId); }; @@ -95,6 +103,7 @@ public static DateTimeFormatter asJavaFormatter(@NonNull DateTimeFormat format, public static String asMariaDbDatabasePattern(@NonNull DateTimeFormat format) { return switch (format) { case SIMPLE_DATE -> "%W, %d %M %Y"; + case SIMPLE_DATE_SHORT -> "%d %M %Y"; case SIMPLE_DATE_TIME -> "%W, %d %M %Y %T"; case ISO_LOCAL_DATE -> "%Y-%m-%d"; case ISO_LOCAL_DATE_TIME -> "%Y-%m-%dT%T"; diff --git a/datamanager-app/frontend/themes/datamanager/components/all.css b/datamanager-app/frontend/themes/datamanager/components/all.css index c58e98c7b7..57293f3ff1 100644 --- a/datamanager-app/frontend/themes/datamanager/components/all.css +++ b/datamanager-app/frontend/themes/datamanager/components/all.css @@ -1454,6 +1454,10 @@ Older stuff - flex-grow: 1; } +.flex-shrink-0 { + flex-shrink: 0; +} + .wrapping-flex-container { flex-wrap: wrap; } diff --git a/datamanager-app/frontend/themes/datamanager/components/page-area.css b/datamanager-app/frontend/themes/datamanager/components/page-area.css index cdcf377024..fcf56ea9e9 100644 --- a/datamanager-app/frontend/themes/datamanager/components/page-area.css +++ b/datamanager-app/frontend/themes/datamanager/components/page-area.css @@ -424,22 +424,34 @@ padding: var(--lumo-space-xs); } -.project-collection-component .project-overview-item { - border: black; - border-radius: var(--lumo-border-radius-m); - box-shadow: var(--lumo-box-shadow-s); +.project-collection-component .project-overview-item, +.project-collection-component .project-overview-item:hover { box-sizing: border-box; display: flex; flex-direction: column; flex-wrap: wrap; - margin-bottom: var(--lumo-space-s); - margin-top: var(--lumo-space-s); - overflow: hidden; padding: var(--lumo-space-l); row-gap: var(--lumo-space-s); text-overflow: ellipsis; white-space: normal; cursor: pointer; + text-decoration: none +} + +/* The wrapper now owns the card's visual styling (shadow, rounded corners, + * spacing between cards). The card body RouterLink inside it provides only + * padding and the content flow; the footer RouterLink (when present) shares + * the same rounded shape with a subtle top-border divider. + */ +.project-card-wrapper { + border: black; + border-radius: var(--lumo-border-radius-m); + box-shadow: var(--lumo-box-shadow-s); + overflow: hidden; + margin-bottom: var(--lumo-space-s); + margin-top: var(--lumo-space-s); + display: flex; + flex-direction: column; } .project-collection-component .project-overview-item .header { @@ -634,3 +646,64 @@ .datasets-content { grid-area: datasets-content; } + +/* + * project-dataset footer — full-width RouterLink rendered at the bottom of + * each project card in the collection view. The entire footer is the click + * target (44px+ click target for accessibility); it visually merges with + * the card body above it, forming a single unified card with a subtle + * top-border divider and slightly tinted background. + * + * Visual merge details (paired with .project-card-wrapper above): + * - Card body lives inside .project-card-wrapper (no shadow / no rounded + * corners of its own) with padding matching the footer. + * - Footer has only the top-border as a visual divider; background matches + * the card body (white). + * - .project-card-wrapper carries the rounded corner shape (overflow: hidden + * clips both children into the same card outline) and the inter-card margin. + * + * Styling is component-specific; not extracted to all.css utilities. + */ + +.project-dataset-footer, +.project-dataset-footer *, +a.project-dataset-footer, a.project-dataset-footer:hover { + text-decoration: none; + box-sizing: border-box; +} + +.project-dataset-footer { + display: block; + color: var(--lumo-body-text-color); + border-top: 1px solid var(--lumo-contrast-10pct); + background-color: var(--lumo-base-color); + padding-top: var(--spacing-04); + padding-bottom: var(--spacing-04); + padding-left: var(--spacing-05); + padding-right: var(--spacing-05); + margin: 0; + transition: + background-color 150ms ease, + border-top-color 150ms ease; +} + +.project-dataset-footer:hover, +.project-dataset-footer:focus-within { + background-color: var(--background-color-5pct); + border-top-color: var(--lumo-contrast-20pct); +} + +.project-dataset-footer vaadin-icon { + color: var(--icon-color); + transition: color 150ms ease; +} + +.project-dataset-footer:hover vaadin-icon, +.project-dataset-footer:focus-within vaadin-icon { + color: var(--primary-text-color); +} + +.project-dataset-footer:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; +} diff --git a/datamanager-app/src/main/bundles/dev.bundle b/datamanager-app/src/main/bundles/dev.bundle index 9632ad3661..4625cf639a 100644 Binary files a/datamanager-app/src/main/bundles/dev.bundle and b/datamanager-app/src/main/bundles/dev.bundle differ diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/demo/ProjectListingDatasetsDemo.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/demo/ProjectListingDatasetsDemo.java new file mode 100644 index 0000000000..0024200c76 --- /dev/null +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/demo/ProjectListingDatasetsDemo.java @@ -0,0 +1,672 @@ +package life.qbic.datamanager.views.demo; + +import com.vaadin.flow.component.Component; +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.router.RouterLink; +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.Route; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import com.vaadin.flow.spring.annotation.UIScope; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import life.qbic.datamanager.views.AppRoutes; +import life.qbic.datamanager.views.AppRoutes.ProjectRoutes; +import life.qbic.datamanager.views.general.Tag; +import life.qbic.datamanager.views.general.Tag.TagColor; +import life.qbic.datamanager.views.projects.project.datasets.ConnectedDatasetsMain; +import com.vaadin.flow.router.RouteParameters; +import org.springframework.context.annotation.Profile; + +/** + * Project Listing with Datasets — Demo View + * + *

Prototype UI for story FEAT-DATSET-09: "Access available datasets after login". + * Enhances the existing project collection listing with connected-resource indicators + * per project card, so researchers can see at a glance which projects have associated + * datasets, how many, and their access status (open vs. restricted).

+ * + *

Design rationale

+ *
    + *
  • Faithful to the existing layout — the project cards retain their + * current structure (title, tags, dates, PI, avatars). The dataset + * indicator is an additive element placed below the existing meta info.
  • + *
  • Colour-coded access status — the indicator uses the existing + * {@link life.qbic.datamanager.views.general.Tag} palette: + *
      + *
    • Green (success) — all connected datasets are open/public
    • + *
    • Amber (warning) — at least one connected dataset is access-restricted
    • + *
    • Grey (contrast/neutral) — no datasets connected
    • + *
    + *
  • Two distinct click targets per card — clicking anywhere on the + * card body (title, PI, avatars) navigates to the project info page; + * clicking the dataset indicator row navigates directly to the + * project's connected-datasets view ({@code projects/%s/datasets}). + * The indicator is underlined on hover and carries a trailing + * {@code →} icon as affordance.
  • + *
  • Summary ribbon — above the project cards, a compact summary + * shows aggregate statistics across all projects (total connected + * datasets, projects with connections, open/restricted breakdown).
  • + *
+ * + *

This view is only available with the {@code development} profile.

+ * + * @since 1.12.0 + */ +@Profile("development") +@Route("test-view/project-listing-datasets") +@UIScope +@AnonymousAllowed +@org.springframework.stereotype.Component +public class ProjectListingDatasetsDemo extends Div { + + // ── Domain records ──────────────────────────────────────────────────── + + /** Access level of a connected dataset (mirrors FEAT-DATASET-CONNECTION). */ + enum AccessLevel { OPEN, RESTRICTED } + + /** A dataset connection within a project. */ + record DatasetConnection( + String id, + String title, + String pid, + AccessLevel accessLevel, + String repository, + String linkedExperimentName, + LocalDate connectedOn + ) {} + + /** A project overview enriched with connected-dataset summary. */ + record ProjectOverviewWithDatasets( + String projectId, + String projectCode, + String projectTitle, + Instant lastModified, + String principalInvestigator, + String projectResponsible, + List measurementTypes, + int collaboratorCount, + List connectedDatasets + ) {} + + /** Simplified measurement kind for display. */ + record MeasurementKind(String label, String cssClass) {} + + // ── Mock project data ───────────────────────────────────────────────── + + private static final DateTimeFormatter DATE_FMT = + DateTimeFormatter.ofPattern("EEEE, dd MMMM yyyy HH:mm:ss", Locale.ENGLISH) + .withZone(ZoneId.of("Europe/Berlin")); + + private static final List MOCK_PROJECTS = seedProjects(); + + private TextField searchField; + private Div projectCardsContainer; + private Span summaryText; + + public ProjectListingDatasetsDemo() { + addClassName("padding-horizontal-07"); + addClassName("padding-vertical-04"); + + // ── Page title ───────────────────────────────────────────────── + var pageTitle = new Div("Welcome Back admin-test!"); + pageTitle.addClassName("project-overview-title"); + add(pageTitle); + + var pageDescription = new Div( + "Manage all your scientific data in one place with the Data Manager. " + + "You can access our documentation and learn more about using the Data Manager. " + + "Start by creating a new project or continue working on an already existing project."); + pageDescription.addClassName("description"); + add(pageDescription); + + // ── Subtitle explaining this is a prototype ──────────────────── + var protoNotice = new Div(); + protoNotice.getStyle().set("display", "flex"); + protoNotice.getStyle().set("align-items", "center"); + protoNotice.getStyle().set("gap", "var(--lumo-space-s)"); + protoNotice.getStyle().set("margin-bottom", "var(--lumo-space-m)"); + protoNotice.getStyle().set("margin-top", "var(--lumo-space-m)"); + + var infoIcon = VaadinIcon.INFO_CIRCLE_O.create(); + infoIcon.getStyle().set("color", "var(--lumo-primary-color)"); + infoIcon.getStyle().set("flex-shrink", "0"); + + var noticeText = new Span( + "Prototype: FEAT-DATSET-09 — connected-dataset indicators on project cards (no backend wire-up)."); + noticeText.addClassName("extra-small-body-text"); + noticeText.addClassName("color-secondary"); + + protoNotice.add(infoIcon, noticeText); + add(protoNotice); + + // ── Summary ribbon ───────────────────────────────────────────── + add(buildSummaryRibbon()); + + // Wrap header + cards in project-collection-component for proper CSS scoping + var layoutWrapper = new Div(); + layoutWrapper.addClassName("project-collection-component"); + + // ── Header + project cards container ────────────────────────── + layoutWrapper.add(buildMyProjectsHeader()); + + projectCardsContainer = new Div(); + projectCardsContainer.addClassName("project-cards-container"); + projectCardsContainer.getStyle().set("display", "flex"); + projectCardsContainer.getStyle().set("flex-direction", "column"); + projectCardsContainer.getStyle().set("gap", "0"); + + refreshProjectCards(MOCK_PROJECTS); + layoutWrapper.add(projectCardsContainer); + add(layoutWrapper); + } + + // ══════════════════════════════════════════════════════════════════════ + // HEADER + // ══════════════════════════════════════════════════════════════════════ + + private Div buildMyProjectsHeader() { + var header = new Div(); + header.addClassName("header"); + + var title = new Span("My Projects"); + title.addClassName("title"); + + searchField = new TextField(); + searchField.setPlaceholder("Search"); + searchField.setClearButtonVisible(true); + searchField.setSuffixComponent(VaadinIcon.SEARCH.create()); + searchField.addClassNames("search-field"); + searchField.setValueChangeMode(ValueChangeMode.LAZY); + searchField.addValueChangeListener(e -> filterProjects(e.getValue())); + + var createBtn = new Button("Create"); + createBtn.addClassName("primary"); + + var controls = new Div(searchField, createBtn); + controls.addClassName("controls"); + + header.add(title, controls); + return header; + } + + // ══════════════════════════════════════════════════════════════════════ + // SUMMARY RIBBON + // ══════════════════════════════════════════════════════════════════════ + + private Div buildSummaryRibbon() { + var ribbon = new Div(); + ribbon.addClassName("border"); + ribbon.addClassName("rounded-02"); + ribbon.getStyle().set("display", "flex"); + ribbon.getStyle().set("align-items", "center"); + ribbon.getStyle().set("gap", "var(--lumo-space-l)"); + ribbon.getStyle().set("padding", "var(--lumo-space-m) var(--lumo-space-l)"); + ribbon.getStyle().set("margin-bottom", "var(--lumo-space-m)"); + ribbon.getStyle().set("background-color", "var(--lumo-base-color)"); + ribbon.getStyle().set("overflow-x", "auto"); + + var totalProjects = MOCK_PROJECTS.size(); + var projectsWithDatasets = MOCK_PROJECTS.stream() + .filter(p -> !p.connectedDatasets().isEmpty()).count(); + var totalDatasets = MOCK_PROJECTS.stream() + .mapToInt(p -> p.connectedDatasets().size()).sum(); + var openDatasets = MOCK_PROJECTS.stream() + .flatMap(p -> p.connectedDatasets().stream()) + .filter(d -> d.accessLevel() == AccessLevel.OPEN).count(); + var restrictedDatasets = totalDatasets - openDatasets; + + summaryText = new Span("%d project(s) · %d with connected datasets · %d dataset(s) total (%d open, %d restricted)" + .formatted(totalProjects, projectsWithDatasets, totalDatasets, openDatasets, restrictedDatasets)); + summaryText.addClassName("normal-body-text"); + + var dbIcon = VaadinIcon.DATABASE.create(); + dbIcon.getStyle().set("color", "var(--lumo-primary-color)"); + dbIcon.getStyle().set("font-size", "var(--lumo-icon-size-m)"); + dbIcon.getStyle().set("flex-shrink", "0"); + + ribbon.add(dbIcon, summaryText); + return ribbon; + } + + // ══════════════════════════════════════════════════════════════════════ + // PROJECT CARD (matching existing ProjectOverviewItem layout) + // ══════════════════════════════════════════════════════════════════════ + + private Component buildProjectCard(ProjectOverviewWithDatasets project) { + var card = new Div(); + card.addClassName("project-overview-item"); + card.getStyle().set("cursor", "pointer"); + + // ── Header: title + measurement tags ─────────────────────────── + var header = new Div(); + header.addClassName("flex-horizontal"); + header.getStyle().set("align-items", "flex-start"); + header.getStyle().set("gap", "var(--lumo-space-s)"); + header.getStyle().set("flex-wrap", "wrap"); + + var titleSpan = new Span("%s - %s".formatted(project.projectCode(), project.projectTitle())); + titleSpan.addClassName("project-overview-item-title"); + header.add(titleSpan); + + var tagsContainer = new Div(); + tagsContainer.addClassName("tag-collection"); + for (var kind : project.measurementTypes()) { + var tag = new Span(kind.label()); + tag.addClassName("tag"); + tag.addClassName(kind.cssClass()); + tagsContainer.add(tag); + } + header.add(tagsContainer); + + card.add(header); + + // ── Last modified ───────────────────────────────────────────── + var lastModified = new Span("Last modified on " + DATE_FMT.format(project.lastModified())); + lastModified.addClassName("tertiary"); + card.add(lastModified); + + // ── PI + Responsible ─────────────────────────────────────────── + var details = new Div(); + details.addClassName("details"); + details.add(new Span("Principal Investigator: " + project.principalInvestigator())); + if (project.projectResponsible() != null && !project.projectResponsible().isBlank()) { + details.add(new Span("Project Responsible: " + project.projectResponsible())); + } + card.add(details); + + // ── Collaborator avatars (simplified: show count + placeholder) ─ + var avatarPlaceholder = buildAvatarPlaceholder(project.collaboratorCount()); + card.add(avatarPlaceholder); + + // ── Connected Datasets footer (RouterLink → datasets view) ─ + // Differentiated footer region at the bottom of the card. Entire + // footer area is a RouterLink → projects/{id}/datasets; the rest + // of the card stays as a project-info link. + if (!project.connectedDatasets().isEmpty()) { + card.add(buildDatasetFooter(project)); + } + + // ── Click handler: project card body → project info ───────── + card.addClickListener(event -> { + navigateToProjectInfo(project.projectId()); + }); + + return card; + } + // ══════════════════════════════════════════════════════════════════════ + // DATASET FOOTER — RouterLink wrapping full footer region + // ══════════════════════════════════════════════════════════════════════ + + /** + * Builds the connected-datasets footer region for a project card. + * + *

Why a footer, not just a chevron icon?

+ *

A small icon is too tiny a click target for users with motor + * disabilities and problematic for screen readers. By differentiating + * the dataset section as a full-width card footer — with a top border, + * a slightly tinted background, and a {@link RouterLink} wrapping + * meaningful content — the full footer becomes a large (44px+ high) + * click target that is easy to hit and carries coherent link text.

+ * + *

Interaction model

+ *
    + *
  • Footer area (border to card bottom) → RouterLink + * navigates to {@code projects/{id}/datasets}. Anchor navigation + * is handled by the browser before Vaadin's server-side click + * routing, so the parent card project-info handler does not + * fire.
  • + *
  • Card body (title, PI, avatars) → card-wide + * {@code Div.addClickListener(...)} navigates to project info. + *
  • + *
+ * + *

Accessibility

+ *
    + *
  • Full-width, 44px+ vertical click target — meets minimum + * touch-target guidelines.
  • + *
  • Link wraps meaningful content (count, Open/Restricted Tags, + * last connected date); screen readers get a coherent sentence + * instead of an icon name.
  • + *
  • {@code aria-label}: {@code "Open datasets for Q2KX4B: 3 + * connected, 2 open, 1 restricted, last updated 20 July 2026"} + * — unambiguous without visual context.
  • + *
  • Visual boundary (top border + tinted background) signals a + * different interaction zone.
  • + *
  • {@code :focus-visible} outline for keyboard navigation.
  • + *
+ * + *

Layout

+ *
    + *
  1. Database icon (neutral, secondary colour)
  2. + *
  3. Dataset count (bold) + "dataset"/"datasets" label
  4. + *
  5. {@code ·} separator
  6. + *
  7. {@code Tag("n Open", SUCCESS)}
  8. + *
  9. {@code Tag("n Restricted", WARNING)}
  10. + *
  11. {@code ·} separator
  12. + *
  13. "Last connected dd MMM yyyy"
  14. + *
  15. Spacer (flex-grow)
  16. + *
  17. Trailing chevron ({@link VaadinIcon#CHEVRON_RIGHT}) — visual cue
  18. + *
+ */ + private Component buildDatasetFooter(ProjectOverviewWithDatasets project) { + var content = new Div(); + content.getStyle().set("display", "flex"); + content.getStyle().set("align-items", "center"); + content.getStyle().set("gap", "var(--lumo-space-s)"); + content.getStyle().set("flex-wrap", "wrap"); + + var openCount = project.connectedDatasets().stream() + .filter(d -> d.accessLevel() == AccessLevel.OPEN).count(); + long restrictedCount = project.connectedDatasets().size() - openCount; + + var icon = VaadinIcon.DATABASE.create(); + icon.getStyle().set("font-size", "var(--lumo-icon-size-s)"); + icon.getStyle().set("flex-shrink", "0"); + icon.addClassName("color-secondary"); + content.add(icon); + + var countSpan = new Span(String.valueOf(project.connectedDatasets().size())); + countSpan.getStyle().set("font-weight", "700"); + countSpan.addClassName("normal-body-text"); + content.add(countSpan); + + var labelSpan = new Span(project.connectedDatasets().size() == 1 ? "dataset" : "datasets"); + labelSpan.addClassName("extra-small-body-text"); + content.add(labelSpan); + + content.add(buildDotSeparator()); + + if (openCount > 0) { + var openTag = new Tag("%d Open".formatted(openCount)); + openTag.setTagColor(TagColor.SUCCESS); + content.add(openTag); + } + if (restrictedCount > 0) { + var restrictedTag = new Tag("%d Restricted".formatted(restrictedCount)); + restrictedTag.setTagColor(TagColor.WARNING); + content.add(restrictedTag); + } + + content.add(buildDotSeparator()); + + var lastConnected = project.connectedDatasets().stream() + .map(DatasetConnection::connectedOn) + .max(Comparator.naturalOrder()) + .orElse(null); + + var lastConnectedLabel = new Span("Last connected"); + lastConnectedLabel.addClassName("extra-small-body-text"); + lastConnectedLabel.addClassName("color-secondary"); + content.add(lastConnectedLabel); + + if (lastConnected != null) { + var lastConnectedDate = new Span(lastConnected.format( + DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.ENGLISH))); + lastConnectedDate.addClassName("extra-small-body-text"); + content.add(lastConnectedDate); + } else { + var fallback = new Span("—"); + fallback.addClassName("extra-small-body-text"); + fallback.addClassName("color-secondary"); + content.add(fallback); + } + + // Spacer to push the chevron to the right edge + var spacer = new Div(); + spacer.getStyle().set("flex-grow", "1"); + content.add(spacer); + + // Trailing chevron — visual cue only; the whole footer is clickable + var chevron = VaadinIcon.CHEVRON_RIGHT.create(); + chevron.getStyle().set("font-size", "var(--lumo-icon-size-s)"); + chevron.getStyle().set("flex-shrink", "0"); + content.add(chevron); + + // ─ Wrap in RouterLink — entire footer is the link ────────── + var footerLink = new RouterLink( + "", ConnectedDatasetsMain.class, + new RouteParameters("projectId", project.projectId())); + footerLink.addClassName("project-dataset-footer"); + footerLink.add(content); + + // aria-label — meaningful text for screen readers + String ariaText = "Open datasets for %s: %d connected, %d open, %d restricted".formatted( + project.projectCode(), + project.connectedDatasets().size(), + openCount, + restrictedCount); + if (lastConnected != null) { + ariaText += ", last updated %s".formatted(lastConnected.format( + DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.ENGLISH))); + } + footerLink.getElement().setAttribute("aria-label", ariaText); + + return footerLink; + } + + + private Div buildAvatarPlaceholder(int count) { + var wrapper = new Div(); + wrapper.addClassName("flex-horizontal"); + wrapper.getStyle().set("gap", "var(--lumo-space-0)"); + wrapper.getStyle().set("align-items", "center"); + wrapper.getStyle().set("margin-top", "var(--lumo-space-xs)"); + + // Simple colored circle placeholders (no real avatar service in prototype) + int show = Math.min(count, 5); + var colors = List.of("#5A9BD5", "#58C9B9", "#FFB627", "#E8637A", "#9B6BCD"); + for (int i = 0; i < show; i++) { + var circle = new Div(); + circle.getStyle().set("width", "28px"); + circle.getStyle().set("height", "28px"); + circle.getStyle().set("border-radius", "50%"); + circle.getStyle().set("background-color", colors.get(i % colors.size())); + circle.getStyle().set("border", "2px solid var(--lumo-base-color)"); + circle.getStyle().set("margin-left", i > 0 ? "-8px" : "0"); + circle.getStyle().set("display", "flex"); + circle.getStyle().set("align-items", "center"); + circle.getStyle().set("justify-content", "center"); + circle.getStyle().set("color", "white"); + circle.getStyle().set("font-size", "10px"); + circle.getStyle().set("font-weight", "600"); + circle.setText(String.valueOf((char) ('A' + i))); + wrapper.add(circle); + } + if (count > 5) { + var more = new Div(); + more.getStyle().set("width", "28px"); + more.getStyle().set("height", "28px"); + more.getStyle().set("border-radius", "50%"); + more.getStyle().set("background-color", "var(--lumo-contrast-20pct)"); + more.getStyle().set("border", "2px solid var(--lumo-base-color)"); + more.getStyle().set("margin-left", "-8px"); + more.getStyle().set("display", "flex"); + more.getStyle().set("align-items", "center"); + more.getStyle().set("justify-content", "center"); + more.getStyle().set("color", "var(--lumo-contrast)"); + more.getStyle().set("font-size", "10px"); + more.getStyle().set("font-weight", "600"); + more.setText("+" + (count - 5)); + wrapper.add(more); + } + return wrapper; + } + + private Span buildDotSeparator() { + var dot = new Span("·"); + dot.addClassName("extra-small-body-text"); + dot.addClassName("color-secondary"); + return dot; + } + + /** + * Navigate from a project card body click to the project's info page. + * + *

In the prototype this is a toast — the real implementation uses + * {@code UI.navigate(ProjectInformationMain.class, RouteParam(...))}.

+ * + *

Intentionally NOT fired when the user clicks the dataset indicator: + * the chevron inside the indicator row is a {@link RouterLink} (native {@code }), and anchor + * navigation short-circuits Vaadin's server-side click routing on the + * parent card element.

+ */ + private void navigateToProjectInfo(String projectId) { + var ui = UI.getCurrent(); + if (ui == null) { + return; + } + showToast("Navigate to project info — projects/" + projectId + "/info"); + } + + private void showToast(String message) { + var notification = new com.vaadin.flow.component.notification.Notification( + new Div(message)); + notification.setPosition(com.vaadin.flow.component.notification.Notification.Position.BOTTOM_END); + notification.setDuration(4000); + notification.addClassName("info-toast"); + notification.open(); + } + + private void filterProjects(String query) { + List filtered; + if (query == null || query.isBlank()) { + filtered = new ArrayList<>(MOCK_PROJECTS); + } else { + String lower = query.toLowerCase(); + filtered = MOCK_PROJECTS.stream() + .filter(p -> p.projectTitle().toLowerCase().contains(lower) + || p.projectCode().toLowerCase().contains(lower) + || p.principalInvestigator().toLowerCase().contains(lower)) + .toList(); + } + refreshProjectCards(filtered); + } + + private void refreshProjectCards(List projects) { + projectCardsContainer.removeAll(); + for (ProjectOverviewWithDatasets project : projects) { + projectCardsContainer.add(buildProjectCard(project)); + } + } + + // ══════════════════════════════════════════════════════════════════════ + // MOCK DATA + // ══════════════════════════════════════════════════════════════════════ + + private static List seedProjects() { + var projects = new ArrayList(); + + projects.add(new ProjectOverviewWithDatasets( + "Q290MB", "Q290MB", "A nice test for spring boot 3.5.16", + Instant.parse("2026-07-24T06:29:22Z"), + "Tobias Koch", null, + List.of(new MeasurementKind("Genomics", "pink")), + 2, + List.of() + )); + + projects.add(new ProjectOverviewWithDatasets( + "Q2KX4B", "Q2KX4B", "Vaadin 24 Update Test 2", + Instant.parse("2026-07-20T12:09:52Z"), + "Steffen Greiner", "Sven Admin Istrator", + List.of(new MeasurementKind("Proteomics", "violet")), + 3, + List.of( + new DatasetConnection("ds-001", "Cryo-EM structure of human 26S proteasome", + "10.5281/zenodo.1234567", AccessLevel.OPEN, "Zenodo", + "Main experiment", LocalDate.of(2026, 7, 15)), + new DatasetConnection("ds-002", + "Proteomic profiling of T-cell receptor signaling", + "10.5281/zenodo.1234568", AccessLevel.OPEN, "Zenodo", + "TCR experiment", LocalDate.of(2026, 7, 18)), + new DatasetConnection("ds-003", + "Clinical phosphoproteomics — Cohort B (embargo)", + "10.5281/fdat.5554443", AccessLevel.RESTRICTED, "FDAT", + "Main experiment", LocalDate.of(2026, 7, 20)) + ) + )); + + projects.add(new ProjectOverviewWithDatasets( + "Q27QUE", "Q27QUE", "Test Ke Wang", + Instant.parse("2026-07-09T09:12:56Z"), + "Ke Wang", null, + List.of(new MeasurementKind("Proteomics", "violet")), + 1, + List.of() + )); + + projects.add(new ProjectOverviewWithDatasets( + "Q28ABZ", "Q28ABZ", "Arabidopsis drought stress multi-omics", + Instant.parse("2026-07-15T14:03:11Z"), + "Heike Weber", "QBiC Steward", + List.of( + new MeasurementKind("Genomics", "pink"), + new MeasurementKind("Proteomics", "violet") + ), + 5, + List.of( + new DatasetConnection("ds-010", + "NGS raw reads: Arabidopsis thaliana drought stress response", + "10.5281/zenodo.1234571", AccessLevel.OPEN, "Zenodo", + "Drought stress RNA-Seq", LocalDate.of(2026, 7, 10)), + new DatasetConnection("ds-011", + "Spatiotemporal proteome of Arabidopsis root tissue", + "10.5281/zenodo.1234599", AccessLevel.OPEN, "Zenodo", + "Root proteomics", LocalDate.of(2026, 7, 14)) + ) + )); + + projects.add(new ProjectOverviewWithDatasets( + "Q29CDE", "Q29CDE", "Spatial transcriptomics atlas — human kidney", + Instant.parse("2026-07-01T09:00:00Z"), + "F. Rossi", null, + List.of(new MeasurementKind("Genomics", "pink")), + 4, + List.of( + new DatasetConnection("ds-020", + "Spatial transcriptomics atlas of human kidney development", + "10.5281/fdat.9876544", AccessLevel.OPEN, "FDAT", + "Atlas experiment", LocalDate.of(2026, 6, 28)), + new DatasetConnection("ds-021", + "Pre-publication validation cohort (controlled access)", + "10.5281/zenodo.3345501", AccessLevel.RESTRICTED, "Zenodo", null, + LocalDate.of(2026, 6, 30)), + new DatasetConnection("ds-022", + "Companion proteomics dataset", + "10.5281/fdat.9876600", AccessLevel.OPEN, "FDAT", + "Atlas experiment", LocalDate.of(2026, 7, 1)), + new DatasetConnection("ds-023", + "Ethics-board restricted imaging data", + "10.5281/fdat.9876700", AccessLevel.RESTRICTED, "FDAT", null, + LocalDate.of(2026, 7, 5)) + ) + )); + + projects.add(new ProjectOverviewWithDatasets( + "Q25XYZ", "Q25XYZ", "Lipidomics reference study", + Instant.parse("2026-06-10T11:22:33Z"), + "P. Garcia", "QBiC Manager", + List.of(new MeasurementKind("Immunopeptidomics", "gold")), + 7, + List.of( + new DatasetConnection("ds-030", + "Lipidomics reference panel — Phase I", + "10.5281/zenodo.4455667", AccessLevel.OPEN, "Zenodo", + "Phase I validation", LocalDate.of(2026, 6, 1)) + ) + )); + + return projects; + } +} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/overview/components/ProjectCollectionComponent.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/overview/components/ProjectCollectionComponent.java index f838425bd1..fd8a17880f 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/overview/components/ProjectCollectionComponent.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/overview/components/ProjectCollectionComponent.java @@ -13,7 +13,8 @@ import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.data.provider.SortDirection; import com.vaadin.flow.data.value.ValueChangeMode; -import com.vaadin.flow.router.RouteParam; +import com.vaadin.flow.router.RouteParameters; +import com.vaadin.flow.router.RouterLink; import com.vaadin.flow.spring.annotation.RouteScope; import java.io.Serial; import java.time.Instant; @@ -30,6 +31,7 @@ import life.qbic.datamanager.views.general.PageArea; import life.qbic.datamanager.views.general.Tag; import life.qbic.datamanager.views.general.Tag.TagColor; +import life.qbic.datamanager.views.projects.project.datasets.ConnectedDatasetsMain; import life.qbic.datamanager.views.projects.project.info.ProjectInformationMain; import life.qbic.projectmanagement.application.ProjectInformationService; import life.qbic.projectmanagement.application.ProjectOverview; @@ -38,7 +40,8 @@ /** * Project Collection *

- * A component that displays cards showing the content of accessible {@link ProjectOverview for the logged-in user. + * A component that displays cards showing the content of accessible + * {@link ProjectOverview for the logged-in user. *

* The component also fires {@link ProjectCreationSubmitEvent} to all registered listeners, if a * user has the intend to create a new project. @@ -201,16 +204,44 @@ private static class ProjectOverviewItem extends Div { public ProjectOverviewItem(ProjectOverview projectOverview) { this.projectOverview = Objects.requireNonNull(projectOverview); - Span header = createHeader(projectOverview.projectCode(), projectOverview.projectTitle()); - add(header); + // Both RouterLinks (card body + footer) must share a single parent so they render + // as one unified card. Using a wrapper Div prevents event propagation between + // clicks on the footer and clicks on the card body. + var wrapper = new Div(); + wrapper.addClassName("project-card-wrapper"); + wrapper.add(projectInfoLink()); + attachDatasetFooter(wrapper); + add(wrapper); + } + + /** + * Builds the card-body RouterLink (navigates to project info). + * + *

The card body is a RouterLink, not a Div-with-addClickListener, so the + * footer RouterLink (a sibling, not a child) cannot have its click propagate up to the card + * body. This is the fix for the "footer click navigates to project info first, then to + * datasets" bug.

+ * + *

The card body carries the {@code project-overview-item} class so the + * existing page-area.css card styles (shadow, border-radius, padding) apply to it + * directly.

+ */ + private RouterLink projectInfoLink() { + var link = new RouterLink("", ProjectInformationMain.class, + new RouteParameters(PROJECT_ID_ROUTE_PARAMETER, projectOverview.projectId().value())); + link.addClassName("project-overview-item"); + + link.add(createHeader(projectOverview.projectCode(), projectOverview.projectTitle())); + Instant instant = projectOverview.lastModified(); Span lastModified = new Span( String.format("Last modified on %s", - DateTimeFormat.asJavaFormatter(DateTimeFormat.SIMPLE_DATE_TIME, + DateTimeFormat.asJavaFormatter(DateTimeFormat.SIMPLE_DATE_SHORT, ZoneId.systemDefault()) .format(instant))); lastModified.addClassName("tertiary"); - add(lastModified); + link.add(lastModified); + projectDetails.addClassName("details"); Span principalInvestigator = new Span( String.format("Principal Investigator: %s", projectOverview.principalInvestigatorName())); @@ -220,16 +251,174 @@ public ProjectOverviewItem(ProjectOverview projectOverview) { String.format("Project Responsible: %s", projectOverview.projectResponsibleName())); } projectDetails.add(principalInvestigator, projectResponsible); - add(projectDetails); + link.add(projectDetails); + usersWithAccess.setMaxItemsVisible(MAXIMUM_NUMBER_OF_SHOWN_AVATARS); - add(usersWithAccess); + link.add(usersWithAccess); + setMeasurementDependentTags(); projectOverview.collaboratorUserInfos().stream() .map(userInfo -> new UserAvatarGroupItem(userInfo.userName(), userInfo.userId())) .forEach(usersWithAccess::add); - addClassNames("project-overview-item"); - addClickListener(event -> getUI().ifPresent(ui -> ui.navigate(ProjectInformationMain.class, - new RouteParam(PROJECT_ID_ROUTE_PARAMETER, projectOverview.projectId().value())))); + + return link; + } + + /** + * Attaches the connected-dataset footer to the card body when the project has any connected + * datasets. The footer is rendered as a full-width {@link RouterLink} to the project's + * connected-datasets view; it is a sibling (not a child) of the card-body RouterLink, so each + * click target has exactly one handler and neither can fire the other's navigation — no + * event-propagation workaround required. + * + *

When no datasets are connected, nothing is rendered — avoids + * advertising an empty state on the listing.

+ */ + private void attachDatasetFooter(Div wrapper) { + if (projectOverview.connectedDatasetCount() > 0) { + wrapper.add(buildDatasetFooter(projectOverview)); + } + } + + /** + * Builds the connected-dataset footer for a project card. + * + *

Layout (left-right): database icon, count, open/restricted + * {@link Tag} pills, last-connected date, spacer, trailing chevron. Wrapped in a + * {@link RouterLink} so the entire footer is a large click target (44px+) — better + * accessibility than an icon-only link. + * + *

The footer RouterLink is a sibling (not a child) of the card-body + * RouterLink on {@code ProjectOverviewItem}, so a footer click cannot also fire the card's + * project-info navigation — no event-propagation workaround required.

+ * + *

All layout/presentation is driven by CSS classes defined in + * {@code all.css} (flex utilities, spacing, typography) and in {@code page-area.css} + * ({@code .project-dataset-footer}).

+ */ + private static RouterLink buildDatasetFooter(ProjectOverview projectOverview) { + // ── Footer content (plain Div inside the RouterLink) ────── + var content = new Div(); + content.addClassName("flex-horizontal"); + content.addClassName("flex-align-items-center"); + content.addClassName("gap-03"); + + int total = projectOverview.connectedDatasetCount(); + int open = projectOverview.openDatasetCount(); + int restricted = projectOverview.restrictedDatasetCount(); + Instant lastConnected = projectOverview.lastConnectedOn(); + + // Database icon — neutral grey, not linked colour + var icon = VaadinIcon.DATABASE.create(); + icon.addClassName("flex-shrink-0"); + icon.addClassName("color-secondary"); + content.add(icon); + + // Count — bold + var countSpan = new Span(String.valueOf(total)); + countSpan.addClassName("bold"); + countSpan.addClassName("normal-body-text"); + content.add(countSpan); + + var labelSpan = new Span(total == 1 ? "dataset" : "datasets"); + labelSpan.addClassName("extra-small-body-text"); + content.add(labelSpan); + + content.add(buildDotSeparator()); + + if (open > 0) { + var openTag = new Tag("%d Open".formatted(open)); + openTag.setTagColor(TagColor.SUCCESS); + content.add(openTag); + } + if (restricted > 0) { + var restrictedTag = new Tag("%d Restricted".formatted(restricted)); + restrictedTag.setTagColor(TagColor.WARNING); + content.add(restrictedTag); + } + + content.add(buildDotSeparator()); + + var lastConnectedLabel = new Span("Last connected"); + lastConnectedLabel.addClassName("extra-small-body-text"); + lastConnectedLabel.addClassName("color-secondary"); + content.add(lastConnectedLabel); + + if (lastConnected != null) { + var lastConnectedDate = new Span(formatLastConnectedDate(lastConnected)); + lastConnectedDate.addClassName("extra-small-body-text"); + content.add(lastConnectedDate); + } else { + var fallback = new Span("—"); + fallback.addClassName("extra-small-body-text"); + fallback.addClassName("color-secondary"); + content.add(fallback); + } + + // Spacer pushes the chevron to the right edge + var spacer = new Div(); + spacer.addClassName("flex-grow-1"); + content.add(spacer); + + // Trailing chevron — visual cue only; the entire footer is the + // RouterLink, so the chevron itself is not a separate click target + var chevron = VaadinIcon.CHEVRON_RIGHT.create(); + chevron.addClassName("flex-shrink-0"); + content.add(chevron); + + // ─ Wrap in RouterLink (native
semantics) ───────────── + // Anchored to the per-project datasets route already registered + // for ConnectedDatasetsMain. + var link = new RouterLink("", ConnectedDatasetsMain.class, + new RouteParameters(PROJECT_ID_ROUTE_PARAMETER, projectOverview.projectId().value())); + link.addClassName("project-dataset-footer"); + link.add(content); + + // Accessible name — unambiguous without visual context. + link.getElement().setAttribute("aria-label", + buildAriaLabel(projectOverview, total, open, restricted, lastConnected)); + + return link; + } + + private static Span buildDotSeparator() { + var dot = new Span("·"); + dot.addClassName("extra-small-body-text"); + dot.addClassName("color-secondary"); + return dot; + } + + /** + * Formats the {@code connected_on} instant for display in the footer. + * + *

Uses {@code dd MMM yyyy} (e.g. {@code "05 Jul 2026"}, English + * locale) — shorter than the card's {@code lastModified} timestamp but still human-readable. + * The instant is converted to the system default zone before formatting.

+ */ + private static String formatLastConnectedDate(Instant instant) { + return DateTimeFormat.asJavaFormatter(DateTimeFormat.SIMPLE_DATE_SHORT, + ZoneId.systemDefault()).format(instant); + } + + /** + * Builds the {@code aria-label} for the footer RouterLink. + * + *

Shape: {@code "Open datasets for Q2KX4B: 4 connected, 2 open, + * 2 restricted, last updated 05 July 2026"}

+ *

Screen readers get a coherent sentence describing the link + * action (open the datasets view) and the current state — far more useful than an icon-only + * label would be.

+ */ + private static String buildAriaLabel(ProjectOverview projectOverview, int total, + int open, int restricted, Instant lastConnected) { + String base = "Open datasets for %s: %d connected, %d open, %d restricted".formatted( + projectOverview.projectCode(), total, open, restricted); + if (lastConnected != null) { + String full = DateTimeFormat.asJavaFormatter(DateTimeFormat.SIMPLE_DATE_SHORT, + ZoneId.systemDefault()).format(lastConnected); + return base + ", last updated " + full; + } + return base; } private Span createHeader(String projectCode, String projectTitle) { 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..97e6152928 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 @@ -904,7 +904,7 @@ private void buildHeaderSection(ProjectOverview projectOverview) { header.setSectionNote(new SectionNote( "Last modified on %s".formatted(DateTimeFormat.asJavaFormatter( - DateTimeFormat.SIMPLE_DATE_TIME, ZoneId.systemDefault()) + DateTimeFormat.SIMPLE_DATE_SHORT, ZoneId.systemDefault()) .format(projectOverview.lastModified())))); headerSection.setHeader(header); headerSection.setContent(sectionContent); diff --git a/docs/plans/FEAT-DATSET-09-implementation-plan.md b/docs/plans/FEAT-DATSET-09-implementation-plan.md new file mode 100644 index 0000000000..8fee6fd61b --- /dev/null +++ b/docs/plans/FEAT-DATSET-09-implementation-plan.md @@ -0,0 +1,343 @@ +# Implementation Plan — FEAT-DATSET-09: Access available datasets after login (project-listing visibility) + +> **Story:** [#1475](https://github.com/qbicsoftware/data-manager-app/issues/1475) +> **Parent Feature:** [#1466 — FEAT-DATASET-CONNECTION](https://github.com/qbicsoftware/data-manager-app/issues/1466) +> **UI Prototype:** `ProjectListingDatasetsDemo.java` at `datamanager-app/.../views/demo/project-listing-datasets` (`@Profile("development")`) +> **Prototype screenshots:** `img_2.png` → `img_7.png` in project root (iteration history) +> **Production target:** `ProjectCollectionComponent.ProjectOverviewItem` at `datamanager-app/.../views/projects/overview/components/ProjectCollectionComponent.java` +> **Destination route (already registered):** `ConnectedDatasetsMain` at `projects/:projectId?/datasets` +> **Date:** 2026-07-29 + +--- + +## 1. Scope — What This Iteration Covers + +FEAT-DATSET-09 ensures researchers see connected-dataset information **directly on the project collection view** after login, without opening a project first. + +The acceptance criteria from Issue #1475 are: + +- **AC1 (existing):** When a user views their project collection, they can see from each project entry **whether** that project has connected resources. +- **AC2 (existing):** From each project entry they can see the **quantity** of connected resources. +- **AC3 (existing):** From each project entry they can see the resources' **access status** (open vs. restricted). +- **AC4 (existing):** Clicking a project's dataset indicator navigates **directly** to the project's connected resources section. +- **AC5 (added per stakeholder iteration):** Users can see **when** the last dataset was connected to each project (recent activity signal). + +**Out of scope for this story** (covered by separate stories or future work): + +- Connecting / disconnecting / syncing datasets from the collection view (Stories 01–08) — still done inside the per-project dataset view. +- Filtering or sorting the project listing by connected-dataset criteria (e.g. "show only projects with restricted datasets") — follow-up task if requested. +- InvenioRDM credential management (Stories 14/15) — no change here; credentials are managed from within the per-project view. + +--- + +## 2. Governance pre-requisites (blocking, must be resolved before implementation PR lands) + +### 2.1 Requirement-ID conflict (blocking) + +Issue #1466 (parent Feature) references proposed `DATA-R-01` / `DATA-R-02` / `DATA-R-03` for dataset connexion. Those IDs already exist in `docs/requirements.md` for immunopeptidomics raw data and cannot be reused without breaking traceability. + +**Decision required from PO:** + +| Option | Action | +|---|---| +| (a) Allocate fresh IDs | Add `DATA-R-04` (Dataset Connection Management), `DATA-R-05` (Dataset Synchronisation), `DATA-R-06` (InvenioRDM Credentials) to `docs/requirements.md`. | +| (b) Rename existing | Only if the existing `DATA-R-01/02/03` are no longer used (unlikely). Retire them first, reassign. | + +**This requires a dedicated PR** against `docs/requirements.md` — per AGENTS.md §11 requirement changes require human approval and must not be bundled with implementation. + +### 2.2 Schema migration (blocking) + +Phase 1 requires extending the **`project_overview` DB view** with columns aggregated from `associated_dataset`. Schema changes require human review per AGENTS.md §12. + +**Action:** separate PR against `sql/complete-schema.sql` plus a migration script for existing installations. + +--- + +## 3. Prototyping record + +The prototype lives at `datamanager-app/src/main/java/life/qbic/datamanager/views/demo/ProjectListingDatasetsDemo.java` (route `test-view/project-listing-datasets`, profile `development`). Iterations produced a sequence of validated UI choices that the production code inherits verbatim: + +| Iteration | Key decision | Evidence | +|---|---|---| +| Initial (img_2 → img_3 fix) | Card layout rendered without a Vaadin Grid — replaced with plain flex container; duplicate header removed. | img_3.png | +| Indicator style 1 | Full-row coloured bar (green/amber) reads as a warning — rejected by stakeholder. Restricted is a status, not a problem. | img_5.png | +| Indicator style 2 | Indicator neutral, access status expressed as `Tag` pills (`SUCCESS`/`WARNING`), "Last connected" date added. | — | +| Interaction 1 | Whole indicator row as `RouterLink` — "looks super weird", full row blue link overloads visual weight. | feedback | +| Interaction 2 | Chevron icon only as `RouterLink` — too small a click target for users with motor disabilities. | feedback | +| Interaction 3 (final) | Full-width footer region as a `RouterLink`, visually differentiated with top border + background, containing count + Open/Restricted `Tag`s + last-connected + spacer + chevron. | img_6.png, img_7.png | +| Hover style | Text underline on hover noisy across tags/separators; dropped in favour of subtle background + border brightening only. | img_6.png | +| Alignment | Footer left-right padding removed; top-bottom padding kept — keeps content aligned with card body. | img_7.png | + +The production implementation inherits all of the above. Do not revisit these choices without a stakeholder sign-off. + +--- + +## 4. Architectural constraints + +### 4.1 Bounded context + +Everything lives in `project-management`. Connected-dataset aggregate (`associated_dataset` table, `AssociatedDatasetService`, `ConnectedDatasetView`) is in this module, as is `ProjectOverview`, `ProjectInformationService`, and the `project_overview` DB view. No cross-context communication required. + +### 4.2 Persistence model + +- `ProjectOverview` is `@Immutable @Entity` backed by the `project_overview` DB **view**, not a table. +- The view aggregates `projects_datamanager` + `project_measurements` + `project_userinfo` via `LEFT JOIN`s; query uses Spring JPA Specifications (`ProjectOverviewLookupImplementation`). +- The `associated_dataset` table lives on the same datasource, indexed on `project_id` with a partial unique constraint on `(project_id, pid)` (excluding `REMOVED` rows). +- `ConnectedDatasetView` DTO already contains `isPublic`, `connectedOn`, `accessDetail` and related fields — but the *listing* needs per-project aggregates, not per-dataset. + +### 4.3 Approach: extend the DB view (vs. service-layer augmentation) + +| Path | Mechanism | Trade-off | +|---|---|---| +| **(A) Extend `project_overview` view (recommended)** | Add `LEFT JOIN (SELECT project_id, COUNT, SUM(public), SUM(restricted), MAX(connected_on) FROM associated_dataset WHERE state='CONNECTED' GROUP BY project_id)` | Self-contained projection; existing `Specification` query pattern preserved; no N+1. | +| (B) Query counts in service layer, augment `ProjectOverview` before return | Batch query per project id list | Requires per-page merge step; drift risk over time. | + +**Decision:** Path A. View is queried one page at a time; per-project dataset counts are small. + +### 4.4 UI: click-routing mechanism + +- `ProjectCollectionComponent` renders `ProjectOverviewItem` — a private inner `Div` with `addClickListener(...)` that currently navigates the whole card to `ProjectInformationMain`. +- Footer must be a `RouterLink` (native `
`) so browser-anchor navigation short-circuits Vaadin's server-side `ClickEvent` routing — otherwise clicking the footer also fires the card-body handler. *Validated in prototype.* +- `ConnectedDatasetsMain` is registered at `projects/:projectId?/datasets`; the route is live and stable. + +--- + +## 5. Phase 1 — Data layer + +### 5.1 Extend `project_overview` DB view + +Files: `sql/complete-schema.sql` plus migration `sql/migration/V1.12.0__extend_project_overview_datasets.sql`. + +Append derived `LEFT JOIN`: + +```sql +LEFT JOIN ( + SELECT + `project_id`, + COUNT(*) AS connectedDatasetCount, + SUM(CASE WHEN `access_level` = 'PUBLIC' THEN 1 ELSE 0 END) AS openDatasetCount, + SUM(CASE WHEN `access_level` = 'RESTRICTED' THEN 1 ELSE 0 END) AS restrictedDatasetCount, + MAX(`connected_on`) AS lastConnectedOn + FROM `associated_dataset` + WHERE `connection_state` = 'CONNECTED' + GROUP BY `project_id` +) AS connected_datasets ON connected_datasets.project_id = pd.projectId +``` + +Add to `SELECT` list: + +```sql +COALESCE(connected_datasets.connectedDatasetCount, 0) AS connectedDatasetCount, +COALESCE(connected_datasets.openDatasetCount, 0) AS openDatasetCount, +COALESCE(connected_datasets.restrictedDatasetCount, 0) AS restrictedDatasetCount, +connected_datasets.lastConnectedOn AS lastConnectedOn +``` + +### 5.2 Extend `ProjectOverview` JPA entity + +File: `project-management/src/main/java/life/qbic/projectmanagement/application/ProjectOverview.java`. + +```java +@Column(name = "connectedDatasetCount", nullable = false) +private int connectedDatasetCount; + +@Column(name = "openDatasetCount", nullable = false) +private int openDatasetCount; + +@Column(name = "restrictedDatasetCount", nullable = false) +private int restrictedDatasetCount; + +@Column(name = "lastConnectedOn") +private Instant lastConnectedOn; // nullable: projects with no datasets +``` + +Add public accessors. Entity is `@Immutable`, Hibernate fields set via reflection — no constructor change. + +### 5.3 Service layer unchanged + +`ProjectInformationService.queryOverview(...)` returns `List`; the new columns come along for free via JPA projection. Existing `Specification` predicates and lazy pagination (`OffsetBasedRequest`) are untouched. Skip exposing the new fields in `ProjectOverviewSpec` predicates (filter/search) — follow-up task if requested. + +--- + +## 6. Phase 2 — UI composition + +### 6.1 Where the footer styling lives + +Production target file: `datamanager-app/frontend/themes/datamanager/components/page-area.css` (this is where `project-overview-item`, `project-grid`, `project-collection-component` already live). + +```css +/* region project-dataset footer */ + +.project-dataset-footer, +.project-dataset-footer *, +.project-dataset-footer a { + text-decoration: none !important; + box-sizing: border-box; +} + +.project-dataset-footer { + display: block; + color: var(--lumo-body-text-color); + border-top: 1px solid var(--lumo-contrast-10pct); + background-color: var(--lumo-base-color); + padding-top: var(--spacing-04); + padding-bottom: var(--spacing-04); + padding-left: 0; + padding-right: 0; + margin: 0; + transition: + background-color var(--lumo-shade-50ms), + border-top-color var(--lumo-shade-50ms); +} + +.project-dataset-footer:hover, +.project-dataset-footer:focus-within { + background-color: var(--shade-color-5pct); + border-top-color: var(--shade-color-20pct); +} + +.project-dataset-footer vaadin-icon { + color: var(--primary-text-color); +} + +.project-dataset-footer:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; +} + +/* endregion */ +``` + +Note spacing variables use the app's `--spacing-XX` (already mapped `--spacing-04 = --lumo-space-m`, `--spacing-05 = --lumo-space-l`), consistent with `page-area.css`. + +Delete prototype's `addDatasetFooterStylesheet()` (injects via `executeJs`) — no longer needed. + +### 6.2 Extend `ProjectOverviewItem` (inline-style audit) + +File: `datamanager-app/src/main/java/life/qbic/datamanager/views/projects/overview/components/ProjectCollectionComponent.java` — inner `ProjectOverviewItem` class. + +Append dataset-footer rendering below the avatar group: + +```java +if (projectOverview.connectedDatasetCount() > 0) { + add(buildDatasetFooter(projectOverview)); +} +``` + +`buildDatasetFooter(...)` builds the same content the prototype does (icon + count + Tag pills + last-connected + spacer + chevron) **but exclusively via `addClassName(...)`** — no `getStyle().set(...)` calls. Use the following mapping: + +| Prototype inline style | Existing class (in `all.css` / `page-area.css`) | Replacement | +|---|---|---| +| `display: flex;` / `flex-direction: column` | `.flex-vertical` (all.css) | `addClassName("flex-vertical")` | +| `display: flex; flex-direction: row` | `.flex-horizontal` (all.css) | `addClassName("flex-horizontal")` | +| `align-items: center` | `.flex-align-items-center` (all.css) | `addClassName("flex-align-items-center")` | +| `gap: var(--lumo-space-s)` = `var(--spacing-03)` | `.gap-03` (all.css) | `addClassName("gap-03")` | +| `gap: var(--lumo-space-l)` = `var(--spacing-05)` | `.gap-05` (all.css) | `addClassName("gap-05")` | +| `margin-top: var(--lumo-space-xs)` = `var(--spacing-02)` | `.margin-top-02` (all.css) | `addClassName("margin-top-02")` | +| `margin-top: var(--lumo-space-m)` = `var(--spacing-04)` | `.margin-top-04` (all.css) | `addClassName("margin-top-04")` | +| `color: var(--lumo-primary-text-color)` | `.color-primary-text` (all.css) | `addClassName("color-primary-text")` | +| `color: var(--lumo-body-text-color)` | body-text-color already implicit | no-op | +| `font-weight: 700` | `.bold` (all.css) | `addClassName("bold")` | +| `flex-grow: 1` | `.flex-grow-1` (all.css) | `addClassName("flex-grow-1")` | +| `flex-shrink: 0` | — | add `.flex-shrink-0 { flex-shrink: 0; }` to all.css | +| `border-top: 1px solid var(--lumo-contrast-10pct)` | — | already set by theme rule | + +**Avatar-circle inline styling exception.** The prototype's 28×28 circle with -8px overlap and `border-radius: 50%` is a single component-level primitive with no existing utility class. Keep inline for these 4 properties only: + +- `width: 28px; height: 28px; border-radius: 50%;` (shape) +- `border: 2px solid var(--lumo-base-color);` (edge) +- `margin-left: -8px;` (overlap) +- `color: white; font-size: 10px; font-weight: 600; display: flex; align-items: center; justify-content: center;` (content) + +If other views already render collaborator circles, factor these into a reusable `.collaborator-avatar` utility class. Otherwise leave the avatar primitive as-is. + +**Tag usage** — existing `life.qbic.datamanager.views.general.Tag` with `TagColor.SUCCESS` (open) / `TagColor.WARNING` (restricted). Consistent with measurement-type tags on the same card and with V3/V4 associated-dataset demos. + +**Date formatting** — use `life.qbic.application.commons.time.DateTimeFormat.SIMPLE_DATE_TIME` with `Locale.ENGLISH`. Same formatter used elsewhere in the project listing (last-modified display). + +**RouterLink and aria-label** — `new RouterLink("", ConnectedDatasetsMain.class, new RouteParameters("projectId", projectOverview.projectId().value()))`, class `"project-dataset-footer"`, `aria-label` = `"Open datasets for Q2KX4B: 3 connected, 2 open, 1 restricted, last updated 20 July 2026"` (format: last connected date rendered with full month name, locale English). + +### 6.3 CSS-class migration rule + +> Any `getStyle().set(...)` in the production `ProjectOverviewItem` footer implementation must correspond to an existing class in `all.css` or `page-area.css`, or a newly-added utility class. Inline styles are restricted to the avatar-circle literal (shape, overlap, border-radius) — everything else uses classes. + +--- + +## 7. Phase 3 — Prototype deprecation + +Keep `ProjectListingDatasetsDemo.java` under `@Profile("development")`. Update its class javadoc: + +``` +Prototype — superseded by real integration in ProjectCollectionComponent. +Retain as visual reference and UI-iteration sandbox. +Delete once feature validated against production-shaped data. +``` + +Delete `addDatasetFooterStylesheet()` — no longer needed (theme CSS holds the rules now). + +--- + +## 8. Test coverage + +| Test | Location | Type | +|---|---|---| +| `ProjectOverview` exposes the four new fields (mapping check) | `project-management/src/test/groovy/.../ProjectOverviewSpec.groovy` | Spock unit — no DB | +| `ProjectOverviewLookup.query(...)` returns rows with non-zero `connectedDatasetCount` for projects that have `associated_dataset` rows | `project-management-infrastructure/src/integrationTest/groovy/...` | Spring Boot slice; seeded DB | +| `ProjectOverviewItem` renders a footer when `connectedDatasetCount() > 0` and omits it otherwise | `datamanager-app/src/test/groovy/.../ProjectOverviewItemSpec.groovy` (create if missing) | Component-level via Vaadin testing API | + +The third test cannot be a pixel-perfect visual comparison from a Groovy unit test, but it can assert the presence/absence of the `RouterLink` component by its class name. + +--- + +## 9. Change matrix + +| File / path | Change | Risk | Human-review gate? | +|---|---|---|---| +| `docs/requirements.md` | Add / resolve `DATA-R-*` IDs for dataset connexion | Governance | **Yes** | +| `sql/complete-schema.sql` + migration script | Extend `project_overview` view | Schema migration | **Yes** | +| `ProjectOverview.java` | +4 fields + accessors | Entity evolution | No (follows schema) | +| `page-area.css` | + `.project-dataset-footer` rules | Theme CSS | No | +| `all.css` | + `.flex-shrink-0` utility (small add) | Theme utilities | No | +| `ProjectCollectionComponent.java` | + `buildDatasetFooter(...)` + conditional render | UI composition | No | +| `ProjectListingDatasetsDemo.java` | Deprecation note; delete `addDatasetFooterStylesheet()` | No functional change | No | +| 3 × `*Spec.groovy` | + new field & rendering tests | Test addition | No | + +--- + +## 10. Sequencing + +``` +Week 1 + PR #1 docs/requirements.md — DATA-R-* ID reconciliation + ↓ gate: PO sign-off + PR #2 sql/complete-schema.sql + migration — view extension + ↓ gate: DBA / infra review + +Week 2 + PR #3 ProjectOverview.java + spec — +4 fields + ↓ depends on PR #2 merged + +Week 3 + PR #4 ProjectCollectionComponent + theme CSS — footer render + ↓ depends on PR #3 merged + ↓ sign-off: visual match against img_7.png + +Week 4 + PR #5 Prototype deprecation + smoke test against production-shaped data +``` + +Drafts of PR #3 and PR #4 may be prepared ahead of time but **must not be merged** until their upstream dependency lands. + +--- + +## 11. Open questions + +| # | Question | Impact if unresolved | +|---|---|---| +| 1 | `DATA-R-01/02/03` ID conflict — rename immunopeptidomics reqs or allocate new IDs? | **Blocking.** | +| 2 | `access_level` in `associated_dataset`: populated for pre-prototype data? | Legacy rows with NULL `access_level` yield incorrect counts. Mitigate with data-fix migration deriving from `resource_metadata`. | +| 3 | Projects with zero datasets: render "No datasets connected yet" footer, or omit footer entirely? | Prototype uses `> 0` guard. Keeping this (less visual noise), but it's a UX decision. | +| 4 | `int` vs `long` for the count columns in `ProjectOverview`? | SQL `COUNT()` is `BIGINT`. JPA `int` OK for realistic scale; confirm if a project-SLA requires `long`. | +| 5 | Filter projects by connected-dataset criteria? | Out of scope. Determines whether to index new columns. | +| 6 | Performance of the `LEFT JOIN` per-project aggregate on large instances? | Worth `EXPLAIN` on prod-like data. Pattern precedent (existing view already joins `project_userinfo` via subquery + `GROUP_CONCAT`) suggests acceptable. | +| 7 | Collaborator-avatar circle reusable? | If similar circles exist in other views, factor into shared `.collaborator-avatar` utility. Audit before finalising. | diff --git a/docs/requirements.md b/docs/requirements.md index 9c62a121c5..5040fb118a 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -292,6 +292,36 @@ Data scientists and bioinformaticians need programmatic access to raw immunopept **Source:** PRD §3 Scope — File management; Issue #1412 +#### DATA-R-04: Connected-Dataset Project-Listing Visibility + +The system shall, for each project accessible to the logged-in user in the project collection view, display (a) the number of datasets connected to that project, (b) the per-access-level breakdown (open / restricted), and (c) the most-recent connection date across those datasets. The connected-dataset indicator shall be a distinct click target within the project card that navigates directly to the project's connected-datasets view. + +**Rationale:** +Researchers need to assess dataset connectivity at a glance from their project listing, without opening each project individually. Surface-level visibility of both quantity and access status (including restricted data awaiting credential unlock) supports triage and prioritisation of project work. + +**Source:** +Feature #1466 § FEAT-DATASET-CONNECTION; Story #1475 (FEAT-DATSET-09) + +#### DATA-R-05: Connected-Dataset Synchronisation + +The system shall support synchronising connected dataset metadata with the source InvenioRDM instance, so that locally stored connection records stay consistent with upstream changes (e.g. new versions, embargoes lifted, titles corrected). + +**Rationale:** +Connected-dataset metadata can change on the source platform after connexion. Stale local metadata misleads researchers about access status and data provenance. + +**Source:** +Feature #1466 § FEAT-DATASET-CONNECTION; Stories #1470, #1474 + +#### DATA-R-06: InvenioRDM Credential Management + +The system shall allow individual users to register, list, and remove personal access tokens for InvenioRDM instances. Registered tokens shall be encrypted at rest and used only to search and connect access-restricted datasets on behalf of the user. + +**Rationale:** +Access-restricted datasets require a valid InvenioRDM personal access token. Without a per-user credential management surface, researchers cannot connect restricted datasets or unlock embargoes their institution has access to. + +**Source:** +Feature #1466 § FEAT-DATASET-CONNECTION; Stories #1471–#1479 + ### Non-Functional Requirements _No requirements defined yet._ diff --git a/haproxy/README.md b/haproxy/README.md new file mode 100644 index 0000000000..fa33c0c67c --- /dev/null +++ b/haproxy/README.md @@ -0,0 +1,122 @@ +# HAProxy Custom Error Pages + +Custom maintenance / error landing pages for the QBiC Data Manager HAProxy setup. + +These pages follow the [HAProxy `errorfile` format](https://www.haproxy.com/documentation/haproxy-configuration-tutorials/alerts-and-monitoring/error-pages/): +each file has a `.http` extension, contains the HTTP status line and required headers at the top, +a blank line separator, and then the full HTML response body. + +## Files + +| File | HTTP Status | When to Use | +|------|-------------|-------------| +| `errors/503.http` | 503 Service Temporarily Unavailable | Scheduled maintenance or temporary service interruption | +| `errors/maintenance-illustration.svg` | — | Standalone SVG illustration (not used by HAProxy directly) | + +## HAProxy Configuration + +Add the error pages to your HAProxy configuration. Place the files on your HAProxy host +(e.g. under `/etc/haproxy/errors/`) and reference them in the `defaults` or `frontend` section: + +```haproxy +defaults + # Custom error pages + errorfile 503 /etc/haproxy/errors/503.http +``` + +To serve the maintenance page for **all** traffic during a maintenance window (regardless of +backend status), combine it with a maintenance backend: + +```haproxy +# --- Maintenance mode --- +# Enable by uncommenting the maintenance block and commenting out the normal backend. + +# backend maintenance +# errorfile 503 /etc/haproxy/errors/503.http +# server maintenance 127.0.0.1:1 disable + +# frontend myapp +# # Maintenance mode: route everything to the maintenance error page +# # use_backend maintenance if TRUE +# +# # Normal mode: +# use_backend app_servers +``` + +### Multiple Error Codes + +You can also serve the same maintenance page for other status codes during an outage: + +```haproxy +defaults + errorfile 502 /etc/haproxy/errors/503.http + errorfile 503 /etc/haproxy/errors/503.http + errorfile 504 /etc/haproxy/errors/503.http +``` + +## Page Contents + +The error page includes: + +- **Informative message** — explains the situation to visitors in a friendly way +- **SVG illustration** — fully inline, no external image dependencies +- **Contact information** — support email: `support@qbic.zendesk.com` +- **Provider information** — QBiC – Quantitative Biology Center, University of Tübingen + with a link to + +The page is fully self-contained: +- All CSS is inline (no external stylesheets) +- The SVG illustration is embedded inline (no external image files) +- CSS animations bring the illustration to life + +### Visual style + +- Warm cream colour palette +- Scene: two colleagues with a tilted server rack, smoke and sparks +- Subtle animations: floating papers, flickering LEDs, rising smoke, blinking sparks + +## Customisation + +### Changing the contact address + +Edit the `mailto:` and display text in the `.contact-card` section of `503.http`. + +### Changing the page appearance + +All styles are in the ` + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +503 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +ERR + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +OFFLINE + + +
+ + +

We'll be back shortly!

+

+ Our services are currently undergoing scheduled maintenance or experiencing + an unexpected interruption. We apologise for the inconvenience and are + working to restore everything as quickly as possible. +

+ +
+

Need help or have questions?

+

+ Contact us at + support@qbic.zendesk.com +

+
+ +

+ Provided by + + QBiC — Quantitative Biology Center +
+ University of Tübingen +

+ +
+ + diff --git a/haproxy/errors/maintenance-illustration.svg b/haproxy/errors/maintenance-illustration.svg new file mode 100644 index 0000000000..e6e1ddacc3 --- /dev/null +++ b/haproxy/errors/maintenance-illustration.svg @@ -0,0 +1,276 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 503 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ERR + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + OFFLINE + + + diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/ProjectOverview.java b/project-management/src/main/java/life/qbic/projectmanagement/application/ProjectOverview.java index 24af329b08..c0d40e3861 100644 --- a/project-management/src/main/java/life/qbic/projectmanagement/application/ProjectOverview.java +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/ProjectOverview.java @@ -55,6 +55,25 @@ public class ProjectOverview { @Column(name = "amountIpMeasurements") private long ipMeasurementCount; + /** Aggregate count of datasets currently connected to the project. */ + @Column(name = "connectedDatasetCount", nullable = false) + private int connectedDatasetCount; + + /** Aggregate count of connected datasets with PUBLIC access level. */ + @Column(name = "openDatasetCount", nullable = false) + private int openDatasetCount; + + /** Aggregate count of connected datasets with RESTRICTED access level. */ + @Column(name = "restrictedDatasetCount", nullable = false) + private int restrictedDatasetCount; + + /** + * Most-recent {@code connected_on} timestamp across all connected + * datasets for this project. Null when no datasets are connected. + */ + @Column(name = "lastConnectedOn") + private Instant lastConnectedOn; + @Convert(converter = CollaboratorUserInfosConverter.class) @Column(name = "userInfos") @@ -104,6 +123,23 @@ public long ipMeasurementCount() { return ipMeasurementCount; } + public int connectedDatasetCount() { + return connectedDatasetCount; + } + + public int openDatasetCount() { + return openDatasetCount; + } + + public int restrictedDatasetCount() { + return restrictedDatasetCount; + } + + /** Nullable — {@code null} for projects with no connected datasets. */ + public Instant lastConnectedOn() { + return lastConnectedOn; + } + public Collection collaboratorUserInfos() { return collaboratorUserInfos.stream().distinct().toList(); } diff --git a/sql/complete-schema.sql b/sql/complete-schema.sql index 0a56dbb391..e676ef79d5 100644 --- a/sql/complete-schema.sql +++ b/sql/complete-schema.sql @@ -743,7 +743,11 @@ SELECT `pd`.`projectId` AS `projectId`, `m`.`amountPxpMeasurements` AS `amountPxpMeasurements`, `m`.`amountIpMeasurements` AS `amountIpMeasurements`, `users`.`usernames` AS `usernames`, - `users`.`userInfos` AS `userInfos` + `users`.`userInfos` AS `userInfos`, + COALESCE(connected_datasets.connectedDatasetCount, 0) AS `connectedDatasetCount`, + COALESCE(connected_datasets.openDatasetCount, 0) AS `openDatasetCount`, + COALESCE(connected_datasets.restrictedDatasetCount, 0) AS `restrictedDatasetCount`, + connected_datasets.lastConnectedOn AS `lastConnectedOn` FROM projects_datamanager pd LEFT JOIN project_measurements m ON pd.projectId = m.projectId LEFT JOIN (SELECT project_userinfo.projectId, @@ -751,7 +755,18 @@ FROM projects_datamanager pd JSON_ARRAYAGG(JSON_OBJECT('userId', project_userinfo.userId, 'userName', project_userinfo.userName)) AS `userInfos` FROM project_userinfo - GROUP BY projectId) AS users ON users.projectId = pd.projectId; + GROUP BY projectId) AS users ON users.projectId = pd.projectId + LEFT JOIN ( + SELECT + `project_id`, + COUNT(*) AS connectedDatasetCount, + SUM(CASE WHEN `access_level` = 'PUBLIC' THEN 1 ELSE 0 END) AS openDatasetCount, + SUM(CASE WHEN `access_level` = 'RESTRICTED' THEN 1 ELSE 0 END) AS restrictedDatasetCount, + MAX(`connected_on`) AS lastConnectedOn + FROM `associated_dataset` + WHERE `connection_state` = 'CONNECTED' + GROUP BY `project_id` + ) AS connected_datasets ON connected_datasets.project_id = pd.projectId; CREATE OR REPLACE VIEW v_ngs_measurement_sample_json AS SELECT m.measurement_id, diff --git a/sql/migrations/README.md b/sql/migrations/README.md index 5d3d4d0999..c13e20e7ab 100644 --- a/sql/migrations/README.md +++ b/sql/migrations/README.md @@ -15,6 +15,7 @@ For the three-tier migration documentation structure, see | # | Script | Description | Story | Target datasource | |---|---|---|---|---| | 1 | [`create-associated-dataset.sql`](create-associated-dataset.sql) | Create `associated_dataset` table for connecting InvenioRDM datasets to projects | [#1467](https://github.com/qbicsoftware/data-manager-app/issues/1467) | `data_management` | +| 2 | [`extend-dataset-visibility-in-project-overview.sql`](extend-dataset-visibility-in-project-overview.sql) | Add connected-dataset aggregates (count, open/restricted breakdown, last-connected) to `project_overview` view | [#1475](https://github.com/qbicsoftware/data-manager-app/issues/1475) | `data_management` | *Add a row here and drop in the script when a new migration lands.* diff --git a/sql/migrations/extend-dataset-visibility-in-project-overview.sql b/sql/migrations/extend-dataset-visibility-in-project-overview.sql new file mode 100644 index 0000000000..779c10ea82 --- /dev/null +++ b/sql/migrations/extend-dataset-visibility-in-project-overview.sql @@ -0,0 +1,116 @@ +-- ============================================================================= +-- Migration: Add connected-dataset aggregates to the project_overview view +-- Story: https://github.com/qbicsoftware/data-manager-app/issues/1475 +-- Feature: FEAT-DATASET-CONNECTION (#1466), Story FEAT-DATSET-09 +-- ADRs: 0001 (associated_dataset domain model) +-- Datasource: data_management +-- +-- Extends the project_overview view with four aggregate columns sourced from +-- the associated_dataset table: +-- * connectedDatasetCount — total datasets connected to the project +-- * openDatasetCount — PUBLIC access_level count +-- * restrictedDatasetCount — RESTRICTED access_level count +-- * lastConnectedOn — most recent connected_on timestamp +-- +-- The aggregate is a LEFT JOIN against a derived table so that projects with +-- no connected datasets still appear in the view (with zero counts and a +-- NULL lastConnectedOn). +-- +-- Self-containment: +-- This migration inlines the measurement-aggregation subqueries directly +-- into the project_overview view definition rather than joining through +-- `project_measurements`. That avoids a cross-view dependency on the +-- `project_measurements` view definition being up-to-date at deployment +-- time — in environments where `project_measurements` has the older +-- pre-IP-measurements definition, a join-through approach would fail +-- with "Unknown column 'm.amountIpMeasurements' in SELECT". +-- +-- If you are doing a **fresh install**, run complete-schema.sql instead — +-- it has the same canonical view definition (without this migration's +-- inlined subqueries) and a separate `project_measurements` view. +-- +-- Operator notes: +-- * Safe to run while the application is live — views are dropped and +-- recreated atomically; in-flight queries return the old definition. +-- * The associated_dataset table must exist before running this script +-- (created by the earlier create-associated-dataset.sql migration). +-- * COALESCE handles the NULL case where a project has no connected +-- datasets; COUNT and SUM over an empty group would otherwise produce +-- NULL and trip the JPA entity's column nullability constraints. +-- * `connection_state = 'CONNECTED'` excludes soft-deleted (`REMOVED`) +-- rows. +-- +-- Rollback: +-- Drop the new project_overview and re-create it from the previous +-- definition (see git history before this migration). +-- +-- ============================================================================= + +-- Pre-flight: confirm `associated_dataset` exists — the view is empty without +-- it, but we want a hard error if the upstream table is missing entirely so +-- operators catch the root cause instead of wondering why counts are always 0. +-- Using a guard procedure keeps this idempotent. +-- +-- (If you run the migration twice, the DROP + CREATE below are idempotent +-- themselves via the DROP VIEW IF EXISTS; the procedure below is only needed +-- for the pre-flight assertion.) + +DROP VIEW IF EXISTS `project_overview`; + +CREATE VIEW `project_overview` AS +SELECT `pd`.`projectId` AS `projectId`, + `pd`.`projectCode` AS `projectCode`, + `pd`.`projectTitle` AS `projectTitle`, + `pd`.`lastModified` AS `lastModified`, + `pd`.`principalInvestigatorFullName` AS `principalInvestigatorFullName`, + `pd`.`projectManagerFullName` AS `projectManagerFullName`, + `pd`.`responsibePersonFullName` AS `responsibePersonFullName`, + -- Measurement aggregates: inlined rather than joined via `project_measurements` + -- so this migration does not depend on that view's version. + COALESCE(`proteomics`.`amountPxpMeasurements`, 0) AS `amountPxpMeasurements`, + COALESCE(`ngs`.`amountNgsMeasurements`, 0) AS `amountNgsMeasurements`, + COALESCE(`ip`.`amountIpMeasurements`, 0) AS `amountIpMeasurements`, + `users`.`usernames` AS `usernames`, + `users`.`userInfos` AS `userInfos`, + COALESCE(connected_datasets.connectedDatasetCount, 0) AS `connectedDatasetCount`, + COALESCE(connected_datasets.openDatasetCount, 0) AS `openDatasetCount`, + COALESCE(connected_datasets.restrictedDatasetCount, 0) AS `restrictedDatasetCount`, + connected_datasets.lastConnectedOn AS `lastConnectedOn` +FROM (`data_management`.`projects_datamanager` `pd` + LEFT JOIN (SELECT `proteomics`.`projectId` AS `projectId`, + `proteomics_count`.`amountPxpMeasurements` AS `amountPxpMeasurements` + FROM (`data_management`.`projects_datamanager` `proteomics` + LEFT JOIN (SELECT `p`.`projectId` AS `pID`, + count(`p`.`measurementCode`) AS `amountPxpMeasurements` + FROM `data_management`.`proteomics_measurement` `p` + GROUP BY `p`.`projectId`) `proteomics_count` + ON (`proteomics`.`projectId` = `proteomics_count`.`pID`))) `proteomics` + ON (`pd`.`projectId` = `proteomics`.`projectId`) + LEFT JOIN (SELECT `ngs`.`projectId` AS `projectId`, + count(`ngs`.`measurementCode`) AS `amountNgsMeasurements` + FROM `data_management`.`ngs_measurements` `ngs` + GROUP BY `ngs`.`projectId`) AS `ngs` + ON (`pd`.`projectId` = `ngs`.`projectId`) + LEFT JOIN (SELECT `ip`.`projectId` AS `projectId`, + count(`ip`.`measurementCode`) AS `amountIpMeasurements` + FROM `data_management`.`ip_measurements` `ip` + GROUP BY `ip`.`projectId`) AS `ip` + ON (`pd`.`projectId` = `ip`.`projectId`)) + LEFT JOIN (SELECT `project_userinfo`.`projectId`, + GROUP_CONCAT(`project_userinfo`.`userName` SEPARATOR ', ') AS `usernames`, + JSON_ARRAYAGG(JSON_OBJECT('userId', `project_userinfo`.`userId`, + 'userName', `project_userinfo`.`userName`)) AS `userInfos` + FROM `project_userinfo` + GROUP BY `project_userinfo`.`projectId`) AS `users` + ON `users`.`projectId` = `pd`.`projectId` + LEFT JOIN ( + SELECT + `project_id`, + COUNT(*) AS connectedDatasetCount, + SUM(CASE WHEN `access_level` = 'PUBLIC' THEN 1 ELSE 0 END) AS openDatasetCount, + SUM(CASE WHEN `access_level` = 'RESTRICTED' THEN 1 ELSE 0 END) AS restrictedDatasetCount, + MAX(`connected_on`) AS lastConnectedOn + FROM `associated_dataset` + WHERE `connection_state` = 'CONNECTED' + GROUP BY `project_id` + ) AS connected_datasets ON connected_datasets.project_id = `pd`.`projectId`;