From 7c0a5d7ea238a3796cf31189f473a306788dd64c Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Fri, 15 May 2026 14:29:56 +0000 Subject: [PATCH] signals: Add UC28-UC35 use cases for advanced Signal API features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each use case is a small product scenario where the signal API feature is the natural tool, not a pure API demo: - UC28 Server log viewer — EffectContext.isBackgroundChange() pulses for server-driven inserts but not for user dropdown edits. - UC29 Profile auto-save — Signal.untracked keeps audit attribution stable when admins are swapped mid-session; Signal.unboundEffect ships audit entries without holding the session lock. - UC30 Catalog filter — Signal.cached lets grid + count badge + export button share one filter pass over 1 000 products. - UC31 Seat reservation — ValueSignal.replace gives one winner per seat under concurrent claim; update(old + 1) keeps the day's reservation tally consistent under burst admin traffic. - UC32 Mutable vs immutable form — same onboarding form bound to a JPA-style Customer (modifier+setter) and immutable CustomerDto (updater+withX); modify/update each fire one notification. - UC33 Stable selection — id-based equality on the selection signal stops the details pane's open animation from replaying when the server pushes a status update to the already-selected order. - UC34 Feature flag service — FeatureFlagService bean exposes Signal via asReadonly(); flips go through service methods. - UC35 Kanban column — ListSignal.moveTo preserves entry signal identity through reorders; insertAllFirst/insertAllAt bulk-insert with a single change notification. HomeView lists the new category; styles.css gets a generic flash animation used by UC28. --- signals/package.json | 294 +++++++++--------- .../java/com/example/usecase28/LogEntry.java | 15 + .../com/example/usecase28/UseCase28View.java | 194 ++++++++++++ .../com/example/usecase29/AuditEntry.java | 8 + .../com/example/usecase29/UseCase29View.java | 195 ++++++++++++ .../com/example/usecase29/UserProfile.java | 18 ++ .../java/com/example/usecase30/Product.java | 7 + .../com/example/usecase30/ProductCatalog.java | 34 ++ .../com/example/usecase30/UseCase30View.java | 181 +++++++++++ .../com/example/usecase31/UseCase31View.java | 259 +++++++++++++++ .../java/com/example/usecase32/Customer.java | 44 +++ .../com/example/usecase32/CustomerDto.java | 23 ++ .../com/example/usecase32/UseCase32View.java | 195 ++++++++++++ .../java/com/example/usecase33/Order.java | 10 + .../com/example/usecase33/UseCase33View.java | 256 +++++++++++++++ .../example/usecase34/FeatureFlagService.java | 38 +++ .../com/example/usecase34/UseCase34View.java | 166 ++++++++++ .../main/java/com/example/usecase35/Card.java | 9 + .../com/example/usecase35/UseCase35View.java | 215 +++++++++++++ .../main/java/com/example/views/HomeView.java | 11 +- .../resources/META-INF/resources/styles.css | 9 + .../example/usecase28/UseCase28ViewTest.java | 88 ++++++ .../example/usecase29/UseCase29ViewTest.java | 111 +++++++ .../example/usecase30/UseCase30ViewTest.java | 107 +++++++ .../example/usecase31/UseCase31ViewTest.java | 100 ++++++ .../example/usecase32/UseCase32ViewTest.java | 104 +++++++ .../example/usecase33/UseCase33ViewTest.java | 107 +++++++ .../example/usecase34/UseCase34ViewTest.java | 126 ++++++++ .../example/usecase35/UseCase35ViewTest.java | 145 +++++++++ 29 files changed, 2918 insertions(+), 151 deletions(-) create mode 100644 signals/src/main/java/com/example/usecase28/LogEntry.java create mode 100644 signals/src/main/java/com/example/usecase28/UseCase28View.java create mode 100644 signals/src/main/java/com/example/usecase29/AuditEntry.java create mode 100644 signals/src/main/java/com/example/usecase29/UseCase29View.java create mode 100644 signals/src/main/java/com/example/usecase29/UserProfile.java create mode 100644 signals/src/main/java/com/example/usecase30/Product.java create mode 100644 signals/src/main/java/com/example/usecase30/ProductCatalog.java create mode 100644 signals/src/main/java/com/example/usecase30/UseCase30View.java create mode 100644 signals/src/main/java/com/example/usecase31/UseCase31View.java create mode 100644 signals/src/main/java/com/example/usecase32/Customer.java create mode 100644 signals/src/main/java/com/example/usecase32/CustomerDto.java create mode 100644 signals/src/main/java/com/example/usecase32/UseCase32View.java create mode 100644 signals/src/main/java/com/example/usecase33/Order.java create mode 100644 signals/src/main/java/com/example/usecase33/UseCase33View.java create mode 100644 signals/src/main/java/com/example/usecase34/FeatureFlagService.java create mode 100644 signals/src/main/java/com/example/usecase34/UseCase34View.java create mode 100644 signals/src/main/java/com/example/usecase35/Card.java create mode 100644 signals/src/main/java/com/example/usecase35/UseCase35View.java create mode 100644 signals/src/test/java/com/example/usecase28/UseCase28ViewTest.java create mode 100644 signals/src/test/java/com/example/usecase29/UseCase29ViewTest.java create mode 100644 signals/src/test/java/com/example/usecase30/UseCase30ViewTest.java create mode 100644 signals/src/test/java/com/example/usecase31/UseCase31ViewTest.java create mode 100644 signals/src/test/java/com/example/usecase32/UseCase32ViewTest.java create mode 100644 signals/src/test/java/com/example/usecase33/UseCase33ViewTest.java create mode 100644 signals/src/test/java/com/example/usecase34/UseCase34ViewTest.java create mode 100644 signals/src/test/java/com/example/usecase35/UseCase35ViewTest.java diff --git a/signals/package.json b/signals/package.json index e8566ee..bc8cc16 100644 --- a/signals/package.json +++ b/signals/package.json @@ -3,17 +3,17 @@ "license": "UNLICENSED", "type": "module", "dependencies": { - "@vaadin/aura": "25.2.0-alpha10", + "@vaadin/aura": "25.2.0-alpha12", "@vaadin/common-frontend": "0.0.22", - "@vaadin/react-components": "25.2.0-alpha10", - "@vaadin/react-components-pro": "25.2.0-alpha10", + "@vaadin/react-components": "25.2.0-alpha12", + "@vaadin/react-components-pro": "25.2.0-alpha12", "@vaadin/vaadin-development-mode-detector": "2.0.7", - "@vaadin/vaadin-lumo-styles": "25.2.0-alpha10", - "@vaadin/vaadin-themable-mixin": "25.2.0-alpha10", + "@vaadin/vaadin-lumo-styles": "25.2.0-alpha12", + "@vaadin/vaadin-themable-mixin": "25.2.0-alpha12", "@vaadin/vaadin-usage-statistics": "2.1.3", "date-fns": "4.1.0", "lit": "3.3.2", - "ol": "10.6.0", + "ol": "10.6.1", "proj4": "2.17.0", "react": "19.2.6", "react-dom": "19.2.6", @@ -43,17 +43,17 @@ }, "vaadin": { "dependencies": { - "@vaadin/aura": "25.2.0-alpha10", + "@vaadin/aura": "25.2.0-alpha12", "@vaadin/common-frontend": "0.0.22", - "@vaadin/react-components": "25.2.0-alpha10", - "@vaadin/react-components-pro": "25.2.0-alpha10", + "@vaadin/react-components": "25.2.0-alpha12", + "@vaadin/react-components-pro": "25.2.0-alpha12", "@vaadin/vaadin-development-mode-detector": "2.0.7", - "@vaadin/vaadin-lumo-styles": "25.2.0-alpha10", - "@vaadin/vaadin-themable-mixin": "25.2.0-alpha10", + "@vaadin/vaadin-lumo-styles": "25.2.0-alpha12", + "@vaadin/vaadin-themable-mixin": "25.2.0-alpha12", "@vaadin/vaadin-usage-statistics": "2.1.3", "date-fns": "4.1.0", "lit": "3.3.2", - "ol": "10.6.0", + "ol": "10.6.1", "proj4": "2.17.0", "react": "19.2.6", "react-dom": "19.2.6", @@ -81,84 +81,84 @@ "vite": "8.0.12", "vite-plugin-checker": "0.13.0" }, - "hash": "3ed2071337aac2431c20e231176ce93aecd97c1e285e70d06f8a2a672f1bf007", + "hash": "93d12508c7bffc595bf70a47e2fd9d5cf2b9ebdb1f1101cae1b31fee95838f5e", "overrides": { - "@vaadin/a11y-base": "25.2.0-alpha10", - "@vaadin/accordion": "25.2.0-alpha10", - "@vaadin/app-layout": "25.2.0-alpha10", + "@vaadin/a11y-base": "25.2.0-alpha12", + "@vaadin/accordion": "25.2.0-alpha12", + "@vaadin/app-layout": "25.2.0-alpha12", "@vaadin/aura": "$@vaadin/aura", - "@vaadin/avatar": "25.2.0-alpha10", - "@vaadin/avatar-group": "25.2.0-alpha10", - "@vaadin/badge": "25.2.0-alpha10", - "@vaadin/board": "25.2.0-alpha10", - "@vaadin/button": "25.2.0-alpha10", - "@vaadin/card": "25.2.0-alpha10", - "@vaadin/charts": "25.2.0-alpha10", - "@vaadin/checkbox": "25.2.0-alpha10", - "@vaadin/checkbox-group": "25.2.0-alpha10", - "@vaadin/combo-box": "25.2.0-alpha10", + "@vaadin/avatar": "25.2.0-alpha12", + "@vaadin/avatar-group": "25.2.0-alpha12", + "@vaadin/badge": "25.2.0-alpha12", + "@vaadin/board": "25.2.0-alpha12", + "@vaadin/button": "25.2.0-alpha12", + "@vaadin/card": "25.2.0-alpha12", + "@vaadin/charts": "25.2.0-alpha12", + "@vaadin/checkbox": "25.2.0-alpha12", + "@vaadin/checkbox-group": "25.2.0-alpha12", + "@vaadin/combo-box": "25.2.0-alpha12", "@vaadin/common-frontend": "$@vaadin/common-frontend", - "@vaadin/component-base": "25.2.0-alpha10", - "@vaadin/confirm-dialog": "25.2.0-alpha10", - "@vaadin/context-menu": "25.2.0-alpha10", - "@vaadin/crud": "25.2.0-alpha10", - "@vaadin/custom-field": "25.2.0-alpha10", - "@vaadin/dashboard": "25.2.0-alpha10", - "@vaadin/date-picker": "25.2.0-alpha10", - "@vaadin/date-time-picker": "25.2.0-alpha10", - "@vaadin/details": "25.2.0-alpha10", - "@vaadin/dialog": "25.2.0-alpha10", - "@vaadin/email-field": "25.2.0-alpha10", - "@vaadin/field-base": "25.2.0-alpha10", - "@vaadin/field-highlighter": "25.2.0-alpha10", - "@vaadin/form-layout": "25.2.0-alpha10", - "@vaadin/grid": "25.2.0-alpha10", - "@vaadin/grid-pro": "25.2.0-alpha10", - "@vaadin/horizontal-layout": "25.2.0-alpha10", - "@vaadin/icon": "25.2.0-alpha10", - "@vaadin/icons": "25.2.0-alpha10", - "@vaadin/input-container": "25.2.0-alpha10", - "@vaadin/integer-field": "25.2.0-alpha10", - "@vaadin/item": "25.2.0-alpha10", - "@vaadin/list-box": "25.2.0-alpha10", - "@vaadin/lit-renderer": "25.2.0-alpha10", - "@vaadin/login": "25.2.0-alpha10", - "@vaadin/map": "25.2.0-alpha10", - "@vaadin/markdown": "25.2.0-alpha10", - "@vaadin/master-detail-layout": "25.2.0-alpha10", - "@vaadin/menu-bar": "25.2.0-alpha10", - "@vaadin/message-input": "25.2.0-alpha10", - "@vaadin/message-list": "25.2.0-alpha10", - "@vaadin/multi-select-combo-box": "25.2.0-alpha10", - "@vaadin/notification": "25.2.0-alpha10", - "@vaadin/number-field": "25.2.0-alpha10", - "@vaadin/overlay": "25.2.0-alpha10", - "@vaadin/password-field": "25.2.0-alpha10", - "@vaadin/popover": "25.2.0-alpha10", - "@vaadin/progress-bar": "25.2.0-alpha10", - "@vaadin/radio-group": "25.2.0-alpha10", + "@vaadin/component-base": "25.2.0-alpha12", + "@vaadin/confirm-dialog": "25.2.0-alpha12", + "@vaadin/context-menu": "25.2.0-alpha12", + "@vaadin/crud": "25.2.0-alpha12", + "@vaadin/custom-field": "25.2.0-alpha12", + "@vaadin/dashboard": "25.2.0-alpha12", + "@vaadin/date-picker": "25.2.0-alpha12", + "@vaadin/date-time-picker": "25.2.0-alpha12", + "@vaadin/details": "25.2.0-alpha12", + "@vaadin/dialog": "25.2.0-alpha12", + "@vaadin/email-field": "25.2.0-alpha12", + "@vaadin/field-base": "25.2.0-alpha12", + "@vaadin/field-highlighter": "25.2.0-alpha12", + "@vaadin/form-layout": "25.2.0-alpha12", + "@vaadin/grid": "25.2.0-alpha12", + "@vaadin/grid-pro": "25.2.0-alpha12", + "@vaadin/horizontal-layout": "25.2.0-alpha12", + "@vaadin/icon": "25.2.0-alpha12", + "@vaadin/icons": "25.2.0-alpha12", + "@vaadin/input-container": "25.2.0-alpha12", + "@vaadin/integer-field": "25.2.0-alpha12", + "@vaadin/item": "25.2.0-alpha12", + "@vaadin/list-box": "25.2.0-alpha12", + "@vaadin/lit-renderer": "25.2.0-alpha12", + "@vaadin/login": "25.2.0-alpha12", + "@vaadin/map": "25.2.0-alpha12", + "@vaadin/markdown": "25.2.0-alpha12", + "@vaadin/master-detail-layout": "25.2.0-alpha12", + "@vaadin/menu-bar": "25.2.0-alpha12", + "@vaadin/message-input": "25.2.0-alpha12", + "@vaadin/message-list": "25.2.0-alpha12", + "@vaadin/multi-select-combo-box": "25.2.0-alpha12", + "@vaadin/notification": "25.2.0-alpha12", + "@vaadin/number-field": "25.2.0-alpha12", + "@vaadin/overlay": "25.2.0-alpha12", + "@vaadin/password-field": "25.2.0-alpha12", + "@vaadin/popover": "25.2.0-alpha12", + "@vaadin/progress-bar": "25.2.0-alpha12", + "@vaadin/radio-group": "25.2.0-alpha12", "@vaadin/react-components": "$@vaadin/react-components", "@vaadin/react-components-pro": "$@vaadin/react-components-pro", - "@vaadin/rich-text-editor": "25.2.0-alpha10", + "@vaadin/rich-text-editor": "25.2.0-alpha12", "@vaadin/router": "2.0.1", - "@vaadin/scroller": "25.2.0-alpha10", - "@vaadin/select": "25.2.0-alpha10", - "@vaadin/side-nav": "25.2.0-alpha10", - "@vaadin/slider": "25.2.0-alpha10", - "@vaadin/split-layout": "25.2.0-alpha10", - "@vaadin/tabs": "25.2.0-alpha10", - "@vaadin/tabsheet": "25.2.0-alpha10", - "@vaadin/text-area": "25.2.0-alpha10", - "@vaadin/text-field": "25.2.0-alpha10", - "@vaadin/time-picker": "25.2.0-alpha10", - "@vaadin/tooltip": "25.2.0-alpha10", - "@vaadin/upload": "25.2.0-alpha10", + "@vaadin/scroller": "25.2.0-alpha12", + "@vaadin/select": "25.2.0-alpha12", + "@vaadin/side-nav": "25.2.0-alpha12", + "@vaadin/slider": "25.2.0-alpha12", + "@vaadin/split-layout": "25.2.0-alpha12", + "@vaadin/tabs": "25.2.0-alpha12", + "@vaadin/tabsheet": "25.2.0-alpha12", + "@vaadin/text-area": "25.2.0-alpha12", + "@vaadin/text-field": "25.2.0-alpha12", + "@vaadin/time-picker": "25.2.0-alpha12", + "@vaadin/tooltip": "25.2.0-alpha12", + "@vaadin/upload": "25.2.0-alpha12", "@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector", "@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles", "@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin", "@vaadin/vaadin-usage-statistics": "$@vaadin/vaadin-usage-statistics", - "@vaadin/vertical-layout": "25.2.0-alpha10", - "@vaadin/virtual-list": "25.2.0-alpha10", + "@vaadin/vertical-layout": "25.2.0-alpha12", + "@vaadin/virtual-list": "25.2.0-alpha12", "date-fns": "$date-fns", "lit": "$lit", "ol": "$ol", @@ -185,72 +185,72 @@ "@vaadin/aura": "$@vaadin/aura", "ol": "$ol", "@vaadin/router": "2.0.1", - "@vaadin/a11y-base": "25.2.0-alpha10", - "@vaadin/accordion": "25.2.0-alpha10", - "@vaadin/app-layout": "25.2.0-alpha10", - "@vaadin/avatar": "25.2.0-alpha10", - "@vaadin/avatar-group": "25.2.0-alpha10", - "@vaadin/badge": "25.2.0-alpha10", - "@vaadin/button": "25.2.0-alpha10", - "@vaadin/card": "25.2.0-alpha10", - "@vaadin/checkbox": "25.2.0-alpha10", - "@vaadin/checkbox-group": "25.2.0-alpha10", - "@vaadin/combo-box": "25.2.0-alpha10", - "@vaadin/component-base": "25.2.0-alpha10", - "@vaadin/confirm-dialog": "25.2.0-alpha10", - "@vaadin/context-menu": "25.2.0-alpha10", - "@vaadin/custom-field": "25.2.0-alpha10", - "@vaadin/date-picker": "25.2.0-alpha10", - "@vaadin/date-time-picker": "25.2.0-alpha10", - "@vaadin/details": "25.2.0-alpha10", - "@vaadin/dialog": "25.2.0-alpha10", - "@vaadin/email-field": "25.2.0-alpha10", - "@vaadin/field-base": "25.2.0-alpha10", - "@vaadin/field-highlighter": "25.2.0-alpha10", - "@vaadin/form-layout": "25.2.0-alpha10", - "@vaadin/grid": "25.2.0-alpha10", - "@vaadin/horizontal-layout": "25.2.0-alpha10", - "@vaadin/icon": "25.2.0-alpha10", - "@vaadin/icons": "25.2.0-alpha10", - "@vaadin/input-container": "25.2.0-alpha10", - "@vaadin/integer-field": "25.2.0-alpha10", - "@vaadin/item": "25.2.0-alpha10", - "@vaadin/list-box": "25.2.0-alpha10", - "@vaadin/lit-renderer": "25.2.0-alpha10", - "@vaadin/login": "25.2.0-alpha10", - "@vaadin/markdown": "25.2.0-alpha10", - "@vaadin/master-detail-layout": "25.2.0-alpha10", - "@vaadin/menu-bar": "25.2.0-alpha10", - "@vaadin/message-input": "25.2.0-alpha10", - "@vaadin/message-list": "25.2.0-alpha10", - "@vaadin/multi-select-combo-box": "25.2.0-alpha10", - "@vaadin/notification": "25.2.0-alpha10", - "@vaadin/number-field": "25.2.0-alpha10", - "@vaadin/overlay": "25.2.0-alpha10", - "@vaadin/password-field": "25.2.0-alpha10", - "@vaadin/popover": "25.2.0-alpha10", - "@vaadin/progress-bar": "25.2.0-alpha10", - "@vaadin/radio-group": "25.2.0-alpha10", - "@vaadin/scroller": "25.2.0-alpha10", - "@vaadin/select": "25.2.0-alpha10", - "@vaadin/side-nav": "25.2.0-alpha10", - "@vaadin/slider": "25.2.0-alpha10", - "@vaadin/split-layout": "25.2.0-alpha10", - "@vaadin/tabs": "25.2.0-alpha10", - "@vaadin/tabsheet": "25.2.0-alpha10", - "@vaadin/text-area": "25.2.0-alpha10", - "@vaadin/text-field": "25.2.0-alpha10", - "@vaadin/time-picker": "25.2.0-alpha10", - "@vaadin/tooltip": "25.2.0-alpha10", - "@vaadin/upload": "25.2.0-alpha10", - "@vaadin/vertical-layout": "25.2.0-alpha10", - "@vaadin/virtual-list": "25.2.0-alpha10", - "@vaadin/board": "25.2.0-alpha10", - "@vaadin/charts": "25.2.0-alpha10", - "@vaadin/crud": "25.2.0-alpha10", - "@vaadin/dashboard": "25.2.0-alpha10", - "@vaadin/grid-pro": "25.2.0-alpha10", - "@vaadin/map": "25.2.0-alpha10", - "@vaadin/rich-text-editor": "25.2.0-alpha10" + "@vaadin/a11y-base": "25.2.0-alpha12", + "@vaadin/accordion": "25.2.0-alpha12", + "@vaadin/app-layout": "25.2.0-alpha12", + "@vaadin/avatar": "25.2.0-alpha12", + "@vaadin/avatar-group": "25.2.0-alpha12", + "@vaadin/badge": "25.2.0-alpha12", + "@vaadin/button": "25.2.0-alpha12", + "@vaadin/card": "25.2.0-alpha12", + "@vaadin/checkbox": "25.2.0-alpha12", + "@vaadin/checkbox-group": "25.2.0-alpha12", + "@vaadin/combo-box": "25.2.0-alpha12", + "@vaadin/component-base": "25.2.0-alpha12", + "@vaadin/confirm-dialog": "25.2.0-alpha12", + "@vaadin/context-menu": "25.2.0-alpha12", + "@vaadin/custom-field": "25.2.0-alpha12", + "@vaadin/date-picker": "25.2.0-alpha12", + "@vaadin/date-time-picker": "25.2.0-alpha12", + "@vaadin/details": "25.2.0-alpha12", + "@vaadin/dialog": "25.2.0-alpha12", + "@vaadin/email-field": "25.2.0-alpha12", + "@vaadin/field-base": "25.2.0-alpha12", + "@vaadin/field-highlighter": "25.2.0-alpha12", + "@vaadin/form-layout": "25.2.0-alpha12", + "@vaadin/grid": "25.2.0-alpha12", + "@vaadin/horizontal-layout": "25.2.0-alpha12", + "@vaadin/icon": "25.2.0-alpha12", + "@vaadin/icons": "25.2.0-alpha12", + "@vaadin/input-container": "25.2.0-alpha12", + "@vaadin/integer-field": "25.2.0-alpha12", + "@vaadin/item": "25.2.0-alpha12", + "@vaadin/list-box": "25.2.0-alpha12", + "@vaadin/lit-renderer": "25.2.0-alpha12", + "@vaadin/login": "25.2.0-alpha12", + "@vaadin/markdown": "25.2.0-alpha12", + "@vaadin/master-detail-layout": "25.2.0-alpha12", + "@vaadin/menu-bar": "25.2.0-alpha12", + "@vaadin/message-input": "25.2.0-alpha12", + "@vaadin/message-list": "25.2.0-alpha12", + "@vaadin/multi-select-combo-box": "25.2.0-alpha12", + "@vaadin/notification": "25.2.0-alpha12", + "@vaadin/number-field": "25.2.0-alpha12", + "@vaadin/overlay": "25.2.0-alpha12", + "@vaadin/password-field": "25.2.0-alpha12", + "@vaadin/popover": "25.2.0-alpha12", + "@vaadin/progress-bar": "25.2.0-alpha12", + "@vaadin/radio-group": "25.2.0-alpha12", + "@vaadin/scroller": "25.2.0-alpha12", + "@vaadin/select": "25.2.0-alpha12", + "@vaadin/side-nav": "25.2.0-alpha12", + "@vaadin/slider": "25.2.0-alpha12", + "@vaadin/split-layout": "25.2.0-alpha12", + "@vaadin/tabs": "25.2.0-alpha12", + "@vaadin/tabsheet": "25.2.0-alpha12", + "@vaadin/text-area": "25.2.0-alpha12", + "@vaadin/text-field": "25.2.0-alpha12", + "@vaadin/time-picker": "25.2.0-alpha12", + "@vaadin/tooltip": "25.2.0-alpha12", + "@vaadin/upload": "25.2.0-alpha12", + "@vaadin/vertical-layout": "25.2.0-alpha12", + "@vaadin/virtual-list": "25.2.0-alpha12", + "@vaadin/board": "25.2.0-alpha12", + "@vaadin/charts": "25.2.0-alpha12", + "@vaadin/crud": "25.2.0-alpha12", + "@vaadin/dashboard": "25.2.0-alpha12", + "@vaadin/grid-pro": "25.2.0-alpha12", + "@vaadin/map": "25.2.0-alpha12", + "@vaadin/rich-text-editor": "25.2.0-alpha12" } } \ No newline at end of file diff --git a/signals/src/main/java/com/example/usecase28/LogEntry.java b/signals/src/main/java/com/example/usecase28/LogEntry.java new file mode 100644 index 0000000..bcd1196 --- /dev/null +++ b/signals/src/main/java/com/example/usecase28/LogEntry.java @@ -0,0 +1,15 @@ +package com.example.usecase28; + +import java.io.Serializable; +import java.time.LocalTime; + +record LogEntry(LocalTime timestamp, String source, String message, + Severity severity) implements Serializable { + enum Severity { + INFO, WARN, ERROR + } + + LogEntry withSeverity(Severity newSeverity) { + return new LogEntry(timestamp, source, message, newSeverity); + } +} diff --git a/signals/src/main/java/com/example/usecase28/UseCase28View.java b/signals/src/main/java/com/example/usecase28/UseCase28View.java new file mode 100644 index 0000000..95b8e71 --- /dev/null +++ b/signals/src/main/java/com/example/usecase28/UseCase28View.java @@ -0,0 +1,194 @@ +package com.example.usecase28; + +import jakarta.annotation.security.PermitAll; + +import java.time.LocalTime; +import java.util.List; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +import com.example.usecase23.SchedulerService; +import com.example.usecase28.LogEntry.Severity; +import com.example.views.MainLayout; +import org.jspecify.annotations.Nullable; + +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.select.Select; +import com.vaadin.flow.router.Menu; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.signals.Signal; +import com.vaadin.flow.signals.local.ListSignal; +import com.vaadin.flow.signals.local.ValueSignal; + +/** + * Server log viewer. Lines arrive from a simulated background feed every second + * and pulse yellow when added. The viewer also lets a triage operator + * re-classify a line's severity through a dropdown; user-driven severity edits + * must NOT pulse, otherwise the operator can't distinguish "the server just + * sent me something new" from "I just changed something". + *

+ * The contextual effect uses + * {@link com.vaadin.flow.signals.EffectContext#isBackgroundChange()} to flash + * only on changes that originated outside the operator's request. + */ +@PageTitle("Use Case 28: Server log viewer") +@Route(value = "use-case-28", layout = MainLayout.class) +@Menu(order = 28, title = "UC 28: Server log viewer") +@PermitAll +public class UseCase28View extends VerticalLayout { + + private static final int MAX_ROWS = 20; + private static final String[] SOURCES = { "auth-svc", "payments-svc", + "scheduler", "ingest-worker", "edge-cache" }; + private static final String[] MESSAGES = { "Request completed in 42 ms", + "Token refreshed", "Retrying upstream call (attempt 2/3)", + "Cache miss, falling back to origin", + "Connection pool saturated, queueing", + "Payment captured for order #4821", "Worker heartbeat received" }; + + final ListSignal entries = new ListSignal<>(); + private final Random random = new Random(); + private @Nullable String taskId; + + public UseCase28View(SchedulerService schedulerService) { + setSpacing(true); + setPadding(true); + + add(new H2("Use Case 28: Server log viewer"), new Paragraph( + "Log lines arrive every second from a simulated server feed." + + " Each row pulses when it first appears AND when its" + + " severity is re-classified by the server — but" + + " never when the operator re-classifies it via the" + + " dropdown. A contextual effect uses" + + " EffectContext.isBackgroundChange() to tell the two" + + " apart.")); + + Div header = createHeaderRow(); + Div rows = new Div(); + rows.getStyle().set("display", "flex").set("flex-direction", "column"); + rows.bindChildren(entries, this::createRow); + + add(header, rows); + + // Seed a few initial lines so the operator has something to look at + // before the first scheduler tick. + for (int i = 0; i < 3; i++) { + entries.insertFirst(generateRandomEntry()); + } + + addAttachListener(event -> { + taskId = "uc28-log-feed-" + event.getUI().getUIId(); + // Mutation happens on the scheduler thread — the contextual + // effect will see this as a background change. + schedulerService.scheduleTask(taskId, this::pushServerLine, 1500, + 1500, TimeUnit.MILLISECONDS); + }); + addDetachListener(event -> { + if (taskId != null) { + schedulerService.cancelTask(taskId); + } + }); + } + + /** + * Simulates a server-driven log update. Either inserts a brand-new line or + * re-classifies an existing one's severity from the server side. + */ + void pushServerLine() { + List> current = entries.peek(); + boolean reclassify = !current.isEmpty() && random.nextInt(4) == 0; + if (reclassify) { + ValueSignal victim = current + .get(random.nextInt(current.size())); + LogEntry existing = victim.peek(); + Severity newSeverity = randomSeverity(); + if (newSeverity != existing.severity()) { + victim.set(existing.withSeverity(newSeverity)); + return; + } + } + entries.insertFirst(generateRandomEntry()); + // Trim to the latest MAX_ROWS lines. + List> after = entries.peek(); + if (after.size() > MAX_ROWS) { + for (int i = after.size() - 1; i >= MAX_ROWS; i--) { + entries.remove(after.get(i)); + } + } + } + + private LogEntry generateRandomEntry() { + return new LogEntry(LocalTime.now(), + SOURCES[random.nextInt(SOURCES.length)], + MESSAGES[random.nextInt(MESSAGES.length)], randomSeverity()); + } + + private Severity randomSeverity() { + int pick = random.nextInt(10); + if (pick < 7) { + return Severity.INFO; + } else if (pick < 9) { + return Severity.WARN; + } + return Severity.ERROR; + } + + private Div createHeaderRow() { + Div row = new Div(); + row.getStyle().set("display", "grid") + .set("grid-template-columns", "100px 130px 1fr 110px") + .set("gap", "var(--lumo-space-s)") + .set("padding", "var(--lumo-space-s) var(--lumo-space-m)") + .set("border-bottom", "2px solid var(--lumo-contrast-20pct)") + .set("font-weight", "bold") + .set("font-size", "var(--lumo-font-size-s)") + .set("color", "var(--lumo-secondary-text-color)"); + row.add(new Span("Time"), new Span("Source"), new Span("Message"), + new Span("Severity")); + return row; + } + + private Div createRow(ValueSignal entrySignal) { + Div row = new Div(); + row.getStyle().set("display", "grid") + .set("grid-template-columns", "100px 130px 1fr 110px") + .set("gap", "var(--lumo-space-s)").set("align-items", "center") + .set("padding", "var(--lumo-space-xs) var(--lumo-space-m)") + .set("border-bottom", "1px solid var(--lumo-contrast-10pct)"); + + Span time = new Span(); + time.bindText( + entrySignal.map(e -> e.timestamp().withNano(0).toString())); + time.getStyle().set("font-family", "monospace").set("color", + "var(--lumo-secondary-text-color)"); + + Span source = new Span(); + source.bindText(entrySignal.map(LogEntry::source)); + source.getStyle().set("font-family", "monospace"); + + Span message = new Span(); + message.bindText(entrySignal.map(LogEntry::message)); + + Select severitySelect = new Select<>(); + severitySelect.setItems(Severity.values()); + severitySelect.bindValue(entrySignal.map(LogEntry::severity), + entrySignal.updater(LogEntry::withSeverity)); + + // Contextual effect: only flash on changes that originated from the + // server feed (background), never on the operator's own dropdown edit. + Signal.effect(row, ctx -> { + entrySignal.get(); + if (ctx.isBackgroundChange()) { + row.getElement().flashClass("uc28-flash"); + } + }); + + row.add(time, source, message, severitySelect); + return row; + } +} diff --git a/signals/src/main/java/com/example/usecase29/AuditEntry.java b/signals/src/main/java/com/example/usecase29/AuditEntry.java new file mode 100644 index 0000000..78c8628 --- /dev/null +++ b/signals/src/main/java/com/example/usecase29/AuditEntry.java @@ -0,0 +1,8 @@ +package com.example.usecase29; + +import java.io.Serializable; +import java.time.LocalTime; + +record AuditEntry(LocalTime time, String admin, + String changeSummary) implements Serializable { +} diff --git a/signals/src/main/java/com/example/usecase29/UseCase29View.java b/signals/src/main/java/com/example/usecase29/UseCase29View.java new file mode 100644 index 0000000..5885157 --- /dev/null +++ b/signals/src/main/java/com/example/usecase29/UseCase29View.java @@ -0,0 +1,195 @@ +package com.example.usecase29; + +import jakarta.annotation.security.PermitAll; + +import java.time.LocalTime; +import java.util.concurrent.atomic.AtomicInteger; + +import com.example.views.MainLayout; + +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.H3; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.select.Select; +import com.vaadin.flow.component.textfield.TextArea; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.Menu; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.shared.Registration; +import com.vaadin.flow.signals.Signal; +import com.vaadin.flow.signals.local.ListSignal; +import com.vaadin.flow.signals.local.ValueSignal; + +/** + * Admin tool for editing a user's profile. Edits auto-save on every field + * change; each save creates an audit entry stamped with the current admin. + *

+ * The current admin is read via {@code Signal.untracked} inside the auto-save + * effect: that way, switching admins ("admin handoff during a shift change") + * does not retroactively replay every prior save under the new admin's name. + *

+ * A second {@code Signal.unboundEffect} acts as the "remote audit shipper": + * whenever new entries land in the local audit log, they're pushed to a remote + * service (simulated by an in-memory counter). The shipper runs without the + * session lock so a slow remote call doesn't block the operator's typing. + */ +@PageTitle("Use Case 29: Profile auto-save") +@Route(value = "use-case-29", layout = MainLayout.class) +@Menu(order = 29, title = "UC 29: Profile auto-save") +@PermitAll +public class UseCase29View extends VerticalLayout { + + final ValueSignal profile = new ValueSignal<>(new UserProfile( + "Bob Brown", "bob@example.com", "Backend engineer.")); + final ValueSignal currentAdmin = new ValueSignal<>("alice"); + final ListSignal auditLog = new ListSignal<>(); + final ValueSignal shippedToRemote = new ValueSignal<>(0); + final AtomicInteger shipperRuns = new AtomicInteger(); + + private final AtomicInteger remoteShipped = new AtomicInteger(); + private boolean firstAutosaveCall = true; + + public UseCase29View() { + setSpacing(true); + setPadding(true); + + add(new H2("Use Case 29: Profile auto-save"), new Paragraph( + "Edits to the profile auto-save and write to an audit log," + + " stamped with the current admin. Switching admins" + + " does NOT retroactively change history — the" + + " auto-save effect reads the admin via" + + " Signal.untracked, so it uses whatever admin is" + + " current at save time without subscribing to admin" + + " changes.")); + + // Admin switcher + Select adminSelect = new Select<>(); + adminSelect.setLabel("Current admin (impersonation)"); + adminSelect.setItems("alice", "bob", "carol"); + adminSelect.bindValue(currentAdmin, currentAdmin::set); + + // Profile form + TextField nameField = new TextField("Display name"); + nameField.bindValue(profile.map(UserProfile::name), + profile.updater(UserProfile::withName)); + TextField emailField = new TextField("Email"); + emailField.bindValue(profile.map(UserProfile::email), + profile.updater(UserProfile::withEmail)); + TextArea bioField = new TextArea("Bio"); + bioField.bindValue(profile.map(UserProfile::bio), + profile.updater(UserProfile::withBio)); + + HorizontalLayout topRow = new HorizontalLayout(adminSelect); + topRow.setWidthFull(); + + VerticalLayout form = new VerticalLayout(nameField, emailField, + bioField); + form.setPadding(false); + form.setSpacing(false); + form.getStyle().set("flex", "1"); + + Div sidePanel = buildSidePanel(); + sidePanel.getStyle().set("flex", "1"); + + HorizontalLayout columns = new HorizontalLayout(form, sidePanel); + columns.setWidthFull(); + + add(topRow, columns, buildExplanation()); + + // Auto-save effect: tracks profile; reads admin untracked. + Signal.effect(this, () -> { + UserProfile current = profile.get(); + if (firstAutosaveCall) { + // Skip the initial run — the form has just been opened. + firstAutosaveCall = false; + return; + } + String admin = Signal.untracked(currentAdmin::get); + auditLog.insertFirst(new AuditEntry(LocalTime.now(), admin, + "name=" + current.name() + " email=" + current.email())); + }); + + // Remote audit shipper: runs without the session lock. Pushes new + // audit entries to a remote service (simulated by an in-memory + // counter). + Registration shipper = Signal.unboundEffect(() -> { + shipperRuns.incrementAndGet(); + int currentSize = auditLog.get().size(); + int alreadyShipped = remoteShipped.get(); + if (currentSize > alreadyShipped) { + remoteShipped.set(currentSize); + shippedToRemote.set(currentSize); + } + }); + addDetachListener(e -> shipper.remove()); + } + + private Div buildSidePanel() { + Div panel = new Div(); + panel.getStyle().set("padding", "var(--lumo-space-m)") + .set("background-color", "var(--lumo-contrast-5pct)") + .set("border-radius", "8px"); + + H3 saveStats = new H3("Audit"); + saveStats.getStyle().set("margin-top", "0"); + + Span saveCount = new Span(); + saveCount.bindText(Signal.computed( + () -> "Local audit entries: " + auditLog.get().size())); + saveCount.getStyle().set("display", "block"); + + Span shippedCount = new Span(); + shippedCount + .bindText(shippedToRemote.map(n -> "Shipped to remote: " + n)); + shippedCount.getStyle().set("display", "block").set("color", + "var(--lumo-secondary-text-color)"); + + H3 logHeader = new H3("Recent saves"); + logHeader.getStyle().set("margin-top", "var(--lumo-space-m)"); + + Div logList = new Div(); + logList.getStyle().set("font-family", "monospace") + .set("font-size", "var(--lumo-font-size-s)") + .set("background-color", "var(--lumo-base-color)") + .set("padding", "var(--lumo-space-s)") + .set("border-radius", "4px").set("max-height", "240px") + .set("overflow-y", "auto"); + logList.bindChildren(auditLog, entry -> { + Div line = new Div(); + line.bindText(entry.map(a -> a.time().withNano(0) + " — admin " + + a.admin() + " — " + a.changeSummary())); + return line; + }); + + panel.add(saveStats, saveCount, shippedCount, logHeader, logList); + return panel; + } + + private Div buildExplanation() { + Div box = new Div(); + box.getStyle().set("background-color", "var(--lumo-contrast-5pct)") + .set("padding", "var(--lumo-space-m)") + .set("border-radius", "8px"); + + H3 title = new H3("Why these signal APIs?"); + title.getStyle().set("margin-top", "0"); + + Paragraph p = new Paragraph( + "Signal.untracked lets the auto-save effect READ the admin" + + " identity at save time without depending on it," + + " so a shift handover (admin dropdown change) does" + + " not retroactively replay past saves. " + + "Signal.unboundEffect runs the remote audit shipper" + + " without holding the session lock, so a slow" + + " network round-trip wouldn't freeze the operator's" + + " typing."); + + box.add(title, p); + return box; + } +} diff --git a/signals/src/main/java/com/example/usecase29/UserProfile.java b/signals/src/main/java/com/example/usecase29/UserProfile.java new file mode 100644 index 0000000..4d69c80 --- /dev/null +++ b/signals/src/main/java/com/example/usecase29/UserProfile.java @@ -0,0 +1,18 @@ +package com.example.usecase29; + +import java.io.Serializable; + +record UserProfile(String name, String email, + String bio) implements Serializable { + UserProfile withName(String name) { + return new UserProfile(name, email, bio); + } + + UserProfile withEmail(String email) { + return new UserProfile(name, email, bio); + } + + UserProfile withBio(String bio) { + return new UserProfile(name, email, bio); + } +} diff --git a/signals/src/main/java/com/example/usecase30/Product.java b/signals/src/main/java/com/example/usecase30/Product.java new file mode 100644 index 0000000..d909a29 --- /dev/null +++ b/signals/src/main/java/com/example/usecase30/Product.java @@ -0,0 +1,7 @@ +package com.example.usecase30; + +import java.io.Serializable; + +record Product(int id, String name, String category, + double price) implements Serializable { +} diff --git a/signals/src/main/java/com/example/usecase30/ProductCatalog.java b/signals/src/main/java/com/example/usecase30/ProductCatalog.java new file mode 100644 index 0000000..038a6db --- /dev/null +++ b/signals/src/main/java/com/example/usecase30/ProductCatalog.java @@ -0,0 +1,34 @@ +package com.example.usecase30; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +final class ProductCatalog { + + private static final String[] CATEGORIES = { "Audio", "Wearables", + "Cameras", "Laptops", "Phones" }; + private static final String[] ADJECTIVES = { "Pro", "Lite", "Max", "Mini", + "Studio", "Ultra", "Plus", "Air" }; + + private ProductCatalog() { + } + + static List generate(int size) { + Random random = new Random(42); + List products = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + String category = CATEGORIES[i % CATEGORIES.length]; + String adjective = ADJECTIVES[random.nextInt(ADJECTIVES.length)]; + double price = 20 + random.nextInt(1900) + random.nextDouble(); + products.add(new Product(i + 1, + category + " " + adjective + " #" + (i + 1), category, + Math.round(price * 100.0) / 100.0)); + } + return List.copyOf(products); + } + + static List categories() { + return List.of(CATEGORIES); + } +} diff --git a/signals/src/main/java/com/example/usecase30/UseCase30View.java b/signals/src/main/java/com/example/usecase30/UseCase30View.java new file mode 100644 index 0000000..e773d04 --- /dev/null +++ b/signals/src/main/java/com/example/usecase30/UseCase30View.java @@ -0,0 +1,181 @@ +package com.example.usecase30; + +import jakarta.annotation.security.PermitAll; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import com.example.views.MainLayout; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.H3; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.select.Select; +import com.vaadin.flow.component.textfield.NumberField; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.Menu; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.signals.Signal; +import com.vaadin.flow.signals.local.ValueSignal; + +/** + * Product catalog filter. 1 000 products are filtered by category, max price + * and name search. Three places consume the filtered list — the grid, the "X + * results" badge, and an "Export N products" button — so without caching, each + * keystroke would run the filter three times. + *

+ * Wrapping the computed filter signal in {@link Signal#cached(Signal)} makes + * the filter run once per input change and serves the same list to all three + * consumers. The "Filter computations" counter on screen proves it. + */ +@PageTitle("Use Case 30: Catalog filter") +@Route(value = "use-case-30", layout = MainLayout.class) +@Menu(order = 30, title = "UC 30: Catalog filter") +@PermitAll +public class UseCase30View extends VerticalLayout { + + private static final int PRODUCT_COUNT = 1000; + private static final String ANY_CATEGORY = "All categories"; + + private final List allProducts = ProductCatalog + .generate(PRODUCT_COUNT); + + final ValueSignal category = new ValueSignal<>(ANY_CATEGORY); + final ValueSignal maxPrice = new ValueSignal<>(5000.0); + final ValueSignal search = new ValueSignal<>(""); + final AtomicInteger filterComputations = new AtomicInteger(); + private final ValueSignal filterComputationsSignal = new ValueSignal<>( + 0); + + final Signal> visibleProducts = Signal + .cached(Signal.computed(() -> { + String cat = category.get(); + double max = maxPrice.get(); + String q = search.get().trim().toLowerCase(); + List result = allProducts.stream() + .filter(p -> ANY_CATEGORY.equals(cat) + || cat.equals(p.category())) + .filter(p -> p.price() <= max).filter(p -> q.isEmpty() + || p.name().toLowerCase().contains(q)) + .toList(); + filterComputationsSignal + .set(filterComputations.incrementAndGet()); + return result; + })); + + public UseCase30View() { + setSpacing(true); + setPadding(true); + + add(new H2("Use Case 30: Catalog filter"), new Paragraph( + "1 000 products are filtered by category, max price and" + + " name search. The filter result is consumed by the" + + " grid, the result-count badge and the export" + + " button — three subscribers reading the same" + + " computed value. Signal.cached makes the filter" + + " run once per input change instead of once per" + + " subscriber.")); + + add(buildFilterControls(), buildStatsRow(), buildGrid(), + buildExplanation()); + } + + private HorizontalLayout buildFilterControls() { + Select categorySelect = new Select<>(); + categorySelect.setLabel("Category"); + List options = new java.util.ArrayList<>(); + options.add(ANY_CATEGORY); + options.addAll(ProductCatalog.categories()); + categorySelect.setItems(options); + categorySelect.bindValue(category, category::set); + + NumberField maxPriceField = new NumberField("Max price"); + maxPriceField.bindValue(maxPrice, maxPrice::set); + + TextField searchField = new TextField("Search name"); + searchField.bindValue(search, search::set); + + HorizontalLayout row = new HorizontalLayout(categorySelect, + maxPriceField, searchField); + row.setAlignItems( + com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.BASELINE); + return row; + } + + private HorizontalLayout buildStatsRow() { + // Consumer #1: result-count badge + Span badge = new Span(); + badge.bindText(visibleProducts.map(list -> list.size() + " results")); + badge.getStyle() + .set("padding", "var(--lumo-space-xs) var(--lumo-space-m)") + .set("background-color", "var(--lumo-primary-color-10pct)") + .set("color", "var(--lumo-primary-text-color)") + .set("border-radius", "999px").set("font-weight", "bold"); + + // Consumer #2: export button + Button export = new Button(); + export.setText("Export 0 to CSV"); + Signal.effect(export, () -> { + int size = visibleProducts.get().size(); + export.setText("Export " + size + " to CSV"); + }); + export.addClickListener(e -> Notification.show( + "Exporting " + visibleProducts.peek().size() + " products")); + + // Computation counter — proves the cache works + Span runs = new Span(); + runs.bindText( + filterComputationsSignal.map(n -> "Filter computations: " + n)); + runs.getStyle().set("color", "var(--lumo-secondary-text-color)") + .set("font-size", "var(--lumo-font-size-s)"); + + HorizontalLayout row = new HorizontalLayout(badge, export, runs); + row.setAlignItems( + com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER); + return row; + } + + private Grid buildGrid() { + // Consumer #3: the grid + Grid grid = new Grid<>(Product.class, false); + grid.addColumn(Product::id).setHeader("ID").setWidth("70px") + .setFlexGrow(0); + grid.addColumn(Product::name).setHeader("Name"); + grid.addColumn(Product::category).setHeader("Category"); + grid.addColumn(p -> String.format("%.2f", p.price())).setHeader("Price") + .setWidth("100px").setFlexGrow(0); + grid.setHeight("400px"); + + Signal.effect(grid, () -> grid.setItems(visibleProducts.get())); + return grid; + } + + private Div buildExplanation() { + Div box = new Div(); + box.getStyle().set("background-color", "var(--lumo-contrast-5pct)") + .set("padding", "var(--lumo-space-m)") + .set("border-radius", "8px"); + + H3 title = new H3("Why Signal.cached?"); + title.getStyle().set("margin-top", "0"); + + Paragraph p = new Paragraph( + "The filter is read by three subscribers (grid, badge, export" + + " label). Without Signal.cached the lambda would" + + " re-run once per subscriber per dependency change" + + " — three full passes over 1 000 products on every" + + " keystroke. With caching, the computation counter" + + " advances exactly once per real input change."); + + box.add(title, p); + return box; + } +} diff --git a/signals/src/main/java/com/example/usecase31/UseCase31View.java b/signals/src/main/java/com/example/usecase31/UseCase31View.java new file mode 100644 index 0000000..d4a21b5 --- /dev/null +++ b/signals/src/main/java/com/example/usecase31/UseCase31View.java @@ -0,0 +1,259 @@ +package com.example.usecase31; + +import jakarta.annotation.security.PermitAll; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; + +import com.example.views.MainLayout; +import org.jspecify.annotations.Nullable; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.H3; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.Menu; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.signals.local.ValueSignal; + +/** + * Workshop seat assignment for an in-person event. Eight numbered seats can be + * claimed by attendees. When two devices try to claim the same seat at the same + * instant (a likely scenario when attendees scan a QR code on arrival), the + * {@code replace(null, name)} compare-and-set ensures exactly one wins; the + * other sees "already taken". + *

+ * A separate "reservations today" counter is incremented from multiple sources + * (admins, the QR endpoint, this view). {@code update(old -> old + 1)} runs the + * increment atomically under the signal lock so concurrent bumps from different + * threads don't lose updates. + */ +@PageTitle("Use Case 31: Seat reservation") +@Route(value = "use-case-31", layout = MainLayout.class) +@Menu(order = 31, title = "UC 31: Seat reservation") +@PermitAll +public class UseCase31View extends VerticalLayout { + + private static final int SEAT_COUNT = 8; + + final List> seats = new ArrayList<>(); + final ValueSignal reservationCount = new ValueSignal<>(0); + + public UseCase31View() { + setSpacing(true); + setPadding(true); + + for (int i = 0; i < SEAT_COUNT; i++) { + seats.add(new ValueSignal<@Nullable String>(null)); + } + + add(new H2("Use Case 31: Workshop seat assignment"), + new Paragraph( + "Each seat is a writable signal. Claims and releases" + + " use compare-and-set (replace), so two" + + " devices racing for the same seat at the" + + " same moment still produce exactly one" + + " winner. The 'reservations today' counter" + + " is shared across admin tools and" + + " incremented with update(old -> old + 1)" + + " for race-free counting.")); + + TextField nameField = new TextField("Your name"); + nameField.setValue("Attendee A"); + nameField.setWidth("220px"); + + add(nameField, buildSeatGrid(nameField), buildContendedClaim(), + buildReservationCounter(), buildExplanation()); + } + + private Div buildSeatGrid(TextField nameField) { + Div grid = new Div(); + grid.getStyle().set("display", "grid") + .set("grid-template-columns", "repeat(4, 1fr)") + .set("gap", "var(--lumo-space-s)"); + + for (int i = 0; i < SEAT_COUNT; i++) { + int seatNumber = i + 1; + ValueSignal<@Nullable String> seat = seats.get(i); + grid.add(buildSeatTile(seatNumber, seat, nameField)); + } + return grid; + } + + private Div buildSeatTile(int seatNumber, + ValueSignal<@Nullable String> seat, TextField nameField) { + Div tile = new Div(); + tile.getStyle().set("padding", "var(--lumo-space-m)") + .set("border-radius", "8px").set("text-align", "center") + .set("cursor", "pointer") + .set("border", "1px solid var(--lumo-contrast-20pct)"); + tile.getStyle().bind("background-color", + seat.map(occupant -> occupant == null + ? "var(--lumo-success-color-10pct)" + : "var(--lumo-error-color-10pct)")); + + Span title = new Span("Seat " + seatNumber); + title.getStyle().set("display", "block").set("font-weight", "bold"); + Span occupant = new Span(); + occupant.bindText(seat.map(o -> o == null ? "Available" : o)); + occupant.getStyle().set("display", "block").set("color", + "var(--lumo-secondary-text-color)"); + + tile.add(title, occupant); + tile.addClickListener(e -> { + String name = nameField.getValue(); + String current = seat.peek(); + if (current == null) { + if (name == null || name.isBlank()) { + Notification.show("Type your name first"); + return; + } + if (seat.replace(null, name)) { + reservationCount.update(n -> n + 1); + Notification.show("Claimed seat " + seatNumber); + } else { + Notification.show( + "Sorry — seat " + seatNumber + " was just taken"); + } + } else if (current.equals(name)) { + seat.replace(name, null); + Notification.show("Released seat " + seatNumber); + } else { + Notification + .show("Seat " + seatNumber + " is held by " + current); + } + }); + return tile; + } + + private Div buildContendedClaim() { + Div section = new Div(); + section.getStyle().set("background-color", "var(--lumo-contrast-5pct)") + .set("padding", "var(--lumo-space-m)") + .set("border-radius", "8px"); + + H3 title = new H3("Simulate 4 devices racing to claim seat 1"); + title.getStyle().set("margin-top", "0"); + + ValueSignal winners = new ValueSignal<>(0); + Span result = new Span(); + result.bindText(winners + .map(n -> "Last race result: " + n + " device claimed seat 1")); + + Button race = new Button("Reset seat 1 and have 4 devices try at once", + e -> { + ValueSignal<@Nullable String> seat = seats.get(0); + seat.set(null); + + AtomicInteger won = new AtomicInteger(); + CountDownLatch start = new CountDownLatch(1); + List threads = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + String attendee = "Device-" + i; + threads.add(Thread.ofVirtual().start(() -> { + try { + start.await(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return; + } + if (seat.replace(null, attendee)) { + won.incrementAndGet(); + reservationCount.update(n -> n + 1); + } + })); + } + start.countDown(); + for (Thread t : threads) { + try { + t.join(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + winners.set(won.get()); + }); + + section.add(title, race, result); + return section; + } + + private Div buildReservationCounter() { + Div section = new Div(); + section.getStyle().set("background-color", "var(--lumo-contrast-5pct)") + .set("padding", "var(--lumo-space-m)") + .set("border-radius", "8px"); + + H3 title = new H3("Reservations today"); + title.getStyle().set("margin-top", "0"); + + Span value = new Span(); + value.bindText(reservationCount.map(n -> "Total: " + n)); + value.getStyle().set("font-size", "var(--lumo-font-size-l)") + .set("font-weight", "bold") + .set("margin-right", "var(--lumo-space-m)"); + + Button burst = new Button( + "Simulate 6 admin tools registering walk-ins at once", e -> { + CountDownLatch start = new CountDownLatch(1); + List threads = new ArrayList<>(); + for (int i = 0; i < 6; i++) { + threads.add(Thread.ofVirtual().start(() -> { + try { + start.await(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return; + } + reservationCount.update(n -> n + 1); + })); + } + start.countDown(); + for (Thread t : threads) { + try { + t.join(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + }); + + HorizontalLayout row = new HorizontalLayout(value, burst); + row.setAlignItems( + com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.BASELINE); + section.add(title, row); + return section; + } + + private Div buildExplanation() { + Div box = new Div(); + box.getStyle().set("background-color", "var(--lumo-contrast-5pct)") + .set("padding", "var(--lumo-space-m)") + .set("border-radius", "8px"); + + H3 title = new H3("Why CAS + update?"); + title.getStyle().set("margin-top", "0"); + + Paragraph p = new Paragraph( + "replace(null, name) is the signal-native compare-and-set:" + + " it only writes if the seat is still empty," + + " so two simultaneous claims can't both succeed." + + " update(old -> old + 1) takes the signal lock" + + " for the duration of the increment, keeping the" + + " reservation counter consistent even when several" + + " admin tools are bumping it from different" + + " threads."); + + box.add(title, p); + return box; + } +} diff --git a/signals/src/main/java/com/example/usecase32/Customer.java b/signals/src/main/java/com/example/usecase32/Customer.java new file mode 100644 index 0000000..1cef472 --- /dev/null +++ b/signals/src/main/java/com/example/usecase32/Customer.java @@ -0,0 +1,44 @@ +package com.example.usecase32; + +import java.io.Serializable; + +/** + * Mutable customer bean — mirrors a typical JPA entity with setters that mutate + * in place. Used to demonstrate the {@code ValueSignal.modify} / + * {@code modifier()} pattern. + */ +class Customer implements Serializable { + private String name; + private String email; + private String plan; + + Customer(String name, String email, String plan) { + this.name = name; + this.email = email; + this.plan = plan; + } + + String getName() { + return name; + } + + void setName(String name) { + this.name = name; + } + + String getEmail() { + return email; + } + + void setEmail(String email) { + this.email = email; + } + + String getPlan() { + return plan; + } + + void setPlan(String plan) { + this.plan = plan; + } +} diff --git a/signals/src/main/java/com/example/usecase32/CustomerDto.java b/signals/src/main/java/com/example/usecase32/CustomerDto.java new file mode 100644 index 0000000..3654f37 --- /dev/null +++ b/signals/src/main/java/com/example/usecase32/CustomerDto.java @@ -0,0 +1,23 @@ +package com.example.usecase32; + +import java.io.Serializable; + +/** + * Immutable customer DTO — the API/wire representation with {@code withX} + * builders. Used to demonstrate the {@code ValueSignal.update} / + * {@code updater()} pattern. + */ +record CustomerDto(String name, String email, + String plan) implements Serializable { + CustomerDto withName(String name) { + return new CustomerDto(name, email, plan); + } + + CustomerDto withEmail(String email) { + return new CustomerDto(name, email, plan); + } + + CustomerDto withPlan(String plan) { + return new CustomerDto(name, email, plan); + } +} diff --git a/signals/src/main/java/com/example/usecase32/UseCase32View.java b/signals/src/main/java/com/example/usecase32/UseCase32View.java new file mode 100644 index 0000000..db5e546 --- /dev/null +++ b/signals/src/main/java/com/example/usecase32/UseCase32View.java @@ -0,0 +1,195 @@ +package com.example.usecase32; + +import jakarta.annotation.security.PermitAll; + +import java.util.concurrent.atomic.AtomicInteger; + +import com.example.views.MainLayout; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.H3; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.select.Select; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.Menu; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.signals.Signal; +import com.vaadin.flow.signals.local.ValueSignal; + +/** + * Customer onboarding form rendered against two backing representations of the + * same data: a mutable JPA-style {@link Customer} bean and an immutable + * {@link CustomerDto} record. Both forms behave identically to the user, + * proving that signal bindings work with either model. The "Import customer + * record" button shows how to apply a whole-object replacement atomically: + * {@code modify} on the mutable side and {@code update} on the immutable side + * each fire exactly one effect notification, even though three fields change. + */ +@PageTitle("Use Case 32: Mutable vs immutable form models") +@Route(value = "use-case-32", layout = MainLayout.class) +@Menu(order = 32, title = "UC 32: Mutable vs immutable") +@PermitAll +public class UseCase32View extends VerticalLayout { + + private static final String[] PLANS = { "Free", "Pro", "Enterprise" }; + + final ValueSignal entitySignal = new ValueSignal<>( + new Customer("Alice Anderson", "alice@example.com", "Free")); + final ValueSignal dtoSignal = new ValueSignal<>( + new CustomerDto("Alice Anderson", "alice@example.com", "Free")); + + private final AtomicInteger entityNotifications = new AtomicInteger(); + private final AtomicInteger dtoNotifications = new AtomicInteger(); + private final ValueSignal entityNotificationsSignal = new ValueSignal<>( + 0); + private final ValueSignal dtoNotificationsSignal = new ValueSignal<>( + 0); + + public UseCase32View() { + setSpacing(true); + setPadding(true); + + add(new H2("Use Case 32: Mutable vs immutable form models"), + new Paragraph( + "The same customer onboarding form rendered against" + + " two different storage models: a mutable" + + " JPA-style bean (left) and an immutable" + + " DTO record (right). Each form uses the" + + " matching signal-binding helper —" + + " modifier(setter) for the mutable bean," + + " updater(withX) for the immutable record." + + " Both feel identical to type in.")); + + HorizontalLayout panels = new HorizontalLayout(buildMutablePanel(), + buildImmutablePanel()); + panels.setWidthFull(); + + Button importBtn = new Button( + "Import customer record (atomic — fires one notification per side)", + e -> { + entitySignal.modify(c -> { + c.setName("Carol Chen"); + c.setEmail("carol@example.com"); + c.setPlan("Enterprise"); + }); + dtoSignal.update(prev -> new CustomerDto("Carol Chen", + "carol@example.com", "Enterprise")); + }); + + add(panels, importBtn, buildExplanation()); + } + + private Div buildMutablePanel() { + Div panel = new Div(); + panel.getStyle().set("flex", "1").set("padding", "var(--lumo-space-m)") + .set("background-color", "var(--lumo-contrast-5pct)") + .set("border-radius", "8px"); + + H3 header = new H3("Mutable Customer (JPA) + modifier(setter)"); + header.getStyle().set("margin-top", "0"); + + TextField name = new TextField("Name"); + name.bindValue(entitySignal.map(Customer::getName), + entitySignal.modifier(Customer::setName)); + TextField email = new TextField("Email"); + email.bindValue(entitySignal.map(Customer::getEmail), + entitySignal.modifier(Customer::setEmail)); + Select plan = new Select<>(); + plan.setLabel("Plan"); + plan.setItems(PLANS); + plan.bindValue(entitySignal.map(Customer::getPlan), + entitySignal.modifier(Customer::setPlan)); + + Span computedSummary = new Span(); + computedSummary.bindText(entitySignal.map(c -> "Summary: " + c.getName() + + " <" + c.getEmail() + "> [" + c.getPlan() + "]")); + + Span runs = new Span(); + runs.bindText(entityNotificationsSignal + .map(n -> "Effect notifications: " + n)); + runs.getStyle().set("display", "block") + .set("color", "var(--lumo-secondary-text-color)") + .set("font-size", "var(--lumo-font-size-s)"); + + Signal.effect(panel, () -> { + entitySignal.get(); + entityNotificationsSignal + .set(entityNotifications.incrementAndGet()); + }); + + panel.add(header, name, email, plan, computedSummary, runs); + return panel; + } + + private Div buildImmutablePanel() { + Div panel = new Div(); + panel.getStyle().set("flex", "1").set("padding", "var(--lumo-space-m)") + .set("background-color", "var(--lumo-contrast-5pct)") + .set("border-radius", "8px"); + + H3 header = new H3("Immutable CustomerDto + updater(withX)"); + header.getStyle().set("margin-top", "0"); + + TextField name = new TextField("Name"); + name.bindValue(dtoSignal.map(CustomerDto::name), + dtoSignal.updater(CustomerDto::withName)); + TextField email = new TextField("Email"); + email.bindValue(dtoSignal.map(CustomerDto::email), + dtoSignal.updater(CustomerDto::withEmail)); + Select plan = new Select<>(); + plan.setLabel("Plan"); + plan.setItems(PLANS); + plan.bindValue(dtoSignal.map(CustomerDto::plan), + dtoSignal.updater(CustomerDto::withPlan)); + + Span computedSummary = new Span(); + computedSummary.bindText(dtoSignal.map(c -> "Summary: " + c.name() + + " <" + c.email() + "> [" + c.plan() + "]")); + + Span runs = new Span(); + runs.bindText( + dtoNotificationsSignal.map(n -> "Effect notifications: " + n)); + runs.getStyle().set("display", "block") + .set("color", "var(--lumo-secondary-text-color)") + .set("font-size", "var(--lumo-font-size-s)"); + + Signal.effect(panel, () -> { + dtoSignal.get(); + dtoNotificationsSignal.set(dtoNotifications.incrementAndGet()); + }); + + panel.add(header, name, email, plan, computedSummary, runs); + return panel; + } + + private Div buildExplanation() { + Div box = new Div(); + box.getStyle().set("background-color", "var(--lumo-contrast-5pct)") + .set("padding", "var(--lumo-space-m)") + .set("border-radius", "8px"); + + H3 title = new H3("When to pick which?"); + title.getStyle().set("margin-top", "0"); + + Paragraph p = new Paragraph( + "Use modifier(setter) when the model is a mutable bean owned" + + " by a framework — JPA entities, JavaBeans inherited" + + " from an SDK, Lombok @Data classes. Use" + + " updater(withX) when the model is a record or any" + + " immutable value class — typical for DTOs sent" + + " over the wire. The " + + "'Import' button shows the matching atomic" + + " batch APIs: modify(c -> { c.setX(); c.setY(); })" + + " and update(prev -> newSnapshot) each notify" + + " subscribers exactly once for the whole update."); + + box.add(title, p); + return box; + } +} diff --git a/signals/src/main/java/com/example/usecase33/Order.java b/signals/src/main/java/com/example/usecase33/Order.java new file mode 100644 index 0000000..c369a03 --- /dev/null +++ b/signals/src/main/java/com/example/usecase33/Order.java @@ -0,0 +1,10 @@ +package com.example.usecase33; + +import java.io.Serializable; + +record Order(long id, String customer, String status, + double total) implements Serializable { + Order withStatus(String status) { + return new Order(id, customer, status, total); + } +} diff --git a/signals/src/main/java/com/example/usecase33/UseCase33View.java b/signals/src/main/java/com/example/usecase33/UseCase33View.java new file mode 100644 index 0000000..3eb281a --- /dev/null +++ b/signals/src/main/java/com/example/usecase33/UseCase33View.java @@ -0,0 +1,256 @@ +package com.example.usecase33; + +import jakarta.annotation.security.PermitAll; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import com.example.views.MainLayout; +import org.jspecify.annotations.Nullable; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.H3; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.Menu; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.signals.Signal; +import com.vaadin.flow.signals.local.ListSignal; +import com.vaadin.flow.signals.local.ValueSignal; + +/** + * Order management view. An order list is on the left; the right pane shows + * details of the currently selected order. When the user picks an order, the + * details pane plays a brief open animation and (in a real app) would fetch + * related data — an expensive side-effect that should fire on user-driven + * selection, but NOT every time the server pushes a status update to the + * already-selected order. + *

+ * The selection signal uses a custom equality checker {@code (a, b) -> + * a.id() == b.id()}, so a {@code set(updatedSameOrder)} call is a no-op and the + * open-animation effect does not re-fire. Status changes still reach the UI + * because the order list itself is the source of truth and the details pane + * reads the live entry from it. + */ +@PageTitle("Use Case 33: Order list with stable selection") +@Route(value = "use-case-33", layout = MainLayout.class) +@Menu(order = 33, title = "UC 33: Stable selection") +@PermitAll +public class UseCase33View extends VerticalLayout { + + final ListSignal orders = new ListSignal<>(); + final ValueSignal<@Nullable Order> selected = new ValueSignal<@Nullable Order>( + null, + (a, b) -> a == null ? b == null : b != null && a.id() == b.id()); + final AtomicInteger detailsOpenAnimations = new AtomicInteger(); + private final ValueSignal animationsSignal = new ValueSignal<>(0); + + public UseCase33View() { + setSpacing(true); + setPadding(true); + + orders.insertAllLast( + List.of(new Order(1001, "Acme Corp", "Pending", 199.00), + new Order(1002, "Globex Ltd", "Shipped", 1450.00), + new Order(1003, "Hooli Inc", "Pending", 79.50), + new Order(1004, "Initech", "Cancelled", 0.00), + new Order(1005, "Soylent", "Delivered", 3200.00))); + + add(new H2("Use Case 33: Order list with stable selection"), + new Paragraph( + "Click an order to open the details pane. The pane's" + + " open-animation effect fires only when the" + + " selected order's identity changes — even" + + " when the server pushes a status update," + + " the animation does NOT replay because" + + " the selection signal's equality checker" + + " ignores anything but the order id.")); + + HorizontalLayout layout = new HorizontalLayout(buildOrderList(), + buildDetailsPane()); + layout.setWidthFull(); + + add(layout, buildServerPushSection(), buildExplanation()); + + // The expensive "open" effect: fires every time the selected order + // identity changes. + Signal.effect(this, () -> { + Order pick = selected.get(); + if (pick != null) { + animationsSignal.set(detailsOpenAnimations.incrementAndGet()); + } + }); + } + + private Div buildOrderList() { + Div panel = new Div(); + panel.getStyle().set("flex", "1").set("padding", "var(--lumo-space-m)") + .set("background-color", "var(--lumo-contrast-5pct)") + .set("border-radius", "8px"); + + H3 header = new H3("Orders"); + header.getStyle().set("margin-top", "0"); + + Div rows = new Div(); + rows.getStyle().set("display", "flex").set("flex-direction", "column") + .set("gap", "var(--lumo-space-xs)"); + rows.bindChildren(orders, entry -> { + Div row = new Div(); + row.getStyle().set("padding", "var(--lumo-space-s)") + .set("border-radius", "4px").set("cursor", "pointer") + .set("background-color", "var(--lumo-base-color)"); + row.getStyle().bind("border-left", Signal.computed(() -> { + Order o = entry.get(); + Order sel = selected.get(); + boolean isSelected = sel != null && sel.id() == o.id(); + return isSelected ? "4px solid var(--lumo-primary-color)" + : "4px solid transparent"; + })); + + Span line = new Span(); + line.bindText(entry.map(o -> "#" + o.id() + " — " + o.customer() + + " · " + o.status() + " · $" + o.total())); + row.add(line); + row.addClickListener(e -> selected.set(entry.peek())); + return row; + }); + + panel.add(header, rows); + return panel; + } + + private Div buildDetailsPane() { + Div panel = new Div(); + panel.getStyle().set("flex", "1").set("padding", "var(--lumo-space-m)") + .set("background-color", "var(--lumo-contrast-5pct)") + .set("border-radius", "8px"); + + H3 header = new H3("Details"); + header.getStyle().set("margin-top", "0"); + + Span empty = new Span("(No order selected)"); + empty.bindVisible(Signal.computed(() -> selected.get() == null)); + empty.getStyle().set("color", "var(--lumo-secondary-text-color)"); + + // Pull live data from the orders list rather than the selection + // signal — so status changes are visible even though the selection + // signal didn't fire. + Span customer = new Span(); + customer.bindText(Signal.computed(() -> { + Order sel = selected.get(); + return sel == null ? "" + : currentOrder(sel.id()).map(Order::customer) + .orElse("(deleted)"); + })); + customer.getStyle().set("font-weight", "bold").set("display", "block"); + + Span status = new Span(); + status.bindText(Signal.computed(() -> { + Order sel = selected.get(); + return sel == null ? "" + : "Status: " + currentOrder(sel.id()).map(Order::status) + .orElse("?"); + })); + status.getStyle().set("display", "block"); + + Span total = new Span(); + total.bindText(Signal.computed(() -> { + Order sel = selected.get(); + return sel == null ? "" + : "Total: $" + currentOrder(sel.id()).map(Order::total) + .orElse(0.0); + })); + total.getStyle().set("display", "block"); + + Span animationCount = new Span(); + animationCount.bindText( + animationsSignal.map(n -> "Open animations played: " + n)); + animationCount.getStyle().set("display", "block") + .set("margin-top", "var(--lumo-space-m)") + .set("color", "var(--lumo-secondary-text-color)") + .set("font-size", "var(--lumo-font-size-s)"); + + panel.add(header, empty, customer, status, total, animationCount); + return panel; + } + + private java.util.Optional currentOrder(long id) { + return orders.get().stream().map(Signal::get).filter(o -> o.id() == id) + .findFirst(); + } + + private Div buildServerPushSection() { + Div section = new Div(); + section.getStyle().set("background-color", "var(--lumo-contrast-5pct)") + .set("padding", "var(--lumo-space-m)") + .set("border-radius", "8px"); + + H3 header = new H3( + "Simulate server push: rotate status on the selected order"); + header.getStyle().set("margin-top", "0"); + + Button push = new Button("Push status update", e -> { + Order sel = selected.peek(); + if (sel == null) { + Notification.show("Select an order first"); + return; + } + List> all = orders.peek(); + for (ValueSignal entry : all) { + Order o = entry.peek(); + if (o.id() == sel.id()) { + String next = nextStatus(o.status()); + Order updated = o.withStatus(next); + entry.set(updated); + // Also push the refreshed object through the selection + // signal — id-equality makes this a no-op for the + // selection's listeners (no animation replay). + selected.set(updated); + return; + } + } + }); + section.add(header, push); + return section; + } + + private String nextStatus(String current) { + return switch (current) { + case "Pending" -> "Shipped"; + case "Shipped" -> "Delivered"; + case "Delivered" -> "Pending"; + default -> "Pending"; + }; + } + + private Div buildExplanation() { + Div box = new Div(); + box.getStyle().set("background-color", "var(--lumo-contrast-5pct)") + .set("padding", "var(--lumo-space-m)") + .set("border-radius", "8px"); + + H3 title = new H3("Why a custom equality checker?"); + title.getStyle().set("margin-top", "0"); + + Paragraph p = new Paragraph( + "The selection signal cares about identity, not contents." + + " An equality checker keyed on Order.id() means" + + " calling selected.set(refreshedSameOrder) is a" + + " no-op, so subscribers like the open-animation" + + " effect don't fire spuriously when the server" + + " merely refreshes the order's metadata. Live" + + " status, total and customer fields still update" + + " because the details pane reads them from the" + + " orders list signal, which is the source of" + + " truth."); + + box.add(title, p); + return box; + } +} diff --git a/signals/src/main/java/com/example/usecase34/FeatureFlagService.java b/signals/src/main/java/com/example/usecase34/FeatureFlagService.java new file mode 100644 index 0000000..c53ba2d --- /dev/null +++ b/signals/src/main/java/com/example/usecase34/FeatureFlagService.java @@ -0,0 +1,38 @@ +package com.example.usecase34; + +import org.springframework.stereotype.Service; + +import com.vaadin.flow.signals.Signal; +import com.vaadin.flow.signals.shared.SharedValueSignal; + +/** + * Application-wide feature-flag service. Owns the writable signals internally + * and exposes only read-only {@link Signal} references via + * {@code asReadonly()}, so views cannot mutate flag state directly — flips have + * to go through {@link #setNewCheckoutFlow(boolean)} / + * {@link #setBetaUi(boolean)}. + */ +@Service +public class FeatureFlagService { + + private final SharedValueSignal newCheckoutFlow = new SharedValueSignal<>( + false); + private final SharedValueSignal betaUi = new SharedValueSignal<>( + false); + + public Signal newCheckoutFlowSignal() { + return newCheckoutFlow.asReadonly(); + } + + public Signal betaUiSignal() { + return betaUi.asReadonly(); + } + + public void setNewCheckoutFlow(boolean enabled) { + newCheckoutFlow.set(enabled); + } + + public void setBetaUi(boolean enabled) { + betaUi.set(enabled); + } +} diff --git a/signals/src/main/java/com/example/usecase34/UseCase34View.java b/signals/src/main/java/com/example/usecase34/UseCase34View.java new file mode 100644 index 0000000..bf92f43 --- /dev/null +++ b/signals/src/main/java/com/example/usecase34/UseCase34View.java @@ -0,0 +1,166 @@ +package com.example.usecase34; + +import jakarta.annotation.security.PermitAll; + +import com.example.views.MainLayout; + +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.H3; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.Menu; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.signals.Signal; + +/** + * Feature flag admin + preview. The {@link FeatureFlagService} owns the + * writable flag signals; this view receives only read-only {@link Signal} + * references from the service — it can't accidentally mutate flag state by + * touching the signal. Flips happen through dedicated service methods that the + * admin UI calls. + *

+ * Two preview sections subscribe to the read-only signals: a checkout summary + * that branches on the new-flow flag, and a "Beta" badge visible when the beta + * UI is enabled. Both are passive consumers — the flags shape the UI but only + * the admin can flip them. + */ +@PageTitle("Use Case 34: Feature flag service") +@Route(value = "use-case-34", layout = MainLayout.class) +@Menu(order = 34, title = "UC 34: Feature flags") +@PermitAll +public class UseCase34View extends VerticalLayout { + + final FeatureFlagService flags; + final Signal newCheckoutFlowSignal; + final Signal betaUiSignal; + + public UseCase34View(FeatureFlagService flags) { + this.flags = flags; + this.newCheckoutFlowSignal = flags.newCheckoutFlowSignal(); + this.betaUiSignal = flags.betaUiSignal(); + + setSpacing(true); + setPadding(true); + + add(new H2("Use Case 34: Feature flag service"), new Paragraph( + "The FeatureFlagService bean owns the writable signals;" + + " views and components receive only Signal references" + + " via asReadonly(). The admin panel below flips flags" + + " through service methods; the two preview sections" + + " subscribe but can't mutate.")); + + add(buildAdminPanel(), buildCheckoutPreview(), buildBetaBadgePreview(), + buildExplanation()); + } + + private Div buildAdminPanel() { + Div panel = new Div(); + panel.getStyle().set("padding", "var(--lumo-space-m)") + .set("background-color", "var(--lumo-contrast-5pct)") + .set("border-radius", "8px"); + + H3 header = new H3("Admin: flip flags"); + header.getStyle().set("margin-top", "0"); + + Checkbox newCheckout = new Checkbox("Enable new checkout flow"); + newCheckout.bindValue(newCheckoutFlowSignal, flags::setNewCheckoutFlow); + + Checkbox beta = new Checkbox("Enable beta UI"); + beta.bindValue(betaUiSignal, flags::setBetaUi); + + panel.add(header, newCheckout, beta); + return panel; + } + + private Div buildCheckoutPreview() { + Div panel = new Div(); + panel.getStyle().set("padding", "var(--lumo-space-m)") + .set("background-color", "var(--lumo-base-color)") + .set("border-radius", "8px") + .set("border", "1px solid var(--lumo-contrast-20pct)"); + + H3 header = new H3("Customer checkout preview"); + header.getStyle().set("margin-top", "0"); + + Span flowLabel = new Span(); + flowLabel.bindText(newCheckoutFlowSignal + .map(enabled -> enabled ? "Variant: NEW one-page checkout" + : "Variant: classic multi-step checkout")); + flowLabel.getStyle().set("display", "block").set("font-weight", "bold"); + + Div newFlow = new Div(); + newFlow.bindVisible(newCheckoutFlowSignal); + newFlow.add( + new Paragraph("1) Cart + shipping + payment on a single page."), + new Paragraph("2) Apple Pay / Google Pay buttons at top."), + new Paragraph( + "3) Order placed without an extra confirmation step.")); + + Div oldFlow = new Div(); + oldFlow.bindVisible(Signal.not(newCheckoutFlowSignal)); + oldFlow.add(new Paragraph("1) Review cart on its own page."), + new Paragraph("2) Enter shipping address."), + new Paragraph("3) Enter payment details."), + new Paragraph("4) Confirm order.")); + + panel.add(header, flowLabel, newFlow, oldFlow); + return panel; + } + + private Div buildBetaBadgePreview() { + Div panel = new Div(); + panel.getStyle().set("padding", "var(--lumo-space-m)") + .set("background-color", "var(--lumo-base-color)") + .set("border-radius", "8px") + .set("border", "1px solid var(--lumo-contrast-20pct)"); + + H3 header = new H3("Navbar preview"); + header.getStyle().set("margin-top", "0"); + + HorizontalLayout navbar = new HorizontalLayout(); + navbar.setAlignItems( + com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER); + navbar.add(new Span("Logo"), new Span("Catalog"), new Span("Pricing")); + + Span betaBadge = new Span("BETA"); + betaBadge.bindVisible(betaUiSignal); + betaBadge.getStyle() + .set("background-color", "var(--lumo-primary-color)") + .set("color", "white").set("padding", "2px 8px") + .set("border-radius", "12px") + .set("font-size", "var(--lumo-font-size-s)") + .set("font-weight", "bold"); + + navbar.add(betaBadge); + panel.add(header, navbar); + return panel; + } + + private Div buildExplanation() { + Div box = new Div(); + box.getStyle().set("background-color", "var(--lumo-contrast-5pct)") + .set("padding", "var(--lumo-space-m)") + .set("border-radius", "8px"); + + H3 title = new H3("Why asReadonly()?"); + title.getStyle().set("margin-top", "0"); + + Paragraph p = new Paragraph( + "FeatureFlagService holds SharedValueSignal internally" + + " and exposes Signal to consumers via" + + " asReadonly(). View code cannot call set(), update()," + + " or replace() on the flag — the methods aren't on" + + " the Signal interface. Flag changes can only happen" + + " through dedicated service methods, which is exactly" + + " the kind of write-channel separation you want for" + + " any feature-flag, auth, or theme service."); + + box.add(title, p); + return box; + } +} diff --git a/signals/src/main/java/com/example/usecase35/Card.java b/signals/src/main/java/com/example/usecase35/Card.java new file mode 100644 index 0000000..ec1c41a --- /dev/null +++ b/signals/src/main/java/com/example/usecase35/Card.java @@ -0,0 +1,9 @@ +package com.example.usecase35; + +import java.io.Serializable; + +record Card(String title, Priority priority) implements Serializable { + enum Priority { + LOW, MEDIUM, HIGH + } +} diff --git a/signals/src/main/java/com/example/usecase35/UseCase35View.java b/signals/src/main/java/com/example/usecase35/UseCase35View.java new file mode 100644 index 0000000..160234c --- /dev/null +++ b/signals/src/main/java/com/example/usecase35/UseCase35View.java @@ -0,0 +1,215 @@ +package com.example.usecase35; + +import jakarta.annotation.security.PermitAll; + +import java.util.List; + +import com.example.usecase35.Card.Priority; +import com.example.views.MainLayout; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.H3; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.select.Select; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.Menu; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.signals.Signal; +import com.vaadin.flow.signals.local.ListSignal; +import com.vaadin.flow.signals.local.ValueSignal; + +/** + * Kanban "To do" column. A team lead can reorder cards with the up/down arrows, + * add a single card via the form, paste a batch of three cards from a + * hypothetical clipboard at a chosen index, and pull a fresh batch of backlog + * items from the server (prepended in one shot). + *

+ * Reorder uses {@code ListSignal.moveTo} — the entry's {@code ValueSignal} + * identity is preserved across the move so children bound to that entry don't + * lose their subscriptions. {@code insertAllFirst} and {@code insertAllAt} + * bulk-insert with a single change notification, so subscribers like the "card + * count" badge update once per batch instead of once per card. + */ +@PageTitle("Use Case 35: Kanban column") +@Route(value = "use-case-35", layout = MainLayout.class) +@Menu(order = 35, title = "UC 35: Kanban column") +@PermitAll +public class UseCase35View extends VerticalLayout { + + final ListSignal cards = new ListSignal<>(); + + public UseCase35View() { + setSpacing(true); + setPadding(true); + + cards.insertAllLast( + List.of(new Card("Wire up the new dashboard", Priority.HIGH), + new Card("Document the SSE endpoint", Priority.MEDIUM), + new Card("Audit dependency licenses", Priority.LOW))); + + add(new H2("Use Case 35: Kanban \"To do\" column"), new Paragraph( + "Reorder cards with the arrows (moveTo preserves the" + + " card's signal identity). Add a single" + + " card with the form below. Use 'Pull" + + " backlog from server' to prepend a batch" + + " of fresh cards, and 'Paste 3 cards from" + + " clipboard' to insert a batch at a chosen" + + " position — both use insertAll* so the" + + " count badge updates only once per" + " batch.")); + + add(buildAddCardRow(), buildBulkRow(), buildColumn(), + buildExplanation()); + } + + private HorizontalLayout buildAddCardRow() { + TextField titleField = new TextField("New card title"); + titleField.setWidth("280px"); + Select priorityField = new Select<>(); + priorityField.setLabel("Priority"); + priorityField.setItems(Priority.values()); + priorityField.setValue(Priority.MEDIUM); + + Button add = new Button("Add to top", e -> { + String title = titleField.getValue(); + if (title == null || title.isBlank()) { + Notification.show("Type a title first"); + return; + } + cards.insertFirst(new Card(title, priorityField.getValue())); + titleField.clear(); + }); + + HorizontalLayout row = new HorizontalLayout(titleField, priorityField, + add); + row.setAlignItems( + com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.BASELINE); + return row; + } + + private HorizontalLayout buildBulkRow() { + Button pullBacklog = new Button("Pull backlog from server", e -> { + // Server returns a batch of 4 — insert all of them at the top in + // one shot so list-level subscribers re-render only once. + cards.insertAllFirst(List.of( + new Card("From server: triage on-call alert", + Priority.HIGH), + new Card("From server: review OAuth migration", + Priority.MEDIUM), + new Card("From server: rotate API keys", Priority.MEDIUM), + new Card("From server: write Q3 retrospective", + Priority.LOW))); + }); + + Button paste = new Button("Paste 3 cards from clipboard at position 2", + e -> { + if (cards.peek().size() < 1) { + Notification.show("Column is empty — paste at top"); + cards.insertAllFirst( + List.of(new Card("Pasted A", Priority.MEDIUM), + new Card("Pasted B", Priority.MEDIUM), + new Card("Pasted C", Priority.MEDIUM))); + return; + } + cards.insertAllAt(1, + List.of(new Card("Pasted A", Priority.MEDIUM), + new Card("Pasted B", Priority.MEDIUM), + new Card("Pasted C", Priority.MEDIUM))); + }); + + Button clear = new Button("Clear column", e -> cards.clear()); + + return new HorizontalLayout(pullBacklog, paste, clear); + } + + private Div buildColumn() { + Div column = new Div(); + column.getStyle().set("background-color", "var(--lumo-contrast-5pct)") + .set("padding", "var(--lumo-space-m)") + .set("border-radius", "8px").set("min-height", "300px"); + + Span count = new Span(); + count.bindText(Signal + .computed(() -> "To do (" + cards.get().size() + " cards):")); + count.getStyle().set("font-weight", "bold").set("display", "block") + .set("margin-bottom", "var(--lumo-space-s)"); + + Div stack = new Div(); + stack.getStyle().set("display", "flex").set("flex-direction", "column") + .set("gap", "var(--lumo-space-xs)"); + stack.bindChildren(cards, this::buildCard); + + column.add(count, stack); + return column; + } + + private Div buildCard(ValueSignal entry) { + Div card = new Div(); + card.getStyle().set("padding", "var(--lumo-space-s)") + .set("background-color", "var(--lumo-base-color)") + .set("border-radius", "4px") + .set("box-shadow", "var(--lumo-box-shadow-xs)") + .set("display", "flex").set("align-items", "center") + .set("gap", "var(--lumo-space-s)"); + + Button up = new Button("↑", e -> { + int idx = cards.peek().indexOf(entry); + if (idx > 0) { + cards.moveTo(entry, idx - 1); + } + }); + Button down = new Button("↓", e -> { + int idx = cards.peek().indexOf(entry); + if (idx >= 0 && idx < cards.peek().size() - 1) { + cards.moveTo(entry, idx + 1); + } + }); + + Span title = new Span(); + title.bindText(entry.map(Card::title)); + title.getStyle().set("flex", "1"); + + Span priority = new Span(); + priority.bindText(entry.map(c -> c.priority().name())); + priority.getStyle().set("font-size", "var(--lumo-font-size-s)") + .set("padding", "2px 8px").set("border-radius", "12px"); + priority.getStyle().bind("background-color", + entry.map(c -> switch (c.priority()) { + case HIGH -> "var(--lumo-error-color-10pct)"; + case MEDIUM -> "var(--lumo-primary-color-10pct)"; + case LOW -> "var(--lumo-contrast-10pct)"; + })); + + Button remove = new Button("✕", e -> cards.remove(entry)); + + card.add(up, down, title, priority, remove); + return card; + } + + private Div buildExplanation() { + Div box = new Div(); + box.getStyle().set("background-color", "var(--lumo-contrast-5pct)") + .set("padding", "var(--lumo-space-m)") + .set("border-radius", "8px"); + + H3 title = new H3("Why moveTo / insertAllAt / insertAllFirst?"); + title.getStyle().set("margin-top", "0"); + + Paragraph p = new Paragraph( + "moveTo keeps the card's ValueSignal identity stable across a" + + " reorder, so the row component bound to that entry" + + " doesn't have to be re-created. insertAll* batches" + + " several inserts into a single change notification" + + " — useful when a bulk-fetch from the server arrives" + + " or when the user pastes multiple items at once."); + + box.add(title, p); + return box; + } +} diff --git a/signals/src/main/java/com/example/views/HomeView.java b/signals/src/main/java/com/example/views/HomeView.java index ea9b7b2..ce18e8e 100644 --- a/signals/src/main/java/com/example/views/HomeView.java +++ b/signals/src/main/java/com/example/views/HomeView.java @@ -30,7 +30,7 @@ public HomeView() { // Introduction Paragraph intro = new Paragraph( - "This application demonstrates 29 use cases (23 single-user + 6 multi-user) for the Vaadin Signal API. " + "This application demonstrates 37 use cases (31 single-user + 6 multi-user) for the Vaadin Signal API. " + "Each use case validates different aspects of the reactive programming model, from basic " + "one-way bindings to complex forms, AI integration, and multi-user collaboration."); @@ -53,9 +53,9 @@ public HomeView() { stats.setSpacing(false); stats.setPadding(false); stats.add( - createStat("29", - "Total use cases (23 single-user + 6 multi-user)"), - createStat("23", "Single-user use cases across 11 categories"), + createStat("37", + "Total use cases (31 single-user + 6 multi-user)"), + createStat("31", "Single-user use cases across 12 categories"), createStat("6", "Multi-user collaboration use cases")); // Categories @@ -85,6 +85,9 @@ public HomeView() { "Reactive i18n, two-way mapped signals, real-time dashboard"), createCategory("VirtualList & Streaming", "UC24-25", "VirtualList notification inbox, real-time stock ticker"), + createCategory("Advanced Signal API", "UC28-35", + "Contextual effects, untracked/unbound effects, Signal.cached, " + + "replace/update/modify, custom equality, asReadonly, ListSignal moveTo/insertAt"), createCategory("Multi-User Collaboration", "MUC01-04, MUC06-07", "Chat, cursors, click race game, collaborative editing, shared tasks, shared LLM tasks")); diff --git a/signals/src/main/resources/META-INF/resources/styles.css b/signals/src/main/resources/META-INF/resources/styles.css index 9759abd..612f1f6 100644 --- a/signals/src/main/resources/META-INF/resources/styles.css +++ b/signals/src/main/resources/META-INF/resources/styles.css @@ -49,3 +49,12 @@ html { 0% { background-color: rgba(244, 67, 54, 0.3); } 100% { background-color: transparent; } } + +/* Generic highlight flash used by UC28 and other "value changed" indicators */ +.uc28-flash { + animation: uc28-flash 0.6s ease-out; +} +@keyframes uc28-flash { + 0% { background-color: rgba(33, 150, 243, 0.35); } + 100% { background-color: transparent; } +} diff --git a/signals/src/test/java/com/example/usecase28/UseCase28ViewTest.java b/signals/src/test/java/com/example/usecase28/UseCase28ViewTest.java new file mode 100644 index 0000000..2a546d5 --- /dev/null +++ b/signals/src/test/java/com/example/usecase28/UseCase28ViewTest.java @@ -0,0 +1,88 @@ +package com.example.usecase28; + +import com.example.usecase28.LogEntry.Severity; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithMockUser; + +import com.vaadin.browserless.SpringBrowserlessTest; +import com.vaadin.browserless.ViewPackages; +import com.vaadin.flow.component.select.Select; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@ViewPackages(classes = UseCase28View.class) +@WithMockUser +class UseCase28ViewTest extends SpringBrowserlessTest { + + @Test + void seedsThreeInitialEntries() { + navigate(UseCase28View.class); + runPendingSignalsTasks(); + + UseCase28View view = (UseCase28View) getCurrentView(); + assertEquals(3, view.entries.peek().size(), + "Three rows should be seeded before the feed starts"); + } + + @Test + void backgroundLineInsertionAddsRow() { + navigate(UseCase28View.class); + runPendingSignalsTasks(); + + UseCase28View view = (UseCase28View) getCurrentView(); + int before = view.entries.peek().size(); + + // Simulate one server-feed tick from a non-UI thread so the + // contextual effect would see this as a background change. + Thread t = new Thread(view::pushServerLine); + t.start(); + try { + t.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + runPendingSignalsTasks(); + + assertTrue(view.entries.peek().size() >= before, + "A server tick should add a line or reclassify one"); + } + + @Test + @SuppressWarnings("unchecked") + void operatorReclassificationUpdatesSeverity() { + navigate(UseCase28View.class); + runPendingSignalsTasks(); + + UseCase28View view = (UseCase28View) getCurrentView(); + Select firstSeverity = (Select) $view(Select.class) + .all().get(0); + + Severity initial = view.entries.peek().get(0).peek().severity(); + Severity target = (initial == Severity.ERROR) ? Severity.INFO + : Severity.ERROR; + test(firstSeverity).selectItem(target.name()); + runPendingSignalsTasks(); + + assertEquals(target, view.entries.peek().get(0).peek().severity(), + "Operator edit should propagate to the underlying entry"); + } + + @Test + void feedKeepsRowsUnderMax() { + navigate(UseCase28View.class); + runPendingSignalsTasks(); + + UseCase28View view = (UseCase28View) getCurrentView(); + for (int i = 0; i < 40; i++) { + view.pushServerLine(); + } + runPendingSignalsTasks(); + + assertTrue(view.entries.peek().size() <= 20, + "Viewer must trim to MAX_ROWS=20 lines, was: " + + view.entries.peek().size()); + } +} diff --git a/signals/src/test/java/com/example/usecase29/UseCase29ViewTest.java b/signals/src/test/java/com/example/usecase29/UseCase29ViewTest.java new file mode 100644 index 0000000..bb32074 --- /dev/null +++ b/signals/src/test/java/com/example/usecase29/UseCase29ViewTest.java @@ -0,0 +1,111 @@ +package com.example.usecase29; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithMockUser; + +import com.vaadin.browserless.SpringBrowserlessTest; +import com.vaadin.browserless.ViewPackages; +import com.vaadin.flow.component.select.Select; +import com.vaadin.flow.component.textfield.TextField; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@ViewPackages(classes = UseCase29View.class) +@WithMockUser +class UseCase29ViewTest extends SpringBrowserlessTest { + + @Test + void initialRenderHasEmptyAuditLog() { + navigate(UseCase29View.class); + runPendingSignalsTasks(); + + UseCase29View view = (UseCase29View) getCurrentView(); + assertEquals(0, view.auditLog.peek().size(), + "Audit log should be empty before any edit"); + } + + @Test + void editingProfileAppendsAuditEntryStampedWithCurrentAdmin() { + navigate(UseCase29View.class); + runPendingSignalsTasks(); + + UseCase29View view = (UseCase29View) getCurrentView(); + + TextField nameField = $view(TextField.class).all().stream() + .filter(f -> "Display name".equals(f.getLabel())).findFirst() + .orElseThrow(); + test(nameField).setValue("Bob Brown II"); + runPendingSignalsTasks(); + + assertEquals(1, view.auditLog.peek().size(), + "Edit should produce one audit entry"); + assertEquals("alice", view.auditLog.peek().get(0).peek().admin(), + "Initial admin alice should be on the entry"); + } + + @Test + @SuppressWarnings("unchecked") + void switchingAdminAloneDoesNotAppendAudit() { + navigate(UseCase29View.class); + runPendingSignalsTasks(); + + UseCase29View view = (UseCase29View) getCurrentView(); + Select adminSelect = (Select) $view(Select.class) + .single(); + + test(adminSelect).selectItem("bob"); + runPendingSignalsTasks(); + + assertEquals(0, view.auditLog.peek().size(), + "Switching admin alone must NOT replay history" + + " — untracked admin read must not retrigger the save effect"); + } + + @Test + @SuppressWarnings("unchecked") + void editAfterAdminSwitchUsesNewAdmin() { + navigate(UseCase29View.class); + runPendingSignalsTasks(); + + UseCase29View view = (UseCase29View) getCurrentView(); + + // First edit as alice + TextField nameField = $view(TextField.class).all().stream() + .filter(f -> "Display name".equals(f.getLabel())).findFirst() + .orElseThrow(); + test(nameField).setValue("Edit 1"); + runPendingSignalsTasks(); + + // Hand over to carol + Select adminSelect = (Select) $view(Select.class) + .single(); + test(adminSelect).selectItem("carol"); + runPendingSignalsTasks(); + + // Second edit + test(nameField).setValue("Edit 2"); + runPendingSignalsTasks(); + + assertEquals(2, view.auditLog.peek().size()); + // List is reverse-chronological (insertFirst), so position 0 is newest + assertEquals("carol", view.auditLog.peek().get(0).peek().admin(), + "Newer edit must be attributed to carol"); + assertEquals("alice", view.auditLog.peek().get(1).peek().admin(), + "Earlier edit must remain attributed to alice"); + } + + @Test + void unboundShipperIsInitializedOnAttach() { + navigate(UseCase29View.class); + runPendingSignalsTasks(); + + UseCase29View view = (UseCase29View) getCurrentView(); + assertTrue(view.shipperRuns.get() >= 1, + "Unbound shipper should have done its initial run"); + assertEquals(0, view.shippedToRemote.peek(), + "Nothing shipped yet — audit log is empty"); + } +} diff --git a/signals/src/test/java/com/example/usecase30/UseCase30ViewTest.java b/signals/src/test/java/com/example/usecase30/UseCase30ViewTest.java new file mode 100644 index 0000000..5d1826e --- /dev/null +++ b/signals/src/test/java/com/example/usecase30/UseCase30ViewTest.java @@ -0,0 +1,107 @@ +package com.example.usecase30; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithMockUser; + +import com.vaadin.browserless.SpringBrowserlessTest; +import com.vaadin.browserless.ViewPackages; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.select.Select; +import com.vaadin.flow.component.textfield.NumberField; +import com.vaadin.flow.component.textfield.TextField; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@ViewPackages(classes = UseCase30View.class) +@WithMockUser +class UseCase30ViewTest extends SpringBrowserlessTest { + + @Test + void initialFilterShowsAllProducts() { + navigate(UseCase30View.class); + runPendingSignalsTasks(); + + assertTrue( + $view(Span.class).all().stream() + .anyMatch(s -> "1000 results".equals(s.getText())), + "All 1 000 products should match the default filter"); + } + + @Test + void filterComputationsRunOncePerInputChangeNotPerSubscriber() { + navigate(UseCase30View.class); + runPendingSignalsTasks(); + + UseCase30View view = (UseCase30View) getCurrentView(); + int initial = view.filterComputations.get(); + + // Three subscribers: grid, badge, export. With caching the filter + // must run only ONCE per filter-input mutation. + view.maxPrice.set(500.0); + runPendingSignalsTasks(); + view.search.set("pro"); + runPendingSignalsTasks(); + + int extraRuns = view.filterComputations.get() - initial; + assertTrue(extraRuns <= 2, + "Filter should run at most twice for two input mutations," + + " was: " + extraRuns); + } + + @Test + @SuppressWarnings("unchecked") + void filterByCategoryReducesResultCount() { + navigate(UseCase30View.class); + runPendingSignalsTasks(); + + Select categorySelect = (Select) $view(Select.class) + .single(); + test(categorySelect).selectItem("Audio"); + runPendingSignalsTasks(); + + UseCase30View view = (UseCase30View) getCurrentView(); + assertEquals(200, view.visibleProducts.peek().size(), + "200 of 1 000 products are in the Audio category"); + assertTrue( + $view(Span.class).all().stream() + .anyMatch(s -> "200 results".equals(s.getText())), + "Badge should reflect the new count"); + } + + @Test + void priceFilterReducesResults() { + navigate(UseCase30View.class); + runPendingSignalsTasks(); + + NumberField maxPriceField = $view(NumberField.class).single(); + test(maxPriceField).setValue(100.0); + runPendingSignalsTasks(); + + UseCase30View view = (UseCase30View) getCurrentView(); + assertTrue(view.visibleProducts.peek().size() < 1000, + "Max price 100 should filter out the majority"); + assertTrue( + view.visibleProducts.peek().stream() + .allMatch(p -> p.price() <= 100.0), + "Every remaining product must be within the price cap"); + } + + @Test + void searchFiltersByName() { + navigate(UseCase30View.class); + runPendingSignalsTasks(); + + TextField searchField = $view(TextField.class).single(); + test(searchField).setValue("Audio"); + runPendingSignalsTasks(); + + UseCase30View view = (UseCase30View) getCurrentView(); + assertTrue( + view.visibleProducts.peek().stream().allMatch( + p -> p.name().toLowerCase().contains("audio")), + "All results must contain the search term"); + } +} diff --git a/signals/src/test/java/com/example/usecase31/UseCase31ViewTest.java b/signals/src/test/java/com/example/usecase31/UseCase31ViewTest.java new file mode 100644 index 0000000..aacd2b7 --- /dev/null +++ b/signals/src/test/java/com/example/usecase31/UseCase31ViewTest.java @@ -0,0 +1,100 @@ +package com.example.usecase31; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithMockUser; + +import com.vaadin.browserless.SpringBrowserlessTest; +import com.vaadin.browserless.ViewPackages; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.html.Span; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@ViewPackages(classes = UseCase31View.class) +@WithMockUser +class UseCase31ViewTest extends SpringBrowserlessTest { + + @Test + void allSeatsStartAvailable() { + navigate(UseCase31View.class); + runPendingSignalsTasks(); + + long available = $view(Span.class).all().stream() + .filter(s -> "Available".equals(s.getText())).count(); + assertEquals(8L, available, "All 8 seats should be available"); + } + + @Test + void claimingAnEmptySeatSucceedsAndBumpsCounter() { + navigate(UseCase31View.class); + runPendingSignalsTasks(); + + UseCase31View view = (UseCase31View) getCurrentView(); + int countBefore = view.reservationCount.peek(); + + // Direct seat mutation models a successful first-come claim + assertTrue(view.seats.get(2).replace(null, "Attendee A"), + "Empty seat must accept a claim"); + view.reservationCount.update(n -> n + 1); + runPendingSignalsTasks(); + + assertEquals(countBefore + 1, view.reservationCount.peek(), + "Counter must bump after a successful claim"); + } + + @Test + void secondClaimOnTakenSeatFails() { + navigate(UseCase31View.class); + runPendingSignalsTasks(); + + UseCase31View view = (UseCase31View) getCurrentView(); + view.seats.get(0).replace(null, "Attendee A"); + runPendingSignalsTasks(); + + assertFalse(view.seats.get(0).replace(null, "Attendee B"), + "Second claim must fail because seat is already taken"); + assertEquals("Attendee A", view.seats.get(0).peek(), + "Original holder retained"); + } + + @Test + void contendedRaceProducesExactlyOneWinner() { + navigate(UseCase31View.class); + runPendingSignalsTasks(); + + Button race = $view(Button.class).all().stream() + .filter(b -> b.getText() != null + && b.getText().startsWith("Reset seat 1")) + .findFirst().orElseThrow(); + test(race).click(); + runPendingSignalsTasks(); + + assertTrue($view(Span.class).all().stream() + .anyMatch(s -> "Last race result: 1 device claimed seat 1" + .equals(s.getText())), + "Exactly one of the 4 virtual-thread devices must win"); + } + + @Test + void concurrentReservationBumpsAreAllCounted() { + navigate(UseCase31View.class); + runPendingSignalsTasks(); + + UseCase31View view = (UseCase31View) getCurrentView(); + int before = view.reservationCount.peek(); + + Button burst = $view(Button.class).all().stream() + .filter(b -> b.getText() != null + && b.getText().startsWith("Simulate 6 admin tools")) + .findFirst().orElseThrow(); + test(burst).click(); + runPendingSignalsTasks(); + + assertEquals(before + 6, view.reservationCount.peek(), + "Six concurrent update(old -> old + 1) calls must all stick"); + } +} diff --git a/signals/src/test/java/com/example/usecase32/UseCase32ViewTest.java b/signals/src/test/java/com/example/usecase32/UseCase32ViewTest.java new file mode 100644 index 0000000..6f6c3c8 --- /dev/null +++ b/signals/src/test/java/com/example/usecase32/UseCase32ViewTest.java @@ -0,0 +1,104 @@ +package com.example.usecase32; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithMockUser; + +import com.vaadin.browserless.SpringBrowserlessTest; +import com.vaadin.browserless.ViewPackages; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.textfield.TextField; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@ViewPackages(classes = UseCase32View.class) +@WithMockUser +class UseCase32ViewTest extends SpringBrowserlessTest { + + @Test + void bothFormsRenderInitialState() { + navigate(UseCase32View.class); + runPendingSignalsTasks(); + + long matches = $view(Span.class).all().stream().filter( + s -> "Summary: Alice Anderson [Free]" + .equals(s.getText())) + .count(); + assertEquals(2L, matches, "Both forms should render the initial state"); + } + + @Test + void editingTheMutableNameUpdatesTheJpaBean() { + navigate(UseCase32View.class); + runPendingSignalsTasks(); + + UseCase32View view = (UseCase32View) getCurrentView(); + + // First Name field belongs to the mutable form + TextField name = $view(TextField.class).all().stream() + .filter(f -> "Name".equals(f.getLabel())).findFirst() + .orElseThrow(); + test(name).setValue("Brian Brown"); + runPendingSignalsTasks(); + + assertEquals("Brian Brown", view.entitySignal.peek().getName()); + assertTrue( + $view(Span.class).all().stream().anyMatch( + s -> ("Summary: Brian Brown [Free]") + .equals(s.getText())), + "Mutable summary should reflect the edited name"); + } + + @Test + void importButtonAppliesBatchAtomicallyOnBothSides() { + navigate(UseCase32View.class); + runPendingSignalsTasks(); + + UseCase32View view = (UseCase32View) getCurrentView(); + + // Capture run counts BEFORE the import to verify single-notification + // behaviour after. + int entityRunsBefore = Integer.parseInt(currentRunCountFor(0)); + int dtoRunsBefore = Integer.parseInt(currentRunCountFor(1)); + + Button importBtn = $view(Button.class).all().stream() + .filter(b -> b.getText() != null + && b.getText().startsWith("Import customer record")) + .findFirst().orElseThrow(); + test(importBtn).click(); + runPendingSignalsTasks(); + + assertEquals("Carol Chen", view.entitySignal.peek().getName()); + assertEquals("carol@example.com", view.entitySignal.peek().getEmail()); + assertEquals("Enterprise", view.entitySignal.peek().getPlan()); + + assertEquals("Carol Chen", view.dtoSignal.peek().name()); + assertEquals("carol@example.com", view.dtoSignal.peek().email()); + assertEquals("Enterprise", view.dtoSignal.peek().plan()); + + // Both sides should have fired exactly ONCE for the three-field + // batch (otherwise we'd see +3 per side). + int entityRunsAfter = Integer.parseInt(currentRunCountFor(0)); + int dtoRunsAfter = Integer.parseInt(currentRunCountFor(1)); + assertEquals(entityRunsBefore + 1, entityRunsAfter, + "modify() must yield exactly one notification"); + assertEquals(dtoRunsBefore + 1, dtoRunsAfter, + "update() must yield exactly one notification"); + } + + /** + * Reads the Nth "Effect notifications: X" span (0 = mutable, 1 = DTO) and + * returns just the numeric portion. + */ + private String currentRunCountFor(int index) { + var runs = $view(Span.class).all().stream() + .filter(s -> s.getText() != null + && s.getText().startsWith("Effect notifications:")) + .toList(); + String text = runs.get(index).getText(); + return text.substring("Effect notifications: ".length()); + } +} diff --git a/signals/src/test/java/com/example/usecase33/UseCase33ViewTest.java b/signals/src/test/java/com/example/usecase33/UseCase33ViewTest.java new file mode 100644 index 0000000..cfcc010 --- /dev/null +++ b/signals/src/test/java/com/example/usecase33/UseCase33ViewTest.java @@ -0,0 +1,107 @@ +package com.example.usecase33; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithMockUser; + +import com.vaadin.browserless.SpringBrowserlessTest; +import com.vaadin.browserless.ViewPackages; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.html.Span; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@ViewPackages(classes = UseCase33View.class) +@WithMockUser +class UseCase33ViewTest extends SpringBrowserlessTest { + + @Test + void initialRenderShowsOrdersAndNoSelection() { + navigate(UseCase33View.class); + runPendingSignalsTasks(); + + UseCase33View view = (UseCase33View) getCurrentView(); + assertEquals(5, view.orders.peek().size()); + assertTrue( + $view(Span.class).all().stream().anyMatch( + s -> "(No order selected)".equals(s.getText())), + "Details pane should show empty placeholder initially"); + assertEquals(0, view.detailsOpenAnimations.get(), + "No animation should have played yet"); + } + + @Test + void selectingOrderFiresOpenAnimationOnce() { + navigate(UseCase33View.class); + runPendingSignalsTasks(); + + UseCase33View view = (UseCase33View) getCurrentView(); + view.selected.set(view.orders.peek().get(0).peek()); + runPendingSignalsTasks(); + + assertEquals(1, view.detailsOpenAnimations.get(), + "Animation should fire exactly once on first selection"); + } + + @Test + void serverStatusPushDoesNotReplayAnimation() { + navigate(UseCase33View.class); + runPendingSignalsTasks(); + + UseCase33View view = (UseCase33View) getCurrentView(); + // Select order 1001 + view.selected.set(view.orders.peek().get(0).peek()); + runPendingSignalsTasks(); + int afterSelect = view.detailsOpenAnimations.get(); + + // Simulate server push: rotate status on the selected order + Button push = $view(Button.class).all().stream() + .filter(b -> "Push status update".equals(b.getText())) + .findFirst().orElseThrow(); + test(push).click(); + runPendingSignalsTasks(); + + assertEquals(afterSelect, view.detailsOpenAnimations.get(), + "Server status push must NOT replay the open animation" + + " (id-equality)"); + } + + @Test + void serverPushIsVisibleInDetailsPane() { + navigate(UseCase33View.class); + runPendingSignalsTasks(); + + UseCase33View view = (UseCase33View) getCurrentView(); + view.selected.set(view.orders.peek().get(0).peek()); // 1001, Pending + runPendingSignalsTasks(); + + Button push = $view(Button.class).all().stream() + .filter(b -> "Push status update".equals(b.getText())) + .findFirst().orElseThrow(); + test(push).click(); + runPendingSignalsTasks(); + + // Status moves Pending -> Shipped + assertTrue( + $view(Span.class).all().stream() + .anyMatch(s -> "Status: Shipped".equals(s.getText())), + "New status should be reflected in the details pane"); + } + + @Test + void switchingToDifferentOrderFiresAnimationAgain() { + navigate(UseCase33View.class); + runPendingSignalsTasks(); + + UseCase33View view = (UseCase33View) getCurrentView(); + view.selected.set(view.orders.peek().get(0).peek()); + runPendingSignalsTasks(); + view.selected.set(view.orders.peek().get(2).peek()); + runPendingSignalsTasks(); + + assertEquals(2, view.detailsOpenAnimations.get(), + "Switching to a different order id must replay the animation"); + } +} diff --git a/signals/src/test/java/com/example/usecase34/UseCase34ViewTest.java b/signals/src/test/java/com/example/usecase34/UseCase34ViewTest.java new file mode 100644 index 0000000..9d2b26e --- /dev/null +++ b/signals/src/test/java/com/example/usecase34/UseCase34ViewTest.java @@ -0,0 +1,126 @@ +package com.example.usecase34; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithMockUser; + +import com.vaadin.browserless.SpringBrowserlessTest; +import com.vaadin.browserless.ViewPackages; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.html.Span; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@ViewPackages(classes = UseCase34View.class) +@WithMockUser +class UseCase34ViewTest extends SpringBrowserlessTest { + + @Autowired + FeatureFlagService flags; + + @BeforeEach + void resetFlagsBetweenTests() { + // FeatureFlagService is application-scoped — reset state so tests + // don't depend on execution order. + flags.setNewCheckoutFlow(false); + flags.setBetaUi(false); + } + + @Test + void readingConsumerSignalReturnsCurrentValue() { + navigate(UseCase34View.class); + runPendingSignalsTasks(); + + UseCase34View view = (UseCase34View) getCurrentView(); + assertEquals(false, view.newCheckoutFlowSignal.peek(), + "Consumer can read the current flag value"); + assertEquals(false, view.betaUiSignal.peek(), + "Consumer can read the current flag value"); + } + + @Test + void defaultsRenderClassicCheckoutAndNoBetaBadge() { + navigate(UseCase34View.class); + runPendingSignalsTasks(); + + assertTrue( + $view(Span.class).all().stream() + .anyMatch(s -> "Variant: classic multi-step checkout" + .equals(s.getText())), + "Default flag state should show the classic checkout"); + + // Beta badge should not be visible + assertFalse( + $view(Span.class).all().stream().anyMatch( + s -> "BETA".equals(s.getText()) && s.isVisible()), + "BETA badge must be hidden by default"); + } + + @Test + void flippingFlagsThroughServiceUpdatesAllConsumers() { + navigate(UseCase34View.class); + runPendingSignalsTasks(); + + UseCase34View view = (UseCase34View) getCurrentView(); + view.flags.setNewCheckoutFlow(true); + view.flags.setBetaUi(true); + runPendingSignalsTasks(); + + assertTrue( + $view(Span.class).all().stream() + .anyMatch(s -> "Variant: NEW one-page checkout" + .equals(s.getText())), + "New checkout label should appear"); + assertTrue( + $view(Span.class).all().stream().anyMatch( + s -> "BETA".equals(s.getText()) && s.isVisible()), + "BETA badge should now be visible"); + } + + @Test + void adminCheckboxFlipsFlagThroughService() { + navigate(UseCase34View.class); + runPendingSignalsTasks(); + + UseCase34View view = (UseCase34View) getCurrentView(); + Checkbox newCheckout = $view(Checkbox.class).all().stream() + .filter(c -> "Enable new checkout flow".equals(c.getLabel())) + .findFirst().orElseThrow(); + test(newCheckout).click(); + runPendingSignalsTasks(); + + assertTrue(view.newCheckoutFlowSignal.peek(), + "Checkbox click should flip the flag via the service"); + } + + @Test + void newFlowParagraphsAreVisibleOnlyWhenFlagIsOn() { + navigate(UseCase34View.class); + runPendingSignalsTasks(); + + UseCase34View view = (UseCase34View) getCurrentView(); + // Initially OFF — the "1) Cart + shipping + payment..." line must + // be in the classic-only flow, not the new flow. + long beforeOn = $view(Paragraph.class).all().stream() + .filter(p -> p.getText() != null + && p.getText().startsWith("1) Cart + shipping")) + .filter(Paragraph::isVisible).count(); + + view.flags.setNewCheckoutFlow(true); + runPendingSignalsTasks(); + + long afterOn = $view(Paragraph.class).all().stream() + .filter(p -> p.getText() != null + && p.getText().startsWith("1) Cart + shipping")) + .filter(Paragraph::isVisible).count(); + + assertTrue(afterOn > beforeOn, + "Enabling the flag should reveal the new-flow paragraphs"); + } +} diff --git a/signals/src/test/java/com/example/usecase35/UseCase35ViewTest.java b/signals/src/test/java/com/example/usecase35/UseCase35ViewTest.java new file mode 100644 index 0000000..af68dbf --- /dev/null +++ b/signals/src/test/java/com/example/usecase35/UseCase35ViewTest.java @@ -0,0 +1,145 @@ +package com.example.usecase35; + +import java.util.List; + +import com.example.usecase35.Card.Priority; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithMockUser; + +import com.vaadin.browserless.SpringBrowserlessTest; +import com.vaadin.browserless.ViewPackages; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.signals.local.ValueSignal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@ViewPackages(classes = UseCase35View.class) +@WithMockUser +class UseCase35ViewTest extends SpringBrowserlessTest { + + @Test + void seedCardsRender() { + navigate(UseCase35View.class); + runPendingSignalsTasks(); + + UseCase35View view = (UseCase35View) getCurrentView(); + assertEquals(3, view.cards.peek().size()); + assertTrue( + $view(Span.class).all().stream() + .anyMatch(s -> "To do (3 cards):".equals(s.getText())), + "Count badge should match seed"); + } + + @Test + void addCardFromForm() { + navigate(UseCase35View.class); + runPendingSignalsTasks(); + + TextField title = $view(TextField.class).all().stream() + .filter(f -> "New card title".equals(f.getLabel())).findFirst() + .orElseThrow(); + test(title).setValue("Investigate flaky CI"); + Button add = $view(Button.class).all().stream() + .filter(b -> "Add to top".equals(b.getText())).findFirst() + .orElseThrow(); + test(add).click(); + runPendingSignalsTasks(); + + UseCase35View view = (UseCase35View) getCurrentView(); + assertEquals(4, view.cards.peek().size()); + assertEquals("Investigate flaky CI", + view.cards.peek().get(0).peek().title()); + } + + @Test + void moveToPreservesValueSignalIdentity() { + navigate(UseCase35View.class); + runPendingSignalsTasks(); + + UseCase35View view = (UseCase35View) getCurrentView(); + List> before = view.cards.peek(); + ValueSignal firstEntry = before.get(0); + + // Move the top card down one slot — same ValueSignal must end up at + // index 1, not be replaced. + view.cards.moveTo(firstEntry, 1); + runPendingSignalsTasks(); + + assertSame(firstEntry, view.cards.peek().get(1), + "moveTo must preserve the ValueSignal instance"); + } + + @Test + void pullBacklogPrependsBatch() { + navigate(UseCase35View.class); + runPendingSignalsTasks(); + + UseCase35View view = (UseCase35View) getCurrentView(); + Button pull = $view(Button.class).all().stream() + .filter(b -> "Pull backlog from server".equals(b.getText())) + .findFirst().orElseThrow(); + test(pull).click(); + runPendingSignalsTasks(); + + // 3 seeded + 4 from server + assertEquals(7, view.cards.peek().size()); + // First card now starts with "From server:" + assertTrue(view.cards.peek().get(0).peek().title() + .startsWith("From server:")); + assertTrue(view.cards.peek().get(3).peek().title() + .startsWith("From server:")); + // Original seed card moved down + assertEquals("Wire up the new dashboard", + view.cards.peek().get(4).peek().title()); + } + + @Test + void pasteInsertsBatchAtPositionTwo() { + navigate(UseCase35View.class); + runPendingSignalsTasks(); + + UseCase35View view = (UseCase35View) getCurrentView(); + Button paste = $view(Button.class).all().stream() + .filter(b -> b.getText() != null + && b.getText().startsWith("Paste 3 cards")) + .findFirst().orElseThrow(); + test(paste).click(); + runPendingSignalsTasks(); + + assertEquals(6, view.cards.peek().size()); + // Index 1, 2, 3 should be the pasted ones + assertEquals("Pasted A", view.cards.peek().get(1).peek().title()); + assertEquals("Pasted B", view.cards.peek().get(2).peek().title()); + assertEquals("Pasted C", view.cards.peek().get(3).peek().title()); + // Index 0 is still the original first card + assertEquals("Wire up the new dashboard", + view.cards.peek().get(0).peek().title()); + } + + @Test + void priorityIsSetFromForm() { + navigate(UseCase35View.class); + runPendingSignalsTasks(); + + UseCase35View view = (UseCase35View) getCurrentView(); + // Default priority is MEDIUM + TextField title = $view(TextField.class).all().stream() + .filter(f -> "New card title".equals(f.getLabel())).findFirst() + .orElseThrow(); + test(title).setValue("Test card"); + Button add = $view(Button.class).all().stream() + .filter(b -> "Add to top".equals(b.getText())).findFirst() + .orElseThrow(); + test(add).click(); + runPendingSignalsTasks(); + + assertEquals(Priority.MEDIUM, + view.cards.peek().get(0).peek().priority()); + } +}