diff --git a/user-interface/front-end-components.md b/user-interface/front-end-components.md index 35af1ab6e..6faad7e53 100644 --- a/user-interface/front-end-components.md +++ b/user-interface/front-end-components.md @@ -1,7 +1,7 @@ # Frontend components Some visual aid of our custom view components structure. -## Dialog window +## App dialog ```mermaid --- @@ -71,3 +71,70 @@ classDiagram } ``` + +## Stepper dialog + +```mermaid + +classDiagram + + StepperDialogFooter ..|> NavigationListener + StepperDialogFooter --> StepperDialog + StepperDialog --> NavigationListener + DialogStep ..|> Step + StepperDialog --> AppDialog + StepperDialog --> Step + StepDisplay ..|> NavigationListener + StepDisplay --> StepperDialog + + + class Step { + <> + + name() String + + content() Component + + userInput() UserInput + } + + class AppDialog { + + } + + class DialogStep { + + } + + class StepperDialog { + AppDialog dialog + Step[] steps + + registerCancelAction(Action action) + + registerConfirmAction(Action action) + + registerNavigationListener(NavigationListener listener) + + setFooter(Component component) + + setHeader(Component component) + + setStepDisplay(Component component) + + cancel() + + confirm() + + next() + + previous() + + } + + class NavigationListener { + <> + + onNavigationUpdate(NavigationInfo info) + } + + class StepDisplay { + StepperDialog dialog + } + + class StepperDialogFooter { + DialogFooter currentState + StepperDialog dialog + + } + +``` + + + diff --git a/user-interface/frontend/themes/datamanager/components/all.css b/user-interface/frontend/themes/datamanager/components/all.css index 4b2b8f083..41916bf82 100644 --- a/user-interface/frontend/themes/datamanager/components/all.css +++ b/user-interface/frontend/themes/datamanager/components/all.css @@ -25,11 +25,16 @@ First some general property definitions --font-weight-medium: 500; --font-weight-semi-bold: 600; --font-weight-bold: 700; + --icon-color-primary: var(--lumo-primary-color); --icon-color-info: rgba(22, 118, 243, 1); --icon-color-error: rgba(255, 66, 56, 1); --icon-color-success: rgba(21, 193, 93, 1); --icon-color-warning: rgba(254, 201, 1, 1); - --icon-size-m: var(--lumo-icon-size-m); + --icon-color-default: rgba(28, 46, 69, 0.6); + --icon-size-xs: 1rem; + --icon-size-s: 1.25rem; + --icon-size-m: 1.5rem; + --icon-size-l: 2rem; --spacing-01: 0.125rem; --spacing-02: 0.25rem; --spacing-03: 0.5rem; @@ -147,6 +152,16 @@ Body text Padding & gaps ***************************/ +.padding-left-right-02 { + padding-left: var(--spacing-02); + padding-right: var(--spacing-02); +} + +.padding-left-right-03 { + padding-left: var(--spacing-03); + padding-right: var(--spacing-03); +} + .padding-left-right-04 { padding-left: var(--spacing-04); padding-right: var(--spacing-04); @@ -187,6 +202,14 @@ Padding & gaps padding-bottom: var(--spacing-07); } +.gap-02 { + gap: var(--spacing-02); +} + +.gap-03 { + gap: var(--spacing-03); +} + .gap-04 { gap: var(--spacing-04); } @@ -275,6 +298,35 @@ Dialog flavours color: var(--lumo-header-text-color); } +.dialog-step-name-text { + font-size: var(--lumo-font-size-m); + font-weight: var(--font-weight-medium); + line-height: var(--lumo-line-height-xs); +} + +.dialog-step-icon-arrow { + font-size: 12px; +} + +.footer { + justify-content: flex-end; +} + +.footer-intermediate { + justify-content: space-between; + width: 100%; +} + +.full-width { + width: 100%; +} + +.border-bottom-solid { + border-bottom-style: solid; + border-bottom-color: rgba(26, 56, 96, 0.1); + border-bottom-width: 1px; +} + /*************************** Buttons **************************/ @@ -301,10 +353,68 @@ Icons color: var(--icon-color-warning) } +.icon-size-xs { + height: var(--icon-size-xs); + width: var(--icon-size-xs); +} + +.icon-size-s { + height: var(--icon-size-s); + width: var(--icon-size-s); +} + .icon-size-m { height: var(--icon-size-m); + width: var(--icon-size-m); +} + +.icon-size-l { + height: var(--icon-size-l); + width: var(--icon-size-l); +} + +.round { + border-radius: 50%; +} + +.icon-background-color-default { + background-color: var(--icon-color-default); } +.icon-background-color-primary { + background-color: var(--icon-color-primary); +} + +.icon-color-default { + color: var(--icon-color-default); +} + +.icon-text-white { + color: white; +} + +.icon-text-inner { + font-size: 0.875rem; +} + +.icon-label-text { + font-size: 0.875rem; +} + +.icon-label-text-color-default { + color: var(--icon-color-default); +} + +.icon-label-text-color-primary { + color: var(--icon-color-primary); +} + +.icon-content-center { + display: inline-flex; + justify-content: center; + align-items: center; + line-height: 1; +} /**************************** @@ -315,11 +425,20 @@ Layout align-items: center; } +.flex-align-items-bottom { + align-items: self-end; +} + .flex-horizontal { display: flex; flex-direction: row; } +.flex-vertical { + display: flex; + flex-direction: column; +} + /**************************** diff --git a/user-interface/src/main/bundles/dev.bundle b/user-interface/src/main/bundles/dev.bundle index a8baeff14..a198bd6c3 100644 Binary files a/user-interface/src/main/bundles/dev.bundle and b/user-interface/src/main/bundles/dev.bundle differ diff --git a/user-interface/src/main/bundles/prod.bundle b/user-interface/src/main/bundles/prod.bundle new file mode 100644 index 000000000..2aeb68e90 Binary files /dev/null and b/user-interface/src/main/bundles/prod.bundle differ diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/demo/ComponentDemo.java b/user-interface/src/main/java/life/qbic/datamanager/views/demo/ComponentDemo.java index fe7316c5d..9196e223f 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/demo/ComponentDemo.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/demo/ComponentDemo.java @@ -9,7 +9,9 @@ import com.vaadin.flow.router.Route; import com.vaadin.flow.server.auth.AnonymousAllowed; import com.vaadin.flow.spring.annotation.UIScope; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.Objects; import life.qbic.datamanager.views.general.dialog.AppDialog; import life.qbic.datamanager.views.general.dialog.DialogBody; @@ -18,6 +20,10 @@ import life.qbic.datamanager.views.general.dialog.DialogSection; import life.qbic.datamanager.views.general.dialog.InputValidation; import life.qbic.datamanager.views.general.dialog.UserInput; +import life.qbic.datamanager.views.general.dialog.stepper.Step; +import life.qbic.datamanager.views.general.dialog.stepper.StepperDisplay; +import life.qbic.datamanager.views.general.dialog.stepper.StepperDialog; +import life.qbic.datamanager.views.general.dialog.stepper.StepperDialogFooter; import life.qbic.datamanager.views.general.icon.IconFactory; import org.springframework.context.annotation.Profile; import org.springframework.lang.NonNull; @@ -38,6 +44,8 @@ public class ComponentDemo extends Div { public static final String HEADING_2 = "heading-2"; + public static final String GAP_04 = "gap-04"; + public static final String FLEX_VERTICAL = "flex-vertical"; Div title = new Div("Data Manager - Component Demo"); public ComponentDemo() { @@ -50,6 +58,7 @@ public ComponentDemo() { add(dialogShowCase(AppDialog.medium(), "Medium Dialog Type")); add(dialogShowCase(AppDialog.large(), "Large Dialog Type")); add(dialogSectionShowCase()); + add(stepperDialogShowCase(threeSteps(), "Three steps example")); } private static Div dialogSectionShowCase() { @@ -88,7 +97,7 @@ private static Div fontsShowCase() { Div header = new Div("Body Font Styles"); header.addClassName(HEADING_2); container.add(header); - container.addClassNames("flex-vertical", "gap-04"); + container.addClassNames(FLEX_VERTICAL, GAP_04); Arrays.stream(BodyFontStyles.fontStyles).forEach(fontStyle -> { Div styleHeader = new Div(); @@ -109,6 +118,69 @@ private static Div fontsShowCase() { return container; } + private static Div stepperDialogShowCase(List steps, String dialogTitle) { + Div content = new Div(); + Div title = new Div("Stepper Dialog"); + title.addClassName(HEADING_2); + Button showDialog = new Button("Show Stepper"); + AppDialog dialog = AppDialog.medium(); + + DialogHeader.with(dialog, dialogTitle); + StepperDialog stepperDialog = StepperDialog.create(dialog, steps); + StepperDialogFooter.with(stepperDialog); + + StepperDisplay.with(stepperDialog, steps.stream().map(Step::name).toList()); + + showDialog.addClickListener(listener -> stepperDialog.open()); + + content.add(title); + content.add(showDialog); + content.addClassNames(FLEX_VERTICAL, GAP_04); + + Div confirmBox = new Div("Click the button and press 'Cancel' or 'Save'"); + dialog.registerConfirmAction(() -> { + confirmBox.setText("Stepper dialog has been confirmed"); + dialog.close(); + }); + + dialog.registerCancelAction(() -> { + confirmBox.setText("Stepper dialog has been cancelled"); + dialog.close(); + }); + + content.add(confirmBox); + + return content; + } + + private static List threeSteps() { + List steps = new ArrayList<>(); + for (int step= 0; step < 3; step++) { + int stepNumber = step + 1; + steps.add(new Step() { + + final ExampleUserInput userInput = new ExampleUserInput("example step " + stepNumber ); + + + @Override + public String name() { + return "Step " + stepNumber; + } + + @Override + public com.vaadin.flow.component.Component component() { + return userInput; + } + + @Override + public UserInput userInput() { + return userInput; + } + }); + } + return steps; + } + private static Div dialogShowCase(AppDialog dialog, String dialogType) { Div content = new Div(); Div title = new Div(); @@ -142,7 +214,7 @@ private static Div dialogShowCase(AppDialog dialog, String dialogType) { }); content.add(showDialog, confirmBox); - content.addClassNames("flex-vertical", "gap-04"); + content.addClassNames(FLEX_VERTICAL, GAP_04); return content; } diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/AppDialog.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/AppDialog.java index 3d513541d..8e24698aa 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/AppDialog.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/AppDialog.java @@ -4,6 +4,7 @@ import com.vaadin.flow.component.dialog.Dialog; import com.vaadin.flow.component.html.Div; import java.util.Objects; +import java.util.Optional; import life.qbic.datamanager.views.general.icon.IconFactory; /** @@ -22,8 +23,12 @@ public class AppDialog extends Dialog { public static final String PADDING_LEFT_RIGHT_07 = "padding-left-right-07"; + public static final String PADDING_TOP_BOTTOM_04 = "padding-top-bottom-04"; public static final String PADDING_TOP_BOTTOM_05 = "padding-top-bottom-05"; + public static final String BORDER_BOTTOM_SOLID = "border-bottom-solid"; + public static final String FULL_WIDTH = "full-width"; private final Div header; + private final Div navigation; private final Div body; private final Div footer; @@ -37,12 +42,16 @@ private AppDialog(Style style) { header = style.header(); body = style.body(); footer = style.footer(); + navigation = style.navigation(); super.getFooter().add(footer); super.getHeader().add(header); + super.add(navigation); super.add(body); setModal(true); setCloseOnOutsideClick(false); setCloseOnEsc(false); + // by default, the navigation is not visible. + navigation.setVisible(false); } /** @@ -78,6 +87,20 @@ public static AppDialog large() { return new AppDialog(new LayoutLarge()); } + private static AppDialog createConfirmDialog(DialogAction onConfirmAction) { + var confirmDialog = AppDialog.small(); + life.qbic.datamanager.views.general.dialog.DialogHeader.withIcon(confirmDialog, + "Discard changes?", + IconFactory.warningIcon()); + DialogBody.withoutUserInput(confirmDialog, new Div( + "By aborting the editing process and closing the dialog, you will loose all information entered.")); + life.qbic.datamanager.views.general.dialog.DialogFooter.with(confirmDialog, "Continue editing", + "Discard changes"); + confirmDialog.registerConfirmAction(onConfirmAction); + confirmDialog.registerCancelAction(confirmDialog::close); + return confirmDialog; + } + public void setHeader(Component header) { this.header.removeAll(); this.header.add(header); @@ -108,7 +131,7 @@ public void confirm() { validation.ifPassed(confirmDialogAction); } else { // no user input was defined, so nothing to validate - confirmDialogAction.execute(); + Optional.ofNullable(confirmDialogAction).ifPresent(DialogAction::execute); } } @@ -127,15 +150,34 @@ public void cancel() { } } - private static AppDialog createConfirmDialog(DialogAction onConfirmAction) { - var confirmDialog = AppDialog.small(); - life.qbic.datamanager.views.general.dialog.DialogHeader.withIcon(confirmDialog, "Discard changes?", - IconFactory.warningIcon()); - DialogBody.withoutUserInput(confirmDialog, new Div("By aborting the editing process and closing the dialog, you will loose all information entered.")); - life.qbic.datamanager.views.general.dialog.DialogFooter.with(confirmDialog, "Continue editing", "Discard changes" ); - confirmDialog.registerConfirmAction(onConfirmAction); - confirmDialog.registerCancelAction(confirmDialog::close); - return confirmDialog; + /** + * Sets a navigation component that provides the user with contextual information in a more + * complex user input scenario. + * + * @param navigation a navigation component that will be placed between dialog header and body + * @since 1.7.0 + */ + public void setNavigation(Component navigation) { + this.navigation.removeAll(); + this.navigation.add(navigation); + } + + /** + * Displays the navigation element between dialog header and body. + * + * @since 1.7.0 + */ + public void displayNavigation() { + navigation.setVisible(true); + } + + /** + * Hides the navigation element between dialog header and body. + * + * @since 1.7.0 + */ + public void hideNavigation() { + navigation.setVisible(false); } /** @@ -190,6 +232,8 @@ private interface Style { Div header(); + Div navigation(); + Div body(); Div footer(); @@ -200,13 +244,16 @@ private interface Style { private static class LayoutSmall implements Style { Div header = new Div(); + Div navigation = new Div(); Div body = new Div(); Div footer = new Div(); LayoutSmall() { header.addClassNames(paddings()); + navigation.addClassNames(PADDING_LEFT_RIGHT_07, PADDING_TOP_BOTTOM_04, BORDER_BOTTOM_SOLID); body.addClassNames(paddings()); footer.addClassNames(paddings()); + footer.addClassName(FULL_WIDTH); } private static String[] paddings() { @@ -218,6 +265,11 @@ public Div header() { return header; } + @Override + public Div navigation() { + return navigation; + } + @Override public Div body() { return body; @@ -236,13 +288,16 @@ public String[] sizes() { private static class LayoutMedium implements Style { Div header = new Div(); + Div navigation = new Div(); Div body = new Div(); Div footer = new Div(); LayoutMedium() { header.addClassNames(paddings()); body.addClassNames(paddings()); + navigation.addClassNames(PADDING_LEFT_RIGHT_07, PADDING_TOP_BOTTOM_04, BORDER_BOTTOM_SOLID); footer.addClassNames(paddings()); + footer.addClassName(FULL_WIDTH); } private static String[] paddings() { @@ -258,6 +313,11 @@ public Div header() { return header; } + @Override + public Div navigation() { + return navigation; + } + @Override public Div body() { return body; @@ -272,13 +332,16 @@ public Div footer() { private static class LayoutLarge implements Style { Div header = new Div(); + Div navigation = new Div(); Div body = new Div(); Div footer = new Div(); LayoutLarge() { header.addClassNames(paddings()); + navigation.addClassNames(PADDING_LEFT_RIGHT_07, PADDING_TOP_BOTTOM_04, BORDER_BOTTOM_SOLID); body.addClassNames(paddings()); footer.addClassNames(paddings()); + footer.addClassName(FULL_WIDTH); } private static String[] paddings() { @@ -290,6 +353,11 @@ public Div header() { return header; } + @Override + public Div navigation() { + return navigation; + } + @Override public Div body() { return body; diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/ButtonFactory.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/ButtonFactory.java new file mode 100644 index 000000000..d69f67d54 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/ButtonFactory.java @@ -0,0 +1,32 @@ +package life.qbic.datamanager.views.general.dialog; + +import com.vaadin.flow.component.button.Button; + +/** + * + * + *

+ * + * @since + */ +public class ButtonFactory { + + public Button createConfirmButton(String label) { + return createButton(label, new String[]{"button-text-primary", "button-color-primary", "button-size-medium-dialog"}); + } + + private static Button createButton(String label, String[] classNames) { + Button button = new Button(label); + button.addClassNames(classNames); + return button; + } + + public Button createCancelButton(String label) { + return createButton(label, new String[]{"button-text"}); + } + + public Button createNavigationButton(String label) { + return createConfirmButton(label); + } + +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/DialogFooter.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/DialogFooter.java index c31bcfe00..07af49a09 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/DialogFooter.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/DialogFooter.java @@ -1,6 +1,5 @@ package life.qbic.datamanager.views.general.dialog; -import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.html.Div; import java.util.Objects; @@ -17,9 +16,10 @@ public class DialogFooter extends Div { private DialogFooter(AppDialog dialog, String abortText, String confirmText) { this.dialog = Objects.requireNonNull(dialog); - addClassNames("flex-horizontal", "gap-04"); - var confirmButton = createConfirmButton(confirmText); - var cancelButton = createCancelButton(abortText); + addClassNames("flex-horizontal", "gap-04", "footer"); + var buttonFactory = new ButtonFactory(); + var confirmButton = buttonFactory.createConfirmButton(confirmText); + var cancelButton = buttonFactory.createCancelButton(abortText); add(cancelButton, confirmButton); dialog.setFooter(this); confirmButton.addClickListener(e -> dialog.confirm()); @@ -34,20 +34,6 @@ private DialogFooter() { dialog = null; } - private static Button createConfirmButton(String label) { - return createButton(label, new String[]{"button-text-primary", "button-color-primary", "button-size-medium-dialog"}); - } - - private static Button createButton(String label, String[] classNames) { - Button button = new Button(label); - button.addClassNames(classNames); - return button; - } - - private static Button createCancelButton(String label) { - return createButton(label, new String[]{"button-text"}); - } - public AppDialog getDialog() { return dialog; } diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/NavigationInformation.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/NavigationInformation.java new file mode 100644 index 000000000..391934041 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/NavigationInformation.java @@ -0,0 +1,12 @@ +package life.qbic.datamanager.views.general.dialog.stepper; + +/** + * Navigation Information + * + *

Some basic navigation information in the context of {@link StepperDialog} navigation.

+ * + * @since 1.7.0 + */ +public record NavigationInformation(int currentStep, int totalSteps) { + +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/NavigationListener.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/NavigationListener.java new file mode 100644 index 000000000..c59690f0d --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/NavigationListener.java @@ -0,0 +1,24 @@ +package life.qbic.datamanager.views.general.dialog.stepper; + +/** + * Navigation Listener + * + *

Used in the context of {@link StepperDialog} navigation changes.

+ *

+ * The {@link StepperDialog} informs all subscribed {@link NavigationListener} on navigation + * changes. + * + * @since 1.7.0 + */ +public interface NavigationListener { + + /** + * Informs the listener about a navigation change in the subscribed {@link StepperDialog} + * instance. + * + * @param navigationInformation about the current new step and total steps available + * @since 1.7.0 + */ + void onNavigationChange(NavigationInformation navigationInformation); + +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/Step.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/Step.java new file mode 100644 index 000000000..0c822dc0d --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/Step.java @@ -0,0 +1,47 @@ +package life.qbic.datamanager.views.general.dialog.stepper; + +import com.vaadin.flow.component.Component; +import life.qbic.datamanager.views.general.dialog.UserInput; + +/** + * Step + * + *

Used in the context of {@link StepperDialog}. Represents an individual step to be displayed + * in a more complex user input scenario.

+ *

+ * A step carries three main building blocks essential for a {@link StepperDialog} to render its + * step properly and include validation behaviour. + *

+ * These are: + * + *

    + *
  • name - a short but precise name of the step
  • + *
  • component - the display component to be shown in the dialog
  • + *
  • userInput - the actual validation behaviour of the current step
  • + *
+ * + * @since 1.7.0 + */ +public interface Step { + + /** + * The name of the current step. + * + * @since 1.7.0 + */ + String name(); + + /** + * The {@link Component} of the current step to be displayed in the {@link StepperDialog}. + * + * @since 1.7.0 + */ + Component component(); + + /** + * The {@link UserInput} that can be validated. + * + * @since 1.7.0 + */ + UserInput userInput(); +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/StepDisplay.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/StepDisplay.java new file mode 100644 index 000000000..4e923b43a --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/StepDisplay.java @@ -0,0 +1,73 @@ +package life.qbic.datamanager.views.general.dialog.stepper; + +import com.vaadin.flow.component.html.Div; + +/** + * Step Display + * + *

A visualisation of an individual step in a {@link StepperDisplay}. Contains an step icon with + * the step number and the step name.

+ * + * @since 1.7.0 + */ +public class StepDisplay extends Div { + + public static final String ICON_BACKGROUND_COLOR_DEFAULT = "icon-background-color-default"; + public static final String ICON_BACKGROUND_COLOR_PRIMARY = "icon-background-color-primary"; + public static final String ICON_LABEL_TEXT_COLOR_DEFAULT = "icon-label-text-color-default"; + public static final String ICON_LABEL_TEXT_COLOR_PRIMARY = "icon-label-text-color-primary"; + private final Div numberIcon; + private final Div stepLabel; + + private StepDisplay(int number, String label) { + this.addClassNames("flex-vertical", "gap-03", "flex-align-items-center"); + this.numberIcon = new Div(String.valueOf(number)); + this.stepLabel = new Div(label); + numberIcon.addClassNames("round", "icon-size-m", ICON_BACKGROUND_COLOR_DEFAULT, + "icon-text-white", "icon-content-center", "icon-text-inner"); + stepLabel.addClassNames("dialog-step-name-text", ICON_LABEL_TEXT_COLOR_DEFAULT); + add(numberIcon, stepLabel); + } + + /** + * Creates a {@link StepDisplay} with a step number and step label. + *

+ * By default, the step display is deactivated. To activate it, {@link #activate()} can be + * called. + * + * @param number the step number + * @param label the step label + * @return a step display + * @since 1.70 + */ + public static StepDisplay with(int number, String label) { + return new StepDisplay(number, label); + } + + /** + * Activates the current {@link StepDisplay}, which highlights it over deactivated step displays + * in a {@link StepperDisplay}. + * + * @since 1.7.0 + */ + public void activate() { + numberIcon.removeClassName(ICON_BACKGROUND_COLOR_DEFAULT); + numberIcon.addClassName(ICON_BACKGROUND_COLOR_PRIMARY); + + stepLabel.removeClassName(ICON_LABEL_TEXT_COLOR_DEFAULT); + stepLabel.addClassName(ICON_LABEL_TEXT_COLOR_PRIMARY); + } + + /** + * Deactivates the current {@link StepDisplay}, which removes any changes made to highlight it. + * + * @since 1.7.0 + */ + public void deactivate() { + numberIcon.removeClassName(ICON_BACKGROUND_COLOR_PRIMARY); + numberIcon.addClassName(ICON_BACKGROUND_COLOR_DEFAULT); + + stepLabel.removeClassName(ICON_LABEL_TEXT_COLOR_PRIMARY); + stepLabel.addClassName(ICON_LABEL_TEXT_COLOR_DEFAULT); + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/StepperDialog.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/StepperDialog.java new file mode 100644 index 000000000..da51b1d32 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/StepperDialog.java @@ -0,0 +1,117 @@ +package life.qbic.datamanager.views.general.dialog.stepper; + +import com.vaadin.flow.component.Component; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import life.qbic.datamanager.views.general.dialog.AppDialog; +import org.springframework.lang.NonNull; + +/** + * Stepper Dialog + * + *

A wizard-like dialog, that usually contains two or more steps a user needs + * to navigate through in order to perform a certain task.

+ * + * @since 1.7.0 + */ +public class StepperDialog { + + private final AppDialog dialog; + private final List steps; + private final List navigationListeners; + private final int numberOfSteps; + private int currentStep; + + private StepperDialog(AppDialog dialog, List steps) { + this.dialog = Objects.requireNonNull(dialog); + this.steps = new ArrayList<>(Objects.requireNonNull(steps)); + this.navigationListeners = new ArrayList<>(); + if (steps.isEmpty()) { + throw new IllegalArgumentException("Steps cannot be empty"); + } + this.numberOfSteps = steps.size(); + currentStep = 1; // we use a 1-based indexing of steps + setCurrentStep(steps.get(0), dialog); + } + + public static StepperDialog create(@NonNull AppDialog dialog, @NonNull List steps) { + return new StepperDialog(dialog, steps); + } + + private static void informNavigationListeners(List listeners, + NavigationInformation information) { + listeners.forEach(listener -> listener.onNavigationChange(information)); + } + + private static boolean hasNextStep(int currentStep, int numberOfSteps) { + return currentStep < numberOfSteps; + } + + private static boolean hasPreviousStep(int currentStep) { + return currentStep > 1; + } + + private static boolean stepIsValid(Step step) { + var userInput = step.userInput(); + return userInput.validate().hasPassed(); + } + + private static void setCurrentStep(Step step, AppDialog dialog) { + dialog.setBody(step.component()); + dialog.registerUserInput(step.userInput()); + } + + public void registerNavigationListener(NavigationListener listener) { + navigationListeners.add(listener); + } + + public NavigationInformation currentNavigation() { + return navigationInformation(); + } + + private NavigationInformation navigationInformation() { + return new NavigationInformation(currentStep, numberOfSteps); + } + + public void setStepper(@NonNull Component component) { + dialog.setNavigation(Objects.requireNonNull(component)); + dialog.displayNavigation(); + } + + public void next() { + if (hasNextStep(currentStep, numberOfSteps) && currentStepIsValid()) { + currentStep++; + setCurrentStep(steps.get(currentStep - 1), dialog); + informNavigationListeners(navigationListeners, currentNavigation()); + } + } + + private boolean currentStepIsValid() { + return stepIsValid(steps.get(currentStep - 1)); + } + + public void previous() { + if (hasPreviousStep(currentStep)) { + currentStep--; + setCurrentStep(steps.get(currentStep - 1), dialog); + informNavigationListeners(navigationListeners, currentNavigation()); + } + } + + public void setFooter(Component footer) { + dialog.setFooter(footer); + } + + public void open() { + dialog.open(); + } + + public void cancel() { + dialog.cancel(); + } + + public void confirm() { + dialog.confirm(); + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/StepperDialogFooter.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/StepperDialogFooter.java new file mode 100644 index 000000000..a9bf3cbb6 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/StepperDialogFooter.java @@ -0,0 +1,143 @@ +package life.qbic.datamanager.views.general.dialog.stepper; + +import com.vaadin.flow.component.html.Div; +import java.util.Objects; +import life.qbic.datamanager.views.general.dialog.ButtonFactory; + +/** + * Stepper Dialog Footer + * + *

A more specialised footer compared to the + * {@link life.qbic.datamanager.views.general.dialog.DialogFooter}

. This footer can be used in + * the context of {@link StepperDialog} and implements the {@link NavigationListener} interface to + * get notified about navigation changes in the stepper. + * + * @since 1.7.0 + */ +public class StepperDialogFooter implements NavigationListener { + + private static final String FLEX_HORIZONTAL = "flex-horizontal"; + private static final String GAP_04 = "gap-04"; + private static final String CANCEL = "Cancel"; + + private final StepperDialog dialog; + + private final FooterFactory footerFactory = new FooterFactory(); + + private StepperDialogFooter(StepperDialog dialog) { + this.dialog = dialog; + dialog.registerNavigationListener(this); + onNavigationChange(dialog.currentNavigation()); // we want to init the footer properly. + } + + /** + * Creates a {@link StepperDialogFooter} that wires to the provided {@link StepperDialog}. During + * instantiation, the footer component of the {@link StepperDialog} is set automatically and also + * the footer subscribes to navigation changes of the {@link StepperDialog}. + * + * @param dialog the stepper dialog to wire into + * @return the fully set-up stepper dialog footer + * @since 1.7.0 + */ + public static StepperDialogFooter with(StepperDialog dialog) { + return new StepperDialogFooter(Objects.requireNonNull(dialog)); + } + + private static void updateFooter(StepperDialog dialog, Div footer) { + dialog.setFooter(footer); + } + + private static boolean hasNextStep(int currentStep, int numberOfSteps) { + return currentStep < numberOfSteps; + } + + private static boolean hasPreviousStep(int currentStep) { + return currentStep > 1; + } + + private static boolean isIntermediateStep(int currentStep, int numberOfSteps) { + return hasNextStep(currentStep, numberOfSteps) && hasPreviousStep(currentStep); + } + + @Override + public void onNavigationChange(NavigationInformation navigationInformation) { + int currentStep = navigationInformation.currentStep(); + int totalSteps = navigationInformation.totalSteps(); + if (isIntermediateStep(currentStep, totalSteps)) { + updateFooter(dialog, footerFactory.createIntermediate(dialog)); + return; + } + if (currentStep == totalSteps) { + updateFooter(dialog, footerFactory.createLast(dialog)); + } else { + updateFooter(dialog, footerFactory.createFirst(dialog)); + } + } + + private static class FooterFactory { + + + Div createFirst(StepperDialog dialog) { + return new FirstFooter(dialog); + } + + Div createIntermediate(StepperDialog dialog) { + return new IntermediateFooter(dialog); + } + + Div createLast(StepperDialog dialog) { + return new LastFooter(dialog); + } + } + + private static class FirstFooter extends Div { + + + + FirstFooter(StepperDialog dialog) { + addClassNames(FLEX_HORIZONTAL, GAP_04, "footer"); + var buttonFactory = new ButtonFactory(); + var cancelButton = buttonFactory.createCancelButton(CANCEL); + var nextButton = buttonFactory.createNavigationButton("Next"); + cancelButton.addClickListener(listener -> dialog.cancel()); + nextButton.addClickListener(listener -> dialog.next()); + add(cancelButton, nextButton); + } + } + + private static class IntermediateFooter extends Div { + + public IntermediateFooter(StepperDialog dialog) { + addClassNames(FLEX_HORIZONTAL, "footer-intermediate"); + var buttonFactory = new ButtonFactory(); + var cancelButton = buttonFactory.createCancelButton(StepperDialogFooter.CANCEL); + var nextButton = buttonFactory.createNavigationButton("Next"); + var previousButton = buttonFactory.createNavigationButton("Previous"); + previousButton.addClickListener(listener -> dialog.previous()); + cancelButton.addClickListener(listener -> dialog.cancel()); + nextButton.addClickListener(listener -> dialog.next()); + var containerRight = new Div(); + containerRight.addClassNames(FLEX_HORIZONTAL, GAP_04); + containerRight.add(cancelButton, nextButton); + add(previousButton, containerRight); + } + } + + private static class LastFooter extends Div { + + public LastFooter(StepperDialog dialog) { + addClassNames(FLEX_HORIZONTAL, "footer-intermediate"); + var buttonFactory = new ButtonFactory(); + var cancelButton = buttonFactory.createCancelButton(StepperDialogFooter.CANCEL); + var confirmButton = buttonFactory.createConfirmButton("Submit"); + var previousButton = buttonFactory.createNavigationButton("Previous"); + previousButton.addClickListener(listener -> dialog.previous()); + cancelButton.addClickListener(listener -> dialog.cancel()); + confirmButton.addClickListener(listener -> dialog.confirm()); + var containerRight = new Div(); + containerRight.addClassNames(FLEX_HORIZONTAL, GAP_04); + containerRight.add(cancelButton, confirmButton); + add(previousButton, containerRight); + } + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/StepperDisplay.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/StepperDisplay.java new file mode 100644 index 000000000..0af56df2c --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/dialog/stepper/StepperDisplay.java @@ -0,0 +1,77 @@ +package life.qbic.datamanager.views.general.dialog.stepper; + +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.IntStream; +import org.springframework.lang.NonNull; + +/** + * Stepper Display + * + *

Shows all available steps in a {@link StepperDialog}.

+ * + * @since 1.7.0 + */ +public class StepperDisplay extends Div implements NavigationListener { + + private final transient StepperDialog dialog; + + private final List steps; + + private StepperDisplay(StepperDialog stepperDialog, List stepNames) { + this.dialog = stepperDialog; + this.steps = new ArrayList<>(stepNames); + dialog.registerNavigationListener(this); + // Init the stepper to the dialogs current navigation point + onNavigationChange(dialog.currentNavigation()); + this.addClassNames("full-width", "flex-horizontal", "gap-04", "flex-align-items-bottom"); + } + + /** + * Creates a {@link StepperDisplay} for the provided {@link StepperDialog}. + *

+ * The client does not need to do anything manually, the wiring with the stepper dialog happens + * during instantiation. The stepper display will show the current step active in the stepper + * dialog. + * + * @param stepperDialog the stepper dialog to subscribe to navigation changes + * @param stepNames the step names to display + * @return a stepper display + * @since 1.7.0 + */ + public static StepperDisplay with(@NonNull StepperDialog stepperDialog, + @NonNull List stepNames) { + return new StepperDisplay(stepperDialog, stepNames); + } + + @Override + public void onNavigationChange(NavigationInformation navigationInformation) { + this.removeAll(); + IntStream.range(0, steps.size()).forEach(index -> { + // The user should see a 1-based counting of the steps + var step = StepDisplay.with(index + 1, steps.get(index)); + // The current navigation point should be highlighted to the user + if (index == navigationInformation.currentStep() - 1) { + step.activate(); + } + add(step); + // Between the steps there need to be an arrow icon pointing to the next step + if (index < navigationInformation.totalSteps() - 1) { + add(new StepPointer(VaadinIcon.ARROW_RIGHT.create())); + } + }); + dialog.setStepper(this); + } + + private static class StepPointer extends Div { + + StepPointer(@NonNull Icon icon) { + this.add(icon); + addClassNames("icon-color-default", "padding-left-right-04", "dialog-step-icon-arrow"); + } + + } +} diff --git a/user-interface/src/main/resources/vaadin-featureflags.properties b/user-interface/src/main/resources/vaadin-featureflags.properties new file mode 100644 index 000000000..0508c9161 --- /dev/null +++ b/user-interface/src/main/resources/vaadin-featureflags.properties @@ -0,0 +1,2 @@ +# Support for editing Flow views with Copilot +com.vaadin.experimental.copilotFlow=true diff --git a/user-interface/vite.config.ts b/user-interface/vite.config.ts new file mode 100644 index 000000000..4d6a0222e --- /dev/null +++ b/user-interface/vite.config.ts @@ -0,0 +1,9 @@ +import { UserConfigFn } from 'vite'; +import { overrideVaadinConfig } from './vite.generated'; + +const customConfig: UserConfigFn = (env) => ({ + // Here you can add custom Vite parameters + // https://vitejs.dev/config/ +}); + +export default overrideVaadinConfig(customConfig);