Frontend Flow — Runner ขับ Engine
หน้าบ้านทำงานยังไงกับ Lego Engine: บทบาทของ FE, 4 ทางเข้า, StepRegistry, StepHostDirective, component contract, round-trip FE↔BE, DebugPanel — อ่านได้โดยไม่ต้องรู้ Angular มาก่อน
อัปเดต: 2026-06-05
เอกสารนี้อธิบายว่า หน้าบ้านทำงานอย่างไรกับ Lego Engine ตั้งแต่แนวคิด ไปจนถึง code จริงของ Runner — เขียนสำหรับคนที่ยังไม่รู้ว่า FE มีบทบาทอะไรในระบบนี้
1. บทบาทของ Frontend ใน Lego Engine — “หน้าจอ ไม่ใช่ สมอง”
ความเข้าใจผิดที่พบบ่อย: FE ต้องรู้ว่า flow มี step อะไรบ้างเพื่อ render ถูกต้อง
ความเป็นจริง: FE ไม่รู้อะไรเลยเกี่ยวกับ flow — BE เป็นคนตัดสินใจทั้งหมด
รู้ว่า action ไหนทำได้
รู้ว่า flow จบหรือยัง
แสดงปุ่มตาม availableActions
ส่ง action กลับ BE แล้วรอ nav ใหม่
ผลที่ได้: สร้าง flow ใหม่ใน DB แล้ว FE วิ่งได้ทันที — ไม่ต้องแก้ FE เลยถ้าใช้ step type เดิม
ปรัชญา “1 Path ทุก Flow”
OnboardingRunner คือ Angular component ที่ /onboarding — path เดียวรันได้ทุก FlowDefinition Engine ฝั่ง BE บอกว่าตอนนี้ step อะไร FE แค่ render component ให้ตรง
เปรียบเทียบกับแบบเก่า:
| แบบเก่า (OnboardingV2 — deprecated) | แบบใหม่ (OnboardingRunner) |
|---|---|
| FE hardcode ว่ามี 5 step | FE ไม่รู้ว่ามีกี่ step |
if step === 2 ใน logic | อ่าน currentStep.type จาก BE |
| เพิ่ม flow ใหม่ = แก้ FE | เพิ่ม flow ใหม่ = seed DB เท่านั้น |
| test ยาก | test ง่าย (component แยกออกจาก flow) |
2. ภาพรวม: ส่วนประกอบของ Runner
3. Entry Points — 4 ทางเข้า Runner
OnboardingRunnerComponent.ngOnInit ตรวจ query params แล้วแยกทางเข้า:
| Param | ความหมาย | การทำงาน |
|---|---|---|
?code=<token> | ลิงก์คำเชิญ (invite) | validateInvite → แสดงหน้า confirm → OTP gate → เข้า v2 flow |
?id=<guid> | resume instance เดิม | open(id) → nav |
?flowCode=<code> | เริ่ม flow ใหม่ | startFlow(code, email?) → set cookie → engine drive |
| (ไม่มี param) | resume จาก cookie | getCurrentSession(); 200 = resume, 404 = ไปหน้า email-entry |
4. STEP_REGISTRY — หัวใจของ “1 Path ทุก Flow”
STEP_REGISTRY คือ map ธรรมดา: string → Angular Component — นี่คือสิ่งที่ทำให้ Runner ไม่ต้องรู้ว่า flow มีกี่ step
// step-registry.ts — เพิ่ม flow ใหม่ที่มี step type ใหม่ = เพิ่ม 1 บรรทัดที่นี่
export const STEP_REGISTRY: Record<string, Type<unknown>> = {
OtpVerificationStep: OtpVerifyV2Component,
ConsentStep: ConsentStepV2Component,
PersonalInfoStep: PersonalInfoV2Component,
NdidStep: NdidStepV2Component,
SubmitRegistrationStep: SubmitRegistrationV2Component,
JuristicBindingStep: JuristicBindingStepComponent,
CompanyInfoStep: CompanyInfoStepComponent,
UserModeStep: UserModeStepComponent,
DesignateCustomersStep: DesignateCustomersStepComponent,
ServiceTcConsentStep: ServiceTcConsentStepComponent,
SignatoryFormStep: SignatoryFormStepComponent,
SubmitApplicationStep: SubmitReviewStepComponent,
// ...
};
export function resolveStepComponent(stepType: string | null): Type<unknown> | null {
return stepType ? (STEP_REGISTRY[stepType] ?? null) : null;
}
กฎ 3 ชื่อต้องตรงกัน (สำคัญมาก)
"PersonalInfoStep" — string เดียวกันทั้ง 3 ที่ ผิดตัวอักษรเดียว = ไม่ render
5. StepHostDirective — สร้าง Component แบบ Dynamic
StepHostDirective คือ Angular directive ที่ทำให้ Runner ไม่ต้องมี @if ทีละ step:
@Directive({ selector: '[appStepHost]', standalone: true })
export class StepHostDirective {
readonly stepType = input.required<string | null>({ alias: 'appStepHost' });
readonly canEdit = input<boolean>(true);
readonly context = input<Record<string, unknown>>({});
readonly dataChange = output<object>(); // ข้อมูลฟอร์มล่าสุด
readonly validChange = output<boolean>(); // บอก valid/invalid → คุมปุ่ม
readonly action = output<StepActionEmit>(); // Next/Back/Submit... → ส่งไป engine
readonly unknownStep = output<string>(); // stepType ไม่อยู่ใน registry
}
วิธีทำงาน:
- รับ
stepTypeเป็น input - เรียก
resolveStepComponent(stepType)→ ได้ Angular Component class ViewContainerRef.createComponent(cmp)→ สร้าง component ใหม่ใน DOMsetInput('canEdit', ...)/setInput('initialData', ...)→ ส่ง input เข้า component- subscribe outputs (dataChange, validChange, action) → forward ขึ้น Runner
- เมื่อ stepType เปลี่ยน → ทำลาย component เก่า สร้างใหม่
<!-- ใน runner template — 1 บรรทัดนี้รองรับทุก step -->
<ng-container
[appStepHost]="nav.currentStep.type"
[canEdit]="!isLocked"
[context]="stepContext"
(dataChange)="onDataChange($event)"
(action)="doAction($event.code, $event.payload)"
></ng-container>
6. Contract ของ Step Component
ทุก step component ที่อยู่ใน STEP_REGISTRY ต้อง conform contract นี้:
| ทิศ | ชื่อ | ต้องมี? | ความหมาย |
|---|---|---|---|
| input | canEdit | ✅ ต้องมี | false = lock form ทั้งหมด (correction mode ล็อก step ที่ไม่ใช่ target) |
| input | initialData | optional | ข้อมูลตั้งต้น (resume / ดูซ้ำ) |
| input | context | optional | context เพิ่ม เช่น userMode, email ของผู้ใช้ |
| output | dataChange | ✅ ต้องมี | ส่งข้อมูลฟอร์มล่าสุดให้ runner เก็บ (ทุกครั้งที่ user แก้) |
| output | validChange | ✅ ต้องมี | บอก valid/invalid → runner คุมปุ่มว่า enable ได้ไหม |
| output | action | ถ้า self-action | { code: 'Next'|'Back'|'Submit'..., payload } → runner forward ไป engine |
| output | resend | เฉพาะ OTP | ขอส่งรหัสใหม่ |
@Component({ selector: 'app-my-step', standalone: true })
export class MyStepComponent {
// inputs
canEdit = input<boolean>(true);
initialData = input<unknown>(undefined);
// outputs
dataChange = output<object>();
validChange = output<boolean>();
action = output<{ code: string; payload?: Record<string, unknown> }>();
onNext() {
if (this.form.invalid) return;
this.action.emit({ code: 'Next', payload: this.form.value });
}
}
7. ปุ่ม Next/Back — ใครคุม?
Runner มี 2 โหมดสำหรับปุ่ม:
Self-action step (อยู่ใน SELF_ACTION_STEPS):
- component มีปุ่มของตัวเอง emit
actionoutput - runner ซ่อน footer กลาง
- เหมาะกับ step ที่ UX ซับซ้อน (OTP, Consent, NdidStep, PersonalInfo, SubmitRegistration)
const SELF_ACTION_STEPS = new Set([
'OtpVerificationStep', 'ConsentStep', 'NdidStep', 'PersonalInfoStep', 'SubmitRegistrationStep'
]);
Step ทั่วไป:
- runner render footer จาก
nav.currentStep.availableActions[]ที่ BE ส่งมา - กดปุ่ม → runner เรียก
doAction(code)
8. Round-trip: กด “ถัดไป” 1 ครั้ง
sequenceDiagram
autonumber
participant U as User
participant Cmp as Step Component
participant Run as Runner
participant Svc as CorporateApplicationService
participant BE as Backend
U->>Cmp: กรอกข้อมูล + กดถัดไป
Cmp->>Run: action.emit({ code:'Next', payload })
Run->>Svc: action(instanceId, stepType, 'Next', payload)
Svc->>BE: POST .../steps/{stepType}/actions/Next
BE-->>Svc: StepNavigationV2 { status, currentStep, availableActions }
Svc-->>Run: nav
Run->>Run: applyNav(nav) → routeView()
Run-->>U: render step ถัดไป (StepHost) หรือหน้า result
applyNav(nav) ทำงานอะไร:
| nav.status | Runner แสดงอะไร |
|---|---|
IN_PROGRESS | render currentStep.type ผ่าน StepHostDirective |
SUBMITTED | หน้า “อยู่ระหว่างการพิจารณา” |
FINALIZED | หน้า “สำเร็จ” พร้อม refNo |
TERMINAL | หน้า rejected/abandoned + terminalStatus |
9. Mock ↔ Real Backend (สลับด้วย toggle)
CorporateApplicationService ถูก provide 2 แบบผ่าน DI:
signature เหมือนกันทั้งคู่ (start, startFlow, open, getCurrentSession, action, listInstances) — สลับได้โดยไม่แตะ component เลย
10. DebugPanel — เครื่องมือสำหรับ Demo
DebugPanel คือ collapsible side panel ใน Runner — แสดง raw BE response real-time:
currentStep.type+ indexavailableActions[]FlowInstance.status- raw JSON ของ response ล่าสุด
วัตถุประสงค์หลัก: ใช้ระหว่าง presentation เพื่อแสดงให้ทีมเห็นว่า FE ไม่รู้ว่า step ถัดไปคืออะไร — BE ตัดสินใจทั้งหมด · กดปุ่ม Next แล้ว DebugPanel แสดง response ใหม่จาก BE ทันที
readonly debugPanelOpen = signal(true);
readonly lastRawResponse = signal<StepNavigationV2 | null>(null);
readonly rawResponseJson = computed(() =>
this.lastRawResponse() ? JSON.stringify(this.lastRawResponse(), null, 2) : null
);
11. FlowStudio — สร้างและจัดการ Flow
FlowStudio (/admin/flow-studio) คือ admin page ที่ทำให้ “Lego bricks” เป็นรูปธรรมจับต้องได้ — admin สามารถสร้าง FlowDefinition ใหม่ ลาก step เข้าไปเรียง และกด Save โดยไม่ต้อง deploy code เลย
Layout หลัก
— แต่ละ card: ชื่อ + ExecutionMode badge
— Interactive = สีน้ำเงิน · Automatic = สีเทา
— Dependencies indicator
— SkipScope badge
— Drag ออกไปวางใน Drop Zone
— Reorder ด้วยการลาก หรือปุ่ม ↑↓
— ✕ ลบ step ออก
— checkbox canGoBack ต่อ step
— Error highlight ถ้า dependency ไม่ครบ
Drag-and-Drop — Angular CDK
FlowStudio ใช้ Angular CDK DragDrop (@angular/cdk/drag-drop) ซึ่งมี 2 drop zone ที่เชื่อมกัน:
<!-- Palette (ซ้าย) — ลากออกได้ ไม่สามารถเรียงลำดับใน palette -->
<div cdkDropList #paletteList [cdkDropListConnectedTo]="[flowList]"
[cdkDropListSortingDisabled]="true">
<div *ngFor="let step of availableCatalog()" cdkDrag [cdkDragData]="step">
<!-- step card -->
</div>
</div>
<!-- Flow (ขวา) — รับจาก palette + reorder ใน flow -->
<div cdkDropList #flowList [cdkDropListData]="draft.steps"
(cdkDropListDropped)="onDrop($event)">
<div *ngFor="let step of draft.steps" cdkDrag>
<!-- step row + canGoBack + remove -->
</div>
</div>
onDrop(event) logic:
onDrop(event: CdkDragDrop<FlowStepV2[]>) {
if (event.previousContainer === event.container) {
// reorder ภายใน flow
moveItemInArray(draft.steps, event.previousIndex, event.currentIndex);
} else {
// ลากจาก palette → flow
const step = event.item.data as StepCatalogV2;
if (draft.steps.some(s => s.stepType === step.stepTypeCode)) return; // block duplicate
draft.steps.splice(event.currentIndex, 0, new FlowStepV2(step));
}
reorder(); // recalculate order indices (0, 1, 2, ...)
revalidate(); // check dependencies
}
Flow Settings Form — ทุก Field
FlowStudio มี settings panel แยกออกมา ทุก field นี้ส่งผลต่อ runtime behavior ของ engine:
Basic Information
| Field | Type | ผลต่อ Engine |
|---|---|---|
| Code | string (required, immutable หลัง create) | ชื่อย่อ "NEW_REQUESTOR" — Frontend ใช้ใน ?flowCode= · API ใช้ตอน Start |
| Name | string (required) | ชื่อเต็มใน Admin UI + DebugPanel |
| Service ID | dropdown (nullable) | null = platform-level · มีค่า = ผูกกับ Service เฉพาะ (FX etc.) |
Engine Behavior
| Field | Type | ตัวเลือก | ผลต่อ Engine |
|---|---|---|---|
| RefNoIssueAt | dropdown | null / OnEnterDraft / OnEnterSubmitted | กำหนดว่าจะ call Centralize Document Service เพื่อออกเลขที่ใบสมัครเมื่อไร |
| SpawnsCustomerFlowCode | dropdown (ดึงจาก flow list) | null / FlowCode | หลัง approve → GenerateCustomerInvitations จะสร้าง InvitationToken ชี้ไปยัง flow นี้ |
Timing & Lifecycle
| Field | Type | Default | ผลต่อ Engine |
|---|---|---|---|
| SessionTtlSeconds | number | 86400 | EditSession cookie (__Host-onboarding) หมดอายุใน N วินาที · sliding refresh ทุก action |
| StaleDaysBeforeAbandon | number (nullable) | ว่าง = ปิด | Draft ที่ไม่มีการแก้ >N วัน → AutoAbandonWorker เปลี่ยนเป็น Abandoned อัตโนมัติ |
Dependency Validation ก่อน Save
FlowStudio validate ทุกครั้ง ที่ flow เปลี่ยน (real-time) ก่อนจะ enable ปุ่ม Save:
validate(flow: FlowDefinitionV2): FlowValidationIssue[] {
const issues: FlowValidationIssue[] = [];
for (const step of flow.steps) {
for (const dep of step.dependencies ?? []) {
if (dep.mode === 'Required') {
const depIdx = flow.steps.findIndex(s => s.stepType === dep.stepTypeCode);
if (depIdx < 0)
issues.push({ stepType: step.stepType, message: `ต้องมี ${dep.stepTypeCode} ใน flow` });
else if (depIdx > flow.steps.indexOf(step))
issues.push({ stepType: step.stepType, message: `${dep.stepTypeCode} ต้องอยู่ก่อน step นี้` });
}
}
}
return issues;
}
ตัวอย่าง dependency จริง:
DesignateCustomersSteprequiresJuristicBindingStepก่อนหน้า (เพื่ออ่าน creditLineCap)SubmitApplicationSteprequires steps ทั้ง 6 ตัวก่อนหน้า
ถ้า validation ไม่ผ่าน → step row แสดง error highlight + tooltip อธิบาย
Save Flow + ทดสอบทันที
// บันทึก FlowDefinition
save() → POST /admin/flow-definitions (ใหม่)
→ PUT /admin/flow-definitions/{code} (update)
// บันทึกแล้ว navigate ไป Runner ทันที (Presentation Moment 4)
saveAndRun() → save() → router.navigate(['/onboarding'], { queryParams: { flowCode } })
HTTP Endpoints ที่ใช้:
| Method | Path | ทำอะไร |
|---|---|---|
| GET | /admin/flow-definitions | List flows ทั้งหมด |
| POST | /admin/flow-definitions | สร้าง flow ใหม่ |
| PUT | /admin/flow-definitions/{code} | อัปเดต flow |
| PATCH | /admin/flow-definitions/{code}/status | Toggle isActive |
| GET | /admin/step-catalogs | โหลด step catalog (cache ใน service) |