Skip to main content

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_task becomes a generated view object named CreateTask.
  • _ui_action="Confirm:submitConfirm,Cancel:submitCancel" creates submit action contracts.
  • ui_onChangeAction_action=submitTaskType creates an onchange action for task_type.
  • ui_with-custom-candidate tells 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 objectGenerated rolePurpose
<create_task/>CreateTaskJava view object carrying field values and metadata keys.
<create_task/>CreateTaskControllerSpring controller entry for initializing and submitting the view.
<create_task/>CreateTaskProcessorDefault lifecycle implementation and extension points.
UI actionsdoSubmitConfirm, doSubmitCancel, doSubmitWeightAction methods called by generated controller endpoints.
Onchange actionsdoSubmitTaskType, doSubmitProductPartial form actions used to refresh dependent UI state.
Candidate fieldsprepareCandidatesForTaskType, prepareCandidatesForProductCandidate providers for select-like controls.
Validation hooksvalidate, validateSubmitConfirm, validateSubmitWeightValidation before submit and before specific actions.
Render hooksbeforeView, defaultPreRenderData 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:

MethodWhen to overrideTypical work
beforeViewBefore the view is renderedLoad current task, user, station, default product, default weight, or current business state.
prepareCandidatesFor<Field>For fields marked with custom candidatesReturn candidate lists for selects, radios, checkboxes, action links, or table rows.
defaultPreRenderJust before responseHide fields, disable fields, set page title, add max/min values, attach display metadata.
validateBefore general validationExclude hidden fields, enforce permissions, validate cross-field rules.
validateSubmit<Action>Before a specific submit actionValidate only the action being executed.
doSubmit<Action>When the user triggers an actionConfirm, 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_type and product.
  • Recalculate max_weight and plan_weight.
  • Hide fields that do not apply to the chosen task type.
  • Provide candidate products and candidate tasks.
  • Save the current service_request before navigating to a check-detail screen.
  • Return a confirmation view when the user needs a second confirmation.
  • Create or update the final domain Task when submitConfirm is 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 init and 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:

  • ViewService decides which screen should be opened.
  • CreateTaskController exposes how the screen is called.
  • CustomCreateTaskProcessor decides 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 actionProcessor methodUsage
submitConfirmdoSubmitConfirm(ctx, viewObject)Main confirmation action.
submitCanceldoSubmitCancel(ctx, viewObject)Cancel or return action.
submitWeightdoSubmitWeight(ctx, viewObject)Field-level confirm action.
submitTaskTypedoSubmitTaskType(ctx, viewObject)Onchange refresh for task type.
submitProductdoSubmitProduct(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.

Use this structure for maintainable server-driven UI code:

LayerRecommended responsibility
XML modelDeclare the view object, field types, UI hints, action names, and entry mapping.
Generated view objectCarry user input and rendered metadata keys.
Generated ControllerBind HTTP requests and delegate to the Processor.
Custom ProcessorImplement screen lifecycle, validation, candidates, and action behavior.
View serviceDecide which view entry is appropriate for the current runtime state.
Domain services / utilsImplement 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 ViewService for entry selection.
  • Use Custom...Processor for screen interaction.
  • Use domain utilities for reusable business rules.
  • Use UserContext for 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 validate when they are not applicable.
  • Page transitions return a view object or page object that the frontend knows how to render.
  • service_request is included when the workflow needs save-and-return behavior.
  • Permission checks happen in validate or 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.