Private Docs

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 เป็นคนตัดสินใจทั้งหมด

Backend(สมอง)
รู้ว่า step ถัดไปคืออะไร
รู้ว่า action ไหนทำได้
รู้ว่า flow จบหรือยัง
Frontend(หน้าจอ)
render component ตาม stepType
แสดงปุ่มตาม availableActions
ส่ง action กลับ BE แล้วรอ nav ใหม่

ผลที่ได้: สร้าง flow ใหม่ใน DB แล้ว FE วิ่งได้ทันที — ไม่ต้องแก้ FE เลยถ้าใช้ step type เดิม

ปรัชญา “1 Path ทุก Flow”

OnboardingRunner คือ Angular component ที่ /onboardingpath เดียวรันได้ทุก FlowDefinition Engine ฝั่ง BE บอกว่าตอนนี้ step อะไร FE แค่ render component ให้ตรง

เปรียบเทียบกับแบบเก่า:

แบบเก่า (OnboardingV2 — deprecated)แบบใหม่ (OnboardingRunner)
FE hardcode ว่ามี 5 stepFE ไม่รู้ว่ามีกี่ step
if step === 2 ใน logicอ่าน currentStep.type จาก BE
เพิ่ม flow ใหม่ = แก้ FEเพิ่ม flow ใหม่ = seed DB เท่านั้น
test ยากtest ง่าย (component แยกออกจาก flow)

2. ภาพรวม: ส่วนประกอบของ Runner

OnboardingRunnerComponent — ควบคุม view state (loading / step / result / error) และ orchestrate ทุก action
StepHostDirective — สร้าง step component แบบ dynamic จาก stepType
STEP_REGISTRY — map `"PersonalInfoStep"` → `PersonalInfoV2Component`
CorporateApplicationService — HTTP calls ทุกอย่าง (หรือ Mock mode)
DebugPanel — แสดง raw BE response real-time (เปิดใน demo mode)
Runner Footer — ปุ่ม ถัดไป/ย้อนกลับ สำหรับ step ที่ไม่มีปุ่มเอง

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 จาก cookiegetCurrentSession(); 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 ชื่อต้องตรงกัน (สำคัญมาก)

StepCatalog.StepTypeCode(DB)
handler.StepType(BE Code)
STEP_REGISTRY key(FE Code)

"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
}

วิธีทำงาน:

  1. รับ stepType เป็น input
  2. เรียก resolveStepComponent(stepType) → ได้ Angular Component class
  3. ViewContainerRef.createComponent(cmp) → สร้าง component ใหม่ใน DOM
  4. setInput('canEdit', ...) / setInput('initialData', ...) → ส่ง input เข้า component
  5. subscribe outputs (dataChange, validChange, action) → forward ขึ้น Runner
  6. เมื่อ 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 นี้:

ทิศชื่อต้องมี?ความหมาย
inputcanEdit✅ ต้องมีfalse = lock form ทั้งหมด (correction mode ล็อก step ที่ไม่ใช่ target)
inputinitialDataoptionalข้อมูลตั้งต้น (resume / ดูซ้ำ)
inputcontextoptionalcontext เพิ่ม เช่น userMode, email ของผู้ใช้
outputdataChange✅ ต้องมีส่งข้อมูลฟอร์มล่าสุดให้ runner เก็บ (ทุกครั้งที่ user แก้)
outputvalidChange✅ ต้องมีบอก valid/invalid → runner คุมปุ่มว่า enable ได้ไหม
outputactionถ้า self-action{ code: 'Next'|'Back'|'Submit'..., payload } → runner forward ไป engine
outputresendเฉพาะ 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 action output
  • 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.statusRunner แสดงอะไร
IN_PROGRESSrender currentStep.type ผ่าน StepHostDirective
SUBMITTEDหน้า “อยู่ระหว่างการพิจารณา”
FINALIZEDหน้า “สำเร็จ” พร้อม refNo
TERMINALหน้า rejected/abandoned + terminalStatus

9. Mock ↔ Real Backend (สลับด้วย toggle)

CorporateApplicationService ถูก provide 2 แบบผ่าน DI:

OnboardingRunner
CorporateApplicationServiceDI · getUseMock()
useMock=true · in-memory state machine
useMock=false · HTTP → BE จริง

signature เหมือนกันทั้งคู่ (start, startFlow, open, getCurrentSession, action, listInstances) — สลับได้โดยไม่แตะ component เลย


10. DebugPanel — เครื่องมือสำหรับ Demo

DebugPanel คือ collapsible side panel ใน Runner — แสดง raw BE response real-time:

  • currentStep.type + index
  • availableActions[]
  • 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 หลัก

LegoBricksPanel(ซ้าย)
— Step catalog ทั้งหมดที่มี
— แต่ละ card: ชื่อ + ExecutionMode badge
— Interactive = สีน้ำเงิน · Automatic = สีเทา
— Dependencies indicator
— SkipScope badge
Drag ออกไปวางใน Drop Zone
drag →
Drop Zone(ขวา)
— Step ที่อยู่ใน flow ปัจจุบัน
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

FieldTypeผลต่อ Engine
Codestring (required, immutable หลัง create)ชื่อย่อ "NEW_REQUESTOR" — Frontend ใช้ใน ?flowCode= · API ใช้ตอน Start
Namestring (required)ชื่อเต็มใน Admin UI + DebugPanel
Service IDdropdown (nullable)null = platform-level · มีค่า = ผูกกับ Service เฉพาะ (FX etc.)

Engine Behavior

FieldTypeตัวเลือกผลต่อ Engine
RefNoIssueAtdropdownnull / OnEnterDraft / OnEnterSubmittedกำหนดว่าจะ call Centralize Document Service เพื่อออกเลขที่ใบสมัครเมื่อไร
SpawnsCustomerFlowCodedropdown (ดึงจาก flow list)null / FlowCodeหลัง approve → GenerateCustomerInvitations จะสร้าง InvitationToken ชี้ไปยัง flow นี้

Timing & Lifecycle

FieldTypeDefaultผลต่อ Engine
SessionTtlSecondsnumber86400EditSession cookie (__Host-onboarding) หมดอายุใน N วินาที · sliding refresh ทุก action
StaleDaysBeforeAbandonnumber (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 จริง:

  • DesignateCustomersStep requires JuristicBindingStep ก่อนหน้า (เพื่ออ่าน creditLineCap)
  • SubmitApplicationStep requires 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 ที่ใช้:

MethodPathทำอะไร
GET/admin/flow-definitionsList flows ทั้งหมด
POST/admin/flow-definitionsสร้าง flow ใหม่
PUT/admin/flow-definitions/{code}อัปเดต flow
PATCH/admin/flow-definitions/{code}/statusToggle isActive
GET/admin/step-catalogsโหลด step catalog (cache ใน service)