diff --git a/.gitignore b/.gitignore index c6b5932879..ccf4e954ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,13 @@ +# Project-local SSH configuration (keys, config, known_hosts) +.ssh/ + # Local agent config files/folders .opencode/ opencode.json +# Any local run configuration file +.run + # Local git worktrees .worktrees/ diff --git a/AGENTS.md b/AGENTS.md index fd9bc27640..8e2ea8c122 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,10 @@ PRD (docs/prd.md) └── Implementation (code, PRs) ``` +Significant architectural decisions are recorded as **Architecture Decision Records (ADRs)** in +[`docs/adr/`](docs/adr/README.md), which provide rationale and context that span across the +traceability chain above. + Stories are tracked in two places: `docs/features.md` (the stakeholder-facing, stable record) and GitHub issues (the implementation tracking record). When a story moves from draft (discussion in SharePoint Word doc) to approved (ready for implementation), it receives a stable Story ID and is @@ -238,21 +242,45 @@ Rules: #### Story Lifecycle -Stories move through a lifecycle from draft to implementation. This flow keeps the GitHub issue history clean and ensures stakeholders have a stable, reviewable document. - -1. **Draft (Discussion)** — The story is captured in a SharePoint Word document or similar internal medium where discussion, refinement, and iteration happen. No stable ID is assigned. The story is not yet tracked in `docs/features.md`. -2. **Approved (Ready for Implementation)** — The story is finalised, a stable `FEAT--` ID is assigned, and it is: - - Written into `docs/features.md` with full narrative and acceptance criteria - - A GitHub issue is created (if it does not already exist) or an existing issue is updated with the stable ID in the title and body - - The status in `docs/features.md` is updated from 🔴 (Open) to 🟡 (In Progress) when implementation begins, and 🟢 (Done) when complete -3. **Implementation** — Tasks are created in GitHub referencing the stable story ID (not the GitHub issue number). Implementation traces: Task → Story (stable ID) → Feature → Requirement → PRD. +Stories move through a lifecycle from draft to implementation. This flow keeps the GitHub issue +history clean and ensures stakeholders have a stable, reviewable document. + +The lifecycle spans two **decoupled surfaces**, with the Product Owner as the handoff actor: + +- **Stakeholder space** (Word Online / SharePoint, email) — where stakeholders and the PO + draft and discuss stories. This material is PO-internal; developers do not read it. +- **Developer space** (GitHub issues, `docs/features.md`) — where approved stories become + versioned, traceable specification that drives Tasks and implementation. + +The handoff between these two spaces is a deliberate, reviewable action performed by the PO: + +1. **Draft (Discussion)** — The story is drafted in stakeholder space (Word Online / + SharePoint) where the PO and stakeholders discuss, refine, and iterate. No stable ID is + assigned. The story is not yet tracked in GitHub or `docs/features.md`. +2. **Approved (Ready for Implementation)** — The PO finalises the story, assigns a stable + `FEAT--` ID, and performs the handoff: + - A Feature issue is created (if one does not already exist). + - A Story issue is created using `.github/ISSUE_TEMPLATE/story.yml`, with the stable ID in + the title and body, the parent Feature linked, and at least one `R-` referenced. + - The story is written into `docs/features.md` with full narrative and acceptance criteria. + - The status in `docs/features.md` starts at 🔴 (Open) and moves to 🟡 (In Progress) when + implementation begins, then 🟢 (Done) when complete. +3. **Implementation** — Tasks are created in GitHub referencing the stable story ID (not the + GitHub issue number). Implementation traces: + Task → Story (stable ID) → Feature → Requirement → PRD. **Rules:** - A story must never be implemented without a stable ID in `docs/features.md`. -- When a story was created on GitHub before this flow existed (e.g., old issue numbers), update the existing issue with the stable ID rather than creating a new one. -- Tasks reference stories by their stable ID in the "Parent Story" field, not by GitHub issue number. -- The GitHub issue remains the source of truth for implementation tracking (comments, sub-issues, assignees), but `docs/features.md` is the source of truth for story content (narrative, acceptance criteria). +- When a story was created on GitHub before this flow existed (e.g., old issue numbers), + update the existing issue with the stable ID rather than creating a new one. +- Tasks reference stories by their stable ID in the "Parent Story" field, not by GitHub issue + number. +- The GitHub issue remains the source of truth for implementation tracking (comments, + sub-issues, assignees), but `docs/features.md` is the source of truth for story content + (narrative, acceptance criteria). +- Post-approval changes to a story happen via a PR to `docs/features.md` and a corresponding + GitHub issue comment — not by editing the original Word Online draft. --- @@ -665,7 +693,11 @@ When working on this codebase, an AI agent should: `finances`, or cross-cutting). 2. **Identify the layer** (`domain`, `application`, `infrastructure`, `views`/UI). 3. Check `ExceptionHandling.md` and `service_api.md` for patterns relevant to the change. -4. Read existing tests in the same module before writing new code. +4. **Check `docs/adr/README.md`** for existing ADRs that apply to the change area. If the change + is a significant architectural decision, consider creating a new ADR using the MADR template + at `docs/adr/templates/madr-template.md`. See [`docs/adr/README.md`](docs/adr/README.md) for + naming conventions and rules. +5. Read existing tests in the same module before writing new code. ### Creating Features @@ -677,11 +709,21 @@ When working on this codebase, an AI agent should: ### Creating Stories -- Stories start as drafts (discussion/refinement in SharePoint Word or similar internal medium). They are NOT tracked in `docs/features.md` or GitHub while in draft. -- When a story is finalised and approved for implementation: assign a stable `FEAT--` ID, write it into `docs/features.md` with full narrative and acceptance criteria, and create or update the corresponding GitHub issue with the stable ID in the title and body. -- For stories that were created on GitHub before this workflow existed, update the existing issue with the stable ID rather than creating a new one. -- Tasks reference the story by its stable ID (e.g., `FEAT-IP-MEAS-01`), not by GitHub issue number. -- If you are unsure whether a new Story is needed or an existing Feature should be extended, pause and ask a human reviewer. +- Stories start as drafts in stakeholder space (Word Online / SharePoint, email) — PO-internal + material that developers do not read. They are NOT tracked in `docs/features.md` or GitHub + while in draft. +- When a story is ready for handoff: the PO translates it into GitHub, using + `.github/ISSUE_TEMPLATE/story.yml`, assigns a stable `FEAT--` ID, writes it into + `docs/features.md` with full narrative and acceptance criteria, and links it to the parent + Feature issue. +- For stories that were created on GitHub before this workflow existed, update the existing + issue with the stable ID rather than creating a new one. +- Tasks reference the story by their stable ID (e.g., `FEAT-IP-MEAS-01`), not by GitHub + issue number. +- Post-approval changes happen via PR against `docs/features.md` — not by editing the Word + Online draft. +- If you are unsure whether a new Story is needed or an existing Feature should be extended, + pause and ask a human reviewer. ### Making domain changes @@ -732,6 +774,8 @@ An agent should pause and request human review/approval before: - Implementing a change that introduces new system capability not covered by an existing requirement. - Retiring or renaming a Feature slug once Stories reference it. - Splitting a Feature into multiple Features when Stories already reference the original Feature. +- Creating a new Architecture Decision Record (ADR) — to ensure the decision is captured in the + correct format and the ADR index in `docs/adr/README.md` is updated. --- @@ -742,6 +786,8 @@ An agent should pause and request human review/approval before: | `docs/requirements.md` | Authoritative requirement registry — all R/NFR/C requirements documented here; must be updated before new capabilities are implemented | | `docs/requirements-guide.md` | Authoring conventions for creating, editing, and retiring requirements | | `docs/features.md` | Stakeholder-facing features and user stories tracker — stable story records with narrative, acceptance criteria, and status; stories move here from draft once approved | +| `docs/adr/README.md` | Architecture Decision Records index — naming rules, creation process, and list of ADRs | +| `docs/adr/templates/madr-template.md` | MADR template for creating new ADRs (see [`docs/adr/README.md`](docs/adr/README.md)) | | `README.md` | Setup, configuration reference, how to run | | `ExceptionHandling.md` | Exception handling conventions (read before touching error handling) | | `service_api.md` | Service API design patterns (Mono/Flux, request/response shapes) | 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/front-end-components.md b/datamanager-app/front-end-components.md index 6faad7e531..287919ea4b 100644 --- a/datamanager-app/front-end-components.md +++ b/datamanager-app/front-end-components.md @@ -136,5 +136,127 @@ classDiagram ``` +## Alert dialog + +```mermaid +classDiagram + note for Component "Vaadin Component" + AlertDialog <-- AppDialog + AppDialog --> DialogHeader + AppDialog --> DialogBody + AppDialog --> DialogFooter + AppDialog --|> Dialog + + class AlertDialog { + <> + +alert(Component parent) Builder + +danger(Component parent, String title, String message, DialogAction onConfirm) AlertDialog + +open() + +close() + +dialog() AppDialog + } + + class Builder { + +intent(Intent intent) Builder + +danger() Builder + +warning() Builder + +error() Builder + +info() Builder + +title(String title) Builder + +message(String message) Builder + +confirmButton(String label, DialogAction action) Builder + +cancelButton(String label, DialogAction action) Builder + +build() AlertDialog + } + + class AppDialog { + +small() AppDialog + +registerConfirmAction(DialogAction action) + +registerCancelAction(DialogAction action) + +open() + +close() + } + + class DialogHeader { + +withIcon(AppDialog dialog, String title, Icon icon) + } + + class DialogBody { + +withoutUserInput(AppDialog dialog, Component component) + } + + class DialogFooter { + +withDangerousConfirm(AppDialog dialog, String cancelText, String confirmText) + +with(AppDialog dialog, String cancelText, String confirmText) + +withConfirmOnly(AppDialog dialog, String confirmText) + } + + class DialogAction { + <> + +execute() + } +``` + +### When to use + +| Need | Pattern | +|---|---| +| Destructive action confirmation | `AlertDialog.danger(parent, title, message, onConfirm).open()` | +| Error requiring acknowledgment | `AlertDialog.alert(parent).error().title(...).message(...).confirmButton("OK", () -> {}).build().open()` | +| Error with redirect + cancel | `AlertDialog.alert(parent).error().confirmButton("Go...", nav).cancelButton("Cancel", close).build().open()` | +| Cancel / discard changes | `CancelConfirmationDialogFactory.cancelConfirmationDialog(action, key, locale).open()` | +| Success / info / transient feedback | `MessageSourceNotificationFactory.toast(...)` (unchanged) | +| Pending task with progress bar | `MessageSourceNotificationFactory.pendingTaskToast(...)` (unchanged) | + +### Code examples + +**Destructive confirmation (most common):** +```java +AlertDialog.danger(this, + "Samples within batch will be deleted", + "Deleting this Batch will also delete the samples contained within. Proceed?", + () -> deleteBatch(batchId)).open(); +``` + +**Error dialog with single button:** +```java +AlertDialog.alert(this) + .error() + .title("Cannot edit variables") + .message("Editing experimental variables is only possible if samples are not registered.") + .confirmButton("Okay", () -> {}) + .build() + .open(); +``` + +**Warning with cancel:** +```java +AlertDialog.alert(this) + .warning() + .title("Discard changes?") + .message("By aborting, you will lose all entered information.") + .confirmButton("Discard", () -> discard()) + .cancelButton("Continue Editing", () -> {}) // close dialog + .build() + .open(); +``` + +### Deprecated: NotificationDialog + +`NotificationDialog` has been removed and replaced with `AlertDialog`. The following classes were deleted: + +- `AccessTokenDeletionConfirmationNotification` +- `BatchDeletionConfirmationNotification` +- `MeasurementDeletionConfirmationNotification` +- `QCItemDeletionConfirmationNotification` +- `PurchaseItemDeletionConfirmationNotification` +- `ProjectUserRemovalConfirmationNotification` +- `ExistingGroupsPreventVariableEdit` +- `ExistingSamplesPreventVariableEdit` +- `ExistingSamplesPreventSampleOriginEdit` +- `ExistingSamplesPreventGroupEdit` + +If any code still references `NotificationDialog`, it will throw `UnsupportedOperationException` at runtime. Use the patterns above instead. + diff --git a/datamanager-app/frontend/themes/datamanager/components/all.css b/datamanager-app/frontend/themes/datamanager/components/all.css index 89da9b2f7e..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; } @@ -1497,3 +1501,42 @@ Older stuff - .overflow-auto { overflow: auto; } + +/*region Multi-line text truncation (line clamp)*/ +/* +Truncate text after N visible lines. Uses the WebKit-specific +-webkit-line-clamp property, which requires -webkit-box + +-webkit-box-orient:vertical to function. Works in all browsers +that support Chromium (including Vaadin). The "line-height:1.4" +keeps truncated text visually consistent with surrounding labels. +*/ +.clamp-1-line, +.clamp-2-line, +.clamp-3-line { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.4; + white-space: normal; +} + +.clamp-1-line { -webkit-line-clamp: 1; } +.clamp-2-line { -webkit-line-clamp: 2; } +.clamp-3-line { -webkit-line-clamp: 3; } +/*endregion*/ + +/*region Positioned centre-fill overlay*/ +/* +Absolutely fills and centres content within its nearest +positioned ancestor. Used for loading spinners, welcome/empty +states, and modal-like overlays that sit inside a flex container. +*/ +.overlay-center-fill { + position: absolute; + inset: 0; /* top:0; right:0; bottom:0; left:0 */ + display: flex; + align-items: center; + justify-content: center; +} +/*endregion*/ diff --git a/datamanager-app/frontend/themes/datamanager/components/combobox.css b/datamanager-app/frontend/themes/datamanager/components/combobox.css index 71cc82e08f..453ad2d7b4 100644 --- a/datamanager-app/frontend/themes/datamanager/components/combobox.css +++ b/datamanager-app/frontend/themes/datamanager/components/combobox.css @@ -1,3 +1,13 @@ .analysis-type-combo-box::part(overlay) { width: 20rem; } + +/* + * The "Connect dataset" sidebar prototype uses a ComboBox inside a fixed-position + * sidebar panel at z-index 1000. Vaadin's base overlay uses a hard-coded z-index of + * 200. This rule, applied via setOverlayClassName(), raises the overlay above the + * sidebar so the dropdown is actually selectable. + */ +vaadin-combo-box-overlay.connect-dataset-sidebar-overlay { + z-index: 1001 !important; +} diff --git a/datamanager-app/frontend/themes/datamanager/components/connect-dataset-sidebar.css b/datamanager-app/frontend/themes/datamanager/components/connect-dataset-sidebar.css new file mode 100644 index 0000000000..7ea394322e --- /dev/null +++ b/datamanager-app/frontend/themes/datamanager/components/connect-dataset-sidebar.css @@ -0,0 +1,211 @@ +/* ──────────────────────────────────────────────────────────────────────────── + * Connect Dataset Sidebar + * + * All styles are scoped under the .connect-dataset-sidebar root class so they + * cannot leak into other components. Layout and spacing that can be expressed + * with Lumo utility classes (flex-grow, gap-s, items-center, mb-m, …) are + * applied via addClassName() in the Java view; this file contains only the + * properties that are too specific or too contextual for a utility class + * (exact widths, box-shadows, borders with project-specific colours, etc.). + * + * Hierarchy (see ConnectDatasetSidebar.java): + * .connect-dataset-sidebar ← root Div (the sidebar itself) + * .cds-backdrop semi-transparent overlay behind panel + * .cds-panel the sliding panel + * .cds-body full-height flex column + * .cds-header top bar with title + close button + * .cds-content scrollable body (search + results) + * .cds-search-row instance-selector + search field + * .cds-results grid + loading/welcome overlays + * .cds-loading loading spinner overlay + * .cds-welcome empty-state overlay + * .cds-card individual search-result card + * .cds-card-top-row first row (provider + access + date) + * .cds-card-title clamped title (also .clamp-2-line) + * .cds-footer experiment selector + connect button + * ──────────────────────────────────────────────────────────────────────────── */ + +/* ── Backdrop (behind the panel) ─────────────────────────────────────────── */ + +.connect-dataset-sidebar .cds-backdrop { + position: fixed; + inset: 0; /* top/right/bottom/left: 0 */ + background-color: rgba(0, 0, 0, 0.3); + z-index: 999; +} + +/* ── Panel ───────────────────────────────────────────────────────────────── */ + +.connect-dataset-sidebar .cds-panel { + position: fixed; + top: 0; + right: 0; + width: min(55%, 720px); + min-width: 460px; + max-width: 100vw; + height: 100%; + background-color: var(--lumo-base-color); + /* Drop shadow to the left — the only open edge of a right-anchored panel. */ + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12); + z-index: 1000; + box-sizing: border-box; +} + +/* ── Body (full-height flex column inside panel) ─────────────────────────── */ + +.connect-dataset-sidebar .cds-body { + height: 100%; + box-sizing: border-box; +} + +/* ── Header (title + close button) ──────────────────────────────────────── */ + +.connect-dataset-sidebar .cds-header { + padding: var(--lumo-space-m) var(--lumo-space-l); + /* Contrast-10 border is lighter than the default project border colour so + it remains subtle inside the panel's own background. */ + border-bottom: 1px solid var(--lumo-contrast-10pct); +} + +/* ── Content area (search + results, scrollable) ────────────────────────── */ + +.connect-dataset-sidebar .cds-content { + padding: var(--lumo-space-l); + /* Critical: allows the search-row + results-container to shrink inside + the flex column without overflowing. */ + min-height: 0; +} + +/* ── Search row (instance selector + text field) ─────────────────────────── */ + +.connect-dataset-sidebar .cds-search-row { + /* flexbox needs min-width:0 so child flex items can shrink below their + min-content size — prevents the search field from overflowing narrow + panels. */ + min-width: 0; +} + +/* ── Results container (relative positioned parent for overlays) ────────── */ + +.connect-dataset-sidebar .cds-results { + position: relative; + min-height: 200px; +} + +/* ── Loading overlay (spinner inside results container) ─────────────────── */ + +.connect-dataset-sidebar .cds-loading { + background-color: var(--lumo-base-color); + z-index: 2; +} + +/* + * Pure-CSS four-dots rotating spinner ("Spinner II", Temani Afif). + * A single grid div with two pseudo-elements; each carries four dots + * at the cardinal points. The outer ring (.cds-loading-spinner::after, + * larger dots, slower animation) and the inner ring (::before, smaller + * dots, margin-inset, linear timing) rotate at slightly different + * rates to produce the characteristic "four-dots orbit" effect. + * + * Each ring has its own named color token: + * --cds-spinner-outer (default: Lumo primary color) + * --cds-spinner-inner (default: hue-rotated Lumo primary) + * Override either from the parent to theme the rings independently. + * + * Credit: https://codepen.io/t_afif/pen/yLMXBRL (Spinner #2) + */ +.connect-dataset-sidebar .cds-loading-spinner { + width: 48px; + aspect-ratio: 1; + display: grid; + --cds-spinner-outer: var(--lumo-primary-color); + /* 45deg hue shift off the primary produces a complementary accent + (e.g. blue primary → teal inner dots, red primary → orange). Keeps + the palette harmonious with any Lumo primary-color choice. */ + --cds-spinner-inner: #00acac; +} + +.connect-dataset-sidebar .cds-loading-spinner::after { + content: ""; + grid-area: 1 / 1; + --c: radial-gradient(farthest-side, var(--cds-spinner-outer) 92%, #0000); + background: + var(--c) 50% 0, + var(--c) 50% 100%, + var(--c) 100% 50%, + var(--c) 0 50%; + background-size: 12px 12px; + background-repeat: no-repeat; + animation: cds-spin-half 1s infinite; +} + +.connect-dataset-sidebar .cds-loading-spinner::before { + content: ""; + grid-area: 1 / 1; + margin: 4px; + --c: radial-gradient(farthest-side, var(--cds-spinner-inner) 92%, #0000); + background: + var(--c) 50% 0, + var(--c) 50% 100%, + var(--c) 100% 50%, + var(--c) 0 50%; + background-size: 8px 8px; + background-repeat: no-repeat; + /*filter: hue-rotate(45deg);*/ + animation: cds-spin-half 1s infinite linear; +} + +@keyframes cds-spin-half { + 100% { transform: rotate(0.5turn); } +} + +.connect-dataset-sidebar .cds-loading-message { + font-weight: var(--font-weight-medium); +} + +.connect-dataset-sidebar .cds-loading-hint { + color: var(--lumo-tertiary-text-color); +} + +/* ── Welcome / empty-state overlay ──────────────────────────────────────── */ + +.connect-dataset-sidebar .cds-welcome { + background-color: var(--lumo-base-color); + color: var(--lumo-secondary-text-color); + z-index: 1; + cursor: default; +} + +.connect-dataset-sidebar .cds-welcome-icon { + font-size: var(--lumo-font-size-xxxl); + color: var(--lumo-tertiary-text-color); +} + +.connect-dataset-sidebar .cds-welcome-subtitle { + text-align: center; + padding: 0 var(--lumo-space-l); + color: var(--lumo-tertiary-text-color); +} + +/* ── Footer (experiment selector + connect button) ───────────────────────── */ + +.connect-dataset-sidebar .cds-footer { + padding: var(--lumo-space-m) var(--lumo-space-l); + border-top: 1px solid var(--lumo-contrast-10pct); +} + +/* ── Search-result card ─────────────────────────────────────────────────── */ + +.connect-dataset-sidebar .cds-card { + border-radius: var(--lumo-border-radius-m); +} + +/* Tighten the gap between the date label and the card's bottom edge when + the only content in the card is the top row + title + PID. */ +.connect-dataset-sidebar .cds-card-top-row { + /* Inherits flex-horizontal, items-center, gap-02 from Java addClassName(). */ +} + +.connect-dataset-sidebar .cds-card-title { + font-weight: var(--font-weight-semi-bold); +} diff --git a/datamanager-app/frontend/themes/datamanager/components/custom.css b/datamanager-app/frontend/themes/datamanager/components/custom.css index ae7ea24536..8c5e069f0d 100644 --- a/datamanager-app/frontend/themes/datamanager/components/custom.css +++ b/datamanager-app/frontend/themes/datamanager/components/custom.css @@ -6,6 +6,7 @@ @import "card.css"; @import "combobox.css"; @import "dialog.css"; +@import "connect-dataset-sidebar.css"; @import "div.css"; @import "image.css"; @import "info.css"; diff --git a/datamanager-app/frontend/themes/datamanager/components/grid-templates.css b/datamanager-app/frontend/themes/datamanager/components/grid-templates.css index 60fa16ed99..b9dc803b92 100644 --- a/datamanager-app/frontend/themes/datamanager/components/grid-templates.css +++ b/datamanager-app/frontend/themes/datamanager/components/grid-templates.css @@ -58,6 +58,18 @@ "projectdetails qualitycontrollist"; } +/* + * Override for the Associated Datasets view (FEAT-DATSET-01). + * The dataset view has a single full-width child, so collapse the + * two-column project grid into a single 1fr column. + * See grid-templates.css. + */ +.main.project.datasets { + grid-template-columns: 1fr; + grid-template-rows: auto; + grid-template-areas: "datasets-content"; +} + .main.ontology-lookup-main { grid-template-columns: auto; grid-template-rows: auto; diff --git a/datamanager-app/frontend/themes/datamanager/components/page-area.css b/datamanager-app/frontend/themes/datamanager/components/page-area.css index 77effb9b4f..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 { @@ -625,3 +637,73 @@ align-items: center; column-gap: var(--lumo-space-s); } + +/* + * Datasets view: Connected Resources section. + * Placed into the `datasets-content` grid area defined in grid-templates.css + * for `.main.project.datasets`, which spans the full available width. + */ +.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/frontend/themes/datamanager/components/toast.css b/datamanager-app/frontend/themes/datamanager/components/toast.css index 25b1e0d9bd..6eef4a3e4b 100644 --- a/datamanager-app/frontend/themes/datamanager/components/toast.css +++ b/datamanager-app/frontend/themes/datamanager/components/toast.css @@ -1,44 +1,214 @@ -.toast-notification::part(content) { - /*you can style the notification content here*/ +/********** + * Toast Notification - Dark Mode Design System + * Regular toasts: 420–640px wide, ~1/3 viewport, text wraps at cap. + * Progress toasts: multiline title/subtext in vertical layout. + * Backgrounds set directly on ::part(overlay) — CSS custom properties + * do not reliably cascade through Vaadin's Shadow DOM parts. + **********/ + +/* === Default surface (base level) === */ +.toast-notification::part(overlay) { + background: linear-gradient(0deg, var(--toast-shade-overlay), var(--toast-shade-overlay)), var(--toast-background-color); + border: 1px solid var(--shade-color-20pct); + border-radius: 8px; + box-shadow: 0px 3px 12px -1px var(--shade-color-30pct), + 0px 2px 4px -1px var(--shade-color-20pct); + /* Consistent width so the close button stays in a fixed position */ + min-width: 420px; + max-width: min(640px, 33vw); } -.toast-notification.success-toast::part(content) { - background-color: var(--lumo-success-color-10pct); - /*you can style the notification content here*/ +/* --- Level-specific overrides --- */ +.toast-notification.success-toast::part(overlay) { + background: var(--toast-background-color); + box-shadow: 0px 4px 4px rgba(21, 193, 93, 0.25); } -.toast-notification.info-toast::part(content) { - /*you can style the notification content here*/ - background-color: var(--lumo-primary-color-10pct); +.toast-notification.error-toast::part(overlay) { + background: linear-gradient(0deg, var(--toast-shade-overlay), var(--toast-shade-overlay)), var(--toast-background-color); + box-shadow: 0px 4px 4px rgba(255, 66, 56, 0.25); } -.toast-notification .toast-content { - /*this is the content of your notification excluding the close button*/ +.toast-notification.info-toast::part(overlay) { + background: linear-gradient(0deg, var(--toast-shade-overlay), var(--toast-shade-overlay)), var(--toast-background-color); + box-shadow: 0px 5px 4px 1px rgba(22, 118, 243, 0.25); +} + +/* --- Icon container (28x28 colored circle) --- */ + +.toast-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--icon-size-m); + height: var(--icon-size-m); + border-radius: 50%; + flex-shrink: 0; +} + +/* Force the icon glyph inside the circle to white */ +.toast-icon vaadin-icon { + color: white; + width: var(--icon-size-xs); + height: var(--icon-size-xs); +} + +.toast-icon-success { + background: var(--success-color); +} + +.toast-icon-error { + background: var(--error-color); +} + +.toast-icon-info { + background: var(--primary-color); +} + +.toast-icon-progress { + background: var(--primary-color); +} + +/* === Layout structure (HorizontalLayout) === */ + +.toast-notification .toast-layout { + display: flex; + flex-direction: row; + align-items: center; + flex-wrap: nowrap; + gap: 14px; + padding: 10px; width: 100%; + box-sizing: border-box; } -.toast-notification .toast-content strong { - margin-left: .5ch; - margin-right: .5ch; +/* --- Content text (non-routing and routing) --- */ +/* Toast grows to fit content — text wraps when max-width is reached */ + +.toast-notification .toast-content { + flex: 1 1 0; + min-width: 0; + max-width: 100%; + color: var(--toast-text-color); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: var(--lumo-font-size-m); + font-weight: var(--font-weight-regular); + line-height: var(--lumo-line-height-m); } -.toast-notification .close-button { - /*you can style the toast close button here*/ +/* Links inside toast content */ +.toast-notification .toast-content a { + color: var(--toast-link-color); + text-decoration: underline; } +/* --- Routing container (flex child when routing) --- */ + .toast-notification .routing-container { - display: grid; - grid-template-columns: minmax(min-content, max-content) auto minmax(min-content, max-content); - column-gap: var(--lumo-space-m); + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: var(--lumo-space-m); } -.toast-notification .routing-content { - /*in case of routing this can style the content without the routing link*/ - align-content: center; +/* --- Title text for progress toasts (16px bold, multiline) --- */ + +.toast-notification .toast-title { + color: var(--toast-text-color); + font-size: var(--lumo-font-size-m); + font-weight: var(--font-weight-semi-bold); + line-height: var(--lumo-line-height-s); } +/* --- Subtext for progress toasts (multiline) --- */ + +.toast-notification .toast-subtext { + color: var(--toast-text-color); + font-size: var(--lumo-font-size-m); + font-weight: var(--font-weight-regular); + line-height: var(--lumo-line-height-m); +} + +/* --- Close button (30x30) --- */ + +.toast-notification .close-button { + width: 30px; + height: 30px; + flex-shrink: 0; + color: var(--toast-subtle-text-color); +} + +/* --- Action button (tertiary styled) --- */ + +.toast-notification .action-button { + background: var(--toast-shade-overlay); + border-radius: 4px; + color: var(--toast-link-color); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: var(--lumo-font-size-m); + font-weight: var(--font-weight-regular); + padding: 8px 14px; + white-space: nowrap; + flex-shrink: 0; +} + +.toast-notification .action-button::part(label) { + color: var(--toast-link-color); +} + +/* --- Progress bar (webcomponent shadow DOM) --- */ + +.toast-notification .progress-vertical { + display: flex; + flex-direction: column; + gap: var(--spacing-04); + flex-grow: 1; + min-width: 0; +} + +.toast-notification .progress-bar-container { + width: 100%; +} + +.toast-notification vaadin-progress-bar { + height: 8px; + width: 100%; + display: block; +} + +.toast-notification vaadin-progress-bar::part(bar) { + background: var(--toast-progress-track-color); + border-radius: 40px; +} + +.toast-notification vaadin-progress-bar::part(value) { + background: var(--primary-color); + border-radius: 40px; +} + +/* --- Routing link and button --- */ + .toast-notification .routing-link { - grid-column: 3; - align-content: center; - justify-content: center; + flex-shrink: 0; +} + +.toast-notification .routing-button { + background: none; + border: none; + padding: 0; + color: var(--toast-link-color); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: var(--lumo-font-size-m); + font-weight: var(--font-weight-regular); + text-decoration: underline; + cursor: pointer; + flex-shrink: 0; + white-space: nowrap; +} + +.toast-notification .routing-button::part(label) { + color: var(--toast-link-color); } diff --git a/datamanager-app/frontend/themes/datamanager/components/variables.css b/datamanager-app/frontend/themes/datamanager/components/variables.css index 4148cda5ce..9cb02e61e2 100644 --- a/datamanager-app/frontend/themes/datamanager/components/variables.css +++ b/datamanager-app/frontend/themes/datamanager/components/variables.css @@ -99,4 +99,12 @@ First some general property definitions --spacing-10: 4rem; --spacing-11: 5rem; --spacing-12: 6rem; + + /* --- Toast dark-surface tokens --- */ + --toast-background-color: #223348; + --toast-text-color: rgba(235, 244, 255, 0.9); + --toast-subtle-text-color: rgba(209, 227, 250, 0.6); + --toast-link-color: #66A8FF; + --toast-progress-track-color: rgba(173, 200, 235, 0.14); + --toast-shade-overlay: rgba(25, 59, 103, 0.05); /* subtle gradient overlay for toast backgrounds */ } diff --git a/datamanager-app/src/main/bundles/dev.bundle b/datamanager-app/src/main/bundles/dev.bundle index 17aa5780be..a814dc4c8b 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/AppConfig.java b/datamanager-app/src/main/java/life/qbic/datamanager/AppConfig.java index 9a36db82c5..9b4ae47223 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/AppConfig.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/AppConfig.java @@ -37,6 +37,8 @@ import life.qbic.projectmanagement.application.concurrent.VirtualThreadScheduler; import life.qbic.projectmanagement.application.experiment.ExperimentInformationService; import life.qbic.projectmanagement.application.measurement.MeasurementLookupService; +import life.qbic.projectmanagement.application.policy.AssociatedDatasetConnectedPolicy; +import life.qbic.projectmanagement.application.policy.AssociatedDatasetRemovedPolicy; import life.qbic.projectmanagement.application.policy.BatchRegisteredPolicy; import life.qbic.projectmanagement.application.policy.ExperimentCreatedPolicy; import life.qbic.projectmanagement.application.policy.ExperimentUpdatedPolicy; @@ -52,6 +54,8 @@ import life.qbic.projectmanagement.application.policy.directive.AddSampleToBatch; import life.qbic.projectmanagement.application.policy.directive.CreateNewSampleStatisticsEntry; import life.qbic.projectmanagement.application.policy.directive.DeleteSampleFromBatch; +import life.qbic.projectmanagement.application.policy.directive.InformProjectCollaboratorsAboutDatasetConnection; +import life.qbic.projectmanagement.application.policy.directive.InformProjectCollaboratorsAboutDatasetRemoval; import life.qbic.projectmanagement.application.policy.directive.InformUserAboutGrantedAccess; import life.qbic.projectmanagement.application.policy.directive.InformUsersAboutBatchRegistration; import life.qbic.projectmanagement.application.policy.directive.UpdateProjectUponBatchCreation; @@ -330,6 +334,34 @@ public OfferAddedPolicy offerAddedPolicy( return new OfferAddedPolicy(updateProjectUponOfferChange); } + @Bean + public AssociatedDatasetConnectedPolicy associatedDatasetConnectedPolicy( + life.qbic.projectmanagement.application.communication.EmailService emailService, + ProjectAccessService projectAccessService, + UserInformationService userInformationService, + ProjectInformationService projectInformationService, + AppContextProvider appContextProvider, + JobScheduler jobScheduler) { + var informCollaborators = new InformProjectCollaboratorsAboutDatasetConnection( + emailService, projectAccessService, userInformationService, projectInformationService, + appContextProvider, jobScheduler); + return new AssociatedDatasetConnectedPolicy(informCollaborators); + } + + @Bean + public AssociatedDatasetRemovedPolicy associatedDatasetRemovedPolicy( + life.qbic.projectmanagement.application.communication.EmailService emailService, + ProjectAccessService projectAccessService, + UserInformationService userInformationService, + ProjectInformationService projectInformationService, + AppContextProvider appContextProvider, + JobScheduler jobScheduler) { + var informCollaborators = new InformProjectCollaboratorsAboutDatasetRemoval( + emailService, projectAccessService, userInformationService, + projectInformationService, appContextProvider, jobScheduler); + return new AssociatedDatasetRemovedPolicy(informCollaborators); + } + /* Section ends diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/AsyncConfig.java b/datamanager-app/src/main/java/life/qbic/datamanager/AsyncConfig.java index c0ea8f86c8..eb308f95a5 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/AsyncConfig.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/AsyncConfig.java @@ -8,7 +8,6 @@ import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; -import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity; import org.springframework.security.task.DelegatingSecurityContextAsyncTaskExecutor; @Configuration diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/configuration/DataManagementDatasourceConfig.java b/datamanager-app/src/main/java/life/qbic/datamanager/configuration/DataManagementDatasourceConfig.java index a457e33e60..d254360979 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/configuration/DataManagementDatasourceConfig.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/configuration/DataManagementDatasourceConfig.java @@ -89,7 +89,11 @@ public LocalContainerEntityManagerFactoryBean entityManagerFactory( .packages("life.qbic.projectmanagement", "life.qbic.identity", "life.qbic.datamanager.announcements") .properties(Map.of( - "hibernate.hbm2ddl.auto", hibernateDdlAuto + "hibernate.hbm2ddl.auto", hibernateDdlAuto, + // Force UTC for all TIMESTAMP ↔ Instant conversions so that event + // timestamps (connectedOn, lastSyncedAt, ...) have consistent + // semantics regardless of the JVM host's default timezone. + "hibernate.jdbc.time_zone", "UTC" )) .build(); } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/configuration/InvenioRdmConfiguration.java b/datamanager-app/src/main/java/life/qbic/datamanager/configuration/InvenioRdmConfiguration.java new file mode 100644 index 0000000000..36344e3301 --- /dev/null +++ b/datamanager-app/src/main/java/life/qbic/datamanager/configuration/InvenioRdmConfiguration.java @@ -0,0 +1,49 @@ +package life.qbic.datamanager.configuration; + +import life.qbic.projectmanagement.application.associated_dataset.DatasetSource; +import life.qbic.projectmanagement.application.associated_dataset.SourceInstanceRegistry; +import life.qbic.projectmanagement.infrastructure.external.invenio.InvenioRdmClient; +import life.qbic.projectmanagement.infrastructure.external.invenio.InvenioRdmClient.InvenioRdmHttpClient; +import life.qbic.projectmanagement.infrastructure.external.invenio.InvenioRdmDatasetSource; +import life.qbic.projectmanagement.infrastructure.external.invenio.InvenioRdmProperties; +import life.qbic.projectmanagement.infrastructure.external.invenio.PropertiesBackedSourceInstanceRegistry; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Wires up the InvenioRDM integration beans. + * + *

Registers: + *

    + *
  • {@link InvenioRdmProperties} — config-bound properties + * ({@code qbic.external-service.invenio-rdm.*})
  • + *
  • {@link InvenioRdmClient} — low-level HTTP client (stateless, + * shared singleton)
  • + *
  • {@link SourceInstanceRegistry} — admin-configured instance + * lookup (ADR-0002 I2)
  • + *
  • {@link DatasetSource} — the port adapter for InvenioRDM + * (ADR-0002 P2)
  • + *
+ * + * @since 1.12.0 + */ +@Configuration +@EnableConfigurationProperties(InvenioRdmProperties.class) +public class InvenioRdmConfiguration { + + @Bean + public InvenioRdmClient invenioRdmClient() { + return new InvenioRdmHttpClient(); + } + + @Bean + public SourceInstanceRegistry sourceInstanceRegistry(InvenioRdmProperties properties) { + return new PropertiesBackedSourceInstanceRegistry(properties); + } + + @Bean + public DatasetSource invenioRdmDatasetSource(InvenioRdmClient client) { + return new InvenioRdmDatasetSource(client); + } +} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/exceptionhandling/UiExceptionHandler.java b/datamanager-app/src/main/java/life/qbic/datamanager/exceptionhandling/UiExceptionHandler.java index dcdf433a08..2917f7af3f 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/exceptionhandling/UiExceptionHandler.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/exceptionhandling/UiExceptionHandler.java @@ -5,14 +5,14 @@ import static life.qbic.logging.service.LoggerFactory.logger; import com.vaadin.flow.component.UI; -import com.vaadin.flow.component.html.Span; import com.vaadin.flow.server.ErrorEvent; import java.net.InetAddress; import java.net.UnknownHostException; import life.qbic.application.commons.ApplicationException; import life.qbic.datamanager.exceptionhandling.ErrorMessageTranslationService.UserFriendlyErrorMessage; -import life.qbic.datamanager.views.notifications.NotificationDialog; +import life.qbic.datamanager.views.general.dialog.AlertDialog; import life.qbic.logging.api.Logger; +import life.qbic.projectmanagement.application.associated_dataset.AssociatedDatasetServiceException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -54,6 +54,20 @@ public void error(ErrorEvent errorEvent, UI ui) { } catch (UnknownHostException ignored) { log.error(throwable.getMessage(), throwable); } + + // Service-level exceptions (e.g. from AssociatedDatasetService) carry + // a user-friendly message that has already been translated at the + // application-layer boundary — no infrastructure details leak through. + // We surface that message directly so the user sees something meaningful + // instead of the generic "Something went wrong" fallback. + if (throwable instanceof AssociatedDatasetServiceException serviceException) { + var friendly = new UserFriendlyErrorMessage( + "Operation failed", + serviceException.userMessage()); + displayUserFriendlyMessage(ui, friendly); + return; + } + ApplicationException applicationException = ApplicationException.wrapping(throwable); displayUserFriendlyMessage(ui, applicationException); } @@ -61,6 +75,28 @@ public void error(ErrorEvent errorEvent, UI ui) { private void displayUserFriendlyMessage(UI ui, ApplicationException exception) { requireNonNull(ui, "ui must not be null"); requireNonNull(exception, "exception must not be null"); + if (!isUiReady(ui)) { + return; + } + UserFriendlyErrorMessage errorMessage = userMessageService.translate(exception, ui.getLocale()); + ui.access(() -> showErrorDialog(errorMessage)); + } + + private void displayUserFriendlyMessage(UI ui, UserFriendlyErrorMessage message) { + requireNonNull(ui, "ui must not be null"); + requireNonNull(message, "message must not be null"); + if (!isUiReady(ui)) { + return; + } + ui.access(() -> showErrorDialog(message)); + } + + /** + * Verifies the UI is still attached and not closing. Returns + * {@code false} (and logs the situation) when the UI cannot receive + * a new message, so callers can simply {@code return} after invoking it. + */ + private boolean isUiReady(UI ui) { if (ui.isClosing()) { if (nonNull(ui.getSession())) { log.error( @@ -70,8 +106,7 @@ private void displayUserFriendlyMessage(UI ui, ApplicationException exception) { log.error( "tried to show message on closing UI ui[%s] session is null".formatted(ui.getUIId())); } - - return; + return false; } if (!ui.isAttached()) { if (nonNull(ui.getSession())) { @@ -84,16 +119,18 @@ private void displayUserFriendlyMessage(UI ui, ApplicationException exception) { "tried to show message on detached UI ui[%s] session is null".formatted( ui.getUIId())); } - return; + return false; } - UserFriendlyErrorMessage errorMessage = userMessageService.translate(exception, ui.getLocale()); - ui.access(() -> showErrorDialog(errorMessage)); + return true; } private void showErrorDialog(UserFriendlyErrorMessage userFriendlyError) { - NotificationDialog.errorDialog() - .withTitle(userFriendlyError.title()) - .withContent(new Span(userFriendlyError.message())) + AlertDialog.alert(UI.getCurrent()) + .error() + .title(userFriendlyError.title()) + .message(userFriendlyError.message()) + .confirmButton("Got it", () -> {}) + .build() .open(); } } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/files/parsing/converters/ConverterRegistry.java b/datamanager-app/src/main/java/life/qbic/datamanager/files/parsing/converters/ConverterRegistry.java index 4585b8b437..d1e02bbbcd 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/files/parsing/converters/ConverterRegistry.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/files/parsing/converters/ConverterRegistry.java @@ -2,16 +2,15 @@ import java.util.Map; import java.util.function.Supplier; +import life.qbic.projectmanagement.application.api.AsyncProjectService.MeasurementRegistrationInformationIP; import life.qbic.projectmanagement.application.api.AsyncProjectService.MeasurementRegistrationInformationNGS; import life.qbic.projectmanagement.application.api.AsyncProjectService.MeasurementRegistrationInformationPxP; -import life.qbic.projectmanagement.application.api.AsyncProjectService.MeasurementRegistrationInformationIP; +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.SampleRegistrationInformation; import life.qbic.projectmanagement.application.api.AsyncProjectService.SampleUpdateInformation; import org.apache.commons.collections.map.HashedMap; -import org.apache.poi.ss.formula.functions.T; /** * Converter Factory for creating {@link MetadataConverterV2} instances. diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/files/parsing/converters/MeasurementRegistrationMetadataConverterNGS.java b/datamanager-app/src/main/java/life/qbic/datamanager/files/parsing/converters/MeasurementRegistrationMetadataConverterNGS.java index 4c26821e89..2531d950be 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/files/parsing/converters/MeasurementRegistrationMetadataConverterNGS.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/files/parsing/converters/MeasurementRegistrationMetadataConverterNGS.java @@ -1,7 +1,6 @@ package life.qbic.datamanager.files.parsing.converters; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; import life.qbic.datamanager.files.parsing.ParsingResult; diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/files/parsing/converters/MeasurementUpdateMetadataConverterPxP.java b/datamanager-app/src/main/java/life/qbic/datamanager/files/parsing/converters/MeasurementUpdateMetadataConverterPxP.java index 569cc45983..11e192ed03 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/files/parsing/converters/MeasurementUpdateMetadataConverterPxP.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/files/parsing/converters/MeasurementUpdateMetadataConverterPxP.java @@ -7,8 +7,6 @@ import life.qbic.datamanager.files.structure.measurement.ProteomicsMeasurementEditColumn; import life.qbic.projectmanagement.application.api.AsyncProjectService.MeasurementSpecificPxP; import life.qbic.projectmanagement.application.api.AsyncProjectService.MeasurementUpdateInformationPxP; -import life.qbic.projectmanagement.application.measurement.Labeling; -import life.qbic.projectmanagement.domain.model.sample.SampleCode; /** * Measurement Update Metadata Converter PxP diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/AppRoutes.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/AppRoutes.java index 825bbfc6f7..a5c5112222 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/AppRoutes.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/AppRoutes.java @@ -112,6 +112,11 @@ private ProjectRoutes() {} public static final String ONTOLOGY = "projects/%s/ontology"; + /** + * Path to the associated datasets view within a project (FEAT-DATSET-01). + */ + public static final String DATASETS = "projects/%s/datasets"; + /** * The profile page that displays information for the currently logged-in user */ diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/account/AccessTokenDeletionConfirmationNotification.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/account/AccessTokenDeletionConfirmationNotification.java deleted file mode 100644 index b8dee23bb5..0000000000 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/account/AccessTokenDeletionConfirmationNotification.java +++ /dev/null @@ -1,24 +0,0 @@ -package life.qbic.datamanager.views.account; - -import com.vaadin.flow.component.html.Span; -import life.qbic.datamanager.views.notifications.NotificationDialog; -import life.qbic.datamanager.views.notifications.NotificationLevel; - -/** - * Warns the user that the personal access token will be deleted and cannot be used - *

- * This dialog is to be shown when PAT delection is triggered by the user. - */ -public class AccessTokenDeletionConfirmationNotification extends NotificationDialog { - - public AccessTokenDeletionConfirmationNotification() { - super(NotificationLevel.WARNING); - withTitle("Personal Access Token will be deleted"); - withContent(new Span( - "Deleting this Personal Access Token will make it unusable. Proceed?")); - setCancelable(true); - setConfirmText("Delete Token"); - } - - -} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/account/PersonalAccessTokenMain.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/account/PersonalAccessTokenMain.java index a0ea248599..8f4ff56bd2 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/account/PersonalAccessTokenMain.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/account/PersonalAccessTokenMain.java @@ -18,6 +18,7 @@ import life.qbic.datamanager.views.account.PersonalAccessTokenComponent.DeleteTokenEvent; import life.qbic.datamanager.views.account.PersonalAccessTokenComponent.PersonalAccessTokenFrontendBean; import life.qbic.datamanager.views.general.Main; +import life.qbic.datamanager.views.general.dialog.AlertDialog; import life.qbic.datamanager.views.notifications.MessageSourceNotificationFactory; import life.qbic.datamanager.views.notifications.Toast; import life.qbic.identity.api.PersonalAccessToken; @@ -74,18 +75,18 @@ public PersonalAccessTokenMain(PersonalAccessTokenService personalAccessTokenSer } private void onDeleteTokenClicked(DeleteTokenEvent deleteTokenEvent) { - AccessTokenDeletionConfirmationNotification tokenDeletionConfirmationNotification = new AccessTokenDeletionConfirmationNotification(); - tokenDeletionConfirmationNotification.open(); - tokenDeletionConfirmationNotification.addConfirmListener(event -> { - var userId = userIdTranslator.translateToUserId( - SecurityContextHolder.getContext().getAuthentication()) - .orElseThrow(); - personalAccessTokenService.delete(deleteTokenEvent.tokenId(), userId); - loadGeneratedPersonalAccessTokens(); - tokenDeletionConfirmationNotification.close(); - }); - tokenDeletionConfirmationNotification.addCancelListener( - event -> tokenDeletionConfirmationNotification.close()); + AlertDialog.danger(this, + "Personal Access Token will be deleted", + "Deleting this Personal Access Token will make it unusable. Proceed?", + "Delete token", + "Keep token", + () -> { + var userId = userIdTranslator.translateToUserId( + SecurityContextHolder.getContext().getAuthentication()) + .orElseThrow(); + personalAccessTokenService.delete(deleteTokenEvent.tokenId(), userId); + loadGeneratedPersonalAccessTokens(); + }).open(); } private void onAddTokenClicked(AddTokenEvent addTokenEvent) { diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/demo/AssociatedDatasetsDemo.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/demo/AssociatedDatasetsDemo.java new file mode 100644 index 0000000000..735f0472c6 --- /dev/null +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/demo/AssociatedDatasetsDemo.java @@ -0,0 +1,801 @@ +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.button.ButtonVariant; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.dialog.Dialog; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.grid.Grid.SelectionMode; +import com.vaadin.flow.component.grid.GridVariant; +import com.vaadin.flow.component.html.Anchor; +import com.vaadin.flow.component.html.AnchorTarget; +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.notification.Notification; +import com.vaadin.flow.component.tabs.TabSheet; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import com.vaadin.flow.spring.annotation.UIScope; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import life.qbic.datamanager.views.general.InfoBox; +import life.qbic.datamanager.views.general.Tag; +import life.qbic.datamanager.views.general.Tag.TagColor; +import life.qbic.datamanager.views.general.section.ActionBar; +import life.qbic.datamanager.views.general.section.Section; +import life.qbic.datamanager.views.general.section.SectionContent; +import life.qbic.datamanager.views.general.section.SectionHeader; +import life.qbic.datamanager.views.general.section.SectionNote; +import life.qbic.datamanager.views.general.section.SectionTitle; +import org.springframework.context.annotation.Profile; + +/** + * Associated Datasets Demo + * + *

Pure UI prototype for the "Connect datasets with research projects" feature. + * Demonstrates the interaction patterns for searching, connecting, viewing, + * syncing, and removing associated datasets from InvenioRDM instances + * (e.g. Zenodo, FDAT).

+ * + *

Covers user stories 01–08 from the feature specification.

+ * + *

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

+ * + * @since 1.12.0 + */ +@Profile("development") +@Route("test-view/associated-datasets") +@UIScope +@AnonymousAllowed +@org.springframework.stereotype.Component +public class AssociatedDatasetsDemo extends Div { + + private static final List INVENIO_INSTANCES = List.of( + "Zenodo (zenodo.org)", + "FDAT (fdat.uni-tuebingen.de)" + ); + + record SearchableDataset( + String id, String title, String doi, String creators, + String publicationDate, String repository, String accessRight, + String description) {} + + record ConnectedDataset( + String id, String title, String doi, String connectedBy, + LocalDate connectedOn, String repository, String accessRight, + String latestVersion, boolean updateAvailable) {} + + // ── Mock search result data ───────────────────────────────────────────── + + private static final List MOCK_PUBLIC_DATASETS = List.of( + new SearchableDataset("zen-001", + "High-resolution cryo-EM structure of the human 26S proteasome", + "10.5281/zenodo.1234567", "M. Bauer, S. Fernandez, L. Chen", + "2024-11-15", "Zenodo", "Open", + "Cryo-electron microscopy structure at 2.8 Å resolution revealing the complete architecture of the human 26S proteasome complex."), + new SearchableDataset("zen-002", + "Proteomic profiling of T-cell receptor signaling in Jurkat cells", + "10.5281/zenodo.1234568", "A. Müller, K. Tanaka", + "2024-10-03", "Zenodo", "Open", + "Mass spectrometry datasets and analysis scripts for TCR signaling pathway characterization."), + new SearchableDataset("zen-003", + "Multi-omics integration pipeline benchmark dataset", + "10.5281/zenodo.1234569", "QBiC Consortium", + "2025-01-20", "Zenodo", "Open", + "Reference dataset for benchmarking multi-omics integration methods across transcriptomics, proteomics, and metabolomics layers."), + new SearchableDataset("zen-004", + "Supplementary figures for 'Metabolic rewiring in tumor microenvironment'", + "10.5281/zenodo.1234570", "J. Park, R. Schmidt, T. Nguyen", + "2025-02-10", "Zenodo", "Open", + "Additional figures, source data, and analysis notebooks accompanying the publication on metabolic rewiring."), + new SearchableDataset("zen-005", + "NGS raw reads: Arabidopsis thaliana drought stress response", + "10.5281/zenodo.1234571", "P. Garcia, H. Weber", + "2024-08-22", "Zenodo", "Open", + "Illumina paired-end RNA-Seq reads from Arabidopsis thaliana under drought and control conditions."), + new SearchableDataset("fdat-001", + "Quantitative phosphoproteomics of DNA damage response", + "10.5281/fdat.9876543", "C. Klein, D. Patel", + "2025-03-05", "FDAT", "Open", + "TMT-labeled phosphoproteomics data from HEK293 cells treated with genotoxic agents."), + new SearchableDataset("fdat-002", + "Spatial transcriptomics atlas of human kidney development", + "10.5281/fdat.9876544", "S. Yamamoto, F. Rossi, QBiC", + "2025-04-12", "FDAT", "Open", + "10x Visium spatial transcriptomics data from human kidney sections at multiple developmental stages.") + ); + + private static final List MOCK_RESTRICTED_DATASETS = List.of( + new SearchableDataset("zen-r01", + "Clinical metabolomics data — Cohort A (embargo until 2026-12)", + "10.5281/zenodo.2345601", "R. Schmidt, M. Bauer", + "2025-06-01", "Zenodo", "Restricted", + "Clinical metabolomics profiles from Cohort A participants. Access restricted pending ethics review completion."), + new SearchableDataset("zen-r02", + "Pre-publication proteomics: Novel biomarker candidates", + "10.5281/zenodo.2345602", "K. Tanaka, A. Petrova", + "2025-05-20", "Zenodo", "Restricted", + "Discovery-phase proteomics data for novel biomarker candidates. Under peer review."), + new SearchableDataset("fdat-r01", + "Oncology panel sequencing — Phase II trial (controlled access)", + "10.5281/fdat.8765401", "QBiC Clinical Collaboration", + "2025-04-01", "FDAT", "Restricted", + "Targeted sequencing data from Phase II clinical trial. Access requires data use agreement.") + ); + + // ── Mutable state ───────────────────────────────────────────────────── + + private final List connectedPublicDatasets = new ArrayList<>(); + private final List connectedRestrictedDatasets = new ArrayList<>(); + + private Grid publicSearchResultsGrid; + private Grid restrictedSearchResultsGrid; + private Grid connectedPublicGrid; + private Grid connectedRestrictedGrid; + + private final TextField publicSearchField = new TextField(); + private final TextField restrictedSearchField = new TextField(); + private final ComboBox publicInstanceSelector = new ComboBox<>(); + private final ComboBox restrictedInstanceSelector = new ComboBox<>(); + + // Buttons referenced from multiple places + private Button connectPublicButton; + private Button connectRestrictedButton; + private Button syncAllPublicButton; + private Button syncAllRestrictedButton; + + public AssociatedDatasetsDemo() { + addClassNames("padding-horizontal-07", "padding-vertical-04"); + addClassName("flex-vertical"); + + var title = new Div("Associated Datasets — UI Prototype"); + title.addClassName("heading-1"); + add(title); + + var subtitle = new Div( + "Demonstrates Stories 01–08 from the \"Connect datasets with research projects\" feature. " + + "All data is mocked — no real API calls are made."); + subtitle.addClassName("normal-body-text"); + subtitle.addClassName("color-secondary"); + add(subtitle); + add(new Div()); // spacer + + seedConnectedDatasets(); + + var tabSheet = new TabSheet(); + tabSheet.addClassName("experimental-sheet"); + tabSheet.setWidthFull(); + tabSheet.add("Public Datasets", buildPublicDatasetsTab()); + tabSheet.add("Restricted Datasets", buildRestrictedDatasetsTab()); + add(tabSheet); + } + + // ══════════════════════════════════════════════════════════════════════ + // PUBLIC DATASETS TAB (Stories 01 – 04) + // ══════════════════════════════════════════════════════════════════════ + + private Component buildPublicDatasetsTab() { + var container = new Div(); + container.addClassNames("flex-vertical", "gap-04"); + container.add(searchAndConnectPublicSection()); + container.add(connectedPublicDatasetsSection()); + return container; + } + + /** + * Story 01 — Searching and connecting open, published datasets. + */ + private Section searchAndConnectPublicSection() { + var section = new Section.SectionBuilder().build(); + + // ── Header: Connect button in ActionBar, selection count in button text ─ + connectPublicButton = new Button("Connect", VaadinIcon.PLUS_CIRCLE.create()); + connectPublicButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + connectPublicButton.addClickListener(e -> connectSelectedPublicDatasets()); + connectPublicButton.setEnabled(false); + + var connectActionBar = new ActionBar(); + connectActionBar.addButton(connectPublicButton); + + var header = new SectionHeader( + new SectionTitle("Search and Connect"), + connectActionBar, + new SectionNote( + "Search for published datasets on InvenioRDM repositories and connect them to this project.") + ); + header.enableControls(); + section.setHeader(header); + + // ── Content ─────────────────────────────────────────────────────── + var content = new SectionContent(); + + // Compact inline search bar: [Repository combo] [Search field] [Search button] + var searchBar = new Div(); + searchBar.addClassNames("flex-horizontal", "gap-03", "width-full"); + + publicInstanceSelector.setItems(INVENIO_INSTANCES); + publicInstanceSelector.setPlaceholder("Select repository…"); + publicInstanceSelector.setValue(INVENIO_INSTANCES.get(0)); + publicInstanceSelector.setWidth("280px"); + publicInstanceSelector.setLabel("Repository"); + + publicSearchField.setPlaceholder("Search by title, DOI, or author…"); + publicSearchField.setWidthFull(); + publicSearchField.setLabel("Search Term"); + publicSearchField.setClearButtonVisible(true); + + var searchButton = new Button("Search", VaadinIcon.SEARCH.create()); + searchButton.addClassName("margin-top-auto"); + searchButton.addClickListener(e -> performPublicSearch()); + + searchBar.add(publicInstanceSelector, publicSearchField, searchButton); + + // Info note + var infoNote = new InfoBox() + .setInfoText("Public datasets do not require authentication. Anyone with write access to this project can connect datasets."); + + // Search results grid + publicSearchResultsGrid = createSearchResultsGrid(); + populatePublicSearchResults(""); + + publicSearchResultsGrid.addSelectionListener(event -> { + int count = event.getAllSelectedItems().size(); + connectPublicButton.setEnabled(count > 0); + connectPublicButton.setText( + count == 0 ? "Connect" + : count == 1 ? "Connect (1)" + : "Connect (" + count + ")"); + }); + + content.add(searchBar, infoNote, publicSearchResultsGrid); + section.setContent(content); + return section; + } + + /** + * Stories 02, 03, 04 — Viewing, removing, and syncing connected public datasets. + */ + private Section connectedPublicDatasetsSection() { + var section = new Section.SectionBuilder().build(); + + syncAllPublicButton = new Button("Sync All", VaadinIcon.REFRESH.create()); + syncAllPublicButton.addClickListener(e -> syncAllPublic()); + + var header = new SectionHeader( + new SectionTitle("Connected Public Datasets"), + new ActionBar(syncAllPublicButton), + new SectionNote("Datasets connected from public InvenioRDM repositories.") + ); + header.enableControls(); + section.setHeader(header); + + var content = new SectionContent(); + connectedPublicGrid = createConnectedDatasetsGrid(); + refreshConnectedPublicGrid(); + content.add(connectedPublicGrid); + section.setContent(content); + return section; + } + + // ══════════════════════════════════════════════════════════════════════ + // RESTRICTED DATASETS TAB (Stories 05 – 08) + // ══════════════════════════════════════════════════════════════════════ + + private Component buildRestrictedDatasetsTab() { + var container = new Div(); + container.addClassNames("flex-vertical", "gap-04"); + container.add(searchAndConnectRestrictedSection()); + container.add(connectedRestrictedDatasetsSection()); + return container; + } + + /** + * Story 05 — Searching and connecting restricted datasets. + */ + private Section searchAndConnectRestrictedSection() { + var section = new Section.SectionBuilder().build(); + + connectRestrictedButton = new Button("Connect", VaadinIcon.PLUS_CIRCLE.create()); + connectRestrictedButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + connectRestrictedButton.addClickListener(e -> connectSelectedRestrictedDatasets()); + connectRestrictedButton.setEnabled(false); + + var connectActionBar = new ActionBar(); + connectActionBar.addButton(connectRestrictedButton); + + var header = new SectionHeader( + new SectionTitle("Search and Connect"), + connectActionBar, + new SectionNote( + "Search for access-restricted datasets on InvenioRDM repositories. Requires an authorization token configured in your account.") + ); + header.enableControls(); + section.setHeader(header); + + // ── Content ─────────────────────────────────────────────────────── + var content = new SectionContent(); + + // Token warning card (Story 05 — last acceptance criterion) + var tokenWarning = new Div(); + tokenWarning.addClassNames("flex-horizontal", "gap-03", "border", "padding-03", + "rounded-02"); + tokenWarning.getStyle().set("background-color", "var(--lumo-contrast-5pct)"); + var warningIcon = VaadinIcon.EXCLAMATION_CIRCLE.create(); + warningIcon.addClassName("icon-color-warning"); + var warningText = new Span("No InvenioRDM authorization token configured. "); + warningText.addClassName("normal-body-text"); + var configureLink = new Anchor("#/account/tokens", "Configure your token"); + configureLink.addClassName("normal-body-text"); + tokenWarning.add(warningIcon, warningText, configureLink); + for (Component child : tokenWarning.getChildren().toList()) { + child.addClassName("margin-top-auto"); + child.addClassName("margin-bottom-auto"); + } + content.add(tokenWarning); + + // Compact inline search bar + var searchBar = new Div(); + searchBar.addClassNames("flex-horizontal", "gap-03", "width-full"); + + restrictedInstanceSelector.setItems(INVENIO_INSTANCES); + restrictedInstanceSelector.setPlaceholder("Select repository…"); + restrictedInstanceSelector.setValue(INVENIO_INSTANCES.get(0)); + restrictedInstanceSelector.setWidth("280px"); + restrictedInstanceSelector.setLabel("Repository"); + + restrictedSearchField.setPlaceholder("Search by title, DOI, or author…"); + restrictedSearchField.setWidthFull(); + restrictedSearchField.setLabel("Search Term"); + restrictedSearchField.setClearButtonVisible(true); + + var searchButton = new Button("Search", VaadinIcon.SEARCH.create()); + searchButton.addClassName("margin-top-auto"); + searchButton.addClickListener(e -> performRestrictedSearch()); + + searchBar.add(restrictedInstanceSelector, restrictedSearchField, searchButton); + + // Search results grid + restrictedSearchResultsGrid = createSearchResultsGrid(); + populateRestrictedSearchResults(""); + + restrictedSearchResultsGrid.addSelectionListener(event -> { + int count = event.getAllSelectedItems().size(); + connectRestrictedButton.setEnabled(count > 0); + connectRestrictedButton.setText( + count == 0 ? "Connect" + : count == 1 ? "Connect (1)" + : "Connect (" + count + ")"); + }); + + // Info note + var infoNote = new InfoBox() + .setInfoText("Restricted datasets require a valid authorization token. Connected project members can access them via the provided access link."); + + content.add(searchBar, restrictedSearchResultsGrid, infoNote); + section.setContent(content); + return section; + } + + /** + * Stories 06, 07, 08 — Viewing, removing, and syncing connected restricted datasets. + */ + private Section connectedRestrictedDatasetsSection() { + var section = new Section.SectionBuilder().build(); + + syncAllRestrictedButton = new Button("Sync All", VaadinIcon.REFRESH.create()); + syncAllRestrictedButton.addClickListener(e -> syncAllRestricted()); + + var header = new SectionHeader( + new SectionTitle("Connected Restricted Datasets"), + new ActionBar(syncAllRestrictedButton), + new SectionNote("Datasets connected from InvenioRDM repositories with restricted access.") + ); + header.enableControls(); + section.setHeader(header); + + var content = new SectionContent(); + connectedRestrictedGrid = createConnectedDatasetsGrid(); + refreshConnectedRestrictedGrid(); + content.add(connectedRestrictedGrid); + section.setContent(content); + return section; + } + + // ══════════════════════════════════════════════════════════════════════ + // GRID BUILDERS + // ═════════════════════════════════════════════════════════════════════ + + private Grid createSearchResultsGrid() { + var grid = new Grid(); + grid.addThemeVariants(GridVariant.LUMO_COMPACT, GridVariant.LUMO_ROW_STRIPES); + grid.setSelectionMode(SelectionMode.MULTI); + grid.setWidthFull(); + grid.setAllRowsVisible(true); + + // Title + creators column + grid.addComponentColumn(dataset -> { + var wrapper = new Div(); + wrapper.addClassNames("flex-vertical", "gap-01"); + var titleSpan = new Span(dataset.title()); + titleSpan.addClassName("normal-body-text"); + titleSpan.getStyle().set("font-weight", "500"); + var creatorsSpan = new Span(dataset.creators()); + creatorsSpan.addClassName("extra-small-body-text"); + creatorsSpan.addClassName("color-secondary"); + wrapper.add(titleSpan, creatorsSpan); + return wrapper; + }).setHeader("Title / Creators").setAutoWidth(true).setFlexGrow(2).setKey("dataset"); + + // DOI column + grid.addComponentColumn(dataset -> { + var anchor = new Anchor("https://doi.org/" + dataset.doi(), dataset.doi()); + anchor.setTarget(AnchorTarget.BLANK); + anchor.addClassName("extra-small-body-text"); + return anchor; + }).setHeader("DOI").setAutoWidth(true).setFlexGrow(1).setKey("doi"); + + // Date column + grid.addColumn(ds -> ds.publicationDate()) + .setHeader("Published").setAutoWidth(true).setKey("date"); + + // Repository column + grid.addComponentColumn(dataset -> { + var tag = new Tag(dataset.repository()); + if ("Zenodo".equals(dataset.repository())) { + tag.setTagColor(TagColor.PRIMARY); + } else { + tag.setTagColor(TagColor.TEAL); + } + return tag; + }).setHeader("Repository").setAutoWidth(true).setKey("repository"); + + // Access type column + grid.addComponentColumn(dataset -> { + var tag = new Tag(dataset.accessRight()); + if ("Open".equals(dataset.accessRight())) { + tag.setTagColor(TagColor.SUCCESS); + } else { + tag.setTagColor(TagColor.WARNING); + } + return tag; + }).setHeader("Access").setAutoWidth(true).setKey("access"); + + return grid; + } + + private Grid createConnectedDatasetsGrid() { + var grid = new Grid(); + grid.addThemeVariants(GridVariant.LUMO_COMPACT, GridVariant.LUMO_ROW_STRIPES); + grid.setSelectionMode(SelectionMode.NONE); + grid.setWidthFull(); + + // Dataset column: title + DOI + update banner + grid.addComponentColumn(dataset -> { + var wrapper = new Div(); + wrapper.addClassNames("flex-vertical", "gap-01"); + var titleSpan = new Span(dataset.title()); + titleSpan.addClassName("normal-body-text"); + titleSpan.getStyle().set("font-weight", "500"); + var doiSpan = new Span(dataset.doi()); + doiSpan.addClassName("extra-small-body-text"); + doiSpan.addClassName("color-secondary"); + wrapper.add(titleSpan, doiSpan); + if (dataset.updateAvailable()) { + var updateTag = new Tag("Update available → v" + dataset.latestVersion()); + updateTag.setTagColor(TagColor.WARNING); + wrapper.add(updateTag); + } + return wrapper; + }).setHeader("Dataset").setFlexGrow(2).setKey("dataset"); + + // Connected By: user + date stacked + grid.addComponentColumn(dataset -> { + var wrapper = new Div(); + wrapper.addClassNames("flex-vertical", "gap-01"); + var userSpan = new Span(dataset.connectedBy()); + userSpan.addClassName("normal-body-text"); + var dateSpan = new Span( + dataset.connectedOn().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))); + dateSpan.addClassName("extra-small-body-text"); + dateSpan.addClassName("color-secondary"); + wrapper.add(userSpan, dateSpan); + return wrapper; + }).setHeader("Connected By").setAutoWidth(true).setKey("connectedBy"); + + // Repository column + grid.addComponentColumn(dataset -> { + var tag = new Tag(dataset.repository()); + if ("Zenodo".equals(dataset.repository())) { + tag.setTagColor(TagColor.PRIMARY); + } else { + tag.setTagColor(TagColor.TEAL); + } + return tag; + }).setHeader("Repository").setAutoWidth(true).setKey("repository"); + + // Access Link column + grid.addComponentColumn(dataset -> { + var link = new Anchor( + "https://doi.org/" + dataset.doi() + "?access=project-token", + "Open"); + link.setTarget(AnchorTarget.BLANK); + link.addClassName("extra-small-body-text"); + var wrapper = new Div(); + wrapper.addClassNames("flex-vertical"); + wrapper.add(link); + var note = new Span("No account required"); + note.addClassName("extra-small-body-text"); + note.addClassName("color-secondary"); + wrapper.add(note); + return wrapper; + }).setHeader("Access Link").setAutoWidth(true).setKey("accessLink"); + + // Actions column: Sync + Remove + grid.addComponentColumn(dataset -> { + var syncButton = new Button(VaadinIcon.REFRESH.create()); + syncButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + syncButton.setTooltipText("Sync with " + dataset.repository()); + syncButton.getStyle().set("padding", "var(--lumo-space-s)"); + syncButton.addClickListener(e -> syncSingleDataset(dataset)); + + var removeButton = new Button(VaadinIcon.TRASH.create()); + removeButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY); + removeButton.setTooltipText("Remove connection"); + removeButton.getStyle().set("padding", "var(--lumo-space-s)"); + removeButton.addClickListener(e -> confirmRemoveDataset(dataset)); + + var wrapper = new Div(); + wrapper.addClassNames("flex-horizontal", "gap-01"); + wrapper.add(syncButton, removeButton); + return wrapper; + }).setHeader("Actions").setAutoWidth(true).setKey("actions"); + + return grid; + } + + // ══════════════════════════════════════════════════════════════════════ + // SEARCH LOGIC (mock) + // ══════════════════════════════════════════════════════════════════════ + + private void performPublicSearch() { + populatePublicSearchResults(publicSearchField.getValue()); + } + + private void populatePublicSearchResults(String term) { + publicSearchResultsGrid.setItems(filterDatasets(MOCK_PUBLIC_DATASETS, term)); + } + + private void performRestrictedSearch() { + populateRestrictedSearchResults(restrictedSearchField.getValue()); + } + + private void populateRestrictedSearchResults(String term) { + restrictedSearchResultsGrid.setItems(filterDatasets(MOCK_RESTRICTED_DATASETS, term)); + } + + private List filterDatasets(List datasets, String term) { + if (term == null || term.isBlank()) { + return new ArrayList<>(datasets); + } + String lower = term.toLowerCase(); + return datasets.stream() + .filter(d -> d.title().toLowerCase().contains(lower) + || d.doi().toLowerCase().contains(lower) + || d.creators().toLowerCase().contains(lower) + || d.description().toLowerCase().contains(lower)) + .toList(); + } + + // ══════════════════════════════════════════════════════════════════════ + // CONNECT / REMOVE / SYNC (mock) + // ══════════════════════════════════════════════════════════════════════ + + private void connectSelectedPublicDatasets() { + var selected = publicSearchResultsGrid.getSelectedItems(); + if (selected.isEmpty()) { + return; + } + for (SearchableDataset ds : selected) { + connectedPublicDatasets.add( + new ConnectedDataset(ds.id(), ds.title(), ds.doi(), + "Current User (demo)", LocalDate.now(), ds.repository(), + ds.accessRight(), "1.0", false)); + } + refreshConnectedPublicGrid(); + publicSearchResultsGrid.deselectAll(); + connectPublicButton.setEnabled(false); + connectPublicButton.setText("Connect"); + showSuccessNotification(selected.size() + + " public dataset(s) connected to this project."); + } + + private void connectSelectedRestrictedDatasets() { + var selected = restrictedSearchResultsGrid.getSelectedItems(); + if (selected.isEmpty()) { + return; + } + for (SearchableDataset ds : selected) { + connectedRestrictedDatasets.add( + new ConnectedDataset(ds.id(), ds.title(), ds.doi(), + "Current User (demo)", LocalDate.now(), ds.repository(), + ds.accessRight(), "1.0", false)); + } + refreshConnectedRestrictedGrid(); + restrictedSearchResultsGrid.deselectAll(); + connectRestrictedButton.setEnabled(false); + connectRestrictedButton.setText("Connect"); + showSuccessNotification(selected.size() + + " restricted dataset(s) connected to this project."); + } + + private void confirmRemoveDataset(ConnectedDataset dataset) { + var dialog = new Dialog(); + dialog.setCloseOnOutsideClick(false); + dialog.setCloseOnEsc(false); + + var headerDiv = new Div(); + headerDiv.addClassNames("flex-horizontal", "gap-03", "padding-horizontal-07", + "padding-vertical-04"); + var icon = VaadinIcon.EXCLAMATION_CIRCLE.create(); + icon.addClassName("icon-color-warning"); + var title = new Span("Remove Dataset Connection"); + title.addClassName("heading-3"); + headerDiv.add(icon, title); + + var body = new Div(); + body.addClassNames("flex-vertical", "gap-03", "padding-horizontal-07", "padding-vertical-04"); + body.add(new Span("Are you sure you want to remove the connection to:")); + var dsLabel = new Span(dataset.title()); + dsLabel.getStyle().set("font-weight", "600"); + body.add(dsLabel); + body.add(new Span("This will not delete the dataset on " + dataset.repository() + + " — it only removes the link from this project.")); + + var footer = new Div(); + footer.addClassNames("flex-horizontal", "gap-03", "padding-horizontal-07", + "padding-vertical-04"); + footer.getStyle().set("justify-content", "flex-end"); + var cancelBtn = new Button("Cancel", e -> dialog.close()); + cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + var removeBtn = new Button("Remove", VaadinIcon.TRASH.create(), e -> { + removeDataset(dataset); + dialog.close(); + }); + removeBtn.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_PRIMARY); + footer.add(cancelBtn, removeBtn); + + dialog.add(headerDiv, body); + dialog.getFooter().add(footer); + dialog.open(); + } + + private void removeDataset(ConnectedDataset dataset) { + connectedPublicDatasets.removeIf(d -> d.id().equals(dataset.id())); + connectedRestrictedDatasets.removeIf(d -> d.id().equals(dataset.id())); + refreshConnectedPublicGrid(); + refreshConnectedRestrictedGrid(); + showSuccessNotification("Dataset connection removed: " + dataset.title()); + } + + private void syncSingleDataset(ConnectedDataset dataset) { + showInfoNotification("Syncing " + dataset.title() + " with " + dataset.repository() + "…"); + var ui = UI.getCurrent(); + new Thread(() -> { + try { + Thread.sleep(1500); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + ui.access(() -> { + simulateUpdateAvailable(dataset); + showInfoNotification("Sync complete for '" + dataset.title() + "'. Version is up to date."); + }); + }).start(); + } + + private void simulateUpdateAvailable(ConnectedDataset dataset) { + for (int i = 0; i < connectedPublicDatasets.size(); i++) { + if (connectedPublicDatasets.get(i).id().equals(dataset.id())) { + var old = connectedPublicDatasets.get(i); + connectedPublicDatasets.set(i, + new ConnectedDataset(old.id(), old.title(), old.doi(), old.connectedBy(), + old.connectedOn(), old.repository(), old.accessRight(), "1.1", true)); + break; + } + } + for (int i = 0; i < connectedRestrictedDatasets.size(); i++) { + if (connectedRestrictedDatasets.get(i).id().equals(dataset.id())) { + var old = connectedRestrictedDatasets.get(i); + connectedRestrictedDatasets.set(i, + new ConnectedDataset(old.id(), old.title(), old.doi(), old.connectedBy(), + old.connectedOn(), old.repository(), old.accessRight(), "1.1", true)); + break; + } + } + refreshConnectedPublicGrid(); + refreshConnectedRestrictedGrid(); + } + + private void syncAllPublic() { + if (connectedPublicDatasets.isEmpty()) { + showInfoNotification("No public datasets connected to sync."); + return; + } + showInfoNotification("Syncing all " + connectedPublicDatasets.size() + + " public datasets…"); + showSuccessNotification("All " + connectedPublicDatasets.size() + + " public datasets are up to date."); + } + + private void syncAllRestricted() { + if (connectedRestrictedDatasets.isEmpty()) { + showInfoNotification("No restricted datasets connected to sync."); + return; + } + showInfoNotification("Syncing all " + connectedRestrictedDatasets.size() + + " restricted datasets…"); + showSuccessNotification("All " + connectedRestrictedDatasets.size() + + " restricted datasets are up to date."); + } + + private void refreshConnectedPublicGrid() { + connectedPublicGrid.setItems(new ArrayList<>(connectedPublicDatasets)); + } + + private void refreshConnectedRestrictedGrid() { + connectedRestrictedGrid.setItems(new ArrayList<>(connectedRestrictedDatasets)); + } + + // ══════════════════════════════════════════════════════════════════════ + // NOTIFICATION HELPERS + // ══════════════════════════════════════════════════════════════════════ + + private void showSuccessNotification(String message) { + var notification = new Notification(message, 3000); + notification.addClassName("success-toast"); + notification.setPosition(Notification.Position.BOTTOM_END); + notification.open(); + } + + private void showInfoNotification(String message) { + var notification = new Notification(message, 3000); + notification.addClassName("info-toast"); + notification.setPosition(Notification.Position.BOTTOM_END); + notification.open(); + } + + private void showErrorNotification(String message) { + var notification = new Notification(message, 5000); + notification.addClassName("error-toast"); + notification.setPosition(Notification.Position.BOTTOM_END); + notification.open(); + } + + // ══════════════════════════════════════════════════════════════════════ + // SEED DATA + // ══════════════════════════════════════════════════════════════════════ + + private void seedConnectedDatasets() { + connectedPublicDatasets.add( + new ConnectedDataset("zen-seed-01", + "Benchmark dataset for reproducibility assessment", + "10.5281/zenodo.9999001", "Alice Schmidt", + LocalDate.of(2025, 1, 15), "Zenodo", "Open", "2.0", false)); + connectedPublicDatasets.add( + new ConnectedDataset("zen-seed-02", + "Metabolomics reference spectra library", + "10.5281/zenodo.9999002", "Bob Fernandez", + LocalDate.of(2025, 3, 22), "Zenodo", "Open", "1.2", true)); + connectedRestrictedDatasets.add( + new ConnectedDataset("fdat-seed-01", + "Multi-center clinical proteomics study (Controlled)", + "10.5281/fdat.7777001", "Carol Yamamoto", + LocalDate.of(2025, 5, 8), "FDAT", "Restricted", "1.0", false)); + } +} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/demo/AssociatedDatasetsDemoV2.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/demo/AssociatedDatasetsDemoV2.java new file mode 100644 index 0000000000..99d8cd1996 --- /dev/null +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/demo/AssociatedDatasetsDemoV2.java @@ -0,0 +1,1745 @@ +package life.qbic.datamanager.views.demo; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.grid.Grid.SelectionMode; +import com.vaadin.flow.component.grid.GridVariant; +import com.vaadin.flow.component.html.Anchor; +import com.vaadin.flow.component.html.AnchorTarget; +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.notification.Notification; +import com.vaadin.flow.component.radiobutton.RadioButtonGroup; +import com.vaadin.flow.component.textfield.PasswordField; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.renderer.ComponentRenderer; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import com.vaadin.flow.spring.annotation.UIScope; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import life.qbic.datamanager.views.general.InfoBox; +import life.qbic.datamanager.views.general.Tag; +import life.qbic.datamanager.views.general.Tag.TagColor; +import life.qbic.datamanager.views.general.section.ActionBar; +import life.qbic.datamanager.views.general.section.Section; +import life.qbic.datamanager.views.general.section.SectionContent; +import life.qbic.datamanager.views.general.section.SectionHeader; +import life.qbic.datamanager.views.general.section.SectionNote; +import life.qbic.datamanager.views.general.section.SectionTitle; +import org.springframework.context.annotation.Profile; + +/** + * Associated Datasets Demo V2 — Sidebar Prototype + * + *

Pure UI prototype for the "Connect datasets with research projects" feature. + * This second iteration addresses feedback from the first prototype:

+ * + *
    + *
  • No modal for connecting datasets — uses a right-side sliding sidebar panel + * that works within the existing AppLayout drawer/content structure.
  • + *
  • Unified resource view — one grid shows all connected resources regardless + * of access level. A segmented filter lets the user show All / Public / Restricted.
  • + *
  • Access level inline — each resource row displays its access status + * (public vs. restricted) as an inline badge, eliminating duplicate sections.
  • + *
  • Stakeholder-aligned properties — high-prio properties (Title, PID, Access + * Status, Version, Access Link, Publication Date) are shown in the main grid; + * medium-prio properties (Connected By, Resource Provider, Creator, Resource Type, + * Community, Linked Experiment) are shown in an expandable detail row.
  • + *
+ * + *

Covers user stories 01–08 from the feature specification.

+ * + *

Stories 14 & 15 (credential management): A dedicated section below the + * connected resources grid allows users to configure InvenioRDM instances by adding or + * removing Personal Access Tokens. Configuring a token unlocks access to restricted + * datasets on that instance; removing it revokes that access. Token validation is + * simulated in this prototype.

+ * + *

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

+ * + * @since 1.12.0 + */ +@Profile("development") +@Route("test-view/associated-datasets-v2") +@UIScope +@AnonymousAllowed +@org.springframework.stereotype.Component +public class AssociatedDatasetsDemoV2 extends Div { + + private static final List INVENIO_INSTANCES = List.of( + "Zenodo (zenodo.org)", + "FDAT (fdat.uni-tuebingen.de)" + ); + + // ── Domain records ──────────────────────────────────────────────────── + + enum AccessLevel { PUBLIC, RESTRICTED } + + /** A connected dataset resource displayed in the main grid. */ + record ConnectedResource( + // High-prio (from stakeholder doc) + String title, + String pid, + AccessLevel accessLevel, // Covers "Record: public/restricted" + "Files: restricted" + String version, + String accessLink, + LocalDate publicationDate, + // Medium-prio (from stakeholder doc) + String connectedBy, + String resourceProvider, + String creator, + String resourceType, + String community, + String linkedExperiment, + // Internal + String id, + LocalDate connectedOn, + boolean updateAvailable + ) {} + + /** A dataset found via search, available to be connected. */ + record SearchableResource( + String id, + String title, + String pid, + AccessLevel accessLevel, + String version, + String accessLink, + LocalDate publicationDate, + String resourceProvider, + String creator, + String resourceType, + String community, + String description + ) {} + + // ── Mock data ───────────────────────────────────────────────────────── + + private static final List MOCK_SEARCH_RESULTS = List.of( + new SearchableResource("zen-001", + "High-resolution cryo-EM structure of the human 26S proteasome", + "10.5281/zenodo.1234567", AccessLevel.PUBLIC, + "v1", "https://zenodo.org/records/1234567", + LocalDate.of(2024, 11, 15), "Zenodo", "M. Bauer, S. Fernandez", + "Dataset", null, + "Cryo-electron microscopy structure at 2.8 Å resolution."), + new SearchableResource("zen-002", + "Proteomic profiling of T-cell receptor signaling in Jurkat cells", + "10.5281/zenodo.1234568", AccessLevel.PUBLIC, + "v1", "https://zenodo.org/records/1234568", + LocalDate.of(2024, 10, 3), "Zenodo", "A. Müller, K. Tanaka", + "Dataset", null, + "Mass spectrometry datasets for TCR signaling pathway characterization."), + new SearchableResource("zen-003", + "Multi-omics integration pipeline benchmark dataset", + "10.5281/zenodo.1234569", AccessLevel.PUBLIC, + "v1", "https://zenodo.org/records/1234569", + LocalDate.of(2025, 1, 20), "Zenodo", "QBiC Consortium", + "Dataset", "QBiC", + "Reference dataset for benchmarking multi-omics integration methods."), + new SearchableResource("fdat-001", + "Quantitative phosphoproteomics of DNA damage response", + "10.5281/fdat.9876543", AccessLevel.PUBLIC, + "v1", "https://fdat.uni-tuebingen.de/records/9876543", + LocalDate.of(2025, 3, 5), "FDAT", "C. Klein, D. Patel", + "Dataset", "CMFI", + "TMT-labeled phosphoproteomics data from HEK293 cells."), + new SearchableResource("zen-r01", + "Clinical metabolomics data — Cohort A (embargo until 2026-12)", + "10.5281/zenodo.2345601", AccessLevel.RESTRICTED, + "v1", "https://zenodo.org/records/2345601", + LocalDate.of(2025, 6, 1), "Zenodo", "R. Schmidt, M. Bauer", + "Dataset", null, + "Clinical metabolomics profiles. Access restricted pending ethics review."), + new SearchableResource("fdat-r01", + "Oncology panel sequencing — Phase II trial (controlled access)", + "10.5281/fdat.8765401", AccessLevel.RESTRICTED, + "v1", "https://fdat.uni-tuebingen.de/records/8765401", + LocalDate.of(2025, 4, 1), "FDAT", "QBiC Clinical Collaboration", + "Dataset", "SFB209", + "Targeted sequencing data from Phase II clinical trial."), + new SearchableResource("zen-004", + "Supplementary figures for 'Metabolic rewiring in tumor microenvironment'", + "10.5281/zenodo.1234570", AccessLevel.PUBLIC, + "v1", "https://zenodo.org/records/1234570", + LocalDate.of(2025, 2, 10), "Zenodo", "J. Park, R. Schmidt", + "Publication", null, + "Additional figures and source data accompanying the publication."), + new SearchableResource("zen-r02", + "Pre-publication proteomics: Novel biomarker candidates", + "10.5281/zenodo.2345602", AccessLevel.RESTRICTED, + "v1", "https://zenodo.org/records/2345602", + LocalDate.of(2025, 5, 20), "Zenodo", "K. Tanaka, A. Petrova", + "Dataset", null, + "Discovery-phase proteomics data. Under peer review.") + ); + + // ── InvenioRDM instance definitions & credential state ──────────────── + + /** + * An InvenioRDM instance available for credential configuration. + * Users can store a Personal Access Token for each instance to access + * restricted datasets. + */ + record InvenioRDMInstance( + String id, + String displayName, + String shortName, + String baseUrl, + String description, + String tokenSetupUrl + ) {} + + /** + * A stored credential (Personal Access Token) for an InvenioRDM instance. + * The actual token value is never held in memory as plaintext in the UI; + * only a masked representation is displayed. + */ + record RepositoryCredential( + String instanceId, + String maskedToken, + LocalDate addedOn + ) {} + + /** + * InvenioRDM instances that users can configure with credentials. + */ + private static final List AVAILABLE_INSTANCES = List.of( + new InvenioRDMInstance( + "zenodo", + "Zenodo (zenodo.org)", + "Zenodo", + "https://zenodo.org", + "Open-access research data repository operated by CERN. Widely accepted " + + "by journals and funders; any researcher can deposit datasets and " + + "receive a DOI.", + "https://zenodo.org/account/settings/applications/tokens/new/" + ), + new InvenioRDMInstance( + "fdat", + "FDAT (fdat.uni-tuebingen.de)", + "FDAT", + "https://fdat.uni-tuebingen.de", + "InvenioRDM instance operated by the University of Tübingen. Provides " + + "FAIR-compliant publishing for institutional and collaborative " + + "research datasets.", + "https://fdat.uni-tuebingen.de/account/settings/applications/tokens/new/" + ) + ); + + // ── Mutable state ───────────────────────────────────────────────────── + + private final List connectedResources = new ArrayList<>(); + + /** + * Maps InvenioRDM instance IDs (e.g. "zenodo") to their stored credentials. + * An entry present in this map means the instance is fully configured and + * the user can search restricted datasets on it. + */ + private final Map configuredCredentials = new HashMap<>(); + + /** Content container for the credentials section — rebuildable when state changes. */ + private Div credentialsContentContainer; + + private Grid resourcesGrid; + private final TextField searchField = new TextField(); + private final ComboBox instanceSelector = new ComboBox<>(); + private RadioButtonGroup accessFilter; + private Button connectButton; + private Grid searchResultsGrid; + + // Sidebar panel elements + private Div sidebarOverlay; + private Div sidebarPanel; + private boolean sidebarOpen = false; + private Span selectionCountLabel; + private Button sidebarConnectBtn; + + public AssociatedDatasetsDemoV2() { + addClassNames("padding-horizontal-07", "padding-vertical-04"); + addClassName("flex-vertical"); + + seedConnectedResources(); + + // ── Page heading ──────────────────────────────────────────────── + var title = new Div("Connected Resources — UI Prototype V2"); + title.addClassName("heading-1"); + add(title); + + var subtitle = new Div( + "Sidebar approach: connecting datasets uses a sliding panel instead of a modal. " + + "All resources are shown in a unified view with access level as an inline indicator."); + subtitle.addClassName("normal-body-text"); + subtitle.addClassName("color-secondary"); + add(subtitle); + add(new Div()); // spacer + + // ── Seed credentials BEFORE building the section so the cards + // render in their correct configured / unconfigured state. + // Zenodo is pre-configured; FDAT is intentionally left empty + // so the demo shows both card states side-by-side. + seedCredentials(); + + // ── Main content: Connected Resources section ────────────────── + add(buildConnectedResourcesSection()); + + // ── Repository credentials section (stories 14 & 15) ───────── + add(buildRepositoryCredentialsSection()); + + // ── Sidebar overlay + panel (initially hidden) ──────────────── + buildConnectSidebar(); + } + + // ══════════════════════════════════════════════════════════════════════ + // CONNECTED RESOURCES — Main Section (High-Prio properties) + // ══════════════════════════════════════════════════════════════════════ + + private Section buildConnectedResourcesSection() { + var section = new Section.SectionBuilder().build(); + + // ── ActionBar: Connect button + Sync All ──────────────────────── + connectButton = new Button("Connect Datasets", VaadinIcon.PLUS_CIRCLE.create()); + connectButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + connectButton.addClickListener(e -> openConnectSidebar()); + + var syncAllButton = new Button("Sync All", VaadinIcon.REFRESH.create()); + syncAllButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + syncAllButton.addClickListener(e -> syncAllResources()); + + var actionBar = new ActionBar(); + actionBar.addButton(connectButton); + actionBar.addButton(syncAllButton); + + var header = new SectionHeader( + new SectionTitle("Connected Resources"), + actionBar, + new SectionNote("Datasets connected from InvenioRDM repositories. " + + "High-priority properties are shown below. Expand a row for details.") + ); + header.enableControls(); + section.setHeader(header); + + // ── Filter bar: Access Level toggle ───────────────────────────── + var content = new SectionContent(); + + var filterBar = new Div(); + filterBar.addClassNames("flex-horizontal", "gap-03", "items-center"); + filterBar.getStyle().set("margin-bottom", "var(--lumo-space-m)"); + + var filterLabel = new Span("Show:"); + filterLabel.addClassName("normal-body-text"); + filterLabel.getStyle().set("font-weight", "500"); + + accessFilter = new RadioButtonGroup<>(); + accessFilter.setItems("All", "Public", "Restricted"); + accessFilter.setValue("All"); + accessFilter.addThemeVariants(); + accessFilter.addValueChangeListener(e -> refreshResourcesGrid()); + + // Resource count badge + var countBadge = new Span(); + countBadge.addClassName("extra-small-body-text"); + countBadge.addClassName("color-secondary"); + countBadge.getStyle().set("margin-left", "auto"); + updateResourceCount(countBadge); + + filterBar.add(filterLabel, accessFilter, countBadge); + content.add(filterBar); + + // ── Unified resources grid ────────────────────────────────────── + resourcesGrid = new Grid<>(); + resourcesGrid.addThemeVariants(GridVariant.LUMO_COMPACT, GridVariant.LUMO_ROW_STRIPES); + resourcesGrid.setSelectionMode(SelectionMode.NONE); + resourcesGrid.setWidthFull(); + + // Expandable detail row for medium-prio properties + resourcesGrid.setItemDetailsRenderer( + new ComponentRenderer<>(resource -> { + var detailPanel = new Div(); + detailPanel.addClassNames("flex-vertical", "gap-03"); + detailPanel.getStyle().set("padding", "var(--lumo-space-s) var(--lumo-space-m)"); + detailPanel.getStyle().set("background-color", "var(--lumo-contrast-5pct)"); + detailPanel.getStyle().set("border-radius", "var(--lumo-border-radius-m)"); + + var detailTitle = new Span("Additional Information"); + detailTitle.getStyle().set("font-weight", "600"); + detailTitle.addClassName("normal-body-text"); + detailPanel.add(detailTitle); + + var detailsGrid = new Div(); + detailsGrid.addClassNames("flex-vertical", "gap-02"); + + addDetailRow(detailsGrid, "Connected By", resource.connectedBy()); + addDetailRow(detailsGrid, "Resource Provider", resource.resourceProvider()); + addDetailRow(detailsGrid, "Creator", resource.creator()); + addDetailRow(detailsGrid, "Resource Type", resource.resourceType()); + addDetailRow(detailsGrid, "Community", + resource.community() != null ? resource.community() : "—"); + addDetailRow(detailsGrid, "Linked Experiment", + resource.linkedExperiment() != null ? resource.linkedExperiment() : "—"); + + detailPanel.add(detailsGrid); + return detailPanel; + })); + + // ── Columns (High-Prio from stakeholder doc) ──────────────────── + + // Title column (expandable for details) + resourcesGrid.addComponentColumn(resource -> { + var wrapper = new Div(); + wrapper.addClassNames("flex-vertical", "gap-01"); + var titleSpan = new Span(resource.title()); + titleSpan.addClassName("normal-body-text"); + titleSpan.getStyle().set("font-weight", "500"); + wrapper.add(titleSpan); + // Update hint inline: a subtle clickable banner that explains the situation + // and directs the user to the Sync button. + if (resource.updateAvailable()) { + var updateHint = new Div(); + updateHint.getStyle().set("display", "inline-flex"); + updateHint.getStyle().set("align-items", "center"); + updateHint.getStyle().set("gap", "var(--lumo-space-xs)"); + updateHint.getStyle().set("padding", + "var(--lumo-space-xxs) var(--lumo-space-s)"); + updateHint.getStyle().set("border-radius", + "var(--lumo-border-radius-m)"); + updateHint.getStyle().set("background-color", + "var(--lumo-warning-color-10pct)"); + updateHint.getStyle().set("border", + "1px solid var(--lumo-warning-color)"); + updateHint.getStyle().set("cursor", "pointer"); + + Icon exclamationIcon = VaadinIcon.EXCLAMATION_CIRCLE_O.create(); + exclamationIcon.getStyle().set("color", "var(--lumo-warning-color)"); + exclamationIcon.getStyle().set("font-size", "var(--lumo-icon-size-s)"); + + var hintText = new Span("New version available — sync to update"); + hintText.addClassName("extra-small-body-text"); + hintText.getStyle().set("color", "var(--lumo-warning-color)"); + hintText.getStyle().set("font-weight", "500"); + + updateHint.add(exclamationIcon, hintText); + updateHint.addClickListener(e -> syncSingleResource(resource)); + wrapper.add(updateHint); + } + return wrapper; + }).setHeader("Title").setFlexGrow(3).setKey("title"); + + // PID column (DOI) + resourcesGrid.addComponentColumn(resource -> { + var anchor = new Anchor( + resource.pid().startsWith("http") ? resource.pid() + : "https://doi.org/" + resource.pid(), + resource.pid()); + anchor.setTarget(AnchorTarget.BLANK); + anchor.addClassName("extra-small-body-text"); + return anchor; + }).setHeader("PID / DOI").setAutoWidth(true).setFlexGrow(1).setKey("pid"); + + // Access Status column — inline badge showing record + file access + resourcesGrid.addComponentColumn(resource -> { + return buildAccessStatusBadge(resource); + }).setHeader("Access Status").setAutoWidth(true).setFlexGrow(0).setKey("access"); + + // Version column + resourcesGrid.addColumn(ConnectedResource::version) + .setHeader("Version").setAutoWidth(true).setKey("version"); + + // Access Link column + resourcesGrid.addComponentColumn(resource -> { + if (resource.accessLink() != null && !resource.accessLink().isBlank()) { + var link = new Anchor(resource.accessLink(), "Open ↗"); + link.setTarget(AnchorTarget.BLANK); + link.addClassName("extra-small-body-text"); + return link; + } + return new Span("—"); + }).setHeader("Access Link").setAutoWidth(true).setKey("accessLink"); + + // Publication Date column + resourcesGrid.addComponentColumn(resource -> { + return new Span(resource.publicationDate().format( + DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))); + }).setHeader("Published").setAutoWidth(true).setKey("publicationDate"); + + // Actions column: Sync + Remove + resourcesGrid.addComponentColumn(resource -> { + var syncBtn = new Button(VaadinIcon.REFRESH.create()); + if (resource.updateAvailable()) { + // Visually indicate that a newer version is available: + // icon gets warning color and tooltip explains what to do. + syncBtn.getStyle().set("color", "var(--lumo-warning-color)"); + syncBtn.setTooltipText( + "New version available on " + resource.resourceProvider() + + ". Click to update this connection."); + } else { + syncBtn.setTooltipText("Check " + resource.resourceProvider() + + " for updates."); + } + syncBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + syncBtn.getStyle().set("padding", "var(--lumo-space-s)"); + syncBtn.addClickListener(e -> syncSingleResource(resource)); + + var removeBtn = new Button(VaadinIcon.TRASH.create()); + removeBtn.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY); + removeBtn.setTooltipText("Remove connection"); + removeBtn.getStyle().set("padding", "var(--lumo-space-s)"); + removeBtn.addClickListener(e -> confirmRemoveResource(resource)); + + var wrapper = new Div(); + wrapper.addClassNames("flex-horizontal", "gap-01"); + wrapper.add(syncBtn, removeBtn); + return wrapper; + }).setHeader("Actions").setAutoWidth(true).setKey("actions"); + + refreshResourcesGrid(); + content.add(resourcesGrid); + section.setContent(content); + return section; + } + + /** + * Builds a compact access status badge. Uses the stakeholder's distinction: + * a record can have public metadata while the files themselves are restricted. + * Format: "Record: public | Files: restricted" + */ + private Component buildAccessStatusBadge(ConnectedResource resource) { + var wrapper = new Div(); + wrapper.addClassNames("flex-vertical", "gap-01"); + + if (resource.accessLevel() == AccessLevel.PUBLIC) { + var badge = new Tag("Public"); + badge.setTagColor(TagColor.SUCCESS); + wrapper.add(badge); + } else { + var badge = new Tag("Restricted"); + badge.setTagColor(TagColor.WARNING); + wrapper.add(badge); + var note = new Span("Record: public | Files: restricted"); + note.addClassName("extra-small-body-text"); + note.addClassName("color-secondary"); + wrapper.add(note); + } + return wrapper; + } + + private void addDetailRow(Div container, String label, String value) { + var row = new Div(); + row.addClassNames("flex-horizontal", "gap-02"); + row.getStyle().set("align-items", "baseline"); + var labelSpan = new Span(label + ":"); + labelSpan.addClassName("extra-small-body-text"); + labelSpan.getStyle().set("font-weight", "600"); + labelSpan.getStyle().set("min-width", "140px"); + labelSpan.addClassName("color-secondary"); + var valueSpan = new Span(value); + valueSpan.addClassName("normal-body-text"); + row.add(labelSpan, valueSpan); + container.add(row); + } + + private void updateResourceCount(Span countBadge) { + int total = connectedResources.size(); + long publicCount = connectedResources.stream() + .filter(r -> r.accessLevel() == AccessLevel.PUBLIC).count(); + long restrictedCount = total - publicCount; + countBadge.setText( + "%d resource(s) — %d public, %d restricted".formatted(total, publicCount, restrictedCount)); + } + + // ══════════════════════════════════════════════════════════════════════ + // CONNECT SIDEBAR (replaces modal — slides in from right) + // ══════════════════════════════════════════════════════════════════════ + + // ── Sidebar CSS class names managed via getStyle() ────────────── + private static final String SIDEBAR_OPEN_CSS = + "position:fixed;top:0;right:0;width:640px;height:100%;" + + "background-color:var(--lumo-base-color);" + + "box-shadow:-4px 0 24px rgba(0,0,0,0.12);" + + "z-index:1000;display:none;box-sizing:border-box;" + + "overflow-y:auto;"; + + private void buildConnectSidebar() { + // Overlay (semi-transparent backdrop) + sidebarOverlay = new Div(); + sidebarOverlay.getStyle().set("position", "fixed"); + sidebarOverlay.getStyle().set("top", "0"); + sidebarOverlay.getStyle().set("left", "0"); + sidebarOverlay.getStyle().set("width", "100%"); + sidebarOverlay.getStyle().set("height", "100%"); + sidebarOverlay.getStyle().set("background-color", "rgba(0,0,0,0.3)"); + sidebarOverlay.getStyle().set("z-index", "999"); + sidebarOverlay.getStyle().set("display", "none"); + sidebarOverlay.addClickListener(e -> closeConnectSidebar()); + + // Sidebar panel — initially hidden (display:none), shown via openConnectSidebar() + sidebarPanel = new Div(); + sidebarPanel.getStyle().set("position", "fixed"); + sidebarPanel.getStyle().set("top", "0"); + sidebarPanel.getStyle().set("right", "0"); + sidebarPanel.getStyle().set("width", "640px"); + sidebarPanel.getStyle().set("height", "100%"); + sidebarPanel.getStyle().set("background-color", "var(--lumo-base-color)"); + sidebarPanel.getStyle().set("box-shadow", "-4px 0 24px rgba(0,0,0,0.12)"); + sidebarPanel.getStyle().set("z-index", "1000"); + sidebarPanel.getStyle().set("box-sizing", "border-box"); + sidebarPanel.getStyle().set("overflow-y", "auto"); + sidebarPanel.getStyle().set("display", "none"); + + // ── Sidebar container (flex layout inside the panel) ───────── + var sidebarBody = new Div(); + // Use explicit CSS instead of flex-vertical to avoid conflicts with fixed positioning + sidebarBody.getStyle().set("height", "100%"); + sidebarBody.getStyle().set("box-sizing", "border-box"); + sidebarBody.getStyle().set("display", "flex"); + sidebarBody.getStyle().set("flex-direction", "column"); + + // ── Header ──────────────────────────────────────────────────── + var sidebarHeader = new Div(); + sidebarHeader.addClassNames("flex-horizontal", "items-center"); + sidebarHeader.getStyle().set("padding", "var(--lumo-space-m) var(--lumo-space-l)"); + sidebarHeader.getStyle().set("border-bottom", "1px solid var(--lumo-contrast-10pct)"); + sidebarHeader.getStyle().set("flex-shrink", "0"); + sidebarHeader.getStyle().set("gap", "var(--lumo-space-s)"); + + var sidebarTitle = new Span("Connect Datasets"); + sidebarTitle.addClassName("heading-3"); + sidebarTitle.getStyle().set("flex-grow", "1"); + + var closeButton = new Button(VaadinIcon.CLOSE_SMALL.create()); + closeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + closeButton.setTooltipText("Close"); + closeButton.addClickListener(e -> closeConnectSidebar()); + + sidebarHeader.add(sidebarTitle, closeButton); + sidebarBody.add(sidebarHeader); + + // ─ Content area (scrollable) ──────────────────────────────── + var contentArea = new Div(); + contentArea.getStyle().set("flex-grow", "1"); + contentArea.getStyle().set("overflow-y", "auto"); + contentArea.getStyle().set("padding", "var(--lumo-space-l)"); + + // Compact credential notice (dismissible, below search form) + contentArea.add(buildCompactCredentialNotice()); + + // Info note + var infoNote = new InfoBox() + .setInfoText( + "Search for datasets on InvenioRDM repositories and connect them to this project. " + + "Both public and restricted datasets can be found here — access level is shown inline.") + .setClosable(true); + infoNote.getStyle().set("margin-bottom", "var(--lumo-space-m)"); + contentArea.add(infoNote); + + // Search bar (compact horizontal layout) + var searchForm = new Div(); + searchForm.getStyle().set("margin-bottom", "var(--lumo-space-l)"); + + instanceSelector.setItems(INVENIO_INSTANCES); + instanceSelector.setPlaceholder("Select repository…"); + instanceSelector.setValue(INVENIO_INSTANCES.get(0)); + instanceSelector.setWidth("180px"); + instanceSelector.setLabel("Repository"); + instanceSelector.getStyle().set("flex-shrink", "0"); + // Tag the overlay so we can raise its z-index via CSS (the overlay is + // teleported to at z-index 200, which sits below the sidebar) + instanceSelector.setOverlayClassName("connect-dataset-sidebar-overlay"); + // Auto-update search results when repository selection changes + instanceSelector.addValueChangeListener(e -> performSidebarSearch()); + + searchField.setPlaceholder("Search by title, DOI, or creator…"); + searchField.setClearButtonVisible(true); + // Key trick: min-width: 0 allows flex children with intrinsic width to shrink + searchField.getStyle().set("min-width", "0"); + searchField.getStyle().set("flex-shrink", "1"); + searchField.getStyle().set("flex-grow", "1"); + + var searchRow = new Div(); + searchRow.getStyle().set("display", "flex"); + searchRow.getStyle().set("gap", "var(--lumo-space-xs)"); + searchRow.getStyle().set("align-items", "flex-end"); + searchRow.getStyle().set("margin-bottom", "var(--lumo-space-s)"); + searchRow.getStyle().set("min-width", "0"); + searchRow.add(instanceSelector, searchField); + + var searchButtonBar = new Div(); + searchButtonBar.getStyle().set("display", "flex"); + searchButtonBar.getStyle().set("gap", "var(--lumo-space-xs)"); + searchButtonBar.getStyle().set("overflow", "hidden"); + + var searchButton = new Button("Search", VaadinIcon.SEARCH.create()); + searchButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + searchButton.addClickListener(e -> performSidebarSearch()); + + // Trigger search when user presses Enter in the search field + searchField.addKeyDownListener(Key.ENTER, e -> performSidebarSearch()); + + var clearButton = new Button("Clear"); + clearButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + clearButton.addClickListener(e -> { + searchField.clear(); + performSidebarSearch(); + }); + + searchButtonBar.add(searchButton, clearButton); + searchForm.add(searchRow, searchButtonBar); + contentArea.add(searchForm); + + // ── Search results as card list (not a multi-column grid) ──── + // Using a Vaadin Grid with a single column that renders a full card per item. + // This avoids column-width issues in narrow containers. + searchResultsGrid = new Grid<>(); + searchResultsGrid.addThemeVariants(GridVariant.LUMO_COMPACT, GridVariant.LUMO_NO_ROW_BORDERS); + searchResultsGrid.setSelectionMode(SelectionMode.MULTI); + searchResultsGrid.setWidthFull(); + searchResultsGrid.setAllRowsVisible(true); + + searchResultsGrid.addComponentColumn(resource -> buildSearchResultCard(resource)) + .setFlexGrow(1) + .setKey("card"); + + searchResultsGrid.addSelectionListener(event -> { + int count = event.getAllSelectedItems().size(); + selectionCountLabel.setText( + count == 0 ? "" : count + " selected"); + sidebarConnectBtn.setEnabled(count > 0); + }); + + contentArea.add(searchResultsGrid); + sidebarBody.add(contentArea); + + // ── Footer: Connect button ──────────────────────────────────── + var sidebarFooter = new Div(); + sidebarFooter.addClassNames("flex-horizontal", "items-center"); + sidebarFooter.getStyle().set("padding", "var(--lumo-space-m) var(--lumo-space-l)"); + sidebarFooter.getStyle().set("border-top", "1px solid var(--lumo-contrast-10pct)"); + sidebarFooter.getStyle().set("flex-shrink", "0"); + sidebarFooter.getStyle().set("gap", "var(--lumo-space-s)"); + + selectionCountLabel = new Span(""); + selectionCountLabel.addClassName("normal-body-text"); + selectionCountLabel.addClassName("color-secondary"); + selectionCountLabel.getStyle().set("flex-grow", "1"); + + sidebarConnectBtn = new Button("Connect Selected", VaadinIcon.PLUS_CIRCLE.create()); + sidebarConnectBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + sidebarConnectBtn.setEnabled(false); + sidebarConnectBtn.addClickListener(e -> connectSelectedFromSidebar()); + + sidebarFooter.add(selectionCountLabel, sidebarConnectBtn); + sidebarBody.add(sidebarFooter); + + // Assemble sidebar + sidebarPanel.add(sidebarBody); + add(sidebarOverlay, sidebarPanel); + } + + /** + * Builds a single card-style row for the sidebar search results. + * Shows title, creator, DOI, access badge, provider, and date stacked + * — all in a single grid column to avoid width issues in narrow containers. + */ + private Div buildSearchResultCard(SearchableResource resource) { + var card = new Div(); + card.addClassName("border"); + card.addClassName("rounded-02"); + card.getStyle().set("padding", "var(--lumo-space-m)"); + card.getStyle().set("margin-bottom", "var(--lumo-space-s)"); + card.getStyle().set("cursor", "pointer"); + + // Top row: access badge + provider tag + date + var topRow = new Div(); + topRow.addClassNames("flex-horizontal", "items-center", "gap-02"); + topRow.getStyle().set("margin-bottom", "var(--lumo-space-xs)"); + + Tag accessBadge; + if (resource.accessLevel() == AccessLevel.PUBLIC) { + accessBadge = new Tag("Public"); + accessBadge.setTagColor(TagColor.SUCCESS); + } else { + accessBadge = new Tag("Restricted"); + accessBadge.setTagColor(TagColor.WARNING); + } + topRow.add(accessBadge); + + var providerTag = new Tag(resource.resourceProvider()); + providerTag.setTagColor("Zenodo".equals(resource.resourceProvider()) + ? TagColor.PRIMARY : TagColor.TEAL); + topRow.add(providerTag); + + var dateSpan = new Span(resource.publicationDate().format( + DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))); + dateSpan.addClassName("extra-small-body-text"); + dateSpan.addClassName("color-secondary"); + dateSpan.getStyle().set("margin-left", "auto"); + topRow.add(dateSpan); + + card.add(topRow); + + // Title + var titleSpan = new Span(resource.title()); + titleSpan.addClassName("normal-body-text"); + titleSpan.getStyle().set("font-weight", "600"); + titleSpan.getStyle().set("margin-bottom", "var(--lumo-space-xs)"); + titleSpan.getStyle().set("display", "block"); + card.add(titleSpan); + + // Creator + DOI row + var metaRow = new Div(); + metaRow.addClassNames("flex-horizontal", "gap-04", "items-center"); + var creatorSpan = new Span("by " + resource.creator()); + creatorSpan.addClassName("extra-small-body-text"); + creatorSpan.addClassName("color-secondary"); + metaRow.add(creatorSpan); + + var doiAnchor = new Anchor("https://doi.org/" + resource.pid(), resource.pid()); + doiAnchor.setTarget(AnchorTarget.BLANK); + doiAnchor.addClassName("extra-small-body-text"); + metaRow.add(doiAnchor); + + card.add(metaRow); + + return card; + } + + /** + * Builds a compact, collapsible credential notice that sits below the search form. + * Minimal visual impact — just a dismissible inline notice with an optional expand for details. + */ + private Div buildCompactCredentialNotice() { + var notice = new Div(); + notice.addClassNames("border", "rounded-02"); + notice.getStyle().set("padding", "var(--lumo-space-s) var(--lumo-space-m)"); + notice.getStyle().set("margin-bottom", "var(--lumo-space-m)"); + notice.getStyle().set("background-color", "var(--lumo-contrast-5pct)"); + + var header = new Div(); + header.addClassNames("flex-horizontal", "gap-02", "items-center"); + + var infoIcon = VaadinIcon.INFO.create(); + infoIcon.getStyle().set("color", "var(--lumo-primary-color)"); + infoIcon.getStyle().set("font-size", "var(--lumo-icon-size-s)"); + + var text = new Span("Repository access requires credentials for restricted datasets."); + text.addClassName("extra-small-body-text"); + text.getStyle().set("flex-grow", "1"); + + header.add(infoIcon, text); + notice.add(header); + + // Expandable details section + var detailsSection = new Div(); + detailsSection.getStyle().set("display", "none"); + detailsSection.getStyle().set("margin-top", "var(--lumo-space-s)"); + detailsSection.getStyle().set("padding-top", "var(--lumo-space-s)"); + detailsSection.getStyle().set("border-top", "1px solid var(--lumo-contrast-10pct)"); + + var expandedText = new Span( + "Public datasets work without credentials. For restricted datasets, " + + "you need a Personal Access Token from your repository account."); + expandedText.addClassName("extra-small-body-text"); + expandedText.getStyle().set("display", "block"); + expandedText.getStyle().set("margin-bottom", "var(--lumo-space-xs)"); + + var actions = new Div(); + actions.addClassNames("flex-horizontal", "gap-02"); + + var setupLink = new Anchor("#/account/tokens", "Set up access \u2192"); + setupLink.addClassName("extra-small-body-text"); + setupLink.getStyle().set("color", "var(--lumo-primary-color)"); + setupLink.getStyle().set("text-decoration", "none"); + setupLink.getStyle().set("font-weight", "500"); + + var learnMoreLink = new Button("What is a token?"); + learnMoreLink.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE); + learnMoreLink.addClickListener(e -> showCredentialsExplanation()); + + actions.add(setupLink, learnMoreLink); + detailsSection.add(expandedText, actions); + + // Toggle button + var toggleBtn = new Button("Details"); + toggleBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL); + boolean[] expanded = {false}; + toggleBtn.addClickListener(e -> { + expanded[0] = !expanded[0]; + detailsSection.getStyle().set("display", expanded[0] ? "block" : "none"); + toggleBtn.setText(expanded[0] ? "Less" : "Details"); + }); + + // Dismiss button + var dismissBtn = new Button(VaadinIcon.CLOSE_SMALL.create()); + dismissBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + dismissBtn.addClickListener(e -> notice.setVisible(false)); + + header.add(toggleBtn, dismissBtn); + notice.add(detailsSection); + + return notice; + } + + /** + * Shows an explanation dialog about what repository credentials are + * and how to set them up. Uses plain language aimed at non-technical users. + */ + private void showCredentialsExplanation() { + var dialog = new com.vaadin.flow.component.dialog.Dialog(); + dialog.setHeaderTitle("What are repository credentials?"); + dialog.setWidth("480px"); + + var content = new Div(); + content.addClassNames("flex-vertical", "gap-03"); + content.getStyle().set("padding", "var(--lumo-space-s) var(--lumo-space-m)"); + + var intro = new Div(); + intro.getElement().setProperty("innerHTML", + "Repositories like Zenodo and FDAT require authentication " + + "to access datasets that are not publicly visible. You can grant " + + "access by generating a Personal Access Token \u2014 a secret " + + "key that identifies you to the repository."); + intro.addClassName("normal-body-text"); + content.add(intro); + + // Steps + var stepsTitle = new Span("How to set up access:"); + stepsTitle.addClassName("normal-body-text"); + stepsTitle.getStyle().set("font-weight", "600"); + content.add(stepsTitle); + + var steps = new Div(); + steps.getElement().setProperty("innerHTML", + "
    " + + "
  1. " + + "Go to your profile \u2192 Personal access tokens
  2. " + + "
  3. " + + "Click Create new token
  4. " + + "
  5. " + + "Give it a name (e.g. 'Data Manager') and select read permissions
  6. " + + "
  7. " + + "Copy the token and save it securely
  8. " + + "
"); + content.add(steps); + + // Security note + var securityNote = new Span( + "\uD83D\uDD12 Your token is stored securely in the system vault and is only " + + "used to search repositories on your behalf. It never leaves the platform."); + securityNote.addClassName("extra-small-body-text"); + securityNote.addClassName("color-secondary"); + securityNote.getStyle().set("margin-top", "var(--lumo-space-xs)"); + securityNote.getStyle().set("display", "block"); + content.add(securityNote); + + dialog.add(content); + + // Footer buttons + var goBtn = new Button("Go to token settings \u2192", e -> { + dialog.close(); + UI.getCurrent().getPage().setLocation("#/account/tokens"); + }); + goBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + var closeBtn = new Button("Close", e -> dialog.close()); + closeBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + dialog.getFooter().add(closeBtn, goBtn); + + // Raise the dialog above the fixed-position sidebar (z-index: 1000). + // Vaadin teleports dialog overlays to with a default z-index of 200, + // which renders behind the sidebar. We add a CSS class to the overlay that + // sets z-index: 1001 via dialog.css in the theme. + dialog.getClassNames().add("connect-dataset-sidebar-dialog"); + + dialog.open(); + } + + private void openConnectSidebar() { + sidebarOpen = true; + sidebarOverlay.getStyle().set("display", "block"); + sidebarPanel.getStyle().set("display", "block"); + // Populate search results lazily (Grid measures width when visible), + // filtered by the currently selected repository + performSidebarSearch(); + } + + private void closeConnectSidebar() { + sidebarOpen = false; + sidebarOverlay.getStyle().set("display", "none"); + sidebarPanel.getStyle().set("display", "none"); + searchResultsGrid.deselectAll(); + selectionCountLabel.setText(""); + sidebarConnectBtn.setEnabled(false); + } + + // ══════════════════════════════════════════════════════════════════════ + // GRID DATA & FILTERING + // ══════════════════════════════════════════════════════════════════════ + + private void refreshResourcesGrid() { + String filter = accessFilter.getValue(); + List filtered; + if ("Public".equals(filter)) { + filtered = connectedResources.stream() + .filter(r -> r.accessLevel() == AccessLevel.PUBLIC) + .toList(); + } else if ("Restricted".equals(filter)) { + filtered = connectedResources.stream() + .filter(r -> r.accessLevel() == AccessLevel.RESTRICTED) + .toList(); + } else { + filtered = new ArrayList<>(connectedResources); + } + resourcesGrid.setItems(filtered); + } + + private void performSidebarSearch() { + String selectedRepo = instanceSelector.getValue(); + String term = searchField.getValue(); + + // Filter by selected repository first + var repoFiltered = MOCK_SEARCH_RESULTS.stream() + .filter(r -> matchesRepository(r, selectedRepo)) + .toList(); + + if (term == null || term.isBlank()) { + searchResultsGrid.setItems(new ArrayList<>(repoFiltered)); + return; + } + String lower = term.toLowerCase(); + var results = repoFiltered.stream() + .filter(r -> r.title().toLowerCase().contains(lower) + || r.pid().toLowerCase().contains(lower) + || r.creator().toLowerCase().contains(lower) + || r.description().toLowerCase().contains(lower)) + .toList(); + searchResultsGrid.setItems(results); + } + + /** + * Checks whether a searchable resource belongs to the selected repository. + */ + private boolean matchesRepository(SearchableResource resource, String repository) { + if (repository == null || repository.isBlank()) { + return true; + } + return resource.resourceProvider().equals(extractProviderName(repository)); + } + + /** + * Extracts the provider display name from a repository selector entry. + * E.g. "Zenodo (zenodo.org)" → "Zenodo" + */ + private String extractProviderName(String repositoryEntry) { + int idx = repositoryEntry.indexOf(' '); + if (idx > 0) { + return repositoryEntry.substring(0, idx); + } + return repositoryEntry; + } + + // ══════════════════════════════════════════════════════════════════════ + // CONNECT / REMOVE / SYNC ACTIONS + // ══════════════════════════════════════════════════════════════════════ + + private void connectSelectedFromSidebar() { + var selected = searchResultsGrid.getSelectedItems(); + if (selected.isEmpty()) { + return; + } + for (SearchableResource sr : selected) { + connectedResources.add(new ConnectedResource( + sr.title(), sr.pid(), sr.accessLevel(), sr.version(), + sr.accessLink(), sr.publicationDate(), + "Current User (demo)", sr.resourceProvider(), sr.creator(), + sr.resourceType(), sr.community(), + null, // linked experiment — not set at connect time + sr.id(), LocalDate.now(), false + )); + } + refreshResourcesGrid(); + searchResultsGrid.deselectAll(); + closeConnectSidebar(); + showSuccessNotification(selected.size() + + " dataset(s) connected to this project."); + } + + private void confirmRemoveResource(ConnectedResource resource) { + // Use a lightweight approach — in a real app, use the project's dialog pattern + var dialog = new com.vaadin.flow.component.dialog.Dialog(); + dialog.setCloseOnOutsideClick(false); + dialog.setCloseOnEsc(false); + dialog.setWidth("480px"); + + var body = new Div(); + body.addClassNames("flex-vertical", "gap-03", "padding-horizontal-05", + "padding-vertical-04"); + var headerRow = new Div(); + headerRow.addClassNames("flex-horizontal", "gap-03"); + var icon = VaadinIcon.EXCLAMATION_CIRCLE.create(); + icon.addClassName("icon-color-warning"); + var titleSpan = new Span("Remove Dataset Connection"); + titleSpan.addClassName("heading-3"); + headerRow.add(icon, titleSpan); + body.add(headerRow); + + body.add(new Span("Are you sure you want to remove the connection to:")); + var dsLabel = new Span(resource.title()); + dsLabel.getStyle().set("font-weight", "600"); + body.add(dsLabel); + body.add(new Span("This will not delete the dataset on " + resource.resourceProvider() + + " — it only removes the link from this project.")); + + var footer = new Div(); + footer.addClassNames("flex-horizontal", "gap-03", "padding-horizontal-05", + "padding-vertical-03"); + footer.getStyle().set("justify-content", "flex-end"); + var cancelBtn = new Button("Cancel", e -> dialog.close()); + cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + var removeBtn = new Button("Remove", VaadinIcon.TRASH.create(), e -> { + connectedResources.removeIf(r -> r.id().equals(resource.id())); + refreshResourcesGrid(); + dialog.close(); + showSuccessNotification("Connection removed: " + resource.title()); + }); + removeBtn.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_PRIMARY); + footer.add(cancelBtn, removeBtn); + + dialog.add(body); + dialog.getFooter().add(footer); + dialog.open(); + } + + private void syncSingleResource(ConnectedResource resource) { + showInfoNotification("Syncing '" + resource.title() + "' with " + + resource.resourceProvider() + "…"); + var ui = UI.getCurrent(); + new Thread(() -> { + try { + Thread.sleep(1500); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + ui.access(() -> { + showInfoNotification("Sync complete for '" + resource.title() + "'."); + }); + }).start(); + } + + private void syncAllResources() { + if (connectedResources.isEmpty()) { + showInfoNotification("No resources connected to sync."); + return; + } + showInfoNotification("Syncing all " + connectedResources.size() + " resources…"); + var ui = UI.getCurrent(); + new Thread(() -> { + try { + Thread.sleep(2000); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + ui.access(() -> { + showSuccessNotification("All " + connectedResources.size() + + " resources are up to date."); + }); + }).start(); + } + + // ══════════════════════════════════════════════════════════════════════ + // NOTIFICATIONS + // ══════════════════════════════════════════════════════════════════════ + + private void showSuccessNotification(String message) { + var notification = new Notification(message, 3000); + notification.addClassName("success-toast"); + notification.setPosition(Notification.Position.BOTTOM_END); + notification.open(); + } + + private void showInfoNotification(String message) { + var notification = new Notification(message, 3000); + notification.addClassName("info-toast"); + notification.setPosition(Notification.Position.BOTTOM_END); + notification.open(); + } + + // ══════════════════════════════════════════════════════════════════════ + // SEED DATA + // ══════════════════════════════════════════════════════════════════════ + + private void seedConnectedResources() { + // Public resources + connectedResources.add(new ConnectedResource( + "Benchmark dataset for reproducibility assessment", + "10.5281/zenodo.9999001", AccessLevel.PUBLIC, + "v2.0", "https://zenodo.org/records/9999001", + LocalDate.of(2024, 6, 10), + "Alice Schmidt", "Zenodo", "Alice Schmidt", + "Dataset", null, + "Explorative analysis of heat regulative proteins", + "seed-01", LocalDate.of(2025, 1, 15), false)); + + connectedResources.add(new ConnectedResource( + "Metabolomics reference spectra library", + "10.5281/zenodo.9999002", AccessLevel.PUBLIC, + "v1.2", "https://zenodo.org/records/9999002", + LocalDate.of(2024, 9, 22), + "Bob Fernandez", "Zenodo", "Bob Fernandez", + "Dataset", "QBiC", + "Metabolomics reference for pathway analysis", + "seed-02", LocalDate.of(2025, 3, 22), true)); + + connectedResources.add(new ConnectedResource( + "Spatial transcriptomics atlas of human kidney development", + "10.5281/fdat.9876544", AccessLevel.PUBLIC, + "v1", "https://fdat.uni-tuebingen.de/records/9876544", + LocalDate.of(2025, 4, 12), + "Carol Yamamoto", "FDAT", "S. Yamamoto, F. Rossi, QBiC", + "Dataset", "CMFI", + null, + "seed-03", LocalDate.of(2025, 5, 1), false)); + + // Restricted resources + connectedResources.add(new ConnectedResource( + "Multi-center clinical proteomics study (Controlled)", + "10.5281/fdat.7777001", AccessLevel.RESTRICTED, + "v1.0", "https://fdat.uni-tuebingen.de/records/7777001", + LocalDate.of(2025, 2, 18), + "Carol Yamamoto", "FDAT", "QBiC Clinical Collaboration", + "Dataset", "SFB209", + "Clinical proteomics — explorative biomarker study", + "seed-04", LocalDate.of(2025, 5, 8), false)); + + connectedResources.add(new ConnectedResource( + "Clinical metabolomics data — Cohort A (embargo until 2026-12)", + "10.5281/zenodo.2345601", AccessLevel.RESTRICTED, + "v1", "https://zenodo.org/records/2345601", + LocalDate.of(2025, 6, 1), + "Current User", "Zenodo", "R. Schmidt, M. Bauer", + "Dataset", null, + null, + "seed-05", LocalDate.of(2025, 6, 20), false)); + } + + // ══════════════════════════════════════════════════════════════════════ + // REPOSITORY CREDENTIALS — Account Settings (Stories 14 & 15) + // ══════════════════════════════════════════════════════════════════════ + + /** + * Builds the "Repository Access" section that lets users configure + * InvenioRDM instances with Personal Access Tokens (Story 14) and remove + * them again (Story 15). Each available instance is shown as a card with + * its connection status and an appropriate action button. + */ + private Section buildRepositoryCredentialsSection() { + var section = new Section.SectionBuilder().build(); + + var header = new SectionHeader( + new SectionTitle("Repository Access — Account Settings"), + new ActionBar(), + new SectionNote( + "Connect your personal access tokens for InvenioRDM repositories. " + + "A valid token enables Data Manager to search and connect " + + "access-restricted datasets from that repository to your projects. " + + "Public datasets are always accessible without a token.") + ); + header.enableControls(); + section.setHeader(header); + + var content = new SectionContent(); + + credentialsContentContainer = new Div(); + credentialsContentContainer.addClassNames("flex-vertical", "gap-03"); + refreshCredentialsContent(); + content.add(credentialsContentContainer); + + section.setContent(content); + return section; + } + + /** + * Rebuilds the cards inside {@link #credentialsContentContainer} to reflect + * the current state of {@link #configuredCredentials}. Called after tokens + * are added or removed. + */ + private void refreshCredentialsContent() { + credentialsContentContainer.removeAll(); + + // Benefits description — shown once at the top of the section + var benefitsCallout = new Div(); + benefitsCallout.addClassNames("border", "rounded-02"); + benefitsCallout.getStyle().set("padding", + "var(--lumo-space-m) var(--lumo-space-l)"); + benefitsCallout.getStyle().set("background-color", + "var(--lumo-primary-color-10pct)"); + benefitsCallout.getStyle().set("border-color", + "var(--lumo-primary-color-30pct)"); + benefitsCallout.getStyle().set("margin-bottom", "var(--lumo-space-s)"); + + var benefitsHeader = new Div(); + benefitsHeader.addClassNames("flex-horizontal", "gap-02", "items-center"); + var benefitsIcon = VaadinIcon.LIGHTBULB.create(); + benefitsIcon.getStyle().set("color", "var(--lumo-primary-color)"); + var benefitsTitle = new Span( + "Why configure repository access?"); + benefitsTitle.getStyle().set("font-weight", "600"); + benefitsTitle.addClassName("normal-body-text"); + benefitsHeader.add(benefitsIcon, benefitsTitle); + benefitsCallout.add(benefitsHeader); + + var benefitsList = new Div(); + benefitsList.getStyle().set("margin-top", "var(--lumo-space-xs)"); + benefitsList.getStyle().set("padding-left", "var(--lumo-space-l)"); + benefitsList.getElement().setProperty("innerHTML", + "
    " + + "
  • " + + "Search and connect access-restricted datasets " + + "to your projects (e.g. datasets under embargo or with controlled access)
  • " + + "
  • " + + "Keep an overview of all associated data — public and restricted — " + + "in a single place
  • " + + "
  • " + + "Collaborate with your team on datasets that are not yet publicly visible
  • " + + "
  • Your token is stored encrypted in the system vault and is only " + + "used to query the repository on your behalf.
  • " + + "
"); + benefitsList.addClassName("normal-body-text"); + benefitsCallout.add(benefitsList); + credentialsContentContainer.add(benefitsCallout); + + // One card per InvenioRDM instance + for (var instance : AVAILABLE_INSTANCES) { + credentialsContentContainer.add(buildInstanceCard(instance)); + } + } + + /** + * Builds a card for a single InvenioRDM instance showing its connection status + * and the appropriate action button (Configure or Remove). + */ + private Div buildInstanceCard(InvenioRDMInstance instance) { + var card = new Div(); + card.addClassNames("border", "rounded-02"); + card.getStyle().set("padding", "var(--lumo-space-m) var(--lumo-space-l)"); + + boolean isConfigured = configuredCredentials.containsKey(instance.id()); + + // ── Top row: icon + name + status badge ─────────────────────── + var topRow = new Div(); + topRow.addClassNames("flex-horizontal", "items-center", "gap-03"); + topRow.getStyle().set("margin-bottom", "var(--lumo-space-xs)"); + + var repoIcon = VaadinIcon.CLOUD.create(); + repoIcon.getStyle().set("font-size", "var(--lumo-icon-size-m)"); + repoIcon.getStyle().set("color", + "Zenodo".equals(instance.shortName()) + ? "var(--lumo-primary-color)" + : "var(--lumo-success-color)"); + topRow.add(repoIcon); + + var nameAndDesc = new Div(); + nameAndDesc.addClassNames("flex-vertical", "gap-01"); + nameAndDesc.getStyle().set("flex-grow", "1"); + + var nameSpan = new Span(instance.displayName()); + nameSpan.getStyle().set("font-weight", "600"); + nameSpan.addClassName("normal-body-text"); + nameAndDesc.add(nameSpan); + topRow.add(nameAndDesc); + + // Status badge + if (isConfigured) { + var connectedBadge = new Tag("\u2713 Connected"); + connectedBadge.setTagColor(TagColor.SUCCESS); + topRow.add(connectedBadge); + } else { + var notConfiguredBadge = new Tag("Not configured"); + notConfiguredBadge.setTagColor(TagColor.CONTRAST); + topRow.add(notConfiguredBadge); + } + card.add(topRow); + + // ── Description ──────────────────────────────────────────────── + var descSpan = new Span(instance.description()); + descSpan.addClassName("extra-small-body-text"); + descSpan.addClassName("color-secondary"); + descSpan.getStyle().set("display", "block"); + descSpan.getStyle().set("margin-bottom", "var(--lumo-space-s)"); + card.add(descSpan); + + // ── Bottom row: details (if configured) + action button ──────── + var bottomRow = new Div(); + bottomRow.addClassNames("flex-horizontal", "items-center"); + bottomRow.getStyle().set("gap", "var(--lumo-space-s)"); + + if (isConfigured) { + RepositoryCredential credential = configuredCredentials.get(instance.id()); + + var detailsWrapper = new Div(); + detailsWrapper.addClassNames("flex-horizontal", "gap-04", "items-center"); + detailsWrapper.getStyle().set("flex-grow", "1"); + + var tokenInfo = new Span( + "Token: " + credential.maskedToken() + + " | Added: " + + credential.addedOn().format( + DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))); + tokenInfo.addClassName("extra-small-body-text"); + tokenInfo.addClassName("color-secondary"); + detailsWrapper.add(tokenInfo); + + var removeButton = new Button("Remove", + VaadinIcon.TRASH.create()); + removeButton.addThemeVariants( + ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY); + removeButton.setTooltipText( + "Remove the stored token for " + instance.shortName() + + ". Restricted datasets from this instance will no longer " + + "be accessible."); + removeButton.addClickListener( + e -> openRemoveTokenDialog(instance)); + bottomRow.add(detailsWrapper, removeButton); + } else { + var hintText = new Span( + "No personal access token configured for this repository."); + hintText.addClassName("extra-small-body-text"); + hintText.addClassName("color-secondary"); + hintText.getStyle().set("flex-grow", "1"); + + var configureButton = new Button("Configure", + VaadinIcon.PLUS_CIRCLE.create()); + configureButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + configureButton.setTooltipText( + "Add a personal access token to connect restricted datasets from " + + instance.shortName() + "."); + configureButton.addClickListener( + e -> openAddTokenDialog(instance)); + bottomRow.add(hintText, configureButton); + } + card.add(bottomRow); + + return card; + } + + // ── Add Token Dialog (Story 14) ──────────────────────────────────────── + + /** + * Opens a dialog that lets the user add a Personal Access Token for the + * given InvenioRDM instance. Token validation is simulated in this + * prototype — any non-empty token is accepted. + */ + private void openAddTokenDialog(InvenioRDMInstance instance) { + var dialog = new com.vaadin.flow.component.dialog.Dialog(); + dialog.setHeaderTitle("Connect " + instance.displayName()); + dialog.setWidth("520px"); + dialog.setCloseOnOutsideClick(false); + + var content = new Div(); + content.addClassNames("flex-vertical", "gap-03"); + content.getStyle().set("padding", + "var(--lumo-space-s) var(--lumo-space-m)"); + + // Explanation + var introText = new Div(); + introText.addClassName("normal-body-text"); + introText.getElement().setProperty("innerHTML", + "To connect access-restricted datasets from " + instance.shortName() + + ", provide a Personal Access Token from your " + + instance.shortName() + " account. The token is validated against " + + "the repository and stored securely."); + content.add(introText); + + // Token input + var tokenField = new PasswordField(); + tokenField.setLabel("Personal Access Token"); + tokenField.setPlaceholder("Paste your token here…"); + tokenField.setWidthFull(); + tokenField.setRequiredIndicatorVisible(true); + tokenField.setHelperText( + "Generate a token in your " + instance.shortName() + + " account settings."); + content.add(tokenField); + + // Helper link to the token creation page + var tokenSetupLink = new Anchor(instance.tokenSetupUrl(), + "Open " + instance.shortName() + " token settings \u2192"); + tokenSetupLink.setTarget(AnchorTarget.BLANK); + tokenSetupLink.addClassName("extra-small-body-text"); + content.add(tokenSetupLink); + + // Token status feedback area + var statusArea = new Div(); + statusArea.getStyle().set("display", "none"); + statusArea.getStyle().set("padding", "var(--lumo-space-s)"); + statusArea.getStyle().set("border-radius", "var(--lumo-border-radius-m)"); + content.add(statusArea); + + // Validation hint + var validationHint = new Div(); + validationHint.addClassName("extra-small-body-text"); + validationHint.addClassName("color-secondary"); + validationHint.getStyle().set("font-style", "italic"); + validationHint.setText( + "In this prototype, any non-empty token is accepted. " + + "In production, the token is validated against the " + + "InvenioRDM REST API."); + content.add(validationHint); + + // Security note + var securityNote = new Div(); + securityNote.addClassNames("flex-horizontal", "gap-02"); + securityNote.getStyle().set("padding", + "var(--lumo-space-xs) var(--lumo-space-s)"); + securityNote.getStyle().set("background-color", + "var(--lumo-contrast-5pct)"); + securityNote.getStyle().set("border-radius", + "var(--lumo-border-radius-m)"); + var lockIcon = VaadinIcon.LOCK.create(); + lockIcon.getStyle().set("color", "var(--lumo-success-color)"); + lockIcon.getStyle().set("font-size", "var(--lumo-icon-size-s)"); + var lockText = new Span( + "Your token is stored encrypted in the system vault. " + + "It never leaves the platform and is only used to query " + + instance.shortName() + " on your behalf."); + lockText.addClassName("extra-small-body-text"); + lockText.addClassName("color-secondary"); + securityNote.add(lockIcon, lockText); + content.add(securityNote); + + dialog.add(content); + + // Build footer buttons — button is created first, then the listener is + // added in a separate step so the lambda can safely reference it. + Button connectBtn = new Button("Validate & Connect"); + connectBtn.addClickListener(e -> { + String tokenValue = tokenField.getValue(); + if (tokenValue == null || tokenValue.isBlank()) { + // Show error in status area + statusArea.removeAll(); + statusArea.getStyle().set("display", "block"); + statusArea.getStyle().set("background-color", + "var(--lumo-error-color-10pct)"); + statusArea.getStyle().set("border", + "1px solid var(--lumo-error-color)"); + var errRow = new Div(); + errRow.addClassNames("flex-horizontal", "gap-02", "items-center"); + var errIcon = VaadinIcon.WARNING.create(); + errIcon.getStyle().set("color", "var(--lumo-error-color)"); + errIcon.getStyle().set("font-size", + "var(--lumo-icon-size-s)"); + var errText = new Span( + "Token cannot be empty. Please paste a valid Personal " + + "Access Token."); + errText.addClassName("normal-body-text"); + errText.getStyle().set("color", + "var(--lumo-error-color)"); + errRow.add(errIcon, errText); + statusArea.add(errRow); + return; + } + + // Simulate token validation (in production this would call + // the InvenioRDM REST API) + // For the prototype, simulate a success response after a + // brief delay. + var ui = UI.getCurrent(); + connectBtn.setEnabled(false); + connectBtn.setText("Validating\u2026"); + + statusArea.removeAll(); + statusArea.getStyle().set("display", "block"); + statusArea.getStyle().set("background-color", + "var(--lumo-primary-color-10pct)"); + statusArea.getStyle().set("border", + "1px solid var(--lumo-primary-color-30pct)"); + var validatingRow = new Div(); + validatingRow.addClassNames("flex-horizontal", "gap-02", + "items-center"); + var spinnerIcon = VaadinIcon.SPINNER.create(); + var validatingText = new Span( + "Validating token against " + instance.baseUrl() + "\u2026"); + validatingText.addClassName("extra-small-body-text"); + validatingRow.add(spinnerIcon, validatingText); + statusArea.add(validatingRow); + + new Thread(() -> { + try { + Thread.sleep(1200); // simulate network call + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + ui.access(() -> { + // Mask the token for safe display + String masked = maskToken(tokenValue); + configuredCredentials.put(instance.id(), + new RepositoryCredential(instance.id(), masked, + LocalDate.now())); + dialog.close(); + refreshCredentialsContent(); + showSuccessNotification( + "Token connected successfully for " + + instance.shortName() + + ". You can now access restricted datasets from " + + "this repository."); + }); + }).start(); + }); + connectBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + var cancelBtn = new Button("Cancel", e -> dialog.close()); + cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + dialog.getFooter().add(cancelBtn, connectBtn); + dialog.open(); + + // Focus the token field for convenience + tokenField.focus(); + } + + /** + * Returns a masked representation of a token, showing only the first 4 + * and last 4 characters, e.g. "abcd••••••••wxyz". + */ + private String maskToken(String token) { + if (token == null || token.length() <= 8) { + return "****"; + } + return token.substring(0, 4) + "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" + + token.substring(token.length() - 4); + } + + // ── Remove Token Dialog (Story 15) ───────────────────────────────────── + + /** + * Opens a confirmation dialog before removing the stored credentials for + * the given InvenioRDM instance. After confirmation, the credential is + * deleted from {@link #configuredCredentials} and the UI is refreshed. + */ + private void openRemoveTokenDialog(InvenioRDMInstance instance) { + var dialog = new com.vaadin.flow.component.dialog.Dialog(); + dialog.setCloseOnOutsideClick(false); + dialog.setCloseOnEsc(false); + dialog.setWidth("480px"); + + var body = new Div(); + body.addClassNames("flex-vertical", "gap-03"); + body.getStyle().set("padding", + "var(--lumo-space-s) var(--lumo-space-m)"); + + // Header with warning icon + var headerRow = new Div(); + headerRow.addClassNames("flex-horizontal", "gap-03", "items-center"); + var warnIcon = VaadinIcon.EXCLAMATION_CIRCLE.create(); + warnIcon.addClassName("icon-color-warning"); + var titleSpan = new Span("Remove Repository Connection"); + titleSpan.addClassName("heading-3"); + headerRow.add(warnIcon, titleSpan); + body.add(headerRow); + + // Confirmation text + var confirmText = new Div(); + confirmText.addClassName("normal-body-text"); + confirmText.getElement().setProperty("innerHTML", + "You are about to remove the Personal Access Token for " + + "" + instance.displayName() + "."); + body.add(confirmText); + + // Impact description + var impactBox = new Div(); + impactBox.addClassNames("flex-vertical", "gap-02"); + impactBox.getStyle().set("padding", + "var(--lumo-space-s) var(--lumo-space-m)"); + impactBox.getStyle().set("background-color", + "var(--lumo-contrast-5pct)"); + impactBox.getStyle().set("border-radius", + "var(--lumo-border-radius-m)"); + + var impactTitle = new Span("What happens:"); + impactTitle.addClassName("normal-body-text"); + impactTitle.getStyle().set("font-weight", "600"); + impactBox.add(impactTitle); + + var impactList = new Div(); + impactList.getElement().setProperty("innerHTML", + "
    " + + "
  • " + + "The stored token is permanently deleted from the system.
  • " + + "
  • " + + "You will no longer be able to add access-restricted datasets " + + "from " + instance.shortName() + " to your projects.
  • " + + "
  • " + + "Already-connected restricted datasets remain in the project but " + + "can no longer be synced.
  • " + + "
"); + impactList.addClassName("extra-small-body-text"); + impactBox.add(impactList); + + body.add(impactBox); + + var reversalNote = new Span( + "You can reconfigure this repository at any time by adding a new token."); + reversalNote.addClassName("extra-small-body-text"); + reversalNote.addClassName("color-secondary"); + reversalNote.getStyle().set("display", "block"); + body.add(reversalNote); + + dialog.add(body); + + // Footer buttons + var removeBtn = new Button("Remove Token", VaadinIcon.TRASH.create(), + e -> { + configuredCredentials.remove(instance.id()); + dialog.close(); + refreshCredentialsContent(); + showSuccessNotification("Repository connection removed for " + + instance.shortName() + + ". Access-restricted datasets from this instance " + + "are no longer available."); + }); + removeBtn.addThemeVariants( + ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_PRIMARY); + + var cancelBtn = new Button("Cancel", e -> dialog.close()); + cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + dialog.getFooter().add(cancelBtn, removeBtn); + dialog.open(); + } + + // ── Credentials seed data ───────────────────────────────────────────── + + /** + * Seeds the demo with Zenodo pre-configured (token present) and FDAT + * not configured, so the prototype shows both card states immediately. + */ + private void seedCredentials() { + configuredCredentials.put("zenodo", + new RepositoryCredential("zenodo", + "a1b2\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022c3d4", + LocalDate.of(2025, 3, 10))); + // FDAT is intentionally left unconfigured for demo contrast + } + + // ── Error notification helper ────────────────────────────────────────── + + private void showErrorNotification(String message) { + var notification = new Notification(message, 4000); + notification.addClassName("error-toast"); + notification.setPosition(Notification.Position.BOTTOM_END); + notification.open(); + } +} 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..dea2f43dd9 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 @@ -19,6 +19,7 @@ import com.vaadin.flow.data.provider.CallbackDataProvider.FetchCallback; import com.vaadin.flow.data.value.ValueChangeMode; import com.vaadin.flow.router.Route; +import com.vaadin.flow.router.RouteParameters; import com.vaadin.flow.server.auth.AnonymousAllowed; import com.vaadin.flow.spring.annotation.UIScope; import java.util.ArrayList; @@ -552,48 +553,173 @@ private static Div dialogShowCase(AppDialog dialog, String dialogType) { } private Div toastShowCase() { - var title = new Div("Toast it!"); + var title = new Div("Toast Notifications"); title.addClassName(HEADING_2); var description = new Div( - "Let's see how toasts work and also how to use them when we want to indicate a background task to the user."); + "Demonstrates all toast notification types with the new dark-mode design system. Includes success, info, error toasts with icons, progress toasts with action buttons, and routing toasts."); description.addClassName(NORMAL_BODY_TEXT); - var content = new Div(); - content.addClassNames(FLEX_VERTICAL, GAP_04); - content.add(title); - content.add(description); + var container = new Div(); + container.addClassNames("border", "padding-04"); - var button = new Button("Show Toast"); + var grid = new Div(); + grid.addClassNames("flex-horizontal"); + grid.addClassName("gap-05"); - content.add(button); + // --- Column 1: Basic Toasts --- + var col1 = new Div(); + col1.addClassNames("flex-vertical", "gap-03", "width-250px"); - button.addClickListener(e -> - { - var progressBar = new ProgressBar(); - progressBar.setIndeterminate(true); - var toast = messageFactory.pendingTaskToast("task.in-progress", - new Object[]{"Doing something really heavy here"}, getLocale()); - var succeededToast = messageFactory.toast("task.finished", new Object[]{"Heavy Task #1"}, + var col1Header = new Div("Basic Toasts"); + col1Header.addClassName(HEADING_3); + col1.add(col1Header); + + // Success toast + var successToastBtn = new Button("Success Toast"); + successToastBtn.addClickListener(e -> { + var toast = messageFactory.toast("experiment.created.success", + new Object[]{"My New Experiment"}, getLocale()); + toast.open(); + }); + col1.add(successToastBtn); + + // Info toast with routing + var infoToastBtn = new Button("Info Toast + Route"); + infoToastBtn.addClickListener(e -> { + var toast = messageFactory.routingToast("from.experiment.to.sample.batch", + new Object[]{}, new Object[]{}, + life.qbic.datamanager.views.projects.project.samples.SampleInformationMain.class, + new RouteParameters("projectId", "test-project"), getLocale()); toast.open(); + }); + col1.add(infoToastBtn); + + // Error toast + var errorToastBtn = new Button("Error Toast"); + errorToastBtn.addClickListener(e -> { + var toast = messageFactory.toast("project.created.error", new Object[]{}, getLocale()); + toast.open(); + }); + col1.add(errorToastBtn); + + // --- Column 2: Action & Progress Toasts --- + var col2 = new Div(); + col2.addClassNames("flex-vertical", "gap-03", "width-250px"); + + var col2Header = new Div("Action & Progress Toasts"); + col2Header.addClassName(HEADING_3); + col2.add(col2Header); + + // Error toast with action (retry) + var actionToastBtn = new Button("Error + Retry"); + actionToastBtn.addClickListener(e -> { + var toast = messageFactory.actionToast( + "project.updated.error.retry", + new Object[]{}, + "Retry", + buttonClick -> { + UI.getCurrent().getPage().executeJs("console.log('Retry action clicked!')"); + }, + getLocale()); + toast.open(); + }); + col2.add(actionToastBtn); + + // Pending task toast (progress) + var pendingToastBtn = new Button("Pending Task (Progress)"); + pendingToastBtn.addClickListener(e -> { + var toast = messageFactory.pendingTaskToast("task.in-progress", + new Object[]{"Registering 50 samples"}, getLocale()); + toast.open(); var ui = UI.getCurrent(); CompletableFuture.runAsync(() -> { try { Thread.sleep(5000); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + }).thenRunAsync(() -> { + ui.access(() -> { + toast.close(); + var success = messageFactory.toast("sample-batch.registered.success", + new Object[]{"Registration Batch #1"}, getLocale()); + success.open(); + }); + }); + }); + col2.add(pendingToastBtn); + // Measurement in-progress with subtext + var measureToastBtn = new Button("Measurement Registration"); + measureToastBtn.addClickListener(e -> { + var toast = messageFactory.pendingTaskToast("measurement.registration.in-progress", + new Object[]{}, getLocale()); + toast.open(); + var ui = UI.getCurrent(); + CompletableFuture.runAsync(() -> { + try { + Thread.sleep(6000); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } }).thenRunAsync(() -> { ui.access(() -> { - toast.close(); - succeededToast.open(); - } - ); + toast.close(); + var success = messageFactory.toast("measurement.registration.successful", + new Object[]{42}, getLocale()); + success.open(); + }); }); }); - content.add(button); - return content; + col2.add(measureToastBtn); + + // --- Column 3: Individual Toast Tests --- + var col3 = new Div(); + col3.addClassNames("flex-vertical", "gap-03", "width-250px"); + + var col3Header = new Div("Toast Variants"); + col3Header.addClassName(HEADING_3); + col3.add(col3Header); + + // Short success toast + var shortSuccessBtn = new Button("Short Success (5s)"); + shortSuccessBtn.addClickListener(e -> { + var toast = messageFactory.toast("experimental.groups.created.success", + new Object[]{}, getLocale()); + toast.open(); + }); + col3.add(shortSuccessBtn); + + // Personal access token success (8s duration) + var patToastBtn = new Button("PAT Created (8s)"); + patToastBtn.addClickListener(e -> { + var toast = messageFactory.toast("personal-access-token.created.success", + new Object[]{"my-token-12345"}, getLocale()); + toast.open(); + }); + col3.add(patToastBtn); + + // Measurement failure toast + var measureFailToastBtn = new Button("Measurement Upload Failed"); + measureFailToastBtn.addClickListener(e -> { + var toast = messageFactory.toast("measurement.upload.failed", + new Object[]{}, getLocale()); + toast.open(); + }); + col3.add(measureFailToastBtn); + + // Profile link success + var profileToastBtn = new Button("Profile Linked"); + profileToastBtn.addClickListener(e -> { + var toast = messageFactory.toast("profile.oidc.link.success", new Object[]{}, getLocale()); + toast.open(); + }); + col3.add(profileToastBtn); + + grid.add(col1, col2, col3); + container.add(title, description, grid); + return container; } private static Div detailBoxShowCase() { 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/general/DataSetTagFactory.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/DataSetTagFactory.java new file mode 100644 index 0000000000..948de0def0 --- /dev/null +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/DataSetTagFactory.java @@ -0,0 +1,78 @@ +package life.qbic.datamanager.views.general; + +/** + * Factory for dataset-related {@link Tag} components. + * + *

Centralized here so the connected-resources list and the + * connect-datasets sidebar (and any future surfaces) share the same + * visual language without duplicating the logic.

+ * + *
    + *
  • Provider tags use a single neutral color + * ({@link Tag.TagColor#CONTRAST}) regardless of which provider they + * represent — color would imply a hierarchy or status that providers + * don't carry, and per-provider coloring does not scale as more + * repositories are added.
  • + *
  • Dataset type tags also use {@link Tag.TagColor#CONTRAST} + * so they share the neutral visual weight of the provider badge.
  • + *
  • Access type tags encode the public/restricted status + * with semantic color: {@link Tag.TagColor#SUCCESS} for public, + * {@link Tag.TagColor#WARNING} for restricted.
  • + *
+ * + *

This factory is purely a UI-layer concern and does not depend on + * any domain or application classes. The caller is responsible for + * resolving domain-level representations (e.g. {@code AccessLevel}) + * into simple values (e.g. a boolean) before delegating here.

+ * + * @since 1.12.0 + */ +public final class DataSetTagFactory { + + public enum TagType { + DATA_SET_TYPE, + PROVIDER, + ACCESS_TYPE + } + + private DataSetTagFactory() {} + + /** + * Creates a tag for the given type and display text. + * + * @param type the tag category + * @param text the display text (e.g. provider name, resource type) + * @return a styled {@link Tag} ready to add to the DOM + */ + public static Tag create(TagType type, String text) { + var tag = new Tag(text != null ? text : ""); + tag.setTagColor(Tag.TagColor.CONTRAST); + return tag; + } + + /** + * Creates an access-level tag. + * + *

{@code isPublic == true} renders with {@link Tag.TagColor#SUCCESS} + * (label "Public"); {@code false} renders with + * {@link Tag.TagColor#WARNING} (label "Restricted").

+ * + * @param type must be {@link TagType#ACCESS_TYPE} + * @param isPublic {@code true} for public access, {@code false} for + * restricted + * @return a styled {@link Tag} ready to add to the DOM + * @throws IllegalArgumentException if {@code type} is not + * {@link TagType#ACCESS_TYPE} + */ + public static Tag create(TagType type, boolean isPublic) { + if (type != TagType.ACCESS_TYPE) { + throw new IllegalArgumentException( + "boolean overload only supports TagType.ACCESS_TYPE"); + } + String label = isPublic ? "Public" : "Restricted"; + Tag.TagColor color = isPublic ? Tag.TagColor.SUCCESS : Tag.TagColor.WARNING; + var tag = new Tag(label); + tag.setTagColor(color); + return tag; + } +} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/dialog/AlertDialog.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/dialog/AlertDialog.java new file mode 100644 index 0000000000..e90dbf7bf6 --- /dev/null +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/dialog/AlertDialog.java @@ -0,0 +1,338 @@ +package life.qbic.datamanager.views.general.dialog; + +import com.vaadin.flow.component.Component; +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 java.util.Objects; +import life.qbic.datamanager.views.general.icon.IconFactory; + +/** + * Alert Dialog + * + *

A centralized dialog for confirming destructive actions and showing + * warnings or errors. Implements the UX designer's Alert Dialog pattern:

+ *
    + *
  • Minimal content — icon, title, short description
  • + *
  • Intent-driven styling — icon colour and confirm-button style + * reflect severity ({@link Intent})
  • + *
  • 1–2 actions maximum (cancel + confirm, or confirm-only)
  • + *
  • No complex layouts or scrolling
  • + *
+ * + *

Uses {@link AppDialog#small()} as its underlying dialog. Icon is + * styled via {@link IconFactory} for the WARNING intent and + * {@link VaadinIcon} for ERROR/INFO. The danger-style confirm button + * leverages the existing {@code .button-danger} CSS class and + * {@link DialogFooter#withDangerousConfirm}.

+ * + *

Two entry points:

+ *
{@code
+ * // Shortcut for the most common case: danger-intent confirm dialog
+ * AlertDialog.danger(parent, "Remove dataset?", "This will disconnect…",
+ *     "Disconnect dataset", "Keep connection", onRemove).open();
+ *
+ * // Fluent builder for full control
+ * AlertDialog.alert(parent)
+ *     .danger()
+ *     .title("Remove dataset?")
+ *     .message("This will disconnect…")
+ *     .confirmButton("Disconnect dataset", onRemove)
+ *     .cancelButton("Keep connection", onCancel)
+ *     .build().open();
+ * }
+ * + *

When to use:

+ *
    + *
  • {@code AlertDialog} — for action confirmation (destructive, + * irreversible ops). Uses {@link AppDialog} which has the + * {@code BeforeLeaveObserver} pattern for unsaved-changes protection.
  • + *
  • {@code MessageSourceNotificationFactory.dialog()} — for + * informational pop-ups (warnings, errors, info notices + * that don't require a destructive action).
  • + *
+ * + * @since 1.12.0 + */ +public class AlertDialog { + + /** + * Indicates the severity and visual treatment of an {@linkplain AlertDialog alert}. + */ + public enum Intent { + /** Destructive action (red confirm button). */ + DANGER, + /** Non-destructive but important confirmation. */ + WARNING, + /** Something went wrong. */ + ERROR, + /** Informational notice. */ + INFO + } + + /** + * Builder for constructing {@link AlertDialog}s with explicit control + * over intent, title, message, and actions. + * + * @since 1.12.0 + */ + public static class Builder { + + private Intent intent = Intent.WARNING; + private String title; + private String message; + private String confirmLabel; + private DialogAction confirmAction; + private String cancelLabel; + private DialogAction cancelAction; + + private Builder() { + } + + /** + * Sets the intent explicitly. + */ + public Builder intent(Intent intent) { + this.intent = Objects.requireNonNull(intent, "intent must not be null"); + return this; + } + + /** Shorthand for {@code intent(Intent.DANGER)}. */ + public Builder danger() { + return intent(Intent.DANGER); + } + + /** Shorthand for {@code intent(Intent.WARNING)}. */ + public Builder warning() { + return intent(Intent.WARNING); + } + + /** Shorthand for {@code intent(Intent.ERROR)}. */ + public Builder error() { + return intent(Intent.ERROR); + } + + /** Shorthand for {@code intent(Intent.INFO)}. */ + public Builder info() { + return intent(Intent.INFO); + } + + /** Sets the dialog title (header text next to the icon). */ + public Builder title(String title) { + this.title = Objects.requireNonNull(title, "title must not be null"); + return this; + } + + /** Sets the body message shown below the title. */ + public Builder message(String message) { + this.message = Objects.requireNonNull(message, "message must not be null"); + return this; + } + + /** + * Configures the confirm button label and action. At least one call + * to {@link #confirmButton(String, DialogAction)} is required. + * + * @throws NullPointerException if either argument is null + */ + public Builder confirmButton(String label, DialogAction action) { + this.confirmLabel = Objects.requireNonNull(label, "confirm label must not be null"); + this.confirmAction = Objects.requireNonNull(action, "confirm action must not be null"); + return this; + } + + /** + * Configures the cancel button label and action. Optional — omitting + * this produces a confirm-only dialog. + * + * @throws NullPointerException if either argument is null + */ + public Builder cancelButton(String label, DialogAction action) { + this.cancelLabel = Objects.requireNonNull(label, "cancel label must not be null"); + this.cancelAction = Objects.requireNonNull(action, "cancel action must not be null"); + return this; + } + + /** + * Builds the {@link AlertDialog}, composing the underlying + * {@link AppDialog} with all configured properties. + * + * @throws IllegalStateException if no confirm button has been set + */ + public AlertDialog build() { + Objects.requireNonNull(confirmLabel, + "confirmButton(label, action) must be set before building"); + Objects.requireNonNull(confirmAction, + "confirmButton(label, action) must be set before building"); + + var dialog = AppDialog.small(); + + // Icon — intent-driven colour via IconFactory for WARNING/DANGER, + // colour-neutral for ERROR/INFO. + var icon = resolveIcon(); + + DialogHeader.withIcon(dialog, title, icon); + + // Body — message text wrapped in a span so Vaadin does not interpret + // HTML; the existing dialog styling handles typography. + var messageDiv = new Div(); + messageDiv.add(new Span(message)); + DialogBody.withoutUserInput(dialog, messageDiv); + + // Footer — DANGER and WARNING intents get a red confirm button via the + // withDangerousConfirm helper; other intents use the default primary + // style. If no cancel action is set, the builder skips the cancel + // button (single-action confirmation). + if ((intent == Intent.DANGER || intent == Intent.WARNING) && cancelLabel != null && cancelAction != null) { + DialogFooter.withDangerousConfirm(dialog, cancelLabel, confirmLabel); + } else if (cancelLabel != null && cancelAction != null) { + DialogFooter.with(dialog, cancelLabel, confirmLabel); + } else { + DialogFooter.withConfirmOnly(dialog, confirmLabel); + } + + // Actions + dialog.registerConfirmAction(() -> { + dialog.close(); + confirmAction.execute(); + }); + if (cancelAction != null) { + dialog.registerCancelAction(() -> { + dialog.close(); + cancelAction.execute(); + }); + } + + return new AlertDialog(dialog); + } + + /** + * Selects the intent-appropriate icon. WARNING and DANGER share the + * same orange exclamation-circle icon (from {@link IconFactory}); + * ERROR and INFO use semantically distinct icons. + */ + private Icon resolveIcon() { + return switch (intent) { + case DANGER, WARNING -> IconFactory.warningIcon(); + case ERROR -> { + var icon = VaadinIcon.CLOSE_CIRCLE.create(); + icon.addClassName("icon-color-warning"); + icon.addClassName("icon-size-m"); + yield icon; + } + case INFO -> { + var icon = VaadinIcon.INFO_CIRCLE.create(); + icon.addClassName("icon-size-m"); + yield icon; + } + }; + } + } + + private final AppDialog dialog; + + private AlertDialog(AppDialog dialog) { + this.dialog = Objects.requireNonNull(dialog, "dialog must not be null"); + } + + /** + * Opens the dialog (modal on top of the current UI). + */ + public void open() { + dialog.open(); + } + + /** + * Closes the dialog without dispatching the confirm action. + */ + public void close() { + dialog.close(); + } + + /** + * Returns the underlying {@link AppDialog}, for cases where the + * caller needs a reference (e.g. to add a {@code BeforeLeaveObserver} + * or attach to a specific component tree). + */ + public AppDialog dialog() { + return dialog; + } + + // ── Static factory method: Builder entry point ──────────────────────────── + + /** + * Entry point for the fluent builder. Call this first, then chain + * {@code .danger()}/{@code .title()}/{@code .message()}/etc., ending + * with {@link Builder#build()}. + * + *

The {@code parent} parameter is reserved for future use (e.g. + * overlay parent). Not used currently but required by the design for + * explicit ownership.

+ */ + public static Builder alert(Component parent) { + Objects.requireNonNull(parent, "parent must not be null"); + return new Builder(); + } + + // ── Static factory: danger-intent shortcut ──────────────────────────────── + + /** + * Builds and returns a pre-configured {@link AlertDialog} for the + * destructive-action pattern: orange warning icon, red "danger" + * confirm button, a cancel button labelled "Cancel" that simply + * closes the dialog, and a body message with the given text. + * + *

The {@code confirmLabel} should describe the destructive action + * in a concise, action-oriented verb (e.g. "Remove user", + * "Disconnect dataset", "Delete offer") so the user knows exactly + * what clicking it will do. The {@code cancelLabel} should describe + * the outcome of dismissing the confirm (e.g. "Keep user", + * "Keep connection", "Keep offer") so both buttons clearly state + * their respective outcomes. This avoids generic single-word labels + * and minimises cognitive load. + * The cancel action only closes the dialog (no external side-effect). + * If the caller needs a custom cancel handler, use + * {@link #alert(Component)} and {@code .cancelButton(...)} explicitly.

+ * + *

Usage:

+ *
{@code
+   *   AlertDialog.danger(this,
+   *       "Remove dataset connection?",
+   *       "This will disconnect the dataset from the project.",
+   *       "Disconnect dataset",
+   *       "Keep connection",
+   *       () -> performRemove())
+   *       .open();
+   * }
+ * + * @param parent the component that owns this dialog (for UI tree + * attachment) + * @param title the header text next to the icon + * @param message the body description + * @param confirmLabel a concise, action-oriented label for the confirm + * button (e.g. "Remove user", "Delete offer", + * "Disconnect dataset") + * @param cancelLabel a label describing the outcome of cancelling + * (e.g. "Keep user", "Keep offer", + * "Keep connection") + * @param onConfirm the action invoked when the user confirms + * @return a ready-to-{@code open()} AlertDialog + * @throws NullPointerException if any argument is null + */ + public static AlertDialog danger( + Component parent, + String title, + String message, + String confirmLabel, + String cancelLabel, + DialogAction onConfirm) { + return alert(parent) + .danger() + .title(title) + .message(message) + .confirmButton(confirmLabel, onConfirm) + .cancelButton(cancelLabel, () -> { /* dismiss only */ }) + .build(); + } +} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/dialog/AppDialog.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/dialog/AppDialog.java index c2282c9346..abd375bc82 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/dialog/AppDialog.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/dialog/AppDialog.java @@ -103,8 +103,8 @@ private static AppDialog createConfirmDialog(DialogAction onConfirmAction) { IconFactory.warningIcon()); DialogBody.withoutUserInput(confirmDialog, new Div( "By aborting the editing process and closing the dialog, you will lose all information entered.")); - life.qbic.datamanager.views.general.dialog.DialogFooter.with(confirmDialog, "Continue editing", - "Discard changes"); + life.qbic.datamanager.views.general.dialog.DialogFooter.withDangerousConfirm(confirmDialog, + "Keep editing", "Discard changes"); confirmDialog.registerConfirmAction(() -> { confirmDialog.close(); onConfirmAction.execute(); diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/navigation/ProjectSideNavigationComponent.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/navigation/ProjectSideNavigationComponent.java index fca98352d5..e6c0527b96 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/navigation/ProjectSideNavigationComponent.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/navigation/ProjectSideNavigationComponent.java @@ -32,9 +32,8 @@ import life.qbic.datamanager.security.UserPermissions; import life.qbic.datamanager.views.AppRoutes.ProjectRoutes; import life.qbic.datamanager.views.Context; -import life.qbic.datamanager.views.notifications.CancelConfirmationDialogFactory; +import life.qbic.datamanager.views.general.dialog.AlertDialog; import life.qbic.datamanager.views.notifications.MessageSourceNotificationFactory; -import life.qbic.datamanager.views.notifications.NotificationDialog; import life.qbic.datamanager.views.notifications.Toast; import life.qbic.datamanager.views.projects.overview.ProjectOverviewMain; import life.qbic.datamanager.views.projects.project.ProjectMainLayout; @@ -79,7 +78,6 @@ public class ProjectSideNavigationComponent extends Div implements private final transient ExperimentInformationService experimentInformationService; private final transient AddExperimentToProjectService addExperimentToProjectService; private final transient UserPermissions userPermissions; - private final transient CancelConfirmationDialogFactory cancelConfirmationDialogFactory; private final transient MessageSourceNotificationFactory messageSourceNotificationFactory; private final transient TerminologyService terminologyService; private final transient SpeciesLookupService speciesLookupService; @@ -92,7 +90,6 @@ public ProjectSideNavigationComponent( UserPermissions userPermissions, SpeciesLookupService speciesLookupService, TerminologyService terminologyService, - CancelConfirmationDialogFactory cancelConfirmationDialogFactory, MessageSourceNotificationFactory messageSourceNotificationFactory) { content = new Div(); requireNonNull(projectInformationService); @@ -100,8 +97,6 @@ public ProjectSideNavigationComponent( requireNonNull(addExperimentToProjectService); this.messageSourceNotificationFactory = requireNonNull(messageSourceNotificationFactory, "messageSourceNotificationFactory must not be null"); - this.cancelConfirmationDialogFactory = requireNonNull(cancelConfirmationDialogFactory, - "cancelConfirmationDialogFactory must not be null"); this.speciesLookupService = speciesLookupService; this.addExperimentToProjectService = addExperimentToProjectService; this.userPermissions = requireNonNull(userPermissions, "userPermissions must not be null"); @@ -186,6 +181,7 @@ private static Span generateSectionDivider() { private static Div createProjectItems(String projectId, boolean canUserAdministrate) { Div projectItems = new Div(); projectItems.add(createProjectSummaryLink(projectId)); + projectItems.add(createProjectDatasetsLink(projectId)); if (canUserAdministrate) { projectItems.add(createProjectUsers(projectId)); } @@ -200,6 +196,11 @@ private static SideNavItem createProjectSummaryLink(String projectId) { projectSummaryPath, VaadinIcon.DEINDENT.create()); } + private static SideNavItem createProjectDatasetsLink(String projectId) { + String datasetsPath = String.format(ProjectRoutes.DATASETS, projectId); + return new SideNavItem("DATASETS", datasetsPath, VaadinIcon.DATABASE.create()); + } + private static SideNavItem createProjectUsers(String projectId) { String projectUsersPath = String.format(ProjectRoutes.ACCESS, projectId); return new SideNavItem("USERS", projectUsersPath, @@ -334,12 +335,14 @@ private void showAddExperimentDialog() { } private void showCancelConfirmationDialog(AddExperimentDialog creationDialog) { - NotificationDialog confirmationDialog = cancelConfirmationDialogFactory.cancelConfirmationDialog( - it -> creationDialog.close(), - "experiment.create", - getLocale() - ); - confirmationDialog.open(); + AlertDialog.alert(this) + .warning() + .title("Discard changes?") + .message("By aborting the editing process and closing the dialog, you will lose all information entered.") + .confirmButton("Discard changes", creationDialog::close) + .cancelButton("Keep editing", () -> {}) + .build() + .open(); } private void onExperimentAddEvent(ExperimentAddEvent event) { diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/notifications/CancelConfirmationDialogFactory.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/notifications/CancelConfirmationDialogFactory.java deleted file mode 100644 index 0bd3895cf8..0000000000 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/notifications/CancelConfirmationDialogFactory.java +++ /dev/null @@ -1,113 +0,0 @@ -package life.qbic.datamanager.views.notifications; - -import com.vaadin.flow.component.Html; -import com.vaadin.flow.component.button.Button; -import com.vaadin.flow.component.confirmdialog.ConfirmDialog.ConfirmEvent; -import com.vaadin.flow.component.html.Span; -import java.util.Locale; -import java.util.function.Consumer; -import life.qbic.logging.api.Logger; -import life.qbic.logging.service.LoggerFactory; -import org.springframework.context.MessageSource; -import org.springframework.context.NoSuchMessageException; -import org.springframework.stereotype.Component; - -@Component -public class CancelConfirmationDialogFactory { - - private static final MessageType DEFAULT_MESSAGE_TYPE = MessageType.HTML; //set to html as text works with it too - private static final String DEFAULT_TITLE = "Discard Changes?"; - private static final String DEFAULT_CONTENT = "By aborting the editing process and closing the dialog, you will lose all information entered."; - private static final String DEFAULT_CONFIRM_TEXT = "Discard Changes"; - private static final Object[] EMPTY_PARAMETERS = new Object[]{}; - private static final Logger log = LoggerFactory.logger(CancelConfirmationDialogFactory.class); - private final MessageSource messageSource; - - - public CancelConfirmationDialogFactory(MessageSource messageSource) { - this.messageSource = messageSource; - } - - private static com.vaadin.flow.component.Component createContentComponent(MessageType contentType, - String contentText) { - return switch (contentType) { - case HTML -> new Html("
%s
".formatted(contentText)); - case TEXT -> new Span(contentText); - }; - } - - /** - * Creates an instance of an {@link NotificationDialog} with title and message based on the - * provided key. - * - * @param key The message key to determine the content and title of the {@link NotificationDialog}. - * @param locale - * @return the notification dialog - * @since - */ - public NotificationDialog cancelConfirmationDialog(String key, Locale locale) { - return buildDialog(key, locale); - } - - public NotificationDialog cancelConfirmationDialog(Consumer onCancelConfirmed, - String key, Locale locale) { - var confirmCancelDialog = buildDialog(key, locale); - confirmCancelDialog.addCancelListener(event -> event.getSource().close()); - - confirmCancelDialog.addConfirmListener(event -> { - event.getSource().close(); - onCancelConfirmed.accept(event); - }); - return confirmCancelDialog; - } - - private NotificationDialog buildDialog(String key, Locale locale) { - String title = parseTitle(key, locale); - MessageType contentType = parseMessageType(key, locale); - String contentText = parseMessageText(key, locale); - String confirmText = parseConfirmText(key, locale); - var content = createContentComponent(contentType, contentText); - - NotificationDialog confirmCancelDialog = NotificationDialog.warningDialog() - .withTitle(title) - .withContent(content); - confirmCancelDialog.setCancelable(true); - confirmCancelDialog.setCancelText("Continue Editing"); - Button redButton = new Button(confirmText); - redButton.addClassName("danger"); - confirmCancelDialog.setConfirmButton(redButton); - return confirmCancelDialog; - } - - private String parseConfirmText(String key, Locale locale) { - return messageSource.getMessage("%s.cancel-confirmation.confirm-text".formatted(key), - new Object[]{}, DEFAULT_CONFIRM_TEXT, locale); - } - - private String parseMessageText(String key, Locale locale) { - return messageSource.getMessage("%s.cancel-confirmation.message.text".formatted(key), - new Object[]{}, DEFAULT_CONTENT, locale); - } - - private String parseTitle(String key, Locale locale) { - return messageSource.getMessage("%s.cancel-confirmation.title".formatted(key), - new Object[]{}, DEFAULT_TITLE, locale); - } - - private MessageType parseMessageType(String key, Locale locale) { - try { - String messageType = messageSource.getMessage( - "%s.cancel-confirmation.message.type".formatted(key), - EMPTY_PARAMETERS, locale).strip().toUpperCase(); - return MessageType.valueOf(messageType); - } catch (NoSuchMessageException e) { - log.warn("No message type specified for %s.".formatted(key)); - return DEFAULT_MESSAGE_TYPE; - } - } - - private enum MessageType { - HTML, - TEXT - } -} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/notifications/MessageSourceNotificationFactory.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/notifications/MessageSourceNotificationFactory.java index e29baf3e49..ef98f5519a 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/notifications/MessageSourceNotificationFactory.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/notifications/MessageSourceNotificationFactory.java @@ -66,7 +66,7 @@ public Toast toast(String key, Object[] parameters, Locale locale) { String messageText = parseMessage(key, parameters, locale); Component content = switch (type) { - case HTML -> new Html("
%s
".formatted(messageText)); + case HTML -> new Html("
%s
".formatted(messageText)); case TEXT -> new Span(messageText); }; @@ -74,6 +74,7 @@ public Toast toast(String key, Object[] parameters, Locale locale) { Duration duration = parseDuration(key, locale).orElse(Toast.DEFAULT_OPEN_DURATION); Toast toast = new Toast(level); + toast.withLevelIcon(level); toast.withContent(content); toast.setDuration(duration); @@ -115,11 +116,11 @@ public Toast routingToast(String key, Object[] messageArgs, Object[] routeArgs, * {@link Duration#ZERO}, since it is the client's job to close the toast explicitly after the * pending task has finished. *

- * The following message keys have to be present: + * The toast uses the progress layout (no icon) with: *

    - *
  • {@code .message.type} - *
  • {@code .message.text} - *
  • {@code .routing.link.text} + *
  • Title (from message.text) in 18px bold
  • + *
  • Indeterminate progress bar
  • + *
  • Optional subtext (from message.subtext) in 16px regular
  • *
*

* For more information please see toast-notifications.properties @@ -127,55 +128,62 @@ public Toast routingToast(String key, Object[] messageArgs, Object[] routeArgs, * @param key the key for the messages * @param messageArgs the parameters shown in the message * @param locale the locale for which to load the message - * @return a Toast with loaded content - * @see #toast(String, Object[], Locale) + * @return a Toast with progress indicator */ public Toast pendingTaskToast(String key, Object[] messageArgs, Locale locale) { - var toast = toast(key, messageArgs, locale); + String messageText = parseMessage(key, messageArgs, locale); + NotificationLevel level = parseLevel(key, locale); + + Toast toast = new Toast(level); + toast.withTitle(messageText); + parseSubtext(key, locale).ifPresent(toast::withSubtext); + var progressBar = new ProgressBar(); progressBar.setIndeterminate(true); + toast.withProgressBar(progressBar); toast.setDuration(Duration.ZERO); - return toast.add(progressBar); + + return toast; } /** - * Creates a dialog notification with the contents found for the message key. This method produces - * a notification dialog with the link text read from the message properties file. - * + * Creates a toast with an action button (e.g., "Retry", "Try Again") for error scenarios. + * Useful for error toasts where the user can take corrective action. *

- * The following message keys have to be present: + * The toast includes: *

    - *
  • {@code .title} - the title; optional - *
  • {@code .level} - the level; mandatory - *
  • {@code .message.type} - the type (text or html); mandatory - *
  • {@code .message.text} - the text; mandatory - *
  • {@code .confirm-text} - the text of the confirm button; optional + *
  • Error icon (red close-circle)
  • + *
  • Error message
  • + *
  • Action button (styled with #66A8FF)
  • + *
  • Close button
  • *
- *

- * For more information please see dialog-notifications.properties * - * @param key the key for the messages - * @param parameters parameters to use in the message - * @param locale the locale for which to load the message - * @return a notification dialog with loaded content + * @param key the key for the messages + * @param parameters the parameters shown in the message + * @param actionLabel the action button label (e.g., "Try Again") + * @param actionListener the click handler for the action button + * @param locale the locale for which to load the message + * @return a Toast with an action button */ - public NotificationDialog dialog(String key, Object[] parameters, Locale locale) { - MessageType type = parseMessageType(key, locale); - String messageText = parseMessage(key, parameters, locale); - Component content = switch (type) { - case HTML -> new Html("

%s
".formatted(messageText)); - case TEXT -> new Span(messageText); - }; - - NotificationLevel level = parseLevel(key, locale); - NotificationDialog notificationDialog = new NotificationDialog(level) - .withContent(content); - parseTitle(key, locale).ifPresent(notificationDialog::withTitle); - parseConfirmText(key, locale).ifPresentOrElse( - notificationDialog::setConfirmText, - () -> notificationDialog.setConfirmText(DEFAULT_CONFIRM_TEXT)); + public Toast actionToast(String key, Object[] parameters, String actionLabel, + com.vaadin.flow.component.ComponentEventListener> actionListener, + Locale locale) { + Toast toast = toast(key, parameters, locale); + toast.withAction(actionLabel, actionListener); + return toast; + } - return notificationDialog; + /** + * Parses an optional subtext key for progress toasts. + */ + private Optional parseSubtext(String key, Locale locale) { + try { + return Optional.of(messageSource.getMessage("%s.message.subtext".formatted(key), + new Object[]{}, locale).strip()); + } catch (NoSuchMessageException e) { + log.debug("No subtext specified for %s.message.subtext".formatted(key)); + return Optional.empty(); + } } private NotificationLevel parseLevel(String key, Locale locale) { @@ -222,16 +230,6 @@ private MessageType parseMessageType(String key, Locale locale) { } - private Optional parseTitle(String key, Locale locale) { - try { - return Optional.of(messageSource.getMessage("%s.title".formatted(key), - EMPTY_PARAMETERS, locale).strip()); - } catch (NoSuchMessageException e) { - log.warn("No title specified for %s.title".formatted(key)); - return Optional.empty(); - } - } - private Optional parseDuration(String key, Locale locale) { String durationProperty = messageSource.getMessage("%s.duration".formatted(key), EMPTY_PARAMETERS, null, locale); @@ -257,11 +255,6 @@ private String parseLinkText(String key, Object[] routeArgs, Locale locale) { return linkText; } - private Optional parseConfirmText(String key, Locale locale) { - return Optional.ofNullable(messageSource.getMessage("%s.confirm-text".formatted(key), - new Object[]{}, null, locale)); - } - private enum MessageType { HTML, TEXT diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/notifications/NotificationDialog.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/notifications/NotificationDialog.java deleted file mode 100644 index 8f3a9b1e3b..0000000000 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/notifications/NotificationDialog.java +++ /dev/null @@ -1,200 +0,0 @@ -package life.qbic.datamanager.views.notifications; - -import static java.util.Objects.requireNonNull; - -import com.vaadin.flow.component.Component; -import com.vaadin.flow.component.confirmdialog.ConfirmDialog; -import com.vaadin.flow.component.html.Div; -import com.vaadin.flow.component.html.H2; -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.dom.Style.Display; -import com.vaadin.flow.router.BeforeLeaveEvent; -import com.vaadin.flow.router.BeforeLeaveObserver; - -/** - * A dialog notifying the user of some event. - *

- * By default, this dialog comes with an icon and a text in the header. You can modify the header - * text with {@link #withTitle}. When setting the icon with {@link #withHeaderIcon}, you can modify - * the color of the icon by assigning the following css classes: - *

    - *
  • success-icon
  • - *
  • warning-icon
  • - *
  • error-icon
  • - *
  • info-icon
  • - *
- * The dialog itself is assigned a corresponding CSS class - *
    - *
  • success-dialog
  • - *
  • warning-dialog
  • - *
  • error-dialog
  • - *
  • info-dialog
  • - *
- */ -public class NotificationDialog extends ConfirmDialog implements BeforeLeaveObserver { - - private final H2 title; - private final NotificationLevel level; - protected final Div layout; - private Icon headerIcon; - protected Component content; - - protected NotificationDialog(NotificationLevel level) { - addClassName("notification-dialog"); - addClassName(switch (level) { - case SUCCESS -> "success-dialog"; - case WARNING -> "warning-dialog"; - case ERROR -> "error-dialog"; - case INFO -> "info-dialog"; - }); - this.level = requireNonNull(level, "level must not be null"); - - var defaultTitle = switch (level) { - case SUCCESS -> "Success"; - case WARNING -> "Warning"; - case ERROR -> "Error"; - case INFO -> "Please note"; - }; - title = new H2(defaultTitle); - title.addClassName("title"); - withHeaderIcon(typeBasedHeaderIcon(this.level)); - updateHeader(); - - layout = new Div(); - layout.addClassName("content"); - this.content = new Div(); - layout.add(this.content); - setText(layout); - setConfirmText("Okay"); - } - - protected static Icon typeBasedHeaderIcon(NotificationLevel notificationLevel) { - var iconCssClass = switch (notificationLevel) { - case SUCCESS -> "success-icon"; - case WARNING -> "warning-icon"; - case ERROR -> "error-icon"; - case INFO -> "info-icon"; - }; - var icon = switch (notificationLevel) { - case SUCCESS -> VaadinIcon.CHECK.create(); - case WARNING -> VaadinIcon.WARNING.create(); - case ERROR -> VaadinIcon.CLOSE_CIRCLE.create(); - case INFO -> VaadinIcon.INFO_CIRCLE.create(); - }; - icon.addClassName(iconCssClass); - return icon; - } - - /** - * Creates a new notification dialog - * - * @return a notification dialog showing a success notification - */ - public static NotificationDialog successDialog() { - return new NotificationDialog(NotificationLevel.SUCCESS); - } - - /** - * Creates a new notification dialog - * - * @return a notification dialog showing a warning notification - */ - public static NotificationDialog warningDialog() { - return new NotificationDialog(NotificationLevel.WARNING); - } - - /** - * Creates a new notification dialog - * - * @return a notification dialog showing an error notification - */ - public static NotificationDialog errorDialog() { - return new NotificationDialog(NotificationLevel.ERROR); - } - - /** - * Creates a new notification dialog - * - * @return a notification dialog showing an info notification - */ - public static NotificationDialog infoDialog() { - return new NotificationDialog(NotificationLevel.INFO); - } - - private void updateHeader() { - setHeader(new Span(headerIcon, title)); - } - - /** - * Changes the header icon to the icon specified. - * - * @param icon the icon to display in the header - * @param - * @return a modified notification dialog - */ - public T withHeaderIcon(Icon icon) { - this.headerIcon = icon; - updateHeader(); - return (T) this; - } - - /** - * Changes the title of the dialog. Does not touch the icon. - * - * @param text the title of the dialog - * @param - * @return a dialog with the provided title - */ - public T withTitle(String text) { - title.setText(text); - updateHeader(); - return (T) this; - } - - /** - * Sets the content of the dialog. - *

- * The content can be any {@link Component}. Previous content is removed from the dialog when - * calling this method. The content provided must not be null but can be any empty component. - * - * @param content the new content of the dialog - * @param - * @return the modified dialog - */ - public T withContent(Component content) { - if (this.content != null) { - this.content.removeFromParent(); - } - this.content = requireNonNull(content, "content must not be null"); - layout.removeAll(); - layout.add(this.content); - return (T) this; - } - - /** - * Sets the content of the dialog. - *

- * The content can be any number of {@link Component}s. At least one component is required. - *

- * Previous content is removed from the dialog when calling this method. - * - * @param content the new content of the dialog - * @param - * @return the modified dialog - */ - public T withContent(Component... content) { - if (content.length <= 0) { - throw new IllegalArgumentException("Content must have at least one element"); - } - Div hiddenCollectionDiv = new Div(content); - hiddenCollectionDiv.getStyle().setDisplay(Display.CONTENTS); - return withContent(hiddenCollectionDiv); - } - - @Override - public void beforeLeave(BeforeLeaveEvent event) { - this.close(); - } -} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/notifications/Toast.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/notifications/Toast.java index 6688b3819e..817be1e93a 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/notifications/Toast.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/notifications/Toast.java @@ -4,18 +4,24 @@ import static java.util.Objects.requireNonNull; import com.vaadin.flow.component.AttachEvent; +import com.vaadin.flow.component.ClickEvent; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.ComponentEventListener; import com.vaadin.flow.component.UI; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.Html; import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.progressbar.ProgressBar; import com.vaadin.flow.router.BeforeLeaveListener; import com.vaadin.flow.router.RouteParameters; import com.vaadin.flow.router.RouterLink; import com.vaadin.flow.shared.Registration; -import com.vaadin.flow.theme.lumo.LumoIcon; import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -33,6 +39,7 @@ */ public final class Toast extends Notification { + static final boolean DEFAULT_CLOSE_ON_NAVIGATION = true; static final Duration DEFAULT_OPEN_DURATION = Duration.ofSeconds(5); private static final Position DEFAULT_POSITION = Position.BOTTOM_START; @@ -40,7 +47,11 @@ public final class Toast extends Notification { private final Button closeButton; private Component content; - + private Component levelIcon; + private Component actionButton; + private ProgressBar progressBar; + private String title; + private String subtext; Toast(NotificationLevel level) { super(); @@ -54,33 +65,125 @@ public final class Toast extends Notification { setPosition(DEFAULT_POSITION); setDuration(DEFAULT_OPEN_DURATION); - closeButton = buttonClosing(this); - add(closeButton); + closeButton = createCloseButton(); closeOnNavigation(DEFAULT_CLOSE_ON_NAVIGATION); } - private static Button buttonClosing(Toast toast) { - var button = new Button(LumoIcon.CROSS.create()); + private Button createCloseButton() { + var icon = new Icon(); + icon.getElement().setAttribute("icon", "vaadin:close-small"); + icon.addClassName("close-icon"); + var button = new Button(icon); button.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE); - button.addClickListener(it -> toast.close()); button.addClassName("close-button"); + button.addClickListener(it -> close()); return button; } /** - * Creates a matching toast content containing routing components with the correct css classes. + * Sets an icon for the toast based on the notification level. Icons are 28x28px circular elements + * with a colored background and a white Lumo symbol centered inside. * - * @param content - * @param routingComponent - * @return + * @param level the notification level determining the icon color + * @return the modified toast */ - private static Component createRoutingContent(Component content, Component routingComponent) { - var container = new Div(); - container.addClassName("routing-container"); - content.addClassName("routing-content"); - routingComponent.addClassName("routing-link"); - container.add(content, routingComponent); - return container; + Toast withLevelIcon(NotificationLevel level) { + Component icon = getInfoForLevel(level); + + Div iconContainer = new Div(icon); + iconContainer.addClassName("toast-icon"); + iconContainer.addClassName(getIconLevelClass(level)); + iconContainer.getElement().setAttribute("aria-label", + "Notification status indicator"); + + this.levelIcon = iconContainer; + refresh(); + return this; + } + + private Component getInfoForLevel(NotificationLevel level) { + return switch (level) { + case SUCCESS -> { + var icon = new Icon(); + icon.getElement().setAttribute("icon", "vaadin:check"); + yield icon; + } + case ERROR, WARNING -> { + var icon = new Icon(); + icon.getElement().setAttribute("icon", "vaadin:close"); + yield icon; + } + case INFO -> { + var icon = new Icon(); + icon.getElement().setAttribute("icon", "vaadin:info"); + yield icon; + } + }; + } + + private String getIconLevelClass(NotificationLevel level) { + return switch (level) { + case SUCCESS -> "toast-icon-success"; + case ERROR, WARNING -> "toast-icon-error"; + case INFO -> "toast-icon-info"; + }; + } + + /** + * Sets a title for the toast (used in progress/indeterminate toasts). + * Title is displayed in 16px bold text above the main content. + * Supports HTML markup. + * + * @param title the title text (may contain HTML markup) + * @return the modified toast + */ + Toast withTitle(String title) { + requireNonNull(title, "title must not be null"); + this.title = title; + if (progressBar != null) { + refresh(); + } + return this; + } + + /** + * Sets subtext for the toast (used in progress/indeterminate toasts). + * Subtext is displayed in 16px regular text below the progress bar. + * Supports HTML markup. + * + * @param subtext the subtext (may contain HTML markup) + * @return the modified toast + */ + Toast withSubtext(String subtext) { + requireNonNull(subtext, "subtext must not be null"); + this.subtext = subtext; + if (progressBar != null) { + refresh(); + } + return this; + } + + /** + * Adds an action button to the toast (e.g., "Retry", "Try Again"). + * Action buttons are styled with the primary accent color (#66A8FF) and a subtle background. + * + * @param label the button label + * @param listener the click listener to invoke when the action button is clicked + * @return the modified toast + */ + Toast withAction(String label, ComponentEventListener> listener) { + requireNonNull(label, "label must not be null"); + requireNonNull(listener, "listener must not be null"); + var button = new Button(label); + button.addClassName("action-button"); + button.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + button.getElement().setAttribute("aria-label", label); + this.actionButton = button; + button.addClickListener(listener); + // Auto-close toast on action click + button.addClickListener(event -> close()); + refresh(); + return this; } /** @@ -121,29 +224,15 @@ public void setDuration(Duration duration) { } /** - * Expands the toast and includes a link to a specific route target. - * - * @param linkText The text of the link shown to the user - * @param navigationTarget the target of the navigation - * @param routeParameters the parameters used for navigation - * @return - */ - Toast withRouting(String linkText, Class navigationTarget, - RouteParameters routeParameters) { - return this.withContent(Toast.createRoutingContent(this.content, createRoutingComponent( - linkText, navigationTarget, routeParameters))); - } - - /** - * Sets the content of the toast + * Sets the content of the toast. + * Content is displayed in with the toast's primary text color. * * @param content the content of the toast to set. - * @return + * @return the modified toast */ Toast withContent(Component content) { requireNonNull(content, "content must not be null"); - //we only want to remove the current content from its parent if the parent is not the new content. - //Otherwise the current content would be removed from the new content as well. + // Remove current content from parent if different if (nonNull(this.content) && !ComponentFunctions.isParentOf(content, this.content)) { this.content.removeFromParent(); } @@ -153,9 +242,86 @@ Toast withContent(Component content) { return this; } + /** + * Adds a progress bar to the toast. When set, the toast enters progress mode: + * the center slot renders a vertical layout (title, progress bar, subtext) + * instead of the content component, and alignment switches to BASELINE. + *

+ * Composable with {@link #withTitle(String)} and {@link #withSubtext(String)} + * in any order. + * + * @param progressBar the progress bar component (should be indeterminate) + * @return the modified toast + */ + Toast withProgressBar(ProgressBar progressBar) { + requireNonNull(progressBar, "progressBar must not be null"); + this.progressBar = progressBar; + refresh(); + return this; + } + + /** + * Builds the vertical layout used in progress mode: title, progress bar, subtext. + */ + private Component buildProgressLayout() { + var barContainer = new Div(progressBar); + barContainer.addClassName("progress-bar-container"); + + var verticalLayout = new VerticalLayout(); + verticalLayout.addClassName("progress-vertical"); + verticalLayout.setSpacing(false); + verticalLayout.setPadding(false); + + if (title != null) { + var titleElement = new Html("" + title + ""); + verticalLayout.add(titleElement); + } + + verticalLayout.add(barContainer); + + if (subtext != null) { + var subtextElement = new Html("" + subtext + ""); + verticalLayout.add(subtextElement); + } + + return verticalLayout; + } + + /** + * Refreshes the toast content and layout. + *

+ * In progress mode (progress bar set), the center slot is a vertical layout + * built from title + progress bar + subtext, aligned at {@code BASELINE}. + * Otherwise the center slot is {@link #content}, aligned at {@code CENTER}. + *

+ * Builds: HorizontalLayout(icon?, center, actionButton?, closeButton) + */ private void refresh() { removeAll(); - add(this.content, this.closeButton); + var layout = new HorizontalLayout(); + layout.addClassName("toast-layout"); + + boolean isProgress = (progressBar != null); + layout.setAlignItems(isProgress ? Alignment.BASELINE : Alignment.CENTER); + + // Add level icon if present (stored separately from content) + if (levelIcon != null) { + layout.add(levelIcon); + } + + if (isProgress) { + layout.add(buildProgressLayout()); + } else if (this.content != null) { + layout.add(this.content); + } + + if (actionButton != null) { + layout.add(actionButton); + } + layout.add(closeButton); + layout.setSpacing(true); + layout.setPadding(false); + super.add(layout); } /** @@ -177,6 +343,40 @@ private Component createRoutingComponent(String text, return routerLink; } + /** + * Creates a matching toast content containing routing components with the correct css classes. + * + * @param content + * @param routingComponent + * @return + */ + private static Component createRoutingContent(Component content, Component routingComponent) { + var container = new Div(); + container.addClassName("routing-container"); + content.addClassName("routing-content"); + routingComponent.addClassName("routing-link"); + container.add(content, routingComponent); + return container; + } + + /** + * Expands the toast and includes a link to a specific route target. + * + * @param linkText The text of the link shown to the user + * @param navigationTarget the target of the navigation + * @param routeParameters the parameters used for navigation + * @return + */ + Toast withRouting(String linkText, Class navigationTarget, + RouteParameters routeParameters) { + var routingContent = createRoutingContent(this.content, createRoutingComponent( + linkText, navigationTarget, routeParameters)); + this.content.removeFromParent(); + this.content = routingContent; + refresh(); + return this; + } + /** * Adds a component to the {@link Toast}. If content already exists, the existing component is * taken and wrapped together with the new component in a {@link Div}, without extra formatting. diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/create/AddProjectDialog.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/create/AddProjectDialog.java index ccad715077..e64060a48a 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/create/AddProjectDialog.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/projects/create/AddProjectDialog.java @@ -23,9 +23,8 @@ import life.qbic.datamanager.views.general.QbicDialog; import life.qbic.datamanager.views.general.Stepper; import life.qbic.datamanager.views.general.Stepper.StepIndicator; +import life.qbic.datamanager.views.general.dialog.AlertDialog; import life.qbic.datamanager.views.general.funding.FundingEntry; -import life.qbic.datamanager.views.notifications.CancelConfirmationDialogFactory; -import life.qbic.datamanager.views.notifications.NotificationDialog; import life.qbic.datamanager.views.projects.create.CollaboratorsLayout.ProjectCollaborators; import life.qbic.datamanager.views.projects.create.ExperimentalInformationLayout.ExperimentalInformation; import life.qbic.datamanager.views.projects.create.ProjectDesignLayout.ProjectDesign; @@ -61,7 +60,6 @@ public class AddProjectDialog extends QbicDialog { private final Button nextButton; private final Map stepContent; - private final transient CancelConfirmationDialogFactory cancelConfirmationDialogFactory; private StepIndicator addStep(Stepper stepper, String label, Component layout) { stepContent.put(label, layout); @@ -71,8 +69,7 @@ private StepIndicator addStep(Stepper stepper, String label, Component layout) { public AddProjectDialog(ProjectInformationService projectInformationService, FinanceService financeService, PersonLookupService personLookupService, - SpeciesLookupService speciesLookupService, TerminologyService terminologyService, - CancelConfirmationDialogFactory cancelConfirmationDialogFactory) { + SpeciesLookupService speciesLookupService, TerminologyService terminologyService) { super(); addClassName("add-project-dialog"); @@ -81,8 +78,6 @@ public AddProjectDialog(ProjectInformationService projectInformationService, requireNonNull(personLookupService, "personLookupService must not be null"); requireNonNull(speciesLookupService, "ontologyTermInformationService must not be null"); - this.cancelConfirmationDialogFactory = requireNonNull(cancelConfirmationDialogFactory, - "cancelConfirmationDialogFactory must not be null"); this.projectDesignLayout = new ProjectDesignLayout(projectInformationService, financeService); this.fundingInformationLayout = new FundingInformationLayout(); this.collaboratorsLayout = new CollaboratorsLayout(personLookupService); @@ -147,9 +142,14 @@ public void enableOfferSearch() { } private void onCancelClicked() { - NotificationDialog cancelConfirmationDialog = cancelConfirmationDialogFactory.cancelConfirmationDialog( - it -> close(), "project.create", getLocale()); - cancelConfirmationDialog.open(); + AlertDialog.alert(this) + .warning() + .title("Discard changes?") + .message("By aborting the editing process and closing the dialog, you will lose all information entered.") + .confirmButton("Discard changes", () -> close()) + .cancelButton("Keep editing", () -> {}) + .build() + .open(); } private void onConfirmClicked(ClickEvent