Skip to content

7.6 · Component + page architecture

บทนี้เล่าว่า component และ page ใน vtrc-web ออกแบบอย่างไร ใช้ pattern อะไร และทำไมการเข้าใจ pattern เหล่านี้จึงสำคัญก่อนแก้ไขโค้ด

ถ้าเพิ่งเริ่มทำงานกับ repo นี้ — บทนี้คือสิ่งที่ต้องอ่านก่อนเขียน component ใหม่


สถิติ pattern

จากการสแกนไฟล์ .js ใน src/:

Patternจำนวนไฟล์สัดส่วน
withApollo(class ...)250+~95%
Form.create(...) (AntD v3 HOC)100+~40% ของ page
Function component1 (App/index.js)< 1%
<Query> / <Mutation> render props00%
useQuery / useMutation (hooks)00%
<ErrorBoundary> ระดับ root00%

ข้อสรุป

vtrc-web เป็น class component + HOC ทั้งหมด — ไม่มี hooks, ไม่มี render props, ไม่มี function component ยกเว้น App/index.js

ถ้าเห็น pattern อื่นใน repo อาจเป็น code ทดลอง หรือเป็น component ที่เขียนใหม่บางส่วน — ให้ถือว่า pattern หลักคือ class + withApollo


Pattern 1 — Class component + withApollo

jsx
import React, { Component } from 'react'
import { withApollo } from 'react-apollo'
import { Queries } from '../../graphql'

class SlipPage extends Component {
  state = {
    slip: null,
    loading: false,
  }

  componentDidMount() {
    this.fetchSlip()
  }

  fetchSlip = async () => {
    this.setState({ loading: true })
    const { data, errors } = await this.props.client.query({
      query: Queries.fetchSlip,
      variables: {
        month: this.state.month,
        year: this.state.year,
        identityCard: this.props.identityCard,
        profileKey: this.props.profileKey,
      },
    })
    if (errors) {
      // errorLink จัดการแล้ว
      this.setState({ loading: false })
      return
    }
    this.setState({ slip: data.fetchSlip, loading: false })
  }

  render() {
    const { slip, loading } = this.state
    if (loading) return <div>Loading...</div>
    return <div>{/* render slip */}</div>
  }
}

export default withApollo(SlipPage)

จุดสำคัญ

  • withApollo HOC ฉีด this.props.client (ApolloClient instance) เข้าไป
  • เรียก query ผ่าน this.props.client.query({ query, variables }) — คืน promise
  • เรียก mutation ผ่าน this.props.client.mutate({ mutation, variables }) — คืน promise
  • error handling อยู่ใน errorLink (ดู บท 6.3) — component แค่เช็ค errors แล้ว return
  • state จัดการใน this.state + this.setState — ไม่มี Redux/Context/Recoil

fetchPolicy default

ทุก query ใช้ fetchPolicy: 'no-cache' (ตั้งใน apolloClient.defaultOptions) — ดึงใหม่จาก server เสมอ ถ้าจะใช้ cache ต้อง override ในแต่ละ query:

js
this.props.client.query({
  query: Queries.dropdownListProvince,
  fetchPolicy: 'cache-first',   // override default
  variables: { ... },
})

Pattern 2 — AntD Form.create HOC

AntD v3 ใช้ Form.create() HOC เพื่อฉีด form prop เข้าไปใน component ทำให้สามารถ validate field, getFieldValue, setFieldsValue ฯลฯ:

jsx
import React, { Component } from 'react'
import { Form, Input, Button } from 'antd'
import { withApollo } from 'react-apollo'
import { Mutations } from '../../graphql'

class LeaveFormBase extends Component {
  handleSubmit = (e) => {
    e.preventDefault()
    this.props.form.validateFields(async (err, values) => {
      if (err) return
      const { data, errors } = await this.props.client.mutate({
        mutation: Mutations.createLeaveDocument,
        variables: values,
      })
      if (errors) return
      alertMessage('success', 'บันทึกสำเร็จ')
      this.props.history.push('/leave/search')
    })
  }

  render() {
    const { getFieldDecorator } = this.props.form
    return (
      <Form onSubmit={this.handleSubmit}>
        <Form.Item label="ประเภทการลา">
          {getFieldDecorator('leaveType', {
            rules: [{ required: true, message: 'กรุณาเลือกประเภท' }],
          })(<Input />)}
        </Form.Item>
        <Button type="primary" htmlType="submit">บันทึก</Button>
      </Form>
    )
  }
}

const LeaveForm = Form.create({ name: 'leave_form' })(LeaveFormBase)
export default withApollo(LeaveForm)

ลำดับ HOC

ลำดับ HOC สำคัญ — Form.create() อยู่ข้างใน withApollo ข้างนอก:

js
// ถูก
export default withApollo(Form.create({ name: 'xxx' })(MyComponent))

// ผิด (Form.create อยู่ข้างนอก จะทำให้ withApollo ไม่ทำงาน)
export default Form.create({ name: 'xxx' })(withApollo(MyComponent))

ข้อจำกัดของ AntD v3 Form

  • ไม่รองรับ nested field ที่ซับซ้อน (ต้องใช้ name: ['user', 'firstName'] array syntax)
  • validation rule แต่ละ field เขียนใน getFieldDecorator — ไม่มี schema-based validation
  • หากจะ reset ต้องใช้ this.props.form.resetFields() ไม่มี auto-reset

Pattern 3 — Page shell ของ SiderLayout

ทุก page ที่อยู่ใต้ shell จะ render ภายใน SiderLayout (ดู บท 6.4) — page component เองไม่ต้องวาด header/sider/content ซ้ำ

jsx
class LeaveFormPage extends Component {
  render() {
    return (
      <div className="page-container">
        <h1>ยื่นใบลา</h1>
        {/* content */}
      </div>
    )
  }
}

สิ่งที่ SiderLayout จัดให้

  • AntD Layout + Sider + Header + Content
  • เมนูซ้าย (toggle ได้)
  • header มีโลโก้ + ชื่อผู้ใช้ + ปุ่ม logout
  • breadcrumb (ในบางหน้า)
  • profile + role flag query ทำครั้งเดียวใน SiderLayout (cached ผ่าน component state)

สิ่งที่ page ต้องทำเอง

  • ดึงข้อมูลเฉพาะของ page ผ่าน this.props.client.query
  • วาด content ภายใน <Content>
  • จัดการ state ของ page เอง

Pattern 4 — Multi-tab form (LeaveStudies, PMS)

บาง page มี form ซับซ้อนแบ่งเป็นหลาย tab โดยใช้ folder structure:

src/pages/user/LeaveStudies/Form/
├── index.js              container + tab orchestrator
├── Tab1/index.js         tab 1 (ข้อมูลทั่วไป)
├── Tab2/index.js         tab 2 (ข้อมูลการศึกษา)
├── Tab3/index.js         tab 3 (เอกสาร)
├── Tab4/index.js         tab 4 (ผู้อนุมัติ)
├── Tab5/index.js         tab 5
├── Tab6/index.js         tab 6
├── Tab7/index.js         tab 7
└── Tab8/index.js         tab 8

container index.js เก็บ state รวมของ form แล้วส่ง props ลงไปในแต่ละ tab — ถ้าแก้ form ต้องระวัง state sync ระหว่าง tab


Pattern 5 — Section sub-components (Recruit, Welfare)

Form ใหญ่ ๆ บางตัวแบ่งเป็น section ย่อยแบบ flat:

src/pages/user/Recruit/Form/
├── index.js                       container + orchestrator
└── sector/
    ├── secProfile.js              ข้อมูลส่วนตัว
    ├── secEducation.js            การศึกษา
    ├── secWorkExperience.js       ประสบการณ์
    ├── secContact.js              ติดต่อ
    ├── secAttachment.js           ไฟล์แนบ
    ├── secCurrentJob.js           งานปัจจุบัน
    └── secCareer.js               ตำแหน่งที่สนใจ

แต่ละ section เป็น component ของตัวเอง รับ props จาก container — คล้ายกับ multi-tab แต่แสดงพร้อมกันในหน้าเดียว


โครงสร้าง component

components/element/ — reusable UI สั้น ๆ

ไฟล์เล็ก 3-4 ตัว เช่น typography, button wrapper — ใช้น้อย ส่วนใหญ่หน้าใช้ AntD component ตรง ๆ

components/user/ — domain components (36 โฟลเดอร์)

component ที่ใช้ซ้ำในหลาย page เช่น:

Componentหน้าที่
SiderLayout/ (1,226 บรรทัด)shell ของทุกหน้า
LoginForm/form login (ใช้ใน Login page)
FormInputAddress/ฟอร์มที่อยู่ (ใช้ในหลาย page)
FormInputTax/ฟอร์มภาษี
FormHospital/, FormHospitalCreate/ฟอร์มโรงพยาบาล
ConfirmUserA1/, SetPasswordsFirstTimeA1/onboarding flow
FormForgetpassword/Page1, Page2, Page3forget password wizard
ModalConfirmForm/modal ยืนยันทั่วไป
Input/, Datetime/, InputUpload/, InputDateStartEnd/input wrapper
Leave/TableCCEmail/ตาราง CC email
Hospital/TalbePeople/, TableSummary/, TableMedicalCertificate/ตารางโรงพยาบาล (สังเกตว่า Talbe สะกดผิดจาก Table แต่เป็นชื่อจริง)

components/user/from/ — typo ที่ห้ามแก้

โฟลเดอร์ from/ (สะกดผิดจาก Form) เป็นชื่อจริงที่ใช้ใน import path ทั้งระบบ — ห้ามแก้ชื่อโฟลเดอร์เด็ดขาด ถ้าแก้จะทำให้ทุก page ที่ import จากโฟลเดอร์นี้พัง

ไฟล์ใน from/ ส่วนใหญ่เป็น input wrapper ที่ใช้ในหลาย page:

src/components/user/from/
├── Input/index.js              input text ทั่วไป
├── Datetime/index.js           datetime picker (มี indexV2.js สำหรับ v2)
├── InputDateStartEnd/          ช่วงวันที่
├── InputUpload/                upload ไฟล์
├── SelectStatus/               dropdown status
├── Switchbox/                  switch
├── SMS/, OTP/                  input สำหรับ OTP
└── MeetingManagement/          component เฉพาะ meeting (หลายตัว)

การจัดการ state — local เท่านั้น

vtrc-web ไม่มี Redux, Recoil, Context หรือ state library ใด ๆ state ทั้งหมดอยู่ใน:

  1. Component statethis.state + this.setState
  2. localStorage / sessionStorage — token, profile flag, noti counter
  3. URL paramthis.props.match.params.<name> (เช่น :postId, :rowID)

ข้อจำกัด

  • ถ้าต้องการแชร์ state ระหว่าง page ต้องเก็บใน localStorage หรือ query ใหม่จาก server
  • ไม่มี "global loading" — แต่ละ page จัด loading ของตัวเอง
  • ไม่มี "global error banner" — error แสดงผ่าน alertMessage() (wrapper ของ AntD message)

Routing props ที่ใช้บ่อย

page ที่ render โดย <Route> จะได้ props 3 ตัวอัตโนมัติ:

Propที่มาใช้ทำอะไร
match.paramsreact-routerอ่าน path param (:id, :postId)
historyreact-routernavigate (history.push('/...'), history.goBack())
locationreact-routerอ่าน query string, pathname

ตัวอย่างการอ่าน param:

js
componentDidMount() {
  const { postId } = this.props.match.params
  this.fetchDetail(postId)
}

เคล็ดลับตอนทำงานจริง

ตอนสร้าง page ใหม่

  1. สร้าง folder src/pages/user/<Feature>/index.js
  2. เขียน class component + withApollo
  3. เพิ่ม route entry ใน routes.js (ดู บท 6.4)
  4. ถ้ามี form เพิ่ม Form.create() HOC

ห้าม ใช้ hooks (useState, useEffect) หรือ function component — เป็นการผิด pattern ทั้งระบบ

ตอนดึงข้อมูลใน component

เลือกตามจังหวะ:

จังหวะPattern
ตอน mountcomponentDidMount + this.props.client.query
ตอน user คลิกonClick handler + this.props.client.query
ตอน user submit formvalidateFields callback + this.props.client.mutate
ตอน prop เปลี่ยนcomponentDidUpdate(prevProps) + เปรียบเทียบ + query

ตอนจัดการ form error แบบ field-level

AntD v3 form แสดง error ใต้ field อัตโนมัติ แต่ถ้าได้ error จาก server ต้อง set เอง:

js
this.props.form.setFields({
  empCode: {
    errors: [new Error('รหัสพนักงานนี้มีอยู่แล้ว')],
  },
})

สำหรับ error ที่ไม่ใช่ field-specific ให้ใช้ alertMessage('error', msg) หรือ message.error(msg)

ตอนเลือกใช้ message vs alertMessage

  • message.error(msg) — AntD toast ปกติ
  • alertMessage('error', msg) — wrapper ใน utils/message.js ที่ใช้ใน errorLink

แนะนำให้ใช้ alertMessage เพื่อความสม่ำเสมอ — ถ้าวันหน้าทีมอยากเปลี่ยน toast library แก้ที่เดียว

ตอน import component จาก from/

ระวังสะกดผิด — โฟลเดอร์ชื่อ from ไม่ใช่ Form:

js
// ถูก
import Input from '../../components/user/from/Input'

// ผิด (จะ import ไม่ได้)
import Input from '../../components/user/Form/Input'

ตอน debug "this.props.client is undefined"

อาจเป็นเพราะลืม withApollo HOC — เช็คบรรทัดสุดท้ายของไฟล์ว่ามี:

js
export default withApollo(MyComponent)

ถ้ามี Form.create ด้วย ต้องครอบ withApollo(Form.create(...)(MyComponent))


ขั้นตอนถัดไป

ไป บท 6.7 Page deep dive — Login, Slip, Leave