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 ^^!).