Refactor: add stable folder layout foundation
This commit is contained in:
@@ -9,9 +9,11 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"moku-backend/internal/database"
|
||||
@@ -38,6 +40,9 @@ const (
|
||||
bootstrapWorkspaceKindDept = "department"
|
||||
bootstrapWorkspaceKindTeam = "team"
|
||||
bootstrapWorkspaceKindProject = "project"
|
||||
projectFolderOrderRootKey = "__root__"
|
||||
projectFolderOrderHierarchy = "hierarchy"
|
||||
projectFolderOrderTree = "tree"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -183,6 +188,7 @@ type namedRecord struct {
|
||||
|
||||
type ProjectHierarchyFolderRecord struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Label string `json:"label"`
|
||||
Children []ProjectHierarchyFolderRecord `json:"children"`
|
||||
}
|
||||
@@ -207,7 +213,10 @@ type RenameProjectFolderInput struct {
|
||||
type MoveProjectFolderInput struct {
|
||||
ProjectID string
|
||||
FolderID string
|
||||
FolderNodeID string
|
||||
ParentFolderID string
|
||||
ParentNodeID string
|
||||
TargetIndex int
|
||||
}
|
||||
|
||||
type CreateProjectFolderResult struct {
|
||||
@@ -217,26 +226,30 @@ type CreateProjectFolderResult struct {
|
||||
}
|
||||
|
||||
type DeleteProjectFolderResult struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
DeletedFolderID string `json:"deletedFolderId"`
|
||||
Folders []ProjectHierarchyFolderRecord `json:"folders"`
|
||||
ProjectID string `json:"projectId"`
|
||||
DeletedFolderID string `json:"deletedFolderId"`
|
||||
DeletedFolderPath string `json:"deletedFolderPath"`
|
||||
Folders []ProjectHierarchyFolderRecord `json:"folders"`
|
||||
}
|
||||
|
||||
type RenameProjectFolderResult struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
PreviousFolderID string `json:"previousFolderId"`
|
||||
RenamedFolder ProjectHierarchyFolderRecord `json:"renamedFolder"`
|
||||
Folders []ProjectHierarchyFolderRecord `json:"folders"`
|
||||
ProjectID string `json:"projectId"`
|
||||
PreviousFolderID string `json:"previousFolderId"`
|
||||
PreviousFolderPath string `json:"previousFolderPath"`
|
||||
RenamedFolder ProjectHierarchyFolderRecord `json:"renamedFolder"`
|
||||
Folders []ProjectHierarchyFolderRecord `json:"folders"`
|
||||
}
|
||||
|
||||
type MoveProjectFolderResult struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
PreviousFolderID string `json:"previousFolderId"`
|
||||
MovedFolder ProjectHierarchyFolderRecord `json:"movedFolder"`
|
||||
Folders []ProjectHierarchyFolderRecord `json:"folders"`
|
||||
ProjectID string `json:"projectId"`
|
||||
PreviousFolderID string `json:"previousFolderId"`
|
||||
PreviousFolderPath string `json:"previousFolderPath"`
|
||||
MovedFolder ProjectHierarchyFolderRecord `json:"movedFolder"`
|
||||
Folders []ProjectHierarchyFolderRecord `json:"folders"`
|
||||
}
|
||||
|
||||
type projectHierarchyFolderRow struct {
|
||||
ID string
|
||||
Path string
|
||||
ParentPath string
|
||||
Label string
|
||||
@@ -900,6 +913,7 @@ func (service *Service) getProjectHierarchyFoldersByRootPath(
|
||||
|
||||
rows, err := service.db.Pool.Query(ctx, `
|
||||
SELECT
|
||||
COALESCE(folder_meta.resource_id, ''),
|
||||
directories.path,
|
||||
COALESCE(directories.parent_path, ''),
|
||||
COALESCE(folder_meta.resource_name, directories.resource_name, '')
|
||||
@@ -921,7 +935,7 @@ func (service *Service) getProjectHierarchyFoldersByRootPath(
|
||||
var folderRows []projectHierarchyFolderRow
|
||||
for rows.Next() {
|
||||
var row projectHierarchyFolderRow
|
||||
if err := rows.Scan(&row.Path, &row.ParentPath, &row.Label); err != nil {
|
||||
if err := rows.Scan(&row.ID, &row.Path, &row.ParentPath, &row.Label); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
folderRows = append(folderRows, row)
|
||||
@@ -931,7 +945,10 @@ func (service *Service) getProjectHierarchyFoldersByRootPath(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildProjectHierarchyFolderTree(folderRows, rootParentPath), nil
|
||||
folders := buildProjectHierarchyFolderTree(folderRows, rootParentPath)
|
||||
folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath)
|
||||
|
||||
return applyProjectHierarchyFolderOrdering(folders, folderOrder), nil
|
||||
}
|
||||
|
||||
func (service *Service) CreateProjectFolder(ctx context.Context, input CreateProjectFolderInput) (CreateProjectFolderResult, error) {
|
||||
@@ -977,6 +994,21 @@ func (service *Service) createProjectHierarchyFolder(
|
||||
return CreateProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
|
||||
if err != nil {
|
||||
return CreateProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
parentOrderID := ""
|
||||
trimmedParentFolderID := strings.TrimSpace(input.ParentFolderID)
|
||||
if trimmedParentFolderID != "" {
|
||||
parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderID)
|
||||
if !found {
|
||||
return CreateProjectFolderResult{}, ErrProjectFolderNotFound
|
||||
}
|
||||
parentOrderID = parentFolder.ID
|
||||
}
|
||||
|
||||
createdPath, _, err := createOnDisk(project.Slug, strings.TrimSpace(input.ParentFolderID), input.Name)
|
||||
if err != nil {
|
||||
return CreateProjectFolderResult{}, err
|
||||
@@ -991,11 +1023,28 @@ func (service *Service) createProjectHierarchyFolder(
|
||||
return CreateProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
createdFolder, ok := findProjectHierarchyFolder(folders, createdPath)
|
||||
createdFolder, ok := findProjectHierarchyFolderByPath(folders, createdPath)
|
||||
if !ok {
|
||||
return CreateProjectFolderResult{}, fmt.Errorf("created project folder missing from projection")
|
||||
}
|
||||
|
||||
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
|
||||
seedFolderOrderParent(folderOrder, currentFolders, parentOrderID)
|
||||
insertFolderOrder(folderOrder, parentOrderID, createdFolder.ID, len(folderOrderChildren(folderOrder, parentOrderID)))
|
||||
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
|
||||
return CreateProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
|
||||
if err != nil {
|
||||
return CreateProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
createdFolder, ok = findProjectHierarchyFolderByPath(folders, createdPath)
|
||||
if !ok {
|
||||
return CreateProjectFolderResult{}, fmt.Errorf("created project folder missing from ordered projection")
|
||||
}
|
||||
|
||||
return CreateProjectFolderResult{
|
||||
ProjectID: project.ID,
|
||||
CreatedFolder: createdFolder,
|
||||
@@ -1014,6 +1063,16 @@ func (service *Service) deleteProjectHierarchyFolder(
|
||||
return DeleteProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
|
||||
if err != nil {
|
||||
return DeleteProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
deletedFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderID))
|
||||
if !found {
|
||||
return DeleteProjectFolderResult{}, ErrProjectFolderNotFound
|
||||
}
|
||||
|
||||
deletedFolderID, err := deleteOnDisk(project.Slug, input.FolderID)
|
||||
if err != nil {
|
||||
return DeleteProjectFolderResult{}, err
|
||||
@@ -1028,14 +1087,26 @@ func (service *Service) deleteProjectHierarchyFolder(
|
||||
return DeleteProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
if _, found := findProjectHierarchyFolder(folders, deletedFolderID); found {
|
||||
if _, found := findProjectHierarchyFolderByPath(folders, deletedFolderID); found {
|
||||
return DeleteProjectFolderResult{}, fmt.Errorf("deleted project folder still present in projection")
|
||||
}
|
||||
|
||||
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
|
||||
removeFolderOrder(folderOrder, deletedFolder.ID)
|
||||
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
|
||||
return DeleteProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
|
||||
if err != nil {
|
||||
return DeleteProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
return DeleteProjectFolderResult{
|
||||
ProjectID: project.ID,
|
||||
DeletedFolderID: deletedFolderID,
|
||||
Folders: folders,
|
||||
ProjectID: project.ID,
|
||||
DeletedFolderID: deletedFolder.ID,
|
||||
DeletedFolderPath: deletedFolderID,
|
||||
Folders: folders,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1064,20 +1135,21 @@ func (service *Service) renameProjectHierarchyFolder(
|
||||
return RenameProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
renamedFolder, found := findProjectHierarchyFolder(folders, renamedFolderID)
|
||||
renamedFolder, found := findProjectHierarchyFolderByPath(folders, renamedFolderID)
|
||||
if !found {
|
||||
return RenameProjectFolderResult{}, fmt.Errorf("renamed project folder missing from projection")
|
||||
}
|
||||
|
||||
if _, found := findProjectHierarchyFolder(folders, previousFolderID); found {
|
||||
if _, found := findProjectHierarchyFolderByPath(folders, previousFolderID); found {
|
||||
return RenameProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection")
|
||||
}
|
||||
|
||||
return RenameProjectFolderResult{
|
||||
ProjectID: project.ID,
|
||||
PreviousFolderID: previousFolderID,
|
||||
RenamedFolder: renamedFolder,
|
||||
Folders: folders,
|
||||
ProjectID: project.ID,
|
||||
PreviousFolderID: renamedFolder.ID,
|
||||
PreviousFolderPath: previousFolderID,
|
||||
RenamedFolder: renamedFolder,
|
||||
Folders: folders,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1092,6 +1164,38 @@ func (service *Service) moveProjectHierarchyFolder(
|
||||
return MoveProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
|
||||
if err != nil {
|
||||
return MoveProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
currentFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderID))
|
||||
if !found {
|
||||
return MoveProjectFolderResult{}, ErrProjectFolderNotFound
|
||||
}
|
||||
|
||||
movedFolderOrderID := currentFolder.ID
|
||||
providedFolderNodeID := strings.TrimSpace(input.FolderNodeID)
|
||||
if providedFolderNodeID != "" && providedFolderNodeID != movedFolderOrderID {
|
||||
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
|
||||
}
|
||||
|
||||
parentOrderID := ""
|
||||
trimmedParentFolderID := strings.TrimSpace(input.ParentFolderID)
|
||||
providedParentNodeID := strings.TrimSpace(input.ParentNodeID)
|
||||
if trimmedParentFolderID != "" {
|
||||
parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderID)
|
||||
if !found {
|
||||
return MoveProjectFolderResult{}, ErrProjectFolderNotFound
|
||||
}
|
||||
parentOrderID = parentFolder.ID
|
||||
if providedParentNodeID != "" && providedParentNodeID != parentOrderID {
|
||||
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
|
||||
}
|
||||
} else if providedParentNodeID != "" {
|
||||
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
|
||||
}
|
||||
|
||||
previousFolderID, movedFolderID, err := moveOnDisk(project.Slug, input.FolderID, input.ParentFolderID)
|
||||
if err != nil {
|
||||
return MoveProjectFolderResult{}, err
|
||||
@@ -1106,22 +1210,42 @@ func (service *Service) moveProjectHierarchyFolder(
|
||||
return MoveProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
movedFolder, found := findProjectHierarchyFolder(folders, movedFolderID)
|
||||
movedFolder, found := findProjectHierarchyFolderByPath(folders, movedFolderID)
|
||||
if !found {
|
||||
return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from projection")
|
||||
}
|
||||
|
||||
if previousFolderID != movedFolderID {
|
||||
if _, found := findProjectHierarchyFolder(folders, previousFolderID); found {
|
||||
if _, found := findProjectHierarchyFolderByPath(folders, previousFolderID); found {
|
||||
return MoveProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection")
|
||||
}
|
||||
}
|
||||
|
||||
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
|
||||
seedFolderOrderParent(folderOrder, currentFolders, parentOrderID)
|
||||
removeFolderOrderReference(folderOrder, movedFolderOrderID)
|
||||
removeFolderOrderReference(folderOrder, movedFolder.ID)
|
||||
insertFolderOrder(folderOrder, parentOrderID, movedFolder.ID, input.TargetIndex)
|
||||
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
|
||||
return MoveProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
|
||||
if err != nil {
|
||||
return MoveProjectFolderResult{}, err
|
||||
}
|
||||
|
||||
movedFolder, found = findProjectHierarchyFolderByPath(folders, movedFolderID)
|
||||
if !found {
|
||||
return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from ordered projection")
|
||||
}
|
||||
|
||||
return MoveProjectFolderResult{
|
||||
ProjectID: project.ID,
|
||||
PreviousFolderID: previousFolderID,
|
||||
MovedFolder: movedFolder,
|
||||
Folders: folders,
|
||||
ProjectID: project.ID,
|
||||
PreviousFolderID: movedFolder.ID,
|
||||
PreviousFolderPath: previousFolderID,
|
||||
MovedFolder: movedFolder,
|
||||
Folders: folders,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1528,7 +1652,10 @@ func (service *Service) createProjectFolderOnDisk(
|
||||
return "", "", fmt.Errorf("create project hierarchy folder: %w", err)
|
||||
}
|
||||
|
||||
folderID := uuid.NewString()
|
||||
|
||||
if err := writeJSONFile(filepath.Join(folderDir, "folder.json"), map[string]any{
|
||||
"id": folderID,
|
||||
"name": trimmedName,
|
||||
"slug": folderSlug,
|
||||
"type": "folder",
|
||||
@@ -1652,7 +1779,14 @@ func (service *Service) renameProjectFolderOnDisk(
|
||||
}
|
||||
}
|
||||
|
||||
folderPayload := readJSONFileMap(filepath.Join(destinationDir, "folder.json"))
|
||||
folderMetadataID, _ := folderPayload["id"].(string)
|
||||
if strings.TrimSpace(folderMetadataID) == "" {
|
||||
folderMetadataID = uuid.NewString()
|
||||
}
|
||||
|
||||
if err := writeJSONFile(filepath.Join(destinationDir, "folder.json"), map[string]any{
|
||||
"id": folderMetadataID,
|
||||
"name": trimmedName,
|
||||
"slug": folderSlug,
|
||||
"type": "folder",
|
||||
@@ -1724,6 +1858,10 @@ func (service *Service) moveProjectFolderOnDisk(
|
||||
}
|
||||
|
||||
folderPayload := readJSONFileMap(filepath.Join(folderDir, "folder.json"))
|
||||
folderMetadataID, _ := folderPayload["id"].(string)
|
||||
if strings.TrimSpace(folderMetadataID) == "" {
|
||||
folderMetadataID = uuid.NewString()
|
||||
}
|
||||
folderName, _ := folderPayload["name"].(string)
|
||||
if strings.TrimSpace(folderName) == "" {
|
||||
folderName = fallbackFolderLabel(folderProjectionPath)
|
||||
@@ -1754,6 +1892,7 @@ func (service *Service) moveProjectFolderOnDisk(
|
||||
return "", "", fmt.Errorf("move project folder: %w", err)
|
||||
}
|
||||
|
||||
folderPayload["id"] = folderMetadataID
|
||||
folderPayload["name"] = folderName
|
||||
folderPayload["slug"] = folderSlug
|
||||
folderPayload["type"] = "folder"
|
||||
@@ -1779,6 +1918,271 @@ func samePath(left, right string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (service *Service) readProjectFolderOrder(projectSlug, rootProjectionPath string) map[string][]string {
|
||||
settingsPath := service.projectSettingsPath(projectSlug)
|
||||
settingsPayload := readJSONFileMap(settingsPath)
|
||||
folderOrderPayload, _ := settingsPayload["folderOrder"].(map[string]any)
|
||||
if folderOrderPayload == nil {
|
||||
return map[string][]string{}
|
||||
}
|
||||
|
||||
scopePayload, _ := folderOrderPayload[projectFolderOrderScope(rootProjectionPath)].(map[string]any)
|
||||
if scopePayload == nil {
|
||||
return map[string][]string{}
|
||||
}
|
||||
|
||||
byParentPayload, _ := scopePayload["byParent"].(map[string]any)
|
||||
if byParentPayload == nil {
|
||||
return map[string][]string{}
|
||||
}
|
||||
|
||||
order := make(map[string][]string, len(byParentPayload))
|
||||
for key, raw := range byParentPayload {
|
||||
for _, id := range stringSliceValue(raw) {
|
||||
trimmedID := strings.TrimSpace(id)
|
||||
if trimmedID == "" || slicesContains(order[key], trimmedID) {
|
||||
continue
|
||||
}
|
||||
order[key] = append(order[key], trimmedID)
|
||||
}
|
||||
}
|
||||
|
||||
return order
|
||||
}
|
||||
|
||||
func (service *Service) writeProjectFolderOrder(projectSlug, rootProjectionPath string, folderOrder map[string][]string) error {
|
||||
settingsPath := service.projectSettingsPath(projectSlug)
|
||||
settingsPayload := readJSONFileMap(settingsPath)
|
||||
if settingsPayload == nil {
|
||||
settingsPayload = map[string]any{}
|
||||
}
|
||||
|
||||
folderOrderPayload, _ := settingsPayload["folderOrder"].(map[string]any)
|
||||
if folderOrderPayload == nil {
|
||||
folderOrderPayload = map[string]any{}
|
||||
}
|
||||
|
||||
scopeKey := projectFolderOrderScope(rootProjectionPath)
|
||||
scopePayload, _ := folderOrderPayload[scopeKey].(map[string]any)
|
||||
if scopePayload == nil {
|
||||
scopePayload = map[string]any{}
|
||||
}
|
||||
|
||||
byParentPayload := map[string]any{}
|
||||
for key, ids := range folderOrder {
|
||||
if len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
copied := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
trimmedID := strings.TrimSpace(id)
|
||||
if trimmedID == "" || slicesContains(copied, trimmedID) {
|
||||
continue
|
||||
}
|
||||
copied = append(copied, trimmedID)
|
||||
}
|
||||
if len(copied) > 0 {
|
||||
byParentPayload[key] = copied
|
||||
}
|
||||
}
|
||||
|
||||
scopePayload["byParent"] = byParentPayload
|
||||
folderOrderPayload[scopeKey] = scopePayload
|
||||
settingsPayload["folderOrder"] = folderOrderPayload
|
||||
|
||||
if err := writeJSONFile(settingsPath, settingsPayload); err != nil {
|
||||
return fmt.Errorf("write project settings.json: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) projectSettingsPath(projectSlug string) string {
|
||||
return filepath.Join(strings.TrimSpace(service.posixRoot), "projects", slugDir("project", projectSlug), "settings.json")
|
||||
}
|
||||
|
||||
func projectFolderOrderScope(rootProjectionPath string) string {
|
||||
if strings.HasSuffix(rootProjectionPath, "/tree") {
|
||||
return projectFolderOrderTree
|
||||
}
|
||||
|
||||
return projectFolderOrderHierarchy
|
||||
}
|
||||
|
||||
func applyProjectHierarchyFolderOrdering(folders []ProjectHierarchyFolderRecord, folderOrder map[string][]string) []ProjectHierarchyFolderRecord {
|
||||
return applyProjectHierarchyFolderOrderingForParent(folders, "", folderOrder)
|
||||
}
|
||||
|
||||
func applyProjectHierarchyFolderOrderingForParent(folders []ProjectHierarchyFolderRecord, parentID string, folderOrder map[string][]string) []ProjectHierarchyFolderRecord {
|
||||
if len(folders) == 0 {
|
||||
return folders
|
||||
}
|
||||
|
||||
nextFolders := make([]ProjectHierarchyFolderRecord, len(folders))
|
||||
copy(nextFolders, folders)
|
||||
for index := range nextFolders {
|
||||
nextFolders[index].Children = applyProjectHierarchyFolderOrderingForParent(nextFolders[index].Children, nextFolders[index].ID, folderOrder)
|
||||
}
|
||||
|
||||
orderIDs := folderOrder[projectFolderOrderParentKey(parentID)]
|
||||
if len(orderIDs) == 0 {
|
||||
return nextFolders
|
||||
}
|
||||
|
||||
rankByID := make(map[string]int, len(orderIDs))
|
||||
for index, id := range orderIDs {
|
||||
if _, exists := rankByID[id]; !exists {
|
||||
rankByID[id] = index
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(nextFolders, func(left, right int) bool {
|
||||
leftRank, leftOrdered := rankByID[nextFolders[left].ID]
|
||||
rightRank, rightOrdered := rankByID[nextFolders[right].ID]
|
||||
if leftOrdered && rightOrdered {
|
||||
return leftRank < rightRank
|
||||
}
|
||||
if leftOrdered != rightOrdered {
|
||||
return leftOrdered
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
return nextFolders
|
||||
}
|
||||
|
||||
func removeFolderOrder(folderOrder map[string][]string, folderID string) {
|
||||
removeFolderOrderReference(folderOrder, folderID)
|
||||
|
||||
trimmedFolderID := strings.TrimSpace(folderID)
|
||||
if trimmedFolderID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
delete(folderOrder, projectFolderOrderParentKey(trimmedFolderID))
|
||||
}
|
||||
|
||||
func removeFolderOrderReference(folderOrder map[string][]string, folderID string) {
|
||||
trimmedFolderID := strings.TrimSpace(folderID)
|
||||
if trimmedFolderID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
for key, ids := range folderOrder {
|
||||
nextIDs := ids[:0]
|
||||
for _, id := range ids {
|
||||
if strings.TrimSpace(id) == trimmedFolderID {
|
||||
continue
|
||||
}
|
||||
nextIDs = append(nextIDs, id)
|
||||
}
|
||||
if len(nextIDs) == 0 {
|
||||
delete(folderOrder, key)
|
||||
continue
|
||||
}
|
||||
folderOrder[key] = append([]string(nil), nextIDs...)
|
||||
}
|
||||
}
|
||||
|
||||
func insertFolderOrder(folderOrder map[string][]string, parentID, folderID string, index int) {
|
||||
trimmedFolderID := strings.TrimSpace(folderID)
|
||||
if trimmedFolderID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
removeFolderOrderReference(folderOrder, trimmedFolderID)
|
||||
|
||||
parentKey := projectFolderOrderParentKey(parentID)
|
||||
children := append([]string(nil), folderOrder[parentKey]...)
|
||||
if index < 0 {
|
||||
index = 0
|
||||
}
|
||||
if index > len(children) {
|
||||
index = len(children)
|
||||
}
|
||||
children = slicesInsert(children, index, trimmedFolderID)
|
||||
folderOrder[parentKey] = children
|
||||
}
|
||||
|
||||
func seedFolderOrderParent(folderOrder map[string][]string, folders []ProjectHierarchyFolderRecord, parentID string) {
|
||||
children := folders
|
||||
trimmedParentID := strings.TrimSpace(parentID)
|
||||
if trimmedParentID != "" {
|
||||
parent, found := findProjectHierarchyFolder(folders, trimmedParentID)
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
children = parent.Children
|
||||
}
|
||||
|
||||
parentKey := projectFolderOrderParentKey(trimmedParentID)
|
||||
seeded := make([]string, 0, len(children))
|
||||
for _, child := range children {
|
||||
childID := strings.TrimSpace(child.ID)
|
||||
if childID == "" || slicesContains(seeded, childID) {
|
||||
continue
|
||||
}
|
||||
seeded = append(seeded, childID)
|
||||
}
|
||||
|
||||
if len(seeded) == 0 {
|
||||
delete(folderOrder, parentKey)
|
||||
return
|
||||
}
|
||||
|
||||
folderOrder[parentKey] = seeded
|
||||
}
|
||||
|
||||
func folderOrderChildren(folderOrder map[string][]string, parentID string) []string {
|
||||
return append([]string(nil), folderOrder[projectFolderOrderParentKey(parentID)]...)
|
||||
}
|
||||
|
||||
func projectFolderOrderParentKey(parentID string) string {
|
||||
trimmedParentID := strings.TrimSpace(parentID)
|
||||
if trimmedParentID == "" {
|
||||
return projectFolderOrderRootKey
|
||||
}
|
||||
|
||||
return trimmedParentID
|
||||
}
|
||||
|
||||
func stringSliceValue(value any) []string {
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
if typed, ok := value.([]string); ok {
|
||||
return typed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
text, ok := item.(string)
|
||||
if ok {
|
||||
result = append(result, text)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func slicesContains(values []string, value string) bool {
|
||||
for _, existing := range values {
|
||||
if existing == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func slicesInsert(values []string, index int, value string) []string {
|
||||
values = append(values, "")
|
||||
copy(values[index+1:], values[index:])
|
||||
values[index] = value
|
||||
return values
|
||||
}
|
||||
|
||||
func readJSONFileMap(path string) map[string]any {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -1802,12 +2206,17 @@ func buildProjectHierarchyFolderTree(rows []projectHierarchyFolderRow, rootParen
|
||||
childrenByParent := make(map[string][]string)
|
||||
|
||||
for _, row := range rows {
|
||||
folderID := strings.TrimSpace(row.ID)
|
||||
if folderID == "" {
|
||||
folderID = row.Path
|
||||
}
|
||||
label := strings.TrimSpace(row.Label)
|
||||
if label == "" {
|
||||
label = fallbackFolderLabel(row.Path)
|
||||
}
|
||||
nodesByPath[row.Path] = &ProjectHierarchyFolderRecord{
|
||||
ID: row.Path,
|
||||
ID: folderID,
|
||||
Path: row.Path,
|
||||
Label: label,
|
||||
Children: []ProjectHierarchyFolderRecord{},
|
||||
}
|
||||
@@ -1830,6 +2239,7 @@ func buildProjectHierarchyFolderTree(rows []projectHierarchyFolderRow, rootParen
|
||||
|
||||
folder := ProjectHierarchyFolderRecord{
|
||||
ID: node.ID,
|
||||
Path: node.Path,
|
||||
Label: node.Label,
|
||||
Children: build(filepath.ToSlash(filepath.Join(childPath, "children"))),
|
||||
}
|
||||
@@ -1856,6 +2266,20 @@ func findProjectHierarchyFolder(folders []ProjectHierarchyFolderRecord, folderID
|
||||
return ProjectHierarchyFolderRecord{}, false
|
||||
}
|
||||
|
||||
func findProjectHierarchyFolderByPath(folders []ProjectHierarchyFolderRecord, folderPath string) (ProjectHierarchyFolderRecord, bool) {
|
||||
for _, folder := range folders {
|
||||
if folder.Path == folderPath {
|
||||
return folder, true
|
||||
}
|
||||
|
||||
if child, ok := findProjectHierarchyFolderByPath(folder.Children, folderPath); ok {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
|
||||
return ProjectHierarchyFolderRecord{}, false
|
||||
}
|
||||
|
||||
func projectHierarchyRootPath(projectSlug string) string {
|
||||
return filepath.ToSlash(filepath.Join("projects", slugDir("project", projectSlug), "children"))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -166,6 +167,9 @@ func TestCreateProjectHierarchyFolderOnDiskCreatesExpectedFolderShape(t *testing
|
||||
}
|
||||
|
||||
folderPayload := readJSONFileForTest[map[string]any](t, filepath.Join(createdFolderPath, "folder.json"))
|
||||
if strings.TrimSpace(asStringForTest(folderPayload["id"])) == "" {
|
||||
t.Fatalf("expected created folder to have stable id, got %#v", folderPayload["id"])
|
||||
}
|
||||
if folderPayload["name"] != "Design System" {
|
||||
t.Fatalf("expected folder name Design System, got %#v", folderPayload["name"])
|
||||
}
|
||||
@@ -282,6 +286,9 @@ func TestRenameProjectHierarchyFolderOnDiskRenamesFolderShape(t *testing.T) {
|
||||
}
|
||||
|
||||
folderPayload := readJSONFileForTest[map[string]any](t, filepath.Join(renamedFolderPath, "folder.json"))
|
||||
if strings.TrimSpace(asStringForTest(folderPayload["id"])) == "" {
|
||||
t.Fatalf("expected renamed folder to preserve stable id, got %#v", folderPayload["id"])
|
||||
}
|
||||
if folderPayload["name"] != "Platform Design" {
|
||||
t.Fatalf("expected renamed folder name Platform Design, got %#v", folderPayload["name"])
|
||||
}
|
||||
@@ -388,6 +395,9 @@ func TestMoveProjectHierarchyFolderOnDiskMovesFolderToNewParent(t *testing.T) {
|
||||
}
|
||||
|
||||
folderPayload := readJSONFileForTest[map[string]any](t, filepath.Join(movedFolderPath, "folder.json"))
|
||||
if strings.TrimSpace(asStringForTest(folderPayload["id"])) == "" {
|
||||
t.Fatalf("expected moved folder to preserve stable id, got %#v", folderPayload["id"])
|
||||
}
|
||||
if folderPayload["name"] != "Research" {
|
||||
t.Fatalf("expected moved folder name Research, got %#v", folderPayload["name"])
|
||||
}
|
||||
@@ -477,9 +487,9 @@ func TestMoveProjectHierarchyFolderOnDiskRejectsDescendantTarget(t *testing.T) {
|
||||
|
||||
func TestBuildProjectHierarchyFolderTreeBuildsNestedStructure(t *testing.T) {
|
||||
rows := []projectHierarchyFolderRow{
|
||||
{Path: "projects/project-primary-project/children/folder-design", ParentPath: projectHierarchyRootPath("primary-project"), Label: "Design"},
|
||||
{Path: "projects/project-primary-project/children/folder-design/children/folder-research", ParentPath: "projects/project-primary-project/children/folder-design/children", Label: "Research"},
|
||||
{Path: "projects/project-primary-project/children/folder-ops", ParentPath: projectHierarchyRootPath("primary-project"), Label: "Ops"},
|
||||
{ID: "folder-design-id", Path: "projects/project-primary-project/children/folder-design", ParentPath: projectHierarchyRootPath("primary-project"), Label: "Design"},
|
||||
{ID: "folder-research-id", Path: "projects/project-primary-project/children/folder-design/children/folder-research", ParentPath: "projects/project-primary-project/children/folder-design/children", Label: "Research"},
|
||||
{ID: "folder-ops-id", Path: "projects/project-primary-project/children/folder-ops", ParentPath: projectHierarchyRootPath("primary-project"), Label: "Ops"},
|
||||
}
|
||||
|
||||
folders := buildProjectHierarchyFolderTree(rows, projectHierarchyRootPath("primary-project"))
|
||||
@@ -492,6 +502,64 @@ func TestBuildProjectHierarchyFolderTreeBuildsNestedStructure(t *testing.T) {
|
||||
if len(folders[0].Children) != 1 || folders[0].Children[0].Label != "Research" {
|
||||
t.Fatalf("unexpected nested folder structure: %#v", folders[0].Children)
|
||||
}
|
||||
if folders[0].ID != "folder-design-id" || folders[0].Path != "projects/project-primary-project/children/folder-design" {
|
||||
t.Fatalf("expected design folder to retain stable id/path, got %#v", folders[0])
|
||||
}
|
||||
if folders[0].Children[0].ID != "folder-research-id" || folders[1].ID != "folder-ops-id" {
|
||||
t.Fatalf("expected nested/top-level folder ids to be preserved, got %#v / %#v", folders[0].Children[0], folders[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyProjectHierarchyFolderOrderingOrdersRootAndChildrenByStableID(t *testing.T) {
|
||||
folders := []ProjectHierarchyFolderRecord{
|
||||
{
|
||||
ID: "folder-design-id",
|
||||
Path: "projects/project-primary-project/children/folder-design",
|
||||
Label: "Design",
|
||||
Children: []ProjectHierarchyFolderRecord{
|
||||
{ID: "folder-research-id", Path: "projects/project-primary-project/children/folder-design/children/folder-research", Label: "Research"},
|
||||
{ID: "folder-assets-id", Path: "projects/project-primary-project/children/folder-design/children/folder-assets", Label: "Assets"},
|
||||
},
|
||||
},
|
||||
{ID: "folder-ops-id", Path: "projects/project-primary-project/children/folder-ops", Label: "Ops"},
|
||||
{ID: "folder-qa-id", Path: "projects/project-primary-project/children/folder-qa", Label: "QA"},
|
||||
}
|
||||
|
||||
ordered := applyProjectHierarchyFolderOrdering(folders, map[string][]string{
|
||||
projectFolderOrderRootKey: {"folder-qa-id", "folder-design-id"},
|
||||
"folder-design-id": {"folder-assets-id", "folder-research-id"},
|
||||
})
|
||||
|
||||
if len(ordered) != 3 {
|
||||
t.Fatalf("expected 3 ordered root folders, got %d", len(ordered))
|
||||
}
|
||||
if ordered[0].ID != "folder-qa-id" || ordered[1].ID != "folder-design-id" || ordered[2].ID != "folder-ops-id" {
|
||||
t.Fatalf("unexpected ordered root ids: %#v", ordered)
|
||||
}
|
||||
if len(ordered[1].Children) != 2 {
|
||||
t.Fatalf("expected design folder children to be preserved, got %#v", ordered[1].Children)
|
||||
}
|
||||
if ordered[1].Children[0].ID != "folder-assets-id" || ordered[1].Children[1].ID != "folder-research-id" {
|
||||
t.Fatalf("unexpected ordered child ids: %#v", ordered[1].Children)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertFolderOrderReordersWithinSameParent(t *testing.T) {
|
||||
folderOrder := map[string][]string{
|
||||
projectFolderOrderRootKey: {"folder-a", "folder-b", "folder-c"},
|
||||
}
|
||||
|
||||
insertFolderOrder(folderOrder, "", "folder-c", 0)
|
||||
|
||||
got := folderOrder[projectFolderOrderRootKey]
|
||||
if len(got) != 3 || got[0] != "folder-c" || got[1] != "folder-a" || got[2] != "folder-b" {
|
||||
t.Fatalf("unexpected reordered root children: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func asStringForTest(value any) string {
|
||||
text, _ := value.(string)
|
||||
return text
|
||||
}
|
||||
|
||||
func readJSONFileForTest[T any](t *testing.T, path string) T {
|
||||
|
||||
Reference in New Issue
Block a user