Implement collection item reordering with bulk operations and persistent HTML attributes

- Add bulk reorder API endpoint (PUT /api/collections/{id}/reorder) with atomic transactions
- Replace individual position updates with efficient bulk operations in frontend
- Implement unified ID generation and proper data-item-id injection during enhancement
- Fix collection item position persistence through content edit cycles
- Add optimistic UI with rollback capability for better user experience
- Update sqlc queries to include last_edited_by fields in position updates
- Remove obsolete data-content-type attributes and unify naming conventions
This commit is contained in:
2025-10-07 22:59:00 +02:00
parent c5754181f6
commit 824719f07d
13 changed files with 545 additions and 55 deletions

View File

@@ -22,7 +22,6 @@ import (
"github.com/insertr/insertr/internal/engine"
)
// ContentHandler handles all content-related HTTP requests
type ContentHandler struct {
database *db.Database
@@ -1094,7 +1093,7 @@ func (h *ContentHandler) UpdateCollectionItem(w http.ResponseWriter, r *http.Req
maxPos = 0
}
}
// Check if position is valid (1-based, within bounds)
if int64(req.Position) > maxPos {
http.Error(w, fmt.Sprintf("Invalid position: %d exceeds max position %d", req.Position, maxPos), http.StatusBadRequest)
@@ -1117,7 +1116,7 @@ func (h *ContentHandler) UpdateCollectionItem(w http.ResponseWriter, r *http.Req
return
}
}
// If only position update (no html_content), just get the updated item
if req.HTMLContent == "" {
updatedItem, err = h.database.GetSQLiteQueries().GetCollectionItem(context.Background(), sqlite.GetCollectionItemParams{
@@ -1149,7 +1148,7 @@ func (h *ContentHandler) UpdateCollectionItem(w http.ResponseWriter, r *http.Req
return
}
}
// If only position update (no html_content), just get the updated item
if req.HTMLContent == "" {
updatedItem, err = h.database.GetPostgreSQLQueries().GetCollectionItem(context.Background(), postgresql.GetCollectionItemParams{
@@ -1226,6 +1225,112 @@ func (h *ContentHandler) DeleteCollectionItem(w http.ResponseWriter, r *http.Req
w.WriteHeader(http.StatusNoContent)
}
// ReorderCollection handles PUT /api/collections/{id}/reorder
func (h *ContentHandler) ReorderCollection(w http.ResponseWriter, r *http.Request) {
collectionID := chi.URLParam(r, "id")
siteID := r.URL.Query().Get("site_id")
if siteID == "" {
http.Error(w, "site_id parameter is required", http.StatusBadRequest)
return
}
var req ReorderCollectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.UpdatedBy == "" {
req.UpdatedBy = "api"
}
// Validate that all items belong to the collection and have valid positions
if len(req.Items) == 0 {
http.Error(w, "Items array cannot be empty", http.StatusBadRequest)
return
}
// Update positions for all items in a transaction for atomicity
switch h.database.GetDBType() {
case "sqlite3":
// Use transaction for atomic bulk updates
tx, err := h.database.GetSQLiteDB().Begin()
if err != nil {
http.Error(w, "Failed to begin transaction", http.StatusInternalServerError)
return
}
defer tx.Rollback()
// Create queries with transaction context
qtx := h.database.GetSQLiteQueries().WithTx(tx)
for _, item := range req.Items {
err = qtx.UpdateCollectionItemPosition(context.Background(), sqlite.UpdateCollectionItemPositionParams{
ItemID: item.ItemID,
CollectionID: collectionID,
SiteID: siteID,
Position: int64(item.Position),
LastEditedBy: req.UpdatedBy,
})
if err != nil {
http.Error(w, fmt.Sprintf("Failed to update position for item %s: %v", item.ItemID, err), http.StatusInternalServerError)
return
}
}
// Commit transaction
if err = tx.Commit(); err != nil {
http.Error(w, "Failed to commit bulk reorder transaction", http.StatusInternalServerError)
return
}
case "postgres":
// Use transaction for atomic bulk updates
tx, err := h.database.GetPostgreSQLDB().Begin()
if err != nil {
http.Error(w, "Failed to begin transaction", http.StatusInternalServerError)
return
}
defer tx.Rollback()
// Create queries with transaction context
qtx := h.database.GetPostgreSQLQueries().WithTx(tx)
for _, item := range req.Items {
err = qtx.UpdateCollectionItemPosition(context.Background(), postgresql.UpdateCollectionItemPositionParams{
ItemID: item.ItemID,
CollectionID: collectionID,
SiteID: siteID,
Position: int32(item.Position),
LastEditedBy: req.UpdatedBy,
})
if err != nil {
http.Error(w, fmt.Sprintf("Failed to update position for item %s: %v", item.ItemID, err), http.StatusInternalServerError)
return
}
}
// Commit transaction
if err = tx.Commit(); err != nil {
http.Error(w, "Failed to commit bulk reorder transaction", http.StatusInternalServerError)
return
}
default:
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
// Return success response
w.Header().Set("Content-Type", "application/json")
response := map[string]interface{}{
"success": true,
"message": fmt.Sprintf("Successfully reordered %d items", len(req.Items)),
}
json.NewEncoder(w).Encode(response)
}
// Collection conversion helpers
// convertToAPICollection converts database collection to API model

View File

@@ -123,3 +123,13 @@ type UpdateCollectionItemRequest struct {
Position int `json:"position"`
UpdatedBy string `json:"updated_by,omitempty"`
}
type CollectionItemPosition struct {
ItemID string `json:"itemId"`
Position int `json:"position"`
}
type ReorderCollectionRequest struct {
Items []CollectionItemPosition `json:"items"`
UpdatedBy string `json:"updated_by,omitempty"`
}

View File

@@ -95,6 +95,16 @@ func (db *Database) GetDBType() string {
return db.dbType
}
// GetSQLiteDB returns the underlying SQLite database connection
func (db *Database) GetSQLiteDB() *sql.DB {
return db.conn
}
// GetPostgreSQLDB returns the underlying PostgreSQL database connection
func (db *Database) GetPostgreSQLDB() *sql.DB {
return db.conn
}
// initializeSQLiteSchema sets up the SQLite database schema
func (db *Database) initializeSQLiteSchema() error {
ctx := context.Background()

View File

@@ -305,12 +305,13 @@ func (q *Queries) UpdateCollectionItem(ctx context.Context, arg UpdateCollection
const updateCollectionItemPosition = `-- name: UpdateCollectionItemPosition :exec
UPDATE collection_items
SET position = $1
WHERE item_id = $2 AND collection_id = $3 AND site_id = $4
SET position = $1, updated_at = CURRENT_TIMESTAMP, last_edited_by = $2
WHERE item_id = $3 AND collection_id = $4 AND site_id = $5
`
type UpdateCollectionItemPositionParams struct {
Position int32 `json:"position"`
LastEditedBy string `json:"last_edited_by"`
ItemID string `json:"item_id"`
CollectionID string `json:"collection_id"`
SiteID string `json:"site_id"`
@@ -319,6 +320,7 @@ type UpdateCollectionItemPositionParams struct {
func (q *Queries) UpdateCollectionItemPosition(ctx context.Context, arg UpdateCollectionItemPositionParams) error {
_, err := q.db.ExecContext(ctx, updateCollectionItemPosition,
arg.Position,
arg.LastEditedBy,
arg.ItemID,
arg.CollectionID,
arg.SiteID,

View File

@@ -38,7 +38,7 @@ RETURNING item_id, collection_id, site_id, template_id, html_content, position,
-- name: UpdateCollectionItemPosition :exec
UPDATE collection_items
SET position = sqlc.arg(position)
SET position = sqlc.arg(position), updated_at = CURRENT_TIMESTAMP, last_edited_by = sqlc.arg(last_edited_by)
WHERE item_id = sqlc.arg(item_id) AND collection_id = sqlc.arg(collection_id) AND site_id = sqlc.arg(site_id);
-- name: ReorderCollectionItems :exec
@@ -47,6 +47,8 @@ SET position = position + sqlc.arg(position_delta)
WHERE collection_id = sqlc.arg(collection_id) AND site_id = sqlc.arg(site_id)
AND position >= sqlc.arg(start_position);
-- name: DeleteCollectionItem :exec
DELETE FROM collection_items
WHERE item_id = sqlc.arg(item_id) AND collection_id = sqlc.arg(collection_id) AND site_id = sqlc.arg(site_id);

View File

@@ -305,12 +305,13 @@ func (q *Queries) UpdateCollectionItem(ctx context.Context, arg UpdateCollection
const updateCollectionItemPosition = `-- name: UpdateCollectionItemPosition :exec
UPDATE collection_items
SET position = ?1
WHERE item_id = ?2 AND collection_id = ?3 AND site_id = ?4
SET position = ?1, updated_at = CURRENT_TIMESTAMP, last_edited_by = ?2
WHERE item_id = ?3 AND collection_id = ?4 AND site_id = ?5
`
type UpdateCollectionItemPositionParams struct {
Position int64 `json:"position"`
LastEditedBy string `json:"last_edited_by"`
ItemID string `json:"item_id"`
CollectionID string `json:"collection_id"`
SiteID string `json:"site_id"`
@@ -319,6 +320,7 @@ type UpdateCollectionItemPositionParams struct {
func (q *Queries) UpdateCollectionItemPosition(ctx context.Context, arg UpdateCollectionItemPositionParams) error {
_, err := q.db.ExecContext(ctx, updateCollectionItemPosition,
arg.Position,
arg.LastEditedBy,
arg.ItemID,
arg.CollectionID,
arg.SiteID,

View File

@@ -3,7 +3,6 @@ package engine
import (
"fmt"
"strings"
"time"
"golang.org/x/net/html"
)
@@ -103,8 +102,8 @@ func (e *ContentEngine) ProcessContent(input ContentInput) (*ContentResult, erro
// Generate structural ID for the collection container
collectionID := e.idGenerator.Generate(collectionElem.Node, input.FilePath)
// Add data-content-id attribute to the collection container
e.setAttribute(collectionElem.Node, "data-content-id", collectionID)
// Add data-collection-id attribute to the collection container
e.setAttribute(collectionElem.Node, "data-collection-id", collectionID)
// Process collection during enhancement or content injection
if input.Mode == Enhancement || input.Mode == ContentInjection {
@@ -243,7 +242,6 @@ func (e *ContentEngine) addContentAttributes(node *html.Node, contentID string)
e.setAttribute(node, "data-content-id", contentID)
}
// setAttribute sets an attribute on an HTML node
func (e *ContentEngine) setAttribute(node *html.Node, key, value string) {
// Remove existing attribute if it exists
@@ -377,8 +375,6 @@ func (e *ContentEngine) extractHTMLContent(node *html.Node) string {
return strings.TrimSpace(content.String())
}
// extractOriginalTemplate extracts the outer HTML of the element (including the element itself)
func (e *ContentEngine) extractOriginalTemplate(node *html.Node) string {
var buf strings.Builder
@@ -549,6 +545,12 @@ func (e *ContentEngine) extractAndStoreTemplatesAndItems(collectionNode *html.No
return fmt.Errorf("failed to store initial collection items: %w", err)
}
// Reconstruct items from database to ensure proper data-item-id injection
err = e.reconstructCollectionItems(collectionNode, collectionID, siteID)
if err != nil {
return fmt.Errorf("failed to reconstruct initial collection items: %w", err)
}
return nil
}
@@ -619,6 +621,12 @@ func (e *ContentEngine) reconstructCollectionItems(collectionNode *html.Node, co
for structuralChild := structuralBody.FirstChild; structuralChild != nil; {
next := structuralChild.NextSibling
structuralBody.RemoveChild(structuralChild)
// Inject data-item-id attribute for collection item identification
if structuralChild.Type == html.ElementNode {
e.setAttribute(structuralChild, "data-item-id", item.ItemID)
}
collectionNode.AppendChild(structuralChild)
structuralChild = next
}
@@ -730,15 +738,18 @@ func (e *ContentEngine) CreateCollectionItemFromTemplate(
templateHTML string,
lastEditedBy string,
) (*CollectionItemWithTemplate, error) {
// Generate unique item ID
itemID := fmt.Sprintf("%s-item-%d", collectionID, time.Now().Unix())
// Create virtual element from template (like enhancement path)
virtualElement, err := e.createVirtualElementFromTemplate(templateHTML)
if err != nil {
return nil, fmt.Errorf("failed to create virtual element: %w", err)
}
// Generate unique item ID using unified generator with collection context
itemID := e.idGenerator.Generate(virtualElement, "collection-item")
if err != nil {
return nil, fmt.Errorf("failed to create virtual element: %w", err)
}
// Process .insertr elements and create content entries (unified approach)
contentEntries, err := e.processChildElementsAsContent(virtualElement, siteID, itemID)
if err != nil {
@@ -808,8 +819,8 @@ func (e *ContentEngine) storeChildrenAsCollectionItems(collectionNode *html.Node
// Store each child using unified .insertr approach (content table + structural template)
for i, childElement := range childElements {
// Generate item ID (like content ID generation)
itemID := fmt.Sprintf("%s-initial-%d", collectionID, i+1)
// Generate item ID using unified generator with collection context
itemID := e.idGenerator.Generate(childElement, "collection-item")
// Process .insertr elements within this child (unified approach)
contentEntries, err := e.processChildElementsAsContent(childElement, siteID, itemID)

View File

@@ -162,7 +162,6 @@ func (i *Injector) findElementByTag(node *html.Node, tag string) *html.Node {
// AddContentAttributes adds necessary data attributes and insertr class for editor functionality
func (i *Injector) AddContentAttributes(node *html.Node, contentID string, contentType string) {
i.setAttribute(node, "data-content-id", contentID)
i.setAttribute(node, "data-content-type", contentType)
i.addClass(node, "insertr")
}