diff --git a/Backend/internal/bootstrap/service.go b/Backend/internal/bootstrap/service.go index 8daa1d2..eedb36d 100644 --- a/Backend/internal/bootstrap/service.go +++ b/Backend/internal/bootstrap/service.go @@ -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")) } diff --git a/Backend/internal/bootstrap/service_test.go b/Backend/internal/bootstrap/service_test.go index ea3e0ae..f88fc14 100644 --- a/Backend/internal/bootstrap/service_test.go +++ b/Backend/internal/bootstrap/service_test.go @@ -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 { diff --git a/Backend/internal/httpx/api_project_routes.go b/Backend/internal/httpx/api_project_routes.go index 8640069..acc2530 100644 --- a/Backend/internal/httpx/api_project_routes.go +++ b/Backend/internal/httpx/api_project_routes.go @@ -28,7 +28,10 @@ type deleteProjectFolderRequest struct { type moveProjectFolderRequest struct { FolderID string `json:"folderId"` + FolderNodeID string `json:"folderNodeId"` ParentFolderID string `json:"parentFolderId"` + ParentNodeID string `json:"parentNodeId"` + TargetIndex int `json:"targetIndex"` } func (routes apiRoutes) handleProjectFolders(w http.ResponseWriter, r *http.Request) { @@ -179,7 +182,9 @@ func (routes apiRoutes) handleMoveProjectFolder(w http.ResponseWriter, r *http.R } payload.FolderID = strings.TrimSpace(payload.FolderID) + payload.FolderNodeID = strings.TrimSpace(payload.FolderNodeID) payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) + payload.ParentNodeID = strings.TrimSpace(payload.ParentNodeID) if payload.FolderID == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") return @@ -188,7 +193,10 @@ func (routes apiRoutes) handleMoveProjectFolder(w http.ResponseWriter, r *http.R result, err := routes.bootstrapService().MoveProjectFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{ ProjectID: projectID, FolderID: payload.FolderID, + FolderNodeID: payload.FolderNodeID, ParentFolderID: payload.ParentFolderID, + ParentNodeID: payload.ParentNodeID, + TargetIndex: payload.TargetIndex, }) if err != nil { routes.writeProjectFolderError(w, r, err, "move") @@ -352,7 +360,9 @@ func (routes apiRoutes) handleMoveProjectTreeFolder(w http.ResponseWriter, r *ht } payload.FolderID = strings.TrimSpace(payload.FolderID) + payload.FolderNodeID = strings.TrimSpace(payload.FolderNodeID) payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) + payload.ParentNodeID = strings.TrimSpace(payload.ParentNodeID) if payload.FolderID == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") return @@ -361,7 +371,10 @@ func (routes apiRoutes) handleMoveProjectTreeFolder(w http.ResponseWriter, r *ht result, err := routes.bootstrapService().MoveProjectTreeFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{ ProjectID: projectID, FolderID: payload.FolderID, + FolderNodeID: payload.FolderNodeID, ParentFolderID: payload.ParentFolderID, + ParentNodeID: payload.ParentNodeID, + TargetIndex: payload.TargetIndex, }) if err != nil { routes.writeProjectFolderError(w, r, err, "move") diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx index c9ea966..5898080 100644 --- a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx +++ b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx @@ -36,6 +36,7 @@ type ProjectSelectorProps = { type ProjectFolderNode = { kind: "folder"; id: string; + path: string; label: string; meta?: string; children: ProjectTreeNode[]; @@ -50,6 +51,7 @@ type ProjectTreeNode = ProjectFolderNode | ProjectLeafNode; type PersistedProjectFolderRecord = { id: string; + path: string; label: string; children: PersistedProjectFolderRecord[]; }; @@ -60,6 +62,7 @@ type ProjectFoldersResponse = { renamedFolder?: PersistedProjectFolderRecord; movedFolder?: PersistedProjectFolderRecord; previousFolderId?: string; + previousFolderPath?: string; }; error?: string; message?: string; @@ -91,6 +94,7 @@ const buildPersistedFolderNodes = (folders: readonly PersistedProjectFolderRecor folders.map((folder) => ({ kind: "folder", id: folder.id, + path: folder.path, label: folder.label, children: buildPersistedFolderNodes(folder.children ?? []), })); @@ -578,18 +582,39 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { const currentNodes = projectTreeNodes(); const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, projectTreeAdapter); - const persistedParentId = nextDragState.dropTarget.parentId; const canPersistMove = isUuidString(selectedProject().id); - const persistedParentLocation = persistedParentId - ? findTreeNodeLocation(currentNodes, persistedParentId, projectTreeAdapter) + const draggedFolderPath = draggedLocation?.node.kind === "folder" ? draggedLocation.node.path : null; + const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget as ProjectDragTarget, projectTreeAdapter); + const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, projectTreeAdapter); + const persistedParentLocation = previewLocation?.parentId + ? findTreeNodeLocation(previewNodes, previewLocation.parentId, projectTreeAdapter) : null; + const persistedParentFolderPath = + persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.path : null; + const previewSiblings = previewLocation?.parentId + ? persistedParentLocation?.node.kind === "folder" + ? persistedParentLocation.node.children + : [] + : previewNodes; + const targetIndex = previewLocation + ? previewSiblings + .slice(0, previewLocation.index) + .filter((node) => node.kind === "folder").length + : 0; if ( canPersistMove && draggedLocation?.node.kind === "folder" && - (persistedParentId === null || persistedParentLocation?.node.kind === "folder") + draggedFolderPath && + (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder") ) { - void movePersistedFolder(draggedLocation.node.id, persistedParentId); + void movePersistedFolder( + draggedFolderPath, + persistedParentFolderPath, + draggedLocation.node.id, + persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null, + targetIndex, + ); } else { setProjectTreeNodes((current) => moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as ProjectDragTarget, projectTreeAdapter), @@ -674,6 +699,11 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { setPendingFolderRenameName(label); }; + const resolveFolderPath = (folderId: string): string | null => { + const location = findTreeNodeLocation(projectTreeNodes(), folderId, projectTreeAdapter); + return location && location.node.kind === "folder" ? location.node.path : null; + }; + const submitPendingFolder = async (): Promise => { const name = pendingFolderName().trim(); const draft = pendingFolderDraft(); @@ -694,6 +724,12 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { return; } + const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null; + if (draft.parentId && !parentFolderPath) { + cancelPendingFolder(); + return; + } + try { const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, { method: "POST", @@ -703,7 +739,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { }, body: JSON.stringify({ name, - parentFolderId: draft.parentId, + parentFolderId: parentFolderPath, }), }); @@ -727,9 +763,14 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { return; } + const folderPath = resolveFolderPath(folderId); + if (!folderPath) { + return; + } + try { const response = await fetch( - `${resolveAPIBase()}/projects/${projectId}/folders?folderId=${encodeURIComponent(folderId)}`, + `${resolveAPIBase()}/projects/${projectId}/folders?folderId=${encodeURIComponent(folderPath)}`, { method: "DELETE", headers: { @@ -751,9 +792,15 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { } }; - const movePersistedFolder = async (folderId: string, parentFolderId: string | null): Promise => { + const movePersistedFolder = async ( + folderPath: string, + parentFolderPath: string | null, + folderNodeId: string, + parentNodeId: string | null, + targetIndex: number, + ): Promise => { const projectId = selectedProject().id; - if (!folderId || !isUuidString(projectId)) { + if (!folderPath || !folderNodeId || !isUuidString(projectId)) { return; } @@ -765,8 +812,11 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { "Content-Type": "application/json", }, body: JSON.stringify({ - folderId, - parentFolderId, + folderId: folderPath, + folderNodeId, + parentFolderId: parentFolderPath, + parentNodeId, + targetIndex, }), }); @@ -777,14 +827,6 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { } setPersistedFolders(readPersistedFolders(body)); - - const previousFolderId = body.data?.previousFolderId; - const movedFolderId = body.data?.movedFolder?.id; - if (previousFolderId && movedFolderId && previousFolderId !== movedFolderId) { - setCollapsedFolderIds((current) => - current.map((id) => (id === previousFolderId ? movedFolderId : id)), - ); - } } catch (error) { console.error(error); } @@ -810,6 +852,12 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { return; } + const folderPath = resolveFolderPath(draft.folderId); + if (!folderPath) { + cancelPendingFolderRename(); + return; + } + try { const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, { method: "PATCH", @@ -818,7 +866,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { "Content-Type": "application/json", }, body: JSON.stringify({ - folderId: draft.folderId, + folderId: folderPath, name, }), }); @@ -832,14 +880,6 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { setPersistedFolders(readPersistedFolders(body)); setPendingFolderRename(null); setPendingFolderRenameName(""); - - const previousFolderId = body.data?.previousFolderId; - const renamedFolderId = body.data?.renamedFolder?.id; - if (previousFolderId && renamedFolderId && previousFolderId !== renamedFolderId) { - setCollapsedFolderIds((current) => - current.map((id) => (id === previousFolderId ? renamedFolderId : id)), - ); - } } catch (error) { console.error(error); } diff --git a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx index 44e31d8..7ac65cc 100644 --- a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -52,6 +52,7 @@ type WorkspaceDragState = { type PersistedWorkspaceFolderRecord = { id: string; + path: string; label: string; children?: PersistedWorkspaceFolderRecord[]; }; @@ -62,6 +63,7 @@ type WorkspaceFoldersResponse = { renamedFolder?: PersistedWorkspaceFolderRecord; movedFolder?: PersistedWorkspaceFolderRecord; previousFolderId?: string; + previousFolderPath?: string; }; error?: string; message?: string; @@ -81,6 +83,7 @@ const buildPersistedWorkspaceFolderNodes = ( ): WorkspaceTreeNode[] => folders.map((folder) => ({ id: folder.id, + path: folder.path, label: folder.label, kind: "folder", icon: Folder, @@ -532,18 +535,38 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { const currentNodes = workspaceTreeNodes(); const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, workspaceTreeAdapter); - const persistedParentId = nextDragState.dropTarget.parentId; const canPersistMove = isUuidString(activeProject()?.id ?? ""); - const persistedParentLocation = persistedParentId - ? findTreeNodeLocation(currentNodes, persistedParentId, workspaceTreeAdapter) + const draggedFolderPath = draggedLocation?.node.kind === "folder" ? draggedLocation.node.path ?? null : null; + const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter); + const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, workspaceTreeAdapter); + const persistedParentLocation = previewLocation?.parentId + ? findTreeNodeLocation(previewNodes, previewLocation.parentId, workspaceTreeAdapter) : null; + const persistedParentFolderPath = persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.path ?? null : null; + const previewSiblings = previewLocation?.parentId + ? persistedParentLocation?.node.kind === "folder" + ? persistedParentLocation.node.children ?? [] + : [] + : previewNodes; + const targetIndex = previewLocation + ? previewSiblings + .slice(0, previewLocation.index) + .filter((node) => node.kind === "folder").length + : 0; if ( canPersistMove && draggedLocation?.node.kind === "folder" && - (persistedParentId === null || persistedParentLocation?.node.kind === "folder") + draggedFolderPath && + (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder") ) { - void movePersistedFolder(draggedLocation.node.id, persistedParentId); + void movePersistedFolder( + draggedFolderPath, + persistedParentFolderPath, + draggedLocation.node.id, + persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null, + targetIndex, + ); } else { setWorkspaceTreeNodes((current) => moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter), @@ -598,6 +621,11 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { setPendingFolderRenameName(label); }; + const resolveFolderPath = (folderId: string): string | null => { + const location = findTreeNodeLocation(workspaceTreeNodes(), folderId, workspaceTreeAdapter); + return location?.node.kind === "folder" ? location.node.path ?? null : null; + }; + const submitPendingFolder = async (): Promise => { const name = pendingFolderName().trim(); const draft = pendingFolderDraft(); @@ -618,6 +646,12 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { return; } + const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null; + if (draft.parentId && !parentFolderPath) { + cancelPendingFolder(); + return; + } + try { const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, { method: "POST", @@ -627,7 +661,7 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { }, body: JSON.stringify({ name, - parentFolderId: draft.parentId, + parentFolderId: parentFolderPath, }), }); @@ -647,13 +681,17 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { const deletePersistedFolder = async (folderId: string): Promise => { const projectId = activeProject()?.id ?? ""; + const folderPath = resolveFolderPath(folderId); if (!folderId || !projectId || !isUuidString(projectId)) { return; } + if (!folderPath) { + return; + } try { const response = await fetch( - `${resolveAPIBase()}/projects/${projectId}/tree/folders?folderId=${encodeURIComponent(folderId)}`, + `${resolveAPIBase()}/projects/${projectId}/tree/folders?folderId=${encodeURIComponent(folderPath)}`, { method: "DELETE", headers: { @@ -675,9 +713,15 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { } }; - const movePersistedFolder = async (folderId: string, parentFolderId: string | null): Promise => { + const movePersistedFolder = async ( + folderPath: string, + parentFolderPath: string | null, + folderNodeId: string, + parentNodeId: string | null, + targetIndex: number, + ): Promise => { const projectId = activeProject()?.id ?? ""; - if (!folderId || !projectId || !isUuidString(projectId)) { + if (!folderPath || !folderNodeId || !projectId || !isUuidString(projectId)) { return; } @@ -689,8 +733,11 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { "Content-Type": "application/json", }, body: JSON.stringify({ - folderId, - parentFolderId, + folderId: folderPath, + folderNodeId, + parentFolderId: parentFolderPath, + parentNodeId, + targetIndex, }), }); @@ -701,14 +748,6 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { } setPersistedFolders(readPersistedWorkspaceFolders(body)); - - const previousFolderId = body.data?.previousFolderId; - const movedFolderId = body.data?.movedFolder?.id; - if (previousFolderId && movedFolderId && previousFolderId !== movedFolderId) { - setCollapsedFolderIds((current) => - current.map((id) => (id === previousFolderId ? movedFolderId : id)), - ); - } } catch (error) { console.error(error); } @@ -734,6 +773,12 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { return; } + const folderPath = resolveFolderPath(draft.folderId); + if (!folderPath) { + cancelPendingFolderRename(); + return; + } + try { const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, { method: "PATCH", @@ -742,7 +787,7 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { "Content-Type": "application/json", }, body: JSON.stringify({ - folderId: draft.folderId, + folderId: folderPath, name, }), }); @@ -756,14 +801,6 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { setPersistedFolders(readPersistedWorkspaceFolders(body)); setPendingFolderRename(null); setPendingFolderRenameName(""); - - const previousFolderId = body.data?.previousFolderId; - const renamedFolderId = body.data?.renamedFolder?.id; - if (previousFolderId && renamedFolderId && previousFolderId !== renamedFolderId) { - setCollapsedFolderIds((current) => - current.map((id) => (id === previousFolderId ? renamedFolderId : id)), - ); - } } catch (error) { console.error(error); } diff --git a/Frontend/src/components/shell/data/shell.data.ts b/Frontend/src/components/shell/data/shell.data.ts index 6fda3c0..0e5cfa1 100644 --- a/Frontend/src/components/shell/data/shell.data.ts +++ b/Frontend/src/components/shell/data/shell.data.ts @@ -129,6 +129,7 @@ export type WorkspaceStaticItem = SidebarItem & { export type WorkspaceFolderNode = { id: string; + path?: string; label: string; kind: "folder"; icon: ShellIcon; diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx index d908a26..a9731e8 100644 --- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx +++ b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx @@ -21,6 +21,29 @@ type BootstrapSubmissionState = { error: string; }; +type InstanceForm = { + protocol: "http" | "https"; + access: "local" | "remote"; + host: string; +}; + +type ModeForm = { + mode: "personal" | "organizational"; + name: string; +}; + +type AdminForm = { + displayName: string; + email: string; + password: string; +}; + +type StructureForm = { + departmentName: string; + teamName: string; + projectName: string; +}; + const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [ { id: "instance", @@ -44,37 +67,37 @@ const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [ }, ]; -const defaultInstanceForm = { +const defaultInstanceForm: InstanceForm = { protocol: "http", access: "local", host: "localhost", -} as const; +}; -const defaultModeForm = { +const defaultModeForm: ModeForm = { mode: "personal", name: "", -} as const; +}; -const defaultAdminForm = { +const defaultAdminForm: AdminForm = { displayName: "Admin", email: "admin@example.com", password: "", -} as const; +}; const personalStructureDefaults = { departmentName: "Default", teamName: "Personal", -} as const; +}; const organizationalStructureDefaults = { departmentName: "Department", teamName: "Team", -} as const; +}; -const defaultStructureForm = { +const defaultStructureForm: StructureForm = { ...personalStructureDefaults, projectName: "Project", -} as const; +}; const initialSubmissionState = (): BootstrapSubmissionState => ({ status: "idle", @@ -148,10 +171,10 @@ type WorkspaceHomeProps = { export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { const appShellData = useAppShellData(); - const [instanceForm, setInstanceForm] = createStore({ ...defaultInstanceForm }); - const [modeForm, setModeForm] = createStore({ ...defaultModeForm }); - const [adminForm, setAdminForm] = createStore({ ...defaultAdminForm }); - const [structureForm, setStructureForm] = createStore({ ...defaultStructureForm }); + const [instanceForm, setInstanceForm] = createStore({ ...defaultInstanceForm }); + const [modeForm, setModeForm] = createStore({ ...defaultModeForm }); + const [adminForm, setAdminForm] = createStore({ ...defaultAdminForm }); + const [structureForm, setStructureForm] = createStore({ ...defaultStructureForm }); const [stepState, setStepState] = createStore>({ instance: initialSubmissionState(), mode: initialSubmissionState(), @@ -363,7 +386,13 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {

{isBootstrapComplete() ? appShellData.activeServer().name : bootstrapTargetLabel()}

-
@@ -384,7 +413,13 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { - @@ -433,14 +468,24 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { <>