Skip to content

4.1.2 · GraphQL catalog + Component patterns

บทนี้รวม 2 หัวข้อที่ V1 แยกเป็น 2 บท (graphql-catalog + component-patterns) เพราะทั้งคู่อยู่ในโครงสร้างเดียวกัน — query/mutation ถูก import เข้า component, component ถูก import เข้า route


Part 1 · GraphQL catalog

โครงสร้างไฟล์

vtrc-web/src/graphql/ มี 3 ไฟล์หลัก:

ไฟล์บรรทัดOperation countExport pattern
queries.js4,493~150 queriesexport default { queryName1, queryName2, ... }
mutations.js1,447~60 mutationsexport default { mutationName1, ... }
index.js2export { default as Queries } from './queries' + Mutations

vtrc-web/src/graphql/queries.js:1-2:

javascript
import gql from "graphql-tag";
import { toGqlTypes, toGqlArgs, toGqlFields } from "../utils/string";

vtrc-web/src/graphql/queries.js:4479-4493 (closing default export):

javascript
  fetchListStudyLeaveRequest,
  fetchMasterConfigTypeLeave,
  fetchStudyLeaveRequestById,
  dropDownstudyLeaveYear,
  fetchDetailProfileListCC,
  employeeDetailProfileForLeave,
  listCancelWaitingForApproval,
  detailLeaveDocumentAsFlow,
  fetchUrlSalaryCertForBank,
  fetchDropDownMasterData,
  dropDownMasterSlipPayment,
  fetchUrlTuitionFeesReport,
  fetchMasterConfigTypePMS
};

pattern: ประกาศ const xxx = gql\...`ทั้งไฟล์ แล้วปิดท้ายด้วยexport default { xxx, yyy, ... }` (ไม่ใช่ named export แต่ละตัว)

vtrc-web/src/graphql/index.js:1-2:

javascript
export { default as Queries } from './queries'
export { default as Mutations } from './mutations'

component ที่จะใช้จะ import ผ่าน hub นี้: import { Queries, Mutations } from '../../graphql'

ตัวอย่าง query block

vtrc-web/src/graphql/queries.js:4-40:

javascript
const fetchListPost = gql`
  query (
    $postId: ID
    $type: String
    $status: String
    $offset: Int
    $limit: Int
    $search: String
    $category: CatType
  ) {
    fetchListPost(
      postId: $postId
      type: $type
      status: $status
      offset: $offset
      limit: $limit
      search: $search
      category: $category
    ) {
      total
      totalPage
      data {
        postId
        postTitleTH
        postTitleEN
        subTitleTH
        subTitleEN
        descriptionTH
        descriptionEN
        orderNo
        status
        type
        date
        linkOriginal
        postBannerId
        postThumbnailId
        bannerLink

จุดที่น่าสนใจ:

  • operation name (fetchListPost) ตรงกับ GraphQL field ใน backend (fetchListPost(...) ใน body) — pattern นี้ลดความสับสนตอน debug
  • query ดึง relation ลึกหลายชั้น (data { postId, postTitleTH, ... }) — แต่ละ query มักฝัง 20-50 field ในก้อนเดียว ไม่มี fragment แยก
  • มีทั้ง TH และ EN suffix — backend คืนข้อมูล 2 ภาษา

การนับ operations

เนื่องจาก pattern คือ const xxx = gql\...`` การนับจำนวน query ใช้:

bash
rg "^const \w+ = gql\`" vtrc-web/src/graphql/queries.js | wc -l

ผลที่ได้คือ ~150 queries ใน queries.js (V1 ระบุตัวเลขใกล้เคียง) — รายละเอียด operation name ทั้งหมดดูในไฟล์จริงหรือ V1 บท 6.5

กลุ่มฟีเจอร์หลัก (11 cluster)

จากการ group ตามชื่อ query prefix:

ClusterPrefix ตัวอย่างQueries เด่น
Auth/onboardinglogin, register, otp, forgotlogin, registerPersonal, requestOtp, verifyOtp
News/policy/aboutfetchListPost, fetchPolicy, fetchAboutfetchListPost, fetchPostById, fetchPolicyCategory
Profile/HR masterfetchProfile, fetchEmployee, fetchMasterfetchProfile, fetchEmployeeDetail, fetchDropDownMasterData
LeavefetchLeave, fetchStudyLeave, fetchListStudyLeaveRequestfetchListLeave, fetchListStudyLeaveRequest, fetchStudyLeaveRequestById
Welfare/fundfetchFund, fetchWelfare, fetchDelegatesfetchListFund, fetchFundDetail, fetchDelegatesExpire
Hospital (medical claim)fetchHospital, fetchHospitalHr, fetchHospitalPaidfetchListHospital, fetchHospitalDetail, fetchHospitalPaid
Income doc (Slip)fetchSlip, dropDownSlipPaymentfetchListSlip, dropDownMasterSlipPayment, fetchUrlSalaryCertForBank
PMSfetchPms, fetchMasterConfigTypePMSfetchPmsList, fetchMasterConfigTypePMS
Course/trainingfetchCourse, fetchTraining, fetchCategoryCoursefetchListCourse, fetchCourseCategory
ContactfetchContact, fetchUrlSalaryCertForBankfetchContactList
Meeting/recruit/miscfetchMeeting, fetchListCancelWaitingForApprovalfetchListCancelWaitingForApproval, detailLeaveDocumentAsFlow

Pattern การ consume

vtrc-web ใช้แต่ class component + withApollo HOCuseQuery hook มี 0 การใช้งานใน codebase ทำให้ทุก query ถูกเรียกผ่าน:

javascript
import { withApollo } from 'react-apollo'
import { Queries } from '../../graphql'

class NewsList extends React.Component {
  state = { news: [], loading: false }

  componentDidMount() {
    this.fetchNews()
  }

  fetchNews = async () => {
    this.setState({ loading: true })
    const { data, errors } = await this.props.client.query({
      query: Queries.fetchListPost,
      variables: { offset: 0, limit: 20 }
    })
    if (!errors) {
      this.setState({ news: data.fetchListPost.data, loading: false })
    }
  }

  render() { /* ... */ }
}

export default withApollo(NewsList)

สำหรับ mutation:

javascript
const { data, errors } = await this.props.client.mutate({
  mutation: Mutations.createPost,
  variables: { postTitleTH: '...', ... }
})

ไฟล์ legacy ใน graphql/

vtrc-web/src/graphql/ มี snapshot เก่าที่ต้องระวัง:

ไฟล์สถานะเหตุผล
queries_pentest.jssnapshot สมัย pentestอาจมี query ที่ไม่ได้ใช้แล้ว — ห้ามใช้เป็นต้นแบบ
querries_prd.js (สะกดผิด)snapshot ของ env prodไม่ใช่ query จริง ใช้ queries.js ตัวหลัก
index_bk.jslegacy hubcommented-out

กฎ — ใช้ queries.js และ mutations.js ตัวจริงเท่านั้น import ผ่าน index.js เสมอ

ปัญหาที่ต้องระวังเมื่อแก้ query/mutation

  1. field ไม่ minimal — query อย่าง fetchListPost ดึง relation ลึก (ทั้ง postTitleTH, postTitleEN, banner, thumbnail, linkOriginal) ในก้อนเดียว ทำให้ response ใหญ่และ resolver ฝั่ง server หนัก
  2. ไม่มี fragment ใช้เลย — ทุก query เขียน field ซ้ำในตัว ถ้าจะเริ่มใช้ fragment ต้องคิดเป็น convention ใหม่ทั้งไฟล์ ไม่ใช่ทีละ query
  3. ห้ามลบ field โดยไม่ grep หา consumer ก่อน — เพราะไม่มี TypeScript ตรวจ compile-time ถ้าลบ field ที่ component อื่นอ้างถึงจะ runtime error ที่หน้าขาว
  4. variables type ฝั่ง backend เปลี่ยนทุก so often — ถ้าเพิ่ม field ใหม่ใน typeDef ฝั่ง vtrc-api ต้องมาอัปเดต query ฝั่ง vtrc-web ด้วย ไม่งั้น validation error ตอน runtime
  5. fetchPolicy default = cache-first — Apollo Client v2 default ใช้ cache ทำให้บางครั้งข้อมูลไม่ update ต้อง set fetchPolicy: 'network-only' ใน query ที่ต้องการข้อมูลใหม่เสมอ

Part 2 · Component patterns

โครงสร้างโฟลเดอร์

vtrc-web/src/:

components/
├── element/             3 ไฟล์      — reusable UI เล็กๆ (เช่น Button, Loader)
└── user/                36 โฟลเดอร์  — domain components
    ├── from/            ← TYPO! (ควรเป็น Form/) — ห้ามแก้ชื่อ import อ้างถึงทั้งระบบ
    ├── News/
    ├── Leave/
    ├── Hospital/
    └── ... (33 folder อื่น)
pages/
└── user/                48 โฟลเดอร์  — route screens (1 folder = 1 feature)
    ├── Login/
    ├── News/
    ├── Profile/
    ├── Leave/
    ├── Welfare/
    └── ... (43 folder อื่น)

จำนวน component vs page: 36 component folders + 48 page folders = 84 unit ของหน้าจอ

Class component style (ทั้ง codebase)

vtrc-web ใช้แต่ class component — ไม่มี function component + hooks เลย (ยืนยันจาก rg "useQuery|useState|useEffect" src/ = 0 match)

ลักษณะ:

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

class NewsForm extends Component {
  state = {
    title: '',
    loading: false,
    error: null
  }

  componentDidMount() {
    // initial fetch
    this.fetchNews()
  }

  componentWillUnmount() {
    // cleanup (e.g., abort fetch)
  }

  fetchNews = async () => {
    this.setState({ loading: true })
    try {
      const { data } = await this.props.client.query({
        query: Queries.fetchListPost,
        variables: { limit: 20 }
      })
      this.setState({ news: data.fetchListPost.data, loading: false })
    } catch (err) {
      this.setState({ error: err.message, loading: false })
    }
  }

  handleSubmit = (e) => {
    e.preventDefault()
    this.props.form.validateFields(async (err, values) => {
      if (!err) {
        await this.props.client.mutate({
          mutation: Mutations.createPost,
          variables: values
        })
      }
    })
  }

  render() {
    const { getFieldDecorator } = this.props.form
    return (
      <Form onSubmit={this.handleSubmit}>
        <Form.Item>
          {getFieldDecorator('title', {
            rules: [{ required: true, message: 'กรุณากรอก title' }]
          })(<Input />)}
        </Form.Item>
        <Button type="primary" htmlType="submit">Save</Button>
      </Form>
    )
  }
}

export default withApollo(Form.create()(NewsForm))

จุดสำคัญ:

  • withApollo(Form.create()(NewsForm)) — 2 HOC ซ้อนกัน withApollo จาก react-apollo ให้ this.props.client และ Form.create() จาก AntD v3 ให้ this.props.form (ที่มี getFieldDecorator, validateFields)
  • this.props.form.validateFields(callback) — pattern validation ของ AntD v3 (v4 เปลี่ยนเป็น Form.useForm() hook)
  • componentDidMount สำหรับ initial fetch — ไม่มี useEffect ทำให้ logic อยู่ใน lifecycle method

from/ folder typo — ห้ามแก้

vtrc-web/src/components/user/from/ (ควรเป็น Form/) — typo ที่ import อ้างถึงทั่วระบบ

javascript
// ตัวอย่าง import path ที่หลายไฟล์ใช้
import FormContactUS from '../components/user/from/ContactUS'
import FormLeave from '../components/user/from/Leave'

กฎเหล็ก — ถ้าจะแก้ชื่อ folder fromForm ต้อง update import ทุกที่ที่อ้างถึง (grep from.*components/user/from/) พร้อมกัน ไม่งั้น build fail

Form patterns

vtrc-web ใช้ AntD v3 Form decorator pattern:

javascript
<Form onSubmit={this.handleSubmit}>
  <Form.Item label="Title">
    {getFieldDecorator('title', {
      rules: [{ required: true, message: 'กรุณากรอกข้อมูล' }]
    })(
      <Input placeholder="กรอก title" />
    )}
  </Form.Item>
</Form>
  • getFieldDecorator(fieldId, options)(component) — HOC ที่ binding component เข้ากับ form state
  • rules — validation rule (required, pattern, min, max)
  • this.props.form.validateFields((err, values) => ...) — trigger validation แล้วคืนค่าถ้าผ่าน

Form controls ที่ใช้บ่อย

Controlใช้เมื่อตัวอย่าง
<Input />, <Input.TextArea />text fieldtitle, description
<Input.Number />ตัวเลขamount, year
<DatePicker />วันที่ (moment.js)startDate, endDate
<DatePicker.RangePicker />ช่วงวันที่วันลาเริ่ม-สิ้นสุด
<Cascader />multi-level dropdown (จังหวัด/อำเภอ/ตำบล)address
<Upload />file uploadแนบเอกสาร, รูป
<Select />single/multi selectorganization, role
<Radio.Group />เลือก 1 จาก Ngender, status
<Checkbox.Group />เลือกหลายtags
<Transfer />ย้ายระหว่าง 2 listassign role to user

Component ที่ใช้ซ้ำ (reusable)

  • AlldataShow — display ข้อมูลทั่วไป (table + pagination)
  • ModalshowAllImage — modal preview image
  • ImageFileNewtab — link เปิด image ใน tab ใหม่
  • FormContactUS — form ติดต่อ (ใช้ในหลาย page)

component เหล่านี้อยู่ใน components/user/ หรือ components/element/ แล้วแต่ขนาด

Routing integration

vtrc-web/src/config/routes.js (1,410 บรรทัด) — import ทุก page component แล้ว mount ใน <Route>:

javascript
import { Switch, Route } from 'react-router-dom'
import Login from '../pages/user/Login'
import News from '../pages/user/News'
import Leave from '../pages/user/Leave'
// ... ~45 imports

const Routes = () => (
  <Switch>
    <Route path="/login" exact component={Login} />
    <Route path="/news" exact component={News} />
    <Route path="/leave" exact component={Leave} />
    {/* ... ~45 routes */}
  </Switch>
)

จุดที่น่าสนใจ:

  • route path = page folder name (ตัวอักษรใหญ่) — ตัวอย่าง /newspages/user/News/
  • ไม่มี lazy loading — ทุก page component ถูก import ตั้งแต่ app boot ทำให้ bundle ใหญ่ (~12 MB)
  • auth gate อยู่ใน containers/App/index.js — ไม่ใช่ใน routes.js (เหมือน vtrc-rc-backoffice)

การจัดการ state ระหว่าง component

vtrc-web ไม่มี state management library (Redux/Recoil/MobX) — ทุก state อยู่ใน this.state ของ component นั้น ๆ ถ้าต้องการ sync state ข้าม component ต้อง:

  1. ยกขึ้น parent — state อยู่ที่ parent component แล้วส่งผ่าน props
  2. ผ่าน localStorage/sessionStorage — สำหรับ token, organization
  3. ผ่าน Apollo cache — ถ้าเป็นข้อมูลที่มาจาก query ฝั่ง backend

ผลคือในบาง flow ที่ซับซ้อน (เช่น multi-step form) state sync ยากและเกิด bug ได้


เปรียบเทียบกับ vtrc-rc-backoffice

ด้านvtrc-webvtrc-rc-backoffice
Component styleclass component ล้วนmix class + function+Recoil
State libraryไม่มี (this.state เท่านั้น)Recoil 0.7 + this.state
GraphQL consumerthis.props.client.query() via withApolloเดียวกัน + useQuery hook
GraphQL queries count~150 queries121 queries (รวม WorkForce)
GraphQL mutations count~60 mutations94 mutations
Folder typocomponents/user/from/ (ควร Form/)TheamCss (ควร ThemeCss)
Form APIAntD v3 getFieldDecoratorAntD v4 Form.useForm() (function) + getFieldDecorator (class)
AntD versionv3.xv4.15
Bundle size~12 MB~11-13 MB
Code splittingแทบไม่มีแทบไม่มี

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

ไป บท 4.1.3 State + Styling + Locale