Server-Driven UI with TeaQL View Objects
TeaQL can model a server-driven UI by declaring view objects in XML and generating the Java objects, controllers, and processors that drive the screen lifecycle.
This pattern is useful when the server owns the workflow state, validation, permissions, candidates, dynamic field visibility, and page transitions. The frontend becomes a renderer of view metadata and submitted actions, while the backend keeps the workflow logic close to the domain model.
1. Core Idea
A server-driven UI flow has four parts:
- A scenario type that decides which UI entry to open.
- A view object that describes the screen fields, actions, and UI hints.
- A generated Controller that exposes the screen lifecycle as HTTP endpoints.
- A Processor that owns initialization, validation, candidates, action handling, and page transition logic.
For example, a request type can map a business scenario to a view entry:
<request_type>
<_value id="1" name="Confirm Task" code="CREATE_TASK" entrance_view="create_task"/>
<_value id="2" name="Check Before Fill" code="CHECK_BEFORE_FILL" entrance_view="check_before_fill"/>
<_value id="3" name="Pre-Fill Analysis" code="ANALYSIS_FORM" entrance_view="analysis_form"/>
<_value id="4" name="Post-Fill Analysis" code="POST_ANALYSIS" entrance_view="analysis_form"/>
<_value id="5" name="Check After Fill" code="CHECK_AFTER_FILL" entrance_view="check_after_fill"/>
<_value id="6" name="Post Weight" code="POST_WEIGHT" entrance_view="finish_fill_task"/>
<_value id="7" name="PLC Mock" code="PLC_MOCK" entrance_view="plc_mock"/>
<_value id="8" name="Check While Filling" code="CHECK_FILLING" entrance_view="check_filling"/>
<_value id="9" name="Plan Task" code="PLAN_TASK" entrance_view="plan_task"/>
<_value id="10" name="Admin Pre-Fill Analysis" code="ADMIN_ANALYSIS_FORM" entrance_view="analysis_form"/>
<_value id="11" name="Admin Post-Fill Analysis" code="ADMIN_POST_ANALYSIS" entrance_view="analysis_form"/>
<_value id="12" name="Make Card" code="ADMIN_MAKE_CARD" entrance_view="make_card"/>
</request_type>
The important field is entrance_view. When entrance_view="create_task", the UI entry is the <create_task/> view object. The frontend can treat create_task as the view key, and the backend can return the generated CreateTask object and its rendered metadata.
2. Define a View Object
A view object is declared with _viewObject="true". It is not just a DTO. It is a workflow screen model with field metadata, action metadata, candidate hints, validation rules, and references to persisted entities.
<create_task
_name="Create task and confirm pre-check passed"
_viewObject="true"
_ui_action="Confirm:submitConfirm,Cancel:submitCancel"
task_type="Task Type:task_type(ui-type=single-select ui_candidate_title=name ui_with-custom-candidate ui_onChangeAction_action=submitTaskType)"
product="Product:product(ui-type=single-select ui_candidate_title=name ui_with-custom-candidate ui_onChangeAction_action=submitProduct)"
weight="Current Vehicle Weight:number(ui_confirmAction_action=Confirm:submitWeight ui-type=weight)"
max_weight="Maximum Fill Weight:number(ui-type=adjustable-weight ui-disableEdit)"
plan_weight="Planned Fill Weight:number(ui-type=adjustable-weight)"
check_item="Qualitative Check:string(ui-type=action-links ui_links_action=View Details:submitCheckDetails)"
check_quantified_item="Quantitative Check:string(ui-type=action-links ui_links_action=View Details:submitQuantifiedCheckDetails)"
batch_number="Batch Number:xxx"
confirmed="bool(ui-hidden)"
task="Dispatch Order:task(ui-type=single-select ui_candidate_title=smsCode ui_with-custom-candidate)"
service_request="service_request()"
/>
This declaration drives several things:
create_taskbecomes a generated view object namedCreateTask._ui_action="Confirm:submitConfirm,Cancel:submitCancel"creates submit action contracts.ui_onChangeAction_action=submitTaskTypecreates an onchange action fortask_type.ui_with-custom-candidatetells the backend to provide candidate values.service_request="service_request()"allows the workflow state to be saved and restored across page transitions.
3. What TeaQL Generates
For a view object named create_task, TeaQL follows the normal snake-case to PascalCase mapping and generates a set of Java artifacts similar to:
| XML view object | Generated role | Purpose |
|---|---|---|
<create_task/> | CreateTask | Java view object carrying field values and metadata keys. |
<create_task/> | CreateTaskController | Spring controller entry for initializing and submitting the view. |
<create_task/> | CreateTaskProcessor | Default lifecycle implementation and extension points. |
| UI actions | doSubmitConfirm, doSubmitCancel, doSubmitWeight | Action methods called by generated controller endpoints. |
| Onchange actions | doSubmitTaskType, doSubmitProduct | Partial form actions used to refresh dependent UI state. |
| Candidate fields | prepareCandidatesForTaskType, prepareCandidatesForProduct | Candidate providers for select-like controls. |
| Validation hooks | validate, validateSubmitConfirm, validateSubmitWeight | Validation before submit and before specific actions. |
| Render hooks | beforeView, defaultPreRender | Data preparation and final render metadata changes. |
The generated Processor already handles the common lifecycle. Your custom class should extend it and override only the behavior that belongs to the business scenario:
@Service
public class CustomCreateTaskProcessor extends CreateTaskProcessor {
@Override
public void beforeView(UserContext ctx, CreateTask createTask) {
// Prepare default form values from the current runtime state.
}
@Override
public List prepareCandidatesForProduct(UserContext ctx, CreateTask createTask) {
// Return selectable products for this user, station, tenant, or scenario.
return FillUtil.prepareCandidatesForProduct(ctx);
}
@Override
public Object defaultPreRender(UserContext ctx, CreateTask data, Object view) {
// Hide fields, set titles, attach max values, or adjust metadata before response.
return super.defaultPreRender(ctx, data, view);
}
@Override
public Object doSubmitConfirm(UserContext ctx, CreateTask createTask) {
// Validate final intent and execute the business transition.
return doConfirm0(ctx, createTask);
}
}
4. Lifecycle
A typical screen request goes through this flow:
sequenceDiagram
participant UI as Frontend Renderer
participant Controller as Generated Controller
participant Processor as Custom Processor
participant Domain as Domain Services / Q / E
UI->>Controller: init or submit action
Controller->>Processor: beforeView(ctx, viewObject)
Processor->>Domain: load defaults and runtime state
Controller->>Processor: prepareCandidatesForX(ctx, viewObject)
Processor->>Domain: query candidate lists
Controller->>Processor: validate or validateSubmitX
Controller->>Processor: doSubmitX(ctx, viewObject)
Processor->>Domain: save, update, or create next view
Controller->>Processor: defaultPreRender(ctx, data, view)
Processor-->>UI: view object + metadata + response headers
In practice, you normally customize these methods:
| Method | When to override | Typical work |
|---|---|---|
beforeView | Before the view is rendered | Load current task, user, station, default product, default weight, or current business state. |
prepareCandidatesFor<Field> | For fields marked with custom candidates | Return candidate lists for selects, radios, checkboxes, action links, or table rows. |
defaultPreRender | Just before response | Hide fields, disable fields, set page title, add max/min values, attach display metadata. |
validate | Before general validation | Exclude hidden fields, enforce permissions, validate cross-field rules. |
validateSubmit<Action> | Before a specific submit action | Validate only the action being executed. |
doSubmit<Action> | When the user triggers an action | Confirm, cancel, refresh dependent fields, create the next page, or save the domain object. |
5. Processor Responsibilities
The Processor is the best place for screen-level business behavior. It should own the interaction between UI state and domain state.
For example, a CreateTask processor can:
- Read the current station weight and set the default
weight. - Load the current task from the filling station.
- Set default
task_typeandproduct. - Recalculate
max_weightandplan_weight. - Hide fields that do not apply to the chosen task type.
- Provide candidate products and candidate tasks.
- Save the current
service_requestbefore navigating to a check-detail screen. - Return a confirmation view when the user needs a second confirmation.
- Create or update the final domain
TaskwhensubmitConfirmis triggered.
A simplified action handler looks like this:
@Override
public Object doSubmitConfirm(UserContext ctx, CreateTask createTask) {
if (((CustomUserContext) ctx).getEmployee() == null) {
errorMessage(ctx, "Please login again");
}
if (needConfirmSmallWeight(ctx, createTask)) {
ServiceRequestUtil.saveRequest(
ctx, createTask, ServiceRequestUtil.ViewOption.OVERRIDE_CURRENT);
return new ConfirmCreateTask()
.updateServiceRequest(createTask.getServiceRequest());
}
return doConfirm0(ctx, createTask);
}
The return value is important. It can be the same view object, another generated view object, a domain object, or a special page object. This is how the backend drives page transitions.
6. Controller Responsibilities
The generated Controller should remain thin. It should:
- Expose
initand submit endpoints. - Accept
UserContext. - Bind request data to the generated view object.
- Delegate lifecycle work to the Processor.
- Return the rendered object and metadata to the frontend.
Application code usually should not put workflow logic in the generated Controller. If the generated behavior is not enough, prefer overriding the Processor first. Create a custom Controller only when the HTTP boundary itself must change.
For a runtime entry point, it is common to have a small application controller or service that decides which generated Controller to call:
@Service
public class ViewService {
public Object currentView(CustomUserContext ctx) {
if (!fillStationBinding(ctx)) {
return ctx.getBean(CustomFillStationSettingController.class).init(ctx);
}
if (!hasTask(ctx)) {
return createTaskPage(ctx);
}
Task task = FillStationUtil.currentTask(ctx);
if (task.isTaskStatusToWeight()) {
return weightPage(ctx, task);
}
return createTaskPage(ctx);
}
private Object createTaskPage(CustomUserContext ctx) {
return ctx.getBean(CreateTaskController.class).init(ctx);
}
}
This keeps routing policy separate from screen behavior:
ViewServicedecides which screen should be opened.CreateTaskControllerexposes how the screen is called.CustomCreateTaskProcessordecides what the screen does.
7. Entrance View Routing
When a scenario is stored as data, entrance_view can be used as a stable bridge from domain state to UI state.
<_value
id="1"
name="Confirm Task"
code="CREATE_TASK"
entrance_view="create_task"/>
This means:
- The business scenario code is
CREATE_TASK. - The default UI entry is
create_task. - The generated UI object is
CreateTask. - The frontend can render the entry as
<create_task/>. - The backend can open it through the generated
CreateTaskController.init(ctx).
Several scenario types can share the same entrance_view. For example, both pre-analysis and post-analysis can enter analysis_form while the backend sets different flags or service request data.
8. Action Naming Rules
TeaQL actions are intentionally simple and stable.
_ui_action="Confirm:submitConfirm,Cancel:submitCancel"
This creates action names that map naturally to Processor methods:
| UI action | Processor method | Usage |
|---|---|---|
submitConfirm | doSubmitConfirm(ctx, viewObject) | Main confirmation action. |
submitCancel | doSubmitCancel(ctx, viewObject) | Cancel or return action. |
submitWeight | doSubmitWeight(ctx, viewObject) | Field-level confirm action. |
submitTaskType | doSubmitTaskType(ctx, viewObject) | Onchange refresh for task type. |
submitProduct | doSubmitProduct(ctx, viewObject) | Onchange refresh for product. |
Validation methods follow the same action name:
@Override
public void validateSubmitWeight(UserContext ctx, CreateTask createTask) {
if (((CustomUserContext) ctx).getEmployee() == null) {
errorMessage(ctx, "Please login again");
}
}
For action-specific validation, use validateSubmit<Action>. For common form validation, use validate.
9. Dynamic Rendering
defaultPreRender is the main place to adjust the rendered form after the backend has prepared the domain state.
@Override
public Object defaultPreRender(UserContext ctx, CreateTask data, Object view) {
if (data.getTaskType().equals(Constants.TASK_TYPE_WEIGHT_ONLY)) {
hiddenFields(view, CreateTask.PRODUCT_PROPERTY);
}
if (!TaskUtil.needFill(ctx, data.getTaskType())) {
hiddenFields(
view,
CreateTask.MAX_WEIGHT_PROPERTY,
CreateTask.PLAN_WEIGHT_PROPERTY,
CreateTask.BATCH_NUMBER_PROPERTY,
CreateTask.TASK_PROPERTY);
}
Object planWeightField = getField(view, CreateTask.PLAN_WEIGHT_PROPERTY);
setValue(planWeightField, "maxValue", data.getMaxWeight());
setValue(view, "pageTitle", "Create and confirm task");
return super.defaultPreRender(ctx, data, view);
}
Use this hook for UI metadata, not for final business commits. Business commits should live in action methods such as doSubmitConfirm.
10. Candidate Providers
Fields with ui_with-custom-candidate should be backed by prepareCandidatesFor<Field> methods.
product="Product:product(ui-type=single-select ui_candidate_title=name ui_with-custom-candidate ui_onChangeAction_action=submitProduct)"
@Override
public List prepareCandidatesForProduct(UserContext ctx, CreateTask createTask) {
return FillUtil.prepareCandidatesForProduct(ctx);
}
Candidate providers can use Q and E just like normal business services:
@Override
public List prepareCandidatesForTask(UserContext ctx, CreateTask createTask) {
Truck trailer = FillStationUtil.getAvailableTrailer(ctx);
if (trailer == null) {
return Collections.emptyList();
}
return FillStationUtil
.getCandidateTaskListForCreatingTask(ctx, trailer)
.getData();
}
Keep candidate methods deterministic and fast. If a candidate list depends on another field, attach an onchange action to that field and return the updated view object.
11. Service Request as Workflow State
Long forms often need to jump to child screens and return later. service_request is the standard way to preserve that workflow state.
ServiceRequestUtil.saveRequest(
ctx,
createTask,
ServiceRequestUtil.ViewOption.OVERRIDE_CURRENT);
return new FillCheck()
.updateCheckStandard(product.getCheckBeforeInit())
.updateServiceRequest(createTask.getServiceRequest())
.updateReturnView(CreateTask.class.getName());
This pattern lets the server:
- Save the current view state.
- Open a child view, such as a check-detail table.
- Store the return view class.
- Restore or merge the view state when the child view completes.
12. Recommended Structure
Use this structure for maintainable server-driven UI code:
| Layer | Recommended responsibility |
|---|---|
| XML model | Declare the view object, field types, UI hints, action names, and entry mapping. |
| Generated view object | Carry user input and rendered metadata keys. |
| Generated Controller | Bind HTTP requests and delegate to the Processor. |
| Custom Processor | Implement screen lifecycle, validation, candidates, and action behavior. |
| View service | Decide which view entry is appropriate for the current runtime state. |
| Domain services / utils | Implement reusable business rules used by the Processor. |
Avoid putting all logic in one class. The best long-term split is:
- Use XML for stable UI protocol.
- Use
ViewServicefor entry selection. - Use
Custom...Processorfor screen interaction. - Use domain utilities for reusable business rules.
- Use
UserContextfor tenant, permission, infrastructure, and runtime capabilities.
13. Checklist
Before adding a new server-driven UI screen, verify:
- The view object has
_viewObject="true". - The scenario has a stable
entrance_view. - Every UI action has a matching
doSubmit<Action>method when custom behavior is required. - Every custom candidate field has a
prepareCandidatesFor<Field>method. - Required hidden fields are excluded in
validatewhen they are not applicable. - Page transitions return a view object or page object that the frontend knows how to render.
service_requestis included when the workflow needs save-and-return behavior.- Permission checks happen in
validateor action-specific validation. - Business commits happen in submit methods, not in render hooks.
With this structure, TeaQL-generated code handles the repetitive mechanics, while custom code stays focused on the domain workflow.