Màn hình dashboard của EzyRating hỗ trợ mở rộng thông qua cơ chế ViewDecorator và Thymeleaf fragment. Một plugin hoặc module khác có thể dùng cơ chế này để:
  • Bổ sung dữ liệu vào View.
  • Chèn thêm nội dung HTML vào dashboard.
  • Nạp CSS và JavaScript riêng.
  • Giữ nguyên mã nguồn dashboard mặc định của EzyRating.
Cách làm này phù hợp khi cần bổ sung KPI, biểu đồ, cảnh báo hoặc dữ liệu nghiệp vụ riêng mà không muốn sửa trực tiếp template của EzyRating.

Cơ chế hoạt động

Luồng xử lý gồm ba bước:
  1. ViewDecorator nhận biết request đang hiển thị dashboard EzyRating.
  2. Decorator bổ sung dữ liệu và đăng ký một Thymeleaf fragment vào biến additionalFragments.
  3. Dashboard render lần lượt các phần styles, content và scripts của fragment đã đăng ký.
Template nhận diện dashboard EzyRating là:
ezyrating/dashboard/index

Tạo ViewDecorator

Tạo một class triển khai ViewDecorator trong module muốn mở rộng dashboard:
package com.example.admin.view;

import com.tvd12.ezyfox.bean.annotation.EzySingleton;
import com.tvd12.ezyhttp.server.core.view.View;
import com.tvd12.ezyhttp.server.core.view.ViewDecorator;

import javax.servlet.http.HttpServletRequest;
import java.util.LinkedHashMap;
import java.util.Map;

import static org.youngmonkeys.ezyplatform.constant.CommonConstants
    .VIEW_VARIABLE_ADDITIONAL_FRAGMENTS;

@EzySingleton
public class CustomDashboardViewDecorator implements ViewDecorator {

    private static final String DASHBOARD_TEMPLATE =
        "ezyrating/dashboard/index";

    private static final String DASHBOARD_FRAGMENT =
        "example/dashboard/custom-dashboard";

    @Override
    public void decorate(HttpServletRequest request, View view) {
        if (!DASHBOARD_TEMPLATE.equals(view.getTemplate())) {
            return;
        }

        Map<String, Object> dashboardData = new LinkedHashMap<>();
        dashboardData.put("newCustomers", 125L);
        dashboardData.put("completedOrders", 86L);
        dashboardData.put("revenue", 12500000L);

        view.setVariable(
            "customDashboardData",
            dashboardData
        );

        view.appendValueToVariable(
            VIEW_VARIABLE_ADDITIONAL_FRAGMENTS,
            DASHBOARD_FRAGMENT
        );
    }

    @Override
    public int getPriority() {
        return Integer.MAX_VALUE;
    }
}
Annotation @EzySingleton giúp runtime phát hiện và đăng ký decorator. Package chứa class cũng phải nằm trong phạm vi component scan của ứng dụng.
Điều kiện kiểm tra template rất quan trọng:
if (!DASHBOARD_TEMPLATE.equals(view.getTemplate())) {
    return;
}
Nếu không có điều kiện này, decorator có thể bổ sung dữ liệu và fragment vào mọi màn hình trong trang quản trị.

Truyền dữ liệu vào dashboard

Dữ liệu được đưa vào View bằng setVariable:
view.setVariable(
    "customDashboardData",
    dashboardData
);
Tên biến này sẽ được sử dụng trực tiếp trong Thymeleaf:
<span th:text="">
    0
</span>
Có thể truyền bất kỳ kiểu dữ liệu nào Thymeleaf hỗ trợ, chẳng hạn:
  • DTO hoặc response object.
  • Map.
  • List.
  • Chuỗi, số hoặc giá trị boolean.
  • Kết quả thống kê lấy từ service.
Trong môi trường thực tế, dữ liệu nên được lấy qua service thay vì khai báo trực tiếp trong decorator:
private final DashboardStatisticsService statisticsService;

public CustomDashboardViewDecorator(
    DashboardStatisticsService statisticsService
) {
    this.statisticsService = statisticsService;
}

@Override
public void decorate(HttpServletRequest request, View view) {
    if (!DASHBOARD_TEMPLATE.equals(view.getTemplate())) {
        return;
    }

    view.setVariable(
        "customDashboardData",
        statisticsService.getDashboardStatistics()
    );

    view.appendValueToVariable(
        VIEW_VARIABLE_ADDITIONAL_FRAGMENTS,
        DASHBOARD_FRAGMENT
    );
}
Không nên thực hiện truy vấn nặng hoặc xử lý kéo dài trực tiếp trong decorator. Với dữ liệu cần cập nhật thường xuyên, nên render phần khung bằng fragment rồi lấy dữ liệu qua API bằng JavaScript.

Đăng ký fragment

Decorator đăng ký fragment bằng biến additionalFragments:
view.appendValueToVariable(
    VIEW_VARIABLE_ADDITIONAL_FRAGMENTS,
    "example/dashboard/custom-dashboard"
);
Giá trị fragment không chứa phần mở rộng .html.
Với giá trị trên, template tương ứng được đặt tại:
src/main/resources/templates/example/dashboard/custom-dashboard.html
Nếu chỉ dùng để chạy thử, file có thể đặt trong:
src/test/resources/templates/example/dashboard/custom-dashboard.html
appendValueToVariable nên được sử dụng thay vì ghi đè toàn bộ biến additionalFragments. Nhờ đó, dashboard có thể nhận fragment từ nhiều plugin cùng lúc.

Tạo Thymeleaf fragment

Một fragment mở rộng dashboard nên khai báo đủ ba phần:
  • styles: nạp CSS.
  • content: nội dung hiển thị.
  • scripts: nạp hoặc khởi tạo JavaScript.
Ví dụ:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
    xmlns:ezy="http://www.ezyplatform.com/thymeleaf/layout">
<head>
    <th:block th:fragment="styles">
        <link rel="stylesheet"
            ezy:vhref="/example/css/custom-dashboard.css">
    </th:block>
</head>
<body>
<th:block th:fragment="content">
    <div class="col-12">
        <div class="card custom-dashboard-card">
            <div class="card-body">
                <div class="font-weight-bold mb-3">
                    <i class="fas fa-chart-bar mr-1 text-primary"></i>
                    [[#{custom_statistics}]]
                </div>

                <div class="row">
                    <div class="col-md-4 mb-3 mb-md-0">
                        <div class="custom-dashboard-metric">
                            <span class="text-muted">
                                [[#{new_customers}]]
                            </span>
                            <strong th:text="">
                                0
                            </strong>
                        </div>
                    </div>

                    <div class="col-md-4 mb-3 mb-md-0">
                        <div class="custom-dashboard-metric">
                            <span class="text-muted">
                                [[#{completed_orders}]]
                            </span>
                            <strong th:text="">
                                0
                            </strong>
                        </div>
                    </div>

                    <div class="col-md-4">
                        <div class="custom-dashboard-metric">
                            <span class="text-muted">
                                [[#{revenue}]]
                            </span>
                            <strong th:text="${#numbers.formatInteger(
                                customDashboardData.get('revenue'),
                                1,
                                'COMMA'
                            )}">
                                0
                            </strong>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</th:block>
<th:block th:fragment="scripts">
    <script ezy:vsrc="/example/js/custom-dashboard.js"></script>
</th:block>
</body>
</html>
Nội dung fragment được đặt trong col-12 vì vùng mở rộng của dashboard nằm bên trong Bootstrap row.

Thêm CSS

Tạo file:
src/main/resources/static/example/css/custom-dashboard.css
Ví dụ:
.custom-dashboard-card {
    border-left: 4px solid #007bff;
}

.custom-dashboard-metric {
    align-items: center;
    background: #f8f9fa;
    border-radius: 0.35rem;
    display: flex;
    justify-content: space-between;
    padding: 1rem;
}

.custom-dashboard-metric strong {
    color: #343a40;
    font-size: 1.5rem;
}
Nạp CSS bằng ezy:vhref:
<link rel="stylesheet"
    ezy:vhref="/example/css/custom-dashboard.css">
ezy:vhref hỗ trợ bổ sung phiên bản tài nguyên tĩnh, giúp hạn chế trình duyệt sử dụng nhầm file đã cache sau khi triển khai phiên bản mới.

Thêm JavaScript

Tạo file:
src/main/resources/static/example/js/custom-dashboard.js
Ví dụ:
var customDashboard = {};

customDashboard.refresh = () => {
    $.get('/api/v1/example/dashboard/statistics')
        .done((data) => {
            $('#customDashboardNewCustomers').text(
                data.newCustomers || 0
            );
            $('#customDashboardCompletedOrders').text(
                data.completedOrders || 0
            );
        });
};

customDashboard.refresh();
Nạp file trong fragment scripts:
<th:block th:fragment="scripts">
    <script ezy:vsrc="/example/js/custom-dashboard.js"></script>
</th:block>
Nếu JavaScript cần truy cập phần tử HTML, nên đặt đoạn khởi tạo trong scripts thay vì styles.

Sử dụng i18n

Không nên viết trực tiếp nội dung hiển thị trong fragment. Khai báo các key tương ứng trong file ngôn ngữ:
completed_orders=Completed orders
custom_statistics=Custom statistics
new_customers=New customers
revenue=Revenue
Bản tiếng Việt sử dụng Unicode escape:
completed_orders=u0110u01A1n hu00E0ng hou00E0n thu00E0nh
custom_statistics=Thu1ED1ng ku00EA bu1ED5 sung
new_customers=Khu00E1ch hu00E0ng mu1EDBi
revenue=Doanh thu
Trong template:
<span>[[#{new_customers}]]</span>
Nếu message key thuộc một plugin riêng, plugin cần đăng ký hoặc cung cấp bundle message để runtime có thể tải các key đó.

Hiển thị dữ liệu cập nhật theo thời gian thực

Có hai cách bổ sung dữ liệu:

Render cùng trang

Decorator lấy dữ liệu từ service và truyền vào View.
Phù hợp với:
  • Dữ liệu nhỏ.
  • Dữ liệu không thay đổi thường xuyên.
  • Thống kê có thể lấy nhanh.
  • Nội dung cần xuất hiện ngay khi trang được render.

Lấy dữ liệu qua API

Decorator chỉ đăng ký fragment. JavaScript trong fragment gọi API để tải dữ liệu.
Phù hợp với:
  • Dữ liệu cập nhật thường xuyên.
  • Truy vấn thống kê tốn thời gian.
  • Biểu đồ hoặc bảng có bộ lọc.
  • Dữ liệu cần tự động làm mới.
  • Dữ liệu phụ không nên làm chậm thời gian tải dashboard.
Trong trường hợp này, fragment có thể chứa trạng thái chờ:
<div class="custom-dashboard-metric">
    <span>[[#{new_customers}]]</span>
    <strong id="customDashboardNewCustomers">-</strong>
</div>
JavaScript cập nhật giá trị sau khi nhận response từ API.

Thứ tự thực thi decorator

Khi hệ thống có nhiều ViewDecorator, có thể điều khiển thứ tự bằng getPriority:
@Override
public int getPriority() {
    return Integer.MAX_VALUE;
}
Priority cao phù hợp với decorator mở rộng vì nó cho phép dashboard gốc và các decorator khác hoàn thành việc chuẩn bị View trước.
Không nên phụ thuộc vào priority nếu các decorator không thực sự cần sử dụng dữ liệu của nhau.

Lưu ý khi triển khai

  • Chỉ áp dụng decorator cho template ezyrating/dashboard/index.
  • Dùng appendValueToVariable để không ghi đè fragment của plugin khác.
  • Đặt tên biến, CSS class và JavaScript object có tiền tố riêng để tránh xung đột.
  • Không đặt truy vấn nặng trực tiếp trong decorate.
  • Sử dụng message key thay cho nội dung hiển thị cố định.
  • Dùng ezy:vhref và ezy:vsrc cho tài nguyên tĩnh.
  • Fragment content nên tuân theo cấu trúc Bootstrap grid của dashboard.
  • API cung cấp dữ liệu động vẫn phải áp dụng authentication và authorization phù hợp.
  • Dữ liệu mockup chỉ nên đặt trong src/test; dữ liệu production phải được lấy từ service hoặc API thực tế.
Với cơ chế này, dashboard EzyRating có thể được mở rộng độc lập bởi nhiều plugin mà không phải sửa template hoặc controller mặc định.