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:
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user