Cách xây dựng các trường sửa xóa bằng HTMX
Chào các bạn, hiện Nam Digital đang nghiên cứu nền tảng HTMX và thấy đây là một công cụ tuyệt vời cho việc phát triển các ứng dụng Web động. Bài viết này cũng là nghiên cứu mà Nam áp dụng cho các dự án của mình, để lưu giữ và nhắc nhớ bản thân thì cách tốt nhất có lẽ là...viết ra một cái gì đó, và bài viết này là kết quả của việc đó, hãy cùng tìm hiểu xem!
Tìm hiểu 1 chút về cấu trúc Nam sẽ sử dụng, từ đó tự suy ra các bảng bằng GORM
type Project struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"not null"` // Tên dự án
URL string `gorm:"unique"` // Đường dẫn
Slider []SliderProject // Khối Upload Slider
Introduction IntroductionProject // Khối Giới thiệu
Repeater []RepeaterProject // Khối Repeater USP
// Khối Number Project
NumberProject []NumberProject
// Khối Section Project
SectionProject []SectionProject
Gallery GalleryProject // Khối Thư viện ảnh
Location LocationProject // Vị trí Google Maps
CreatedAt time.Time
UpdatedAt time.Time
}
// Slider Project: Ok
type SliderProject struct {
ID uint `gorm:"primaryKey"`
ProjectID uint // Liên kết đến dự án
ImageURL string // Đường dẫn ảnh
Caption string // Chú thích (nếu có)
CreatedAt time.Time
UpdatedAt time.Time
}
// Introduction Project: Ok
type IntroductionProject struct {
ID uint `gorm:"primaryKey"`
ProjectID uint // Liên kết đến dự án
Title string // Tiêu đề, lời dẫn
Description string // Mô tả
CreatedAt time.Time
UpdatedAt time.Time
}
// Khối lợi ích
type RepeaterProject struct {
ID uint `gorm:"primaryKey"`
ProjectID uint // Liên kết đến dự án
Icon string // Icon
Title string // Tiêu đề
Description string // Mô tả
CreatedAt time.Time
UpdatedAt time.Time
}
// Khối con số
type NumberProject struct {
ID uint `gorm:"primaryKey"`
ProjectID uint // Liên kết đến dự án
Number int // Con số (nếu có)
NumberDescription string // Mô tả con số (nếu có)
Quantity string // Dữ liệu về đơn vị
CreatedAt time.Time
UpdatedAt time.Time
}
type SectionProject struct {
ID uint `gorm:"primaryKey"`
ProjectID uint // Liên kết đến dự án
SectionTitle string // Tiêu đề của section
SectionContent string // Content (có thể có hoặc không)
SectionImageURL string // Đường dẫn ảnh (Slider)
CreatedAt time.Time
UpdatedAt time.Time
}
// Gallery: OK
type GalleryProject struct {
ID uint `gorm:"primaryKey"`
ProjectID uint // Liên kết đến dự án
Interiors []ImageProject `gorm:"foreignKey:GalleryID;where:category = 'Interior'"`
Exteriors []ImageProject `gorm:"foreignKey:GalleryID;where:category = 'Exterior'"`
LegalDocs []ImageProject `gorm:"foreignKey:GalleryID;where:category = 'Legal'"`
CreatedAt time.Time
UpdatedAt time.Time
}
type ImageProject struct {
ID uint `gorm:"primaryKey"`
GalleryID uint `gorm:"index"` // Khóa ngoại liên kết đến GalleryProject
ImageURL string // Đường dẫn ảnh
Category string // Nội thất, Ngoại thất, Pháp lý
CreatedAt time.Time
UpdatedAt time.Time
}
// Location: ok
type LocationProject struct {
ID uint `gorm:"primaryKey"`
ProjectID uint // Liên kết đến dự án
EmbedURL string
CreatedAt time.Time
UpdatedAt time.Time
}
Trông rất phức tạp đúng không, cơ bản là Nam muốn xây 1 khối dự án theo diện bất động sản, và nó là tập hợp của nhiều bảng khác nhau
- Slider
- Khối giới thiệu dự án
- Một số lợi thế bán hàng của dự án (Có thể tạo mới thoải mái)
- Một số con số đáng chú ý của dự án (Có thể tạo mới thoải mái)
- ...
Như vậy bảng Project sẽ ánh xạ và join tới nhiều bảng khác để lấy dữ liệu, sau đó hiển thị lên các trang mong muốn, may mắn là GORM có cơ chế Preloader khá hay, hãy cùng thử tạo trang edit thông tin của Project này nhé, và để đơn giản hóa, Nam sẽ chỉ tác động tới dữ liệu Slider thôi nha
// Route chỉnh sửa dự án
r.HandleFunc("/project-edit", EditProjectPageHandler)
// Hàm sửa dự án
func EditProjectPageHandler(w http.ResponseWriter, req *http.Request) {
// Parse the form data
err := req.ParseForm()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Tiếp tục xử lý logic của hàm
id := req.FormValue("id")
var project models.Project
if err := config.Db.Preload("Slider").Preload("Introduction").Preload("Repeater").Preload("NumberProject").Preload("SectionProject").Preload("Gallery").Preload("Location").Where("id = ?", id).First(&project).Error; err != nil {
http.Error(w, "Project not found", http.StatusNotFound)
return
}
// Trả về template chứa thông tin tổng quan dự án
config.Tpl.ExecuteTemplate(w, "edit-project.html", project)
}
Mình sử dụng Gorilla Mux để định tuyến, về cách triển khai Gorilla thì cả nhà đọc Docs nhé
Tại trang edit-project.html thì mình sẽ bố trí như sau (Để truyền dữ liệu project vào data)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Edit Project</title>
<script src="https://unpkg.com/htmx.org@1.8.4"></script>
</head>
<body>
<h1>Edit Sliders</h1>
<!-- Vùng chứa slider, sẽ được cập nhật qua htmx -->
<div id="slider-list">
<!-- Tải danh sách slider từ server -->
<div hx-get="/project/{{.ID}}/sliders" hx-trigger="load" hx-target="#slider-list"></div>
</div>
<!-- Nút thêm slider mới -->
<button hx-get="/project/{{.ID}}/sliders/new" hx-target="#slider-form">Thêm Slider</button>
<!-- Form thêm slider mới -->
<div id="slider-form"></div>
</body>
</html>
Lúc này ta sẽ thấy 1 số route được htmx xử lý như hàm get lấy danh sách slider từ server
Hàm lấy danh sách Slider
r.HandleFunc("/project/{projectID}/sliders", GetSlidersHandler).Methods("GET")
// Khu vực Slider
func GetSlidersHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
projectID := vars["projectID"]
var sliders []models.SliderProject
if err := config.Db.Where("project_id = ?", projectID).Find(&sliders).Error; err != nil {
http.Error(w, "Unable to fetch sliders", http.StatusInternalServerError)
return
}
// Trả về HTML của danh sách slider (được render)
config.Tpl.ExecuteTemplate(w, "sliders-list.html", sliders)
}
Hàm này sẽ get được dữ liệu trong sliders-list.html và đẩy nó ra khu vực chỉnh sửa Project
<ul>
{{range .}}
<li>
<img src="/{{.ImageURL}}" alt="Slider Image" width="100">
<p>{{.Caption}}</p>
<button hx-get="/project/{{.ProjectID}}/sliders/edit/{{.ID}}" hx-target="#slider-form">Sửa</button>
</li>
{{end}}
</ul>
Nút thêm Sliders mới
Tiếp đó ta tạo Form để upload Slider mới bằng việc chạy handler NewSliderFormHandler
r.HandleFunc("/project/{projectID}/sliders/new", NewSliderFormHandler).Methods("GET")
func NewSliderFormHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
projectIDInt, err := strconv.Atoi(vars["projectID"])
if err != nil {
panic(err)
}
projectID := uint(projectIDInt)
// Trả về HTML của form thêm slider
data := struct {
ProjectID uint
}{
ProjectID: projectID,
}
config.Tpl.ExecuteTemplate(w, "slider-form.html", data)
}
slider-form.html có dạng như sau
<form hx-post="/project/{{.ProjectID}}/sliders" hx-target="#slider-list" enctype="multipart/form-data">
<input type="hidden" name="project_id" value="{{.ProjectID}}">
<label for="image">Chọn ảnh:</label>
<input type="file" name="image" id="image" required><br>
<label for="caption">Caption:</label>
<input type="text" name="caption" id="caption" required><br>
<button type="submit">Lưu Slider</button>
</form>
Như vậy khi dùng htmx gọi tới route phía trên thì đoạn Form sẽ được gọi ra để cho người dùng tải ảnh cũng như điền Caption
Submit Form để tạo Slider mới
// Định tuyến route
r.HandleFunc("/project/{projectID}/sliders", CreateSliderHandler).Methods("POST")
// Handler để xử lý thêm Slider mới
func CreateSliderHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "Unable to parse form", http.StatusBadRequest)
return
}
projectIDInt, err := strconv.Atoi(r.FormValue("project_id"))
if err != nil {
panic(err)
}
projectID := uint(projectIDInt)
// Upload ảnh
imageURL, err := config.HandleImageUploadForm(r, "image")
if err != nil {
http.Error(w, "Unable to upload image", http.StatusInternalServerError)
return
}
// Tạo slider mới
slider := models.SliderProject{
ProjectID: projectID,
ImageURL: imageURL,
Caption: r.FormValue("caption"),
}
// Lưu vào cơ sở dữ liệu
if err := config.Db.Create(&slider).Error; err != nil {
http.Error(w, "Unable to create slider", http.StatusInternalServerError)
return
}
// Trả về danh sách slider mới sau khi thêm
GetSlidersHandler(w, r)
}
Mình đã xử lý logic upload form và submit, điểm hay của htmx là nó xử lý thay thế Ajax và có tính động cao (mà không phải tải lại trang) nên mình dễ dàng xử lý server-side khá nhanh, đồng thời tạo mới Slider khá linh hoạt, lưu ý là hàm HandleImageUploadForm là hàm của dự án của mình nhé (nó sẽ lưu ảnh vào vị trí mong muốn)
Tương tự cho hàm Edit và Update nhé
r.HandleFunc("/project/{projectID}/sliders/edit/{sliderID}", EditSliderFormHandler).Methods("GET")
r.HandleFunc("/project/{projectID}/sliders/update/{sliderID}", UpdateSliderHandler).Methods("POST")
// Handler để chỉnh sửa Slider
func EditSliderFormHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
sliderID := vars["sliderID"]
var slider models.SliderProject
if err := config.Db.First(&slider, sliderID).Error; err != nil {
http.Error(w, "Slider not found", http.StatusNotFound)
return
}
config.Tpl.ExecuteTemplate(w, "slider-edit-form.html", slider)
}
func UpdateSliderHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
sliderID := vars["sliderID"]
projectID := vars["projectID"]
var slider models.SliderProject
if err := config.Db.First(&slider, sliderID).Error; err != nil {
http.Error(w, "Slider not found", http.StatusNotFound)
return
}
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "Unable to parse form", http.StatusBadRequest)
return
}
// Upload ảnh nếu có
if image, _, err := r.FormFile("image"); err == nil {
imageURL, err := config.HandleImageUploadForm(r, "image")
if err == nil {
slider.ImageURL = imageURL
}
fmt.Println(image)
}
slider.Caption = r.FormValue("caption")
// Cập nhật slider
if err := config.Db.Save(&slider).Error; err != nil {
http.Error(w, "Unable to update slider", http.StatusInternalServerError)
return
}
// Trả về danh sách slider sau khi cập nhật
GetSlidersHandler(w, r)
}
Tương tự phần Slider Edit form được xử lý như sau:
<form hx-post="/project/{{.ProjectID}}/sliders/update/{{.ID}}" hx-target="#slider-list" enctype="multipart/form-data">
<label for="image">Chọn ảnh mới (nếu có):</label>
<input type="file" name="image" id="image"><br>
<label for="caption">Caption:</label>
<input type="text" name="caption" id="caption" value="{{.Caption}}" required><br>
<button type="submit">Cập nhật Slider</button>
</form>
Tổng kết
Dẫu biết với cấu trúc dữ liệu mình thiết kế thì việc thiết kế sửa xóa cho từng trường sẽ khá mất thời gian, nhưng nó đảm bảo logic rất tốt với số lượng bản ghi lớn (và cũng rất chưa phù hợp với dự án nhỏ, vì case này mình dùng Wordpress sẽ hợp lý hơn nhiều ^^!).