7. 从头创建 UI

到这一步,应用程序已经具备了管理员和 HR 经理使用的所有功能:可以配置部门和入职步骤,管理用户,生成并跟踪每个新员工的入职步骤。

现在我们需要为员工创建 UI,支持他们管理自己的入职流程。员工需要能登录系统并打开 My Onboarding 界面查看自己的入职步骤。每个步骤可以通过勾选复选框的方式标记为已完成。超期的步骤需要被高亮显示以作提醒。

下面是 My Onboarding 界面的一个原型:

my onboarding

前面的章节中,我们是通过自动生成和修改实体的 CRUD 界面的方式来创建 UI。本节中,我们将从头创建 My Onboarding 界面。

创建空界面

如果你的应用程序正在运行,先通过主工具栏的 Stopsuspend)按钮停止运行。

Jmix 工具窗口中,点击 Newadd)→ Screen

create screen 1

Create Jmix Screen 窗口中,选择 Blank screen(空界面)模板:

create screen 2

点击 Next

向导的下一步中,输入:

  • Package namecom.company.onboarding.screen.myonboarding

  • Descriptor namemy-onboarding-screen

  • Controller nameMyOnboardingScreen

create screen 3

点击 Next

向导的下一步中,修改界面标题为 My onboarding

create screen 4

点击 Create

Studio 会创建一个空界面,并在设计器打开:

create screen 5

新界面也会被添加到主菜单中。在 Jmix 工具窗口,双击 User InterfaceMain Menu 节点,切换到 Structure 标签页,将 MyOnboardingScreen 拖放至顶部:

create screen 6

点击主工具栏中的 Debugstart debugger)按钮启动应用程序。在浏览器打开 http://localhost:8080 然后登录应用程序。

点击主菜单的 ApplicationMy onboarding,会打开一个空界面。

添加表格

我们先给界面添加一个表格,用来展示当前用户的入职步骤。

定义数据容器

首先,添加一个数据容器,用于为 UI 表格提供 UserStep 实体集合。从 Component PaletteData componentsCollection 拖放至 Component Hierarchy 中的 window 元素,在弹出的对话框中选择 UserStep,点击 OK

data container 1

Studio 会创建集合数据容器:

<data>
    <collection id="userStepsDc"
                class="com.company.onboarding.entity.UserStep">
        <fetchPlan extends="_base"/>
        <loader id="userStepsDl">
            <query>
                <![CDATA[select e from UserStep e]]>
            </query>
        </loader>
    </collection>
</data>

加载数据

默认的查询语句会加载所有的 UserStep 实例,但是这里我们仅需要加载当前用户的入职步骤,且有特定的顺序。我们用 JPQL 设计器修改这个查询语句。在 Component Hierarchy 中选择 userStepsDc 容器,然后点击 query 属性的值。然后添加一个使用 :user 参数的 where 子句,以及一个 order by 子句:

data container 2

形成的查询语句如下:

<query>
    <![CDATA[select e from UserStep e
    where e.user = :user
    order by e.sortValue asc]]>
</query>

下一个任务是为 :user 参数提供一个值。可以在界面的 BeforeShowEvent 处理方法中实现。切换至 MyOnboardingScreen 控制器类,点击顶部操作面板的 Generate Handler 按钮,选择 Controller handlersBeforeShowEvent

data container 3

点击 OK。Studio 会生成一个处理方法的桩代码:

@UiController("MyOnboardingScreen")
@UiDescriptor("my-onboarding-screen.xml")
public class MyOnboardingScreen extends Screen {

    @Subscribe
    public void onBeforeShow(BeforeShowEvent event) {

    }
}

需要获取当前登录的用户,然后将该用户设置到加载器的查询参数中。

使用 Code Snippets 工具窗口生成获取当前用户的代码:

data container 4

然后注入加载器,将 :user 参数设置为当前用户并调用 load() 方法执行查询语句,将数据加载至集合数据容器:

data container 5

加载数据至集合容器的代码如下:

@Autowired
private CurrentAuthentication currentAuthentication;

@Autowired
private CollectionLoader<UserStep> userStepsDl;

@Subscribe
public void onBeforeShow(BeforeShowEvent event) {
    User user = (User) currentAuthentication.getUser();
    userStepsDl.setParameter("user", user);
    userStepsDl.load();
}

通过 Studio 生成的实体浏览或编辑界面中,数据加载默认是通过 DataLoadCoordinator facet 触发的:

<facets>
    <dataLoadCoordinator auto="true"/>
</facets>

这就是前面几章节中我们不需要手动调用 CRUD 界面中数据加载器 load() 方法的原因。

表格配置

Component Palette 中将 Table 拖放至 Component Hierarchy 中的 layout 元素。在 Table Properties Editor 弹窗中,选择 userStepsDc 数据容器,然后在 Component Inspector 中设置表格的宽为 100%,高为 400px

table 1

可以看到,表格没有展示步骤名称的列:

<table id="userStepsTable" height="400px" width="100%"
       dataContainer="userStepsDc">
    <columns>
        <column id="dueDate"/>
        <column id="completedDate"/>
        <column id="sortValue"/>
    </columns>
</table>

Step 是一个关联属性,默认不包含在表格的 fetch plan 中。前一章节 中我们在用户编辑界面展示 UserSteps 表格的时候就遇到过这种情况。

在 fetch plan 中添加 step 属性,然后在表格中添加相应的列,并删除不需要的 sortValue 列:

table 2

此时,界面的 XML 如下:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<window xmlns="http://jmix.io/schema/ui/window"
        caption="msg://myOnboardingScreen.caption">
    <data>
        <collection id="userStepsDc" class="com.company.onboarding.entity.UserStep">
            <fetchPlan extends="_base">
                <property name="step" fetchPlan="_base"/>
            </fetchPlan>
            <loader id="userStepsDl">
                <query>
                    <![CDATA[select e from UserStep e
                    where e.user = :user
                    order by e.sortValue asc]]>
                </query>
            </loader>
        </collection>
    </data>
    <layout>
        <table id="userStepsTable" height="400px" width="100%"
               dataContainer="userStepsDc">
            <columns>
                <column id="step.name"/>
                <column id="dueDate"/>
                <column id="completedDate"/>
            </columns>
        </table>
    </layout>
</window>

按下 Ctrl/Cmd+S 保存修改然后切换至运行中的程序。确保为当前用户(可能是 admin)在用户编辑界面生成几条 UserSteps。然后重新打开 My onboarding 界面,查看入职步骤:

table 3

添加生成列

本小节中,我们将为表格添加一个带有复选框的生成列,用于标记入职步骤已完成。之前章节 中,我们为用户编辑界面的 UserSteps 表格完成了类似的任务。

XML 描述中,添加 completed 的列声明:

<table id="userStepsTable" height="400px" width="100%"
       dataContainer="userStepsDc">
    <columns>
        <column id="completed" caption="" width="50px"/>
        <column id="step.name"/>
        <column id="dueDate"/>
        <column id="completedDate"/>
    </columns>
</table>

在控制器中,注入 UiComponents 工厂并实现 columnGenerator 处理方法:

@Autowired
private UiComponents uiComponents;

@Install(to = "userStepsTable.completed", subject = "columnGenerator")
private Component userStepsTableCompletedColumnGenerator(UserStep userStep) {
    CheckBox checkBox = uiComponents.create(CheckBox.class);
    checkBox.setValue(userStep.getCompletedDate() != null);
    checkBox.addValueChangeListener(e -> {
        if (userStep.getCompletedDate() == null) {
            userStep.setCompletedDate(LocalDate.now());
        } else {
            userStep.setCompletedDate(null);
        }
    });
    return checkBox;
}

按下 Ctrl/Cmd+S 保存修改然后切换至运行中的程序。重新打开 My onboarding 界面,测试最新的改动:

gen column 1

添加标签

表格基本完成了,下面我们添加展示步骤总数、完成总数和超期步骤的文本标签。

Component Palette 拖拽 ContainersVBox(垂直布局盒子)至 Component Hierarchy 中的 layout 元素中,放置于 userStepsTable 之前。然后在 vbox 中添加三个 Label 组件:

label 1

设置标签的 id:

<layout>
    <vbox spacing="true">
        <label id="totalStepsLabel"/>
        <label id="completedStepsLabel"/>
        <label id="overdueStepsLabel"/>
    </vbox>

现在,我们可以在控制器的代码中通过编程的方式计算并设置这些标签。切换至 MyOnboardingScreen 控制器,注入三个标签和 userStepsDc 集合容器:

@Autowired
private Label totalStepsLabel;

@Autowired
private Label completedStepsLabel;

@Autowired
private Label overdueStepsLabel;

@Autowired
private CollectionContainer<UserStep> userStepsDc;

然后添加两个方法,用于计算和设置计数器:

private void updateLabels() {
    totalStepsLabel.setValue("Total steps: " + userStepsDc.getItems().size());

    long completedCount = userStepsDc.getItems().stream()
            .filter(us -> us.getCompletedDate() != null)
            .count();
    completedStepsLabel.setValue("Completed steps: " + completedCount);

    long overdueCount = userStepsDc.getItems().stream()
            .filter(us -> isOverdue(us))
            .count();
    overdueStepsLabel.setValue("Overdue steps: " + overdueCount);
}

private boolean isOverdue(UserStep us) {
    return us.getCompletedDate() == null
            && us.getDueDate() != null
            && us.getDueDate().isBefore(LocalDate.now());
}

最后,从两个事件处理器中调用 updateLabels() 方法:

  1. 已有的 BeforeShowEvent 处理器:

    @Subscribe
    public void onBeforeShow(BeforeShowEvent event) {
        // ...
        updateLabels();
    }

    这样在界面打开时就会更新这些标签。

  2. 点击 Generate Handler 并选择 Data container handlersuserStepsDcItemPropertyChangeEvent

    label 2
    @Subscribe(id = "userStepsDc", target = Target.DATA_CONTAINER)
    public void onUserStepsDcItemPropertyChange(InstanceContainer.ItemPropertyChangeEvent<UserStep> event) {
        updateLabels();
    }

    有了 ItemPropertyChangeEvent 处理方法,当使用表格中的复选框修改步骤的 completedDate 属性时,也会更新标签。

按下 Ctrl/Cmd+S 保存修改然后切换至运行中的程序。重新打开 My onboarding 界面,测试标签值:

label 3

延展布局容器中的组件

从上面的截图可以看到,界面布局还需要调整,将标签和表格之间的空白区域想办法去掉。

此时,layout 根元素的垂直可用空间被等分成了两份:vboxtable。因此,table 从界面中间开始占用下半部可用空间。

一般来说,为了占满空白区域,布局容器(这里是 layout)内的某个组件需要进行 延展(expanded)。我们可以对表格进行延展,或者还有一种方法,就是添加一个不可见的元素,并将这个元素进行延展而保留表格本身的大小不变。

我们按照后面一种方式,添加一个没有值的标签,并对其进行延展。

Label 拖放至 layout 元素,设置标签 id 并在 layout 元素的 expand 属性中指定:

expand 1

XML 如下:

    <layout expand="spacer" spacing="true">
        <vbox spacing="true">
            ...
        </vbox>
        <table id="userStepsTable" ...>
            ...
        </table>
        <label id="spacer"/>
    </layout>

现在 layout 将会对 spacer 标签进行延展,而不会为其内部的子元素均匀地分配空间。

spacing="true" 属性会告诉布局容器在子组件之间保留一些间距。

按下 Ctrl/Cmd+S 保存修改然后切换至运行中的程序。重新打开 My onboarding 界面查看布局:

expand 2

保存更改并关闭界面

在这个界面内,现在我们可以修改入职步骤的状态,但是这些改动在重新打开界面后就会丢失。我们现在为界面添加两个按钮:Save 按钮用于保存更改并关闭界面;Discard 按钮用于关闭界面不保存数据。

首先,从 Component Palette 拖拽一个 ContainersHBox(水平布局盒子),放置于 userStepstablespacer 之间。然后在其内部添加两个按钮:

buttons 1

设置按钮的 id 和名称。对于 Save 按钮,添加 primary="true" 属性:

<hbox spacing="true">
    <button id="saveButton" caption="Save" primary="true"/>
    <button id="discardButton" caption="Discard"/>
</hbox>

通过 Component InspectorHandlers 生成按钮点击事件的处理方法:

buttons 2

在控制器类中注入 DataContext 并实现点击处理方法:

@Autowired
private DataContext dataContext;

@Subscribe("saveButton")
public void onSaveButtonClick(Button.ClickEvent event) {
    dataContext.commit(); (1)
    close(StandardOutcome.COMMIT); (2)
}

@Subscribe("discardButton")
public void onDiscardButtonClick(Button.ClickEvent event) {
    close(StandardOutcome.DISCARD); (2)
}
1 DataContext 会跟踪数据容器中加载实体的更改。当调用 commit() 方法时,所有对实例的改动都将保存至数据库。
2 close() 方法关闭界面。接收一个“输出”对象,可以由调用方处理。

按下 Ctrl/Cmd+S 保存修改然后切换至运行中的程序。重新打开 My onboarding 界面并查看按钮:

buttons 3

样式调整

My onboarding 界面的最后一个需求就是通过修改 Due date 单元格的字体颜色对超期的入职步骤进行高亮展示。我们将创建一个 CSS 类,并在表格中使用。

扩展默认主题

默认情况下,应用程序使用框架自带的 Helium 主题,主题为所有的 UI 组件定义了样式。需要添加我们自己的样式时,可以基于默认的主题创建一个自定义的主题。

如果你的应用程序正在运行,先通过主工具栏的 Stopsuspend)按钮停止运行。

Jmix 工具窗口中,点击 Newadd)→ AdvancedCustom Theme

theme 1

Create Custom Theme 弹窗中,在 Theme name 字段输入 helium-ext 并在 Base theme 下拉框中选择 helium

theme 2

点击 OK

Studio 会创建新主题的文件结构:

theme 3

同时也会重新配置 build.gradle 中的依赖以及在 application.properties 文件中添加两个属性:

jmix.ui.theme.name=helium-ext
jmix.ui.theme-config=com/company/onboarding/theme/helium-ext-theme.properties

打开 styles.css 文件,添加 overdue-step CSS 类的样式:

@import "helium-ext-defaults";
@import "addons";
@import "helium-ext";

.helium-ext {
    @include addons;
    @include helium-ext;

    .overdue-step {
      color: red;
    }
}

然后我们可以在 UI 组件的 stylename 属性中使用 overdue-step

添加表格样式 Provider

为表格的单元格应用自定义样式,需要给表格组件定义一个 Style Provider。

打开 MyOnboardingScreen 控制器类并点击顶部操作面板的 Generate Handler 按钮。选择 Component handlersuserStepsTablestyleProvider

style 1

点击 OK

也可以通过 Component Inspector 工具窗口的 Handlers 标签页生成处理方法。

实现 styleProvider

@Install(to = "userStepsTable", subject = "styleProvider") (1)
private String userStepsTableStyleProvider(
        UserStep entity, String property) { (2)
    if ("dueDate".equals(property) && isOverdue(entity)) {
        return "overdue-step"; (3)
    }
    return null; (4)
}
1 @Install 注解表示该方法是一个 代理(delegate):一个 UI 组件(这个 case 中是表格)会在生命周期的某个阶段调用该方法。
2 这个特殊的代理(style provider)接收一个实体实例以及单元格展示的属性名称作为参数。
3 如果该处理在 dueDate 属性调用且步骤已经超期,则处理器返回自定义样式名。
4 否则,单元格按照默认样式渲染。

按下 Ctrl/Cmd+S 保存修改然后切换至运行中的程序。重新打开 My onboarding 界面,测试超期步骤的样式:

theme 4

当修改自定义主题的 CSS 时,可以在运行的应用程序中进行快速测试。打开终端并执行:

./gradlew compileThemes

然后切换至应用程序,强制重新加载网页(Chrome 中可以按下 Shift+Ctrl/Cmd+R)。

小结

本节中,我们从头开发了一个能处理数据的 UI 界面。

学习内容:

  • 数据加载器 的查询语句可以带参数。参数值可以在 BeforeShowEvent 中设置,或者通过其他界面或其他 UI 组件事件处理方法中设置。

  • 如需触发数据加载,可以在事件处理器中调用数据加载器的 load() 方法或为界面添加 DataLoadCoordinator facet。

  • VBox 和 HBox 容器用于将内部组件进行垂直或水平摆放。根容器 layout 本身是一个垂直布局盒子。

  • UI 容器的 expand 属性指定一个内部的组件,这个组件会占满容器内部的可用空间。如果没有使用该属性,则容器的空间由内部组件均分。

  • 数据上下文commit() 方法将所有实体改动保存至数据库。

  • 界面可以通过由 Screen 基类提供的 close() 方法编程式关闭。

  • 自定义主题 可以定义 UI 组件使用的附加样式。

  • 使用 style provider 修改表格内单元格的样式。

  • 代码片段 工具窗口可以用来快速查找和生成使用框架 API 的代码。

参阅 界面布局规则 了解更多关于放置 UI 组件和布局容器的信息。