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 count | Export pattern |
|---|---|---|---|
queries.js | 4,493 | ~150 queries | export default { queryName1, queryName2, ... } |
mutations.js | 1,447 | ~60 mutations | export default { mutationName1, ... } |
index.js | 2 | — | export { default as Queries } from './queries' + Mutations |
vtrc-web/src/graphql/queries.js:1-2:
import gql from "graphql-tag";
import { toGqlTypes, toGqlArgs, toGqlFields } from "../utils/string";vtrc-web/src/graphql/queries.js:4479-4493 (closing default export):
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:
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:
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และENsuffix — backend คืนข้อมูล 2 ภาษา
การนับ operations
เนื่องจาก pattern คือ const xxx = gql\...`` การนับจำนวน query ใช้:
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:
| Cluster | Prefix ตัวอย่าง | Queries เด่น |
|---|---|---|
| Auth/onboarding | login, register, otp, forgot | login, registerPersonal, requestOtp, verifyOtp |
| News/policy/about | fetchListPost, fetchPolicy, fetchAbout | fetchListPost, fetchPostById, fetchPolicyCategory |
| Profile/HR master | fetchProfile, fetchEmployee, fetchMaster | fetchProfile, fetchEmployeeDetail, fetchDropDownMasterData |
| Leave | fetchLeave, fetchStudyLeave, fetchListStudyLeaveRequest | fetchListLeave, fetchListStudyLeaveRequest, fetchStudyLeaveRequestById |
| Welfare/fund | fetchFund, fetchWelfare, fetchDelegates | fetchListFund, fetchFundDetail, fetchDelegatesExpire |
| Hospital (medical claim) | fetchHospital, fetchHospitalHr, fetchHospitalPaid | fetchListHospital, fetchHospitalDetail, fetchHospitalPaid |
| Income doc (Slip) | fetchSlip, dropDownSlipPayment | fetchListSlip, dropDownMasterSlipPayment, fetchUrlSalaryCertForBank |
| PMS | fetchPms, fetchMasterConfigTypePMS | fetchPmsList, fetchMasterConfigTypePMS |
| Course/training | fetchCourse, fetchTraining, fetchCategoryCourse | fetchListCourse, fetchCourseCategory |
| Contact | fetchContact, fetchUrlSalaryCertForBank | fetchContactList |
| Meeting/recruit/misc | fetchMeeting, fetchListCancelWaitingForApproval | fetchListCancelWaitingForApproval, detailLeaveDocumentAsFlow |
Pattern การ consume
vtrc-web ใช้แต่ class component + withApollo HOC — useQuery hook มี 0 การใช้งานใน codebase ทำให้ทุก query ถูกเรียกผ่าน:
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:
const { data, errors } = await this.props.client.mutate({
mutation: Mutations.createPost,
variables: { postTitleTH: '...', ... }
})ไฟล์ legacy ใน graphql/
vtrc-web/src/graphql/ มี snapshot เก่าที่ต้องระวัง:
| ไฟล์ | สถานะ | เหตุผล |
|---|---|---|
queries_pentest.js | snapshot สมัย pentest | อาจมี query ที่ไม่ได้ใช้แล้ว — ห้ามใช้เป็นต้นแบบ |
querries_prd.js (สะกดผิด) | snapshot ของ env prod | ไม่ใช่ query จริง ใช้ queries.js ตัวหลัก |
index_bk.js | legacy hub | commented-out |
กฎ — ใช้ queries.js และ mutations.js ตัวจริงเท่านั้น import ผ่าน index.js เสมอ
ปัญหาที่ต้องระวังเมื่อแก้ query/mutation
- field ไม่ minimal — query อย่าง
fetchListPostดึง relation ลึก (ทั้งpostTitleTH,postTitleEN,banner,thumbnail,linkOriginal) ในก้อนเดียว ทำให้ response ใหญ่และ resolver ฝั่ง server หนัก - ไม่มี fragment ใช้เลย — ทุก query เขียน field ซ้ำในตัว ถ้าจะเริ่มใช้ fragment ต้องคิดเป็น convention ใหม่ทั้งไฟล์ ไม่ใช่ทีละ query
- ห้ามลบ field โดยไม่ grep หา consumer ก่อน — เพราะไม่มี TypeScript ตรวจ compile-time ถ้าลบ field ที่ component อื่นอ้างถึงจะ runtime error ที่หน้าขาว
- variables type ฝั่ง backend เปลี่ยนทุก so often — ถ้าเพิ่ม field ใหม่ใน typeDef ฝั่ง
vtrc-apiต้องมาอัปเดต query ฝั่งvtrc-webด้วย ไม่งั้น validation error ตอน runtime fetchPolicydefault =cache-first— Apollo Client v2 default ใช้ cache ทำให้บางครั้งข้อมูลไม่ update ต้อง setfetchPolicy: '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)
ลักษณะ:
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 อ้างถึงทั่วระบบ
// ตัวอย่าง import path ที่หลายไฟล์ใช้
import FormContactUS from '../components/user/from/ContactUS'
import FormLeave from '../components/user/from/Leave'กฎเหล็ก — ถ้าจะแก้ชื่อ folder from → Form ต้อง update import ทุกที่ที่อ้างถึง (grep from.*components/user/from/) พร้อมกัน ไม่งั้น build fail
Form patterns
vtrc-web ใช้ AntD v3 Form decorator pattern:
<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 staterules— validation rule (required, pattern, min, max)this.props.form.validateFields((err, values) => ...)— trigger validation แล้วคืนค่าถ้าผ่าน
Form controls ที่ใช้บ่อย
| Control | ใช้เมื่อ | ตัวอย่าง |
|---|---|---|
<Input />, <Input.TextArea /> | text field | title, description |
<Input.Number /> | ตัวเลข | amount, year |
<DatePicker /> | วันที่ (moment.js) | startDate, endDate |
<DatePicker.RangePicker /> | ช่วงวันที่ | วันลาเริ่ม-สิ้นสุด |
<Cascader /> | multi-level dropdown (จังหวัด/อำเภอ/ตำบล) | address |
<Upload /> | file upload | แนบเอกสาร, รูป |
<Select /> | single/multi select | organization, role |
<Radio.Group /> | เลือก 1 จาก N | gender, status |
<Checkbox.Group /> | เลือกหลาย | tags |
<Transfer /> | ย้ายระหว่าง 2 list | assign role to user |
Component ที่ใช้ซ้ำ (reusable)
AlldataShow— display ข้อมูลทั่วไป (table + pagination)ModalshowAllImage— modal preview imageImageFileNewtab— 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>:
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 (ตัวอักษรใหญ่) — ตัวอย่าง
/news↔pages/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 ต้อง:
- ยกขึ้น parent — state อยู่ที่ parent component แล้วส่งผ่าน props
- ผ่าน localStorage/sessionStorage — สำหรับ token, organization
- ผ่าน Apollo cache — ถ้าเป็นข้อมูลที่มาจาก query ฝั่ง backend
ผลคือในบาง flow ที่ซับซ้อน (เช่น multi-step form) state sync ยากและเกิด bug ได้
เปรียบเทียบกับ vtrc-rc-backoffice
| ด้าน | vtrc-web | vtrc-rc-backoffice |
|---|---|---|
| Component style | class component ล้วน | mix class + function+Recoil |
| State library | ไม่มี (this.state เท่านั้น) | Recoil 0.7 + this.state |
| GraphQL consumer | this.props.client.query() via withApollo | เดียวกัน + useQuery hook |
| GraphQL queries count | ~150 queries | 121 queries (รวม WorkForce) |
| GraphQL mutations count | ~60 mutations | 94 mutations |
| Folder typo | components/user/from/ (ควร Form/) | TheamCss (ควร ThemeCss) |
| Form API | AntD v3 getFieldDecorator | AntD v4 Form.useForm() (function) + getFieldDecorator (class) |
| AntD version | v3.x | v4.15 |
| Bundle size | ~12 MB | ~11-13 MB |
| Code splitting | แทบไม่มี | แทบไม่มี |