diff --git a/chaoscenter/authentication/api/handlers/rest/project_handler.go b/chaoscenter/authentication/api/handlers/rest/project_handler.go index 142fd179be8..8ebe227df8b 100644 --- a/chaoscenter/authentication/api/handlers/rest/project_handler.go +++ b/chaoscenter/authentication/api/handlers/rest/project_handler.go @@ -5,6 +5,8 @@ import ( "time" "github.com/litmuschaos/litmus/chaoscenter/authentication/api/presenter" + "github.com/litmuschaos/litmus/chaoscenter/authentication/api/types" + project_utils "github.com/litmuschaos/litmus/chaoscenter/authentication/api/utils" "github.com/litmuschaos/litmus/chaoscenter/authentication/pkg/entities" "github.com/litmuschaos/litmus/chaoscenter/authentication/pkg/services" "github.com/litmuschaos/litmus/chaoscenter/authentication/pkg/utils" @@ -53,16 +55,23 @@ func GetUserWithProject(service services.ApplicationService) gin.HandlerFunc { return } - outputUser := user.GetUserWithProject() + request := project_utils.GetProjectFilters(c) + request.UserID = user.ID - projects, err := service.GetProjectsByUserID(outputUser.ID, false) + response, err := service.GetProjectsByUserID(request) if err != nil { log.Error(err) c.JSON(utils.ErrorStatusCodes[utils.ErrServerError], presenter.CreateErrorResponse(utils.ErrServerError)) return } - outputUser.Projects = projects + outputUser := &entities.UserWithProject{ + Username: user.Username, + ID: user.ID, + Email: user.Email, + Name: user.Name, + Projects: response.Projects, + } c.JSON(http.StatusOK, gin.H{"data": outputUser}) } @@ -122,9 +131,10 @@ func GetProject(service services.ApplicationService) gin.HandlerFunc { // GetProjectsByUserID queries the project with a given userID from the database and returns it in the appropriate format func GetProjectsByUserID(service services.ApplicationService) gin.HandlerFunc { return func(c *gin.Context) { - uID := c.MustGet("uid").(string) - projects, err := service.GetProjectsByUserID(uID, false) - if projects == nil { + request := project_utils.GetProjectFilters(c) + + response, err := service.GetProjectsByUserID(request) + if response == nil || (response.TotalNumberOfProjects != nil && *response.TotalNumberOfProjects == 0) { c.JSON(http.StatusOK, gin.H{ "message": "No projects found", }) @@ -135,7 +145,7 @@ func GetProjectsByUserID(service services.ApplicationService) gin.HandlerFunc { return } - c.JSON(http.StatusOK, gin.H{"data": projects}) + c.JSON(http.StatusOK, gin.H{"data": response}) } } @@ -309,6 +319,7 @@ func CreateProject(service services.ApplicationService) gin.HandlerFunc { c.JSON(utils.ErrorStatusCodes[utils.ErrInvalidRequest], presenter.CreateErrorResponse(utils.ErrInvalidRequest)) return } + userRequest.UserID = c.MustGet("uid").(string) // admin/user shouldn't be able to perform any task if it's default pwd is not changes(initial login is true) initialLogin, err := CheckInitialLogin(service, userRequest.UserID) @@ -324,7 +335,17 @@ func CreateProject(service services.ApplicationService) gin.HandlerFunc { return } - userRequest.UserID = c.MustGet("uid").(string) + if userRequest.Description == nil { + // If description is not provided, set it to an empty string + emptyDescription := "" + userRequest.Description = &emptyDescription + } + + if userRequest.Tags == nil { + // If tags are not provided, set it to an empty slice + emptyTags := make([]*string, 0) + userRequest.Tags = emptyTags + } user, err := service.GetUser(userRequest.UserID) if err != nil { @@ -349,18 +370,22 @@ func CreateProject(service services.ApplicationService) gin.HandlerFunc { // Adding user as project owner in project's member list newMember := &entities.Member{ UserID: user.ID, + Username: user.Name, + Email: user.Email, Role: entities.RoleOwner, Invitation: entities.AcceptedInvitation, JoinedAt: time.Now().UnixMilli(), } var members []*entities.Member members = append(members, newMember) - state := "active" + state := string(types.MemberStateActive) newProject := &entities.Project{ - ID: pID, - Name: userRequest.ProjectName, - Members: members, - State: &state, + ID: pID, + Name: userRequest.ProjectName, + Members: members, + State: &state, + Description: userRequest.Description, + Tags: userRequest.Tags, Audit: entities.Audit{ IsRemoved: false, CreatedAt: time.Now().UnixMilli(), @@ -436,7 +461,7 @@ func SendInvitation(service services.ApplicationService) gin.HandlerFunc { } // Validating member role - if member.Role == nil || (*member.Role != entities.RoleExecutor && *member.Role != entities.RoleViewer) { + if member.Role == nil || (*member.Role != entities.RoleExecutor && *member.Role != entities.RoleViewer && *member.Role != entities.RoleOwner) { c.JSON(utils.ErrorStatusCodes[utils.ErrInvalidRole], presenter.CreateErrorResponse(utils.ErrInvalidRole)) return } @@ -734,6 +759,12 @@ func RemoveInvitation(service services.ApplicationService) gin.HandlerFunc { return } + uid := c.MustGet("uid").(string) + if uid == member.UserID { + c.JSON(http.StatusBadRequest, gin.H{"message": "User cannot remove invitation of themselves use Leave Project."}) + return + } + invitation, err := getInvitation(service, member) if err != nil { log.Error(err) diff --git a/chaoscenter/authentication/api/handlers/rest/project_handler_test.go b/chaoscenter/authentication/api/handlers/rest/project_handler_test.go index e801241ffc7..3a7b63b693d 100644 --- a/chaoscenter/authentication/api/handlers/rest/project_handler_test.go +++ b/chaoscenter/authentication/api/handlers/rest/project_handler_test.go @@ -34,10 +34,14 @@ func TestGetUserWithProject(t *testing.T) { Username: "testUser", Email: "test@example.com", } - project := &entities.Project{} + response := &entities.ListProjectResponse{} + + request := &entities.ListProjectRequest{ + UserID: "testUID", + } service.On("FindUserByUsername", "testUser").Return(user, errors.New("failed")) - service.On("GetProjectsByUserID", "testUID", false).Return([]*entities.Project{project}, errors.New("failed")) + service.On("GetProjectsByUserID", request).Return(response, errors.New("failed")) rest.GetUserWithProject(service)(c) @@ -60,10 +64,30 @@ func TestGetUserWithProject(t *testing.T) { Username: "testUser1", Email: "test@example.com", } - project := &entities.Project{} + + response := &entities.ListProjectResponse{} + + fieldName := entities.ProjectSortingFieldTime + + request := &entities.ListProjectRequest{ + UserID: "testUID", + Pagination: &entities.Pagination{ + Page: 0, + Limit: 15, + }, + Sort: &entities.SortInput{ + Field: &fieldName, + Ascending: nil, + }, + Filter: &entities.ListProjectInputFilter{ + CreatedByMe: nil, + InvitedByOthers: nil, + ProjectName: nil, + }, + } service.On("FindUserByUsername", "testUser1").Return(user, nil) - service.On("GetProjectsByUserID", "testUID", false).Return([]*entities.Project{project}, nil) + service.On("GetProjectsByUserID", request).Return(response, nil) rest.GetUserWithProject(service)(c) @@ -87,10 +111,29 @@ func TestGetUserWithProject(t *testing.T) { Email: "test@example.com", Role: entities.RoleAdmin, } - project := &entities.Project{} + response := &entities.ListProjectResponse{} + + fieldName := entities.ProjectSortingFieldTime + + request := &entities.ListProjectRequest{ + UserID: "testUID", + Pagination: &entities.Pagination{ + Page: 0, + Limit: 15, + }, + Sort: &entities.SortInput{ + Field: &fieldName, + Ascending: nil, + }, + Filter: &entities.ListProjectInputFilter{ + CreatedByMe: nil, + InvitedByOthers: nil, + ProjectName: nil, + }, + } service.On("FindUserByUsername", "testUser").Return(user, nil) - service.On("GetProjectsByUserID", "testUID", false).Return([]*entities.Project{project}, nil) + service.On("GetProjectsByUserID", request).Return(response, nil) rest.GetUserWithProject(service)(c) @@ -106,14 +149,30 @@ func TestGetProjectsByUserID(t *testing.T) { w := httptest.NewRecorder() ctx := GetTestGinContext(w) ctx.Set("uid", "testUserID") - projects := []*entities.Project{ - { - ID: "testProjectID", - Name: "Test Project", + + response := &entities.ListProjectResponse{} + + fieldName := entities.ProjectSortingFieldTime + + request := &entities.ListProjectRequest{ + UserID: "testUserID", + Pagination: &entities.Pagination{ + Page: 0, + Limit: 15, + }, + Sort: &entities.SortInput{ + Field: &fieldName, + Ascending: nil, + }, + Filter: &entities.ListProjectInputFilter{ + CreatedByMe: nil, + InvitedByOthers: nil, + ProjectName: nil, }, } + service := new(mocks.MockedApplicationService) - service.On("GetProjectsByUserID", "testUserID", false).Return(projects, errors.New("Failed")) + service.On("GetProjectsByUserID", request).Return(response, errors.New("Failed")) rest.GetProjectsByUserID(service)(ctx) assert.Equal(t, utils.ErrorStatusCodes[utils.ErrServerError], w.Code) }) @@ -129,8 +188,32 @@ func TestGetProjectsByUserID(t *testing.T) { Name: "Test Project", }, } + + response := &entities.ListProjectResponse{ + Projects: projects, + } + + fieldName := entities.ProjectSortingFieldTime + + request := &entities.ListProjectRequest{ + UserID: "testUserID", + Pagination: &entities.Pagination{ + Page: 0, + Limit: 15, + }, + Sort: &entities.SortInput{ + Field: &fieldName, + Ascending: nil, + }, + Filter: &entities.ListProjectInputFilter{ + CreatedByMe: nil, + InvitedByOthers: nil, + ProjectName: nil, + }, + } + service := new(mocks.MockedApplicationService) - service.On("GetProjectsByUserID", "testUserID", false).Return(projects, nil) + service.On("GetProjectsByUserID", request).Return(response, nil) rest.GetProjectsByUserID(service)(ctx) assert.Equal(t, http.StatusOK, w.Code) }) diff --git a/chaoscenter/authentication/api/mocks/rest_mocks.go b/chaoscenter/authentication/api/mocks/rest_mocks.go index bf9526ebbbc..f2830ad90b3 100644 --- a/chaoscenter/authentication/api/mocks/rest_mocks.go +++ b/chaoscenter/authentication/api/mocks/rest_mocks.go @@ -97,9 +97,9 @@ func (m *MockedApplicationService) GetProjects(query bson.D) ([]*entities.Projec return args.Get(0).([]*entities.Project), args.Error(1) } -func (m *MockedApplicationService) GetProjectsByUserID(uid string, isOwner bool) ([]*entities.Project, error) { - args := m.Called(uid, isOwner) - return args.Get(0).([]*entities.Project), args.Error(1) +func (m *MockedApplicationService) GetProjectsByUserID(request *entities.ListProjectRequest) (*entities.ListProjectResponse, error) { + args := m.Called(request) + return args.Get(0).(*entities.ListProjectResponse), args.Error(1) } func (m *MockedApplicationService) GetProjectStats() ([]*entities.ProjectStats, error) { diff --git a/chaoscenter/authentication/api/types/project_types.go b/chaoscenter/authentication/api/types/project_types.go new file mode 100644 index 00000000000..d267ff24110 --- /dev/null +++ b/chaoscenter/authentication/api/types/project_types.go @@ -0,0 +1,17 @@ +package types + +type MemberState string + +const ( + MemberStateActive MemberState = "active" + MemberStateInactive MemberState = "inactive" +) + +const ( + ProjectName = "projectName" + SortField = "sortField" + Ascending = "sortAscending" + CreatedByMe = "createdByMe" + Page = "page" + Limit = "limit" +) diff --git a/chaoscenter/authentication/api/utils/project_utils.go b/chaoscenter/authentication/api/utils/project_utils.go new file mode 100644 index 00000000000..9d77f3a2704 --- /dev/null +++ b/chaoscenter/authentication/api/utils/project_utils.go @@ -0,0 +1,208 @@ +package utils + +import ( + "log" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/litmuschaos/litmus/chaoscenter/authentication/api/types" + "github.com/litmuschaos/litmus/chaoscenter/authentication/pkg/entities" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func GetProjectFilters(c *gin.Context) *entities.ListProjectRequest { + var request entities.ListProjectRequest + + uID, exists := c.Get("uid") + if exists { + request.UserID = uID.(string) + } + + // Initialize request.Filter and request.Sort if they are nil + if request.Filter == nil { + request.Filter = &entities.ListProjectInputFilter{} + } + if request.Sort == nil { + request.Sort = &entities.SortInput{} + } + + // filters + createdByMeStr := c.Query(types.CreatedByMe) + if createdByMeStr != "" { + createdByMe, err := strconv.ParseBool(createdByMeStr) + if err != nil { + log.Fatal(err) + return nil + } + request.Filter.CreatedByMe = &createdByMe + } + + projectNameStr := c.Query(types.ProjectName) + + if projectNameStr != "" { + request.Filter.ProjectName = &projectNameStr + } + + // sorts + var sortField entities.ProjectSortingField + sortFieldStr := c.Query(types.SortField) + + // Convert the string value to the appropriate type + switch sortFieldStr { + case "name": + sortField = entities.ProjectSortingFieldName + case "time": + sortField = entities.ProjectSortingFieldTime + default: + sortField = entities.ProjectSortingFieldTime + } + + // Now assign the converted value to the sort field + request.Sort.Field = &sortField + + ascendingStr := c.Query(types.Ascending) + if ascendingStr != "" { + ascending, err := strconv.ParseBool(ascendingStr) + if err != nil { + log.Fatal(err) + return nil + } + request.Sort.Ascending = &ascending + } + + // pagination + // Extract page and limit from query parameters + pageStr := c.Query(types.Page) + limitStr := c.Query(types.Limit) + + // Convert strings to integers + page, err := strconv.Atoi(pageStr) + if err != nil { + // Handle error if conversion fails + // For example, set a default value or return an error response + page = 0 // Setting a default value of 1 + } + + limit, err := strconv.Atoi(limitStr) + if err != nil { + // Handle error if conversion fails + // For example, set a default value or return an error response + limit = 15 // Setting a default value of 15 + } + + pagination := entities.Pagination{ + Page: page, + Limit: limit, + } + + request.Pagination = &pagination + + return &request +} + +func CreateMatchStage(userID string) bson.D { + return bson.D{ + {"$match", bson.D{ + {"is_removed", false}, + {"members", bson.D{ + {"$elemMatch", bson.D{ + {"user_id", userID}, + {"invitation", bson.D{ + {"$nin", bson.A{ + string(entities.PendingInvitation), + string(entities.DeclinedInvitation), + string(entities.ExitedProject), + }}, + }}, + }}, + }}, + }}, + } +} + +func CreateFilterStages(filter *entities.ListProjectInputFilter, userID string) []bson.D { + var stages []bson.D + + if filter == nil { + return stages + } + + if filter.CreatedByMe != nil { + if *filter.CreatedByMe { + stages = append(stages, bson.D{ + {"$match", bson.D{ + {"created_by.user_id", bson.M{"$eq": userID}}, + }}, + }) + } else { + stages = append(stages, bson.D{ + {"$match", bson.D{ + {"created_by.user_id", bson.M{"$ne": userID}}, + }}, + }) + } + } + + if filter.ProjectName != nil { + stages = append(stages, bson.D{ + {"$match", bson.D{ + {"name", bson.D{ + {"$regex", primitive.Regex{Pattern: *filter.ProjectName, Options: "i"}}, + }}, + }}, + }) + } + + return stages +} + +func CreateSortStage(sort *entities.SortInput) bson.D { + if sort == nil || sort.Field == nil { + return bson.D{} + } + + var sortField string + switch *sort.Field { + case entities.ProjectSortingFieldTime: + sortField = "updated_at" + case entities.ProjectSortingFieldName: + sortField = "name" + default: + sortField = "updated_at" + } + + sortDirection := -1 + if sort.Ascending != nil && *sort.Ascending { + sortDirection = 1 + } + + return bson.D{ + {"$sort", bson.D{ + {sortField, sortDirection}, + }}, + } +} + +func CreatePaginationStage(pagination *entities.Pagination) []bson.D { + var stages []bson.D + if pagination != nil { + page := pagination.Page + limit := pagination.Limit + // upper limit of 50 to prevent exceeding max limit 16mb + if pagination.Limit > 50 { + limit = 50 + } + stages = append(stages, bson.D{ + {"$skip", page * limit}, + }) + stages = append(stages, bson.D{ + {"$limit", limit}, + }) + } else { + stages = append(stages, bson.D{ + {"$limit", 10}, + }) + } + return stages +} diff --git a/chaoscenter/authentication/pkg/entities/project.go b/chaoscenter/authentication/pkg/entities/project.go index dbe22bcf67e..7df7927685a 100644 --- a/chaoscenter/authentication/pkg/entities/project.go +++ b/chaoscenter/authentication/pkg/entities/project.go @@ -2,11 +2,13 @@ package entities // Project contains the required fields to be stored in the database for a project type Project struct { - Audit `bson:",inline"` - ID string `bson:"_id" json:"projectID"` - Name string `bson:"name" json:"name"` - Members []*Member `bson:"members" json:"members"` - State *string `bson:"state" json:"state"` + Audit `bson:",inline"` + ID string `bson:"_id" json:"projectID"` + Name string `bson:"name" json:"name"` + Members []*Member `bson:"members" json:"members"` + State *string `bson:"state" json:"state"` + Tags []*string `bson:"tags" json:"tags"` + Description *string `bson:"description" json:"description"` } type Owner struct { @@ -50,8 +52,10 @@ type ProjectInput struct { } type CreateProjectInput struct { - ProjectName string `bson:"project_name" json:"projectName"` - UserID string `bson:"user_id" json:"userID"` + ProjectName string `bson:"project_name" json:"projectName"` + UserID string `bson:"user_id" json:"userID"` + Description *string `bson:"description" json:"description"` + Tags []*string `bson:"tags" json:"tags"` } type DeleteProjectInput struct { @@ -71,6 +75,50 @@ type ListInvitationResponse struct { InvitationRole MemberRole `json:"invitationRole"` } +type ListProjectResponse struct { + Projects []*Project `json:"projects"` + TotalNumberOfProjects *int64 `json:"totalNumberOfProjects"` +} + +type Pagination struct { + Page int `json:"page"` + Limit int `json:"limit"` +} + +type ListProjectRequest struct { + UserID string `json:"userID"` + Sort *SortInput `json:"sort,omitempty"` + Filter *ListProjectInputFilter `json:"filter,omitempty"` + Pagination *Pagination `json:"pagination,omitempty"` +} + +type ListProjectInputFilter struct { + ProjectName *string `json:"projectName"` + CreatedByMe *bool `json:"createdByMe"` + InvitedByOthers *bool `json:"invitedByOthers"` +} + +type ProjectSortingField string + +const ( + ProjectSortingFieldName ProjectSortingField = "NAME" + ProjectSortingFieldTime ProjectSortingField = "TIME" +) + +type SortInput struct { + Field *ProjectSortingField `json:"field"` + Ascending *bool `json:"ascending"` +} + +const ( + ProjectName = "projectName" + SortField = "sortField" + Ascending = "sortAscending" + CreatedByMe = "createdByMe" + Page = "page" + Limit = "limit" +) + // GetProjectOutput takes a Project struct as input and returns the graphQL model equivalent func (project *Project) GetProjectOutput() *Project { diff --git a/chaoscenter/authentication/pkg/entities/user.go b/chaoscenter/authentication/pkg/entities/user.go index a3bcd5285a9..fb65c112e2c 100644 --- a/chaoscenter/authentication/pkg/entities/user.go +++ b/chaoscenter/authentication/pkg/entities/user.go @@ -63,23 +63,6 @@ type UserWithProject struct { Projects []*Project `bson:"projects" json:"projects"` } -func (user User) GetUserWithProject() *UserWithProject { - - return &UserWithProject{ - ID: user.ID, - Username: user.Username, - Name: user.Name, - Audit: Audit{ - IsRemoved: user.IsRemoved, - CreatedAt: user.CreatedAt, - CreatedBy: user.UpdatedBy, - UpdatedAt: user.UpdatedAt, - UpdatedBy: user.UpdatedBy, - }, - Email: user.Email, - } -} - // SanitizedUser returns the user object without sensitive information func (user *User) SanitizedUser() *User { user.Password = "" diff --git a/chaoscenter/authentication/pkg/project/repository.go b/chaoscenter/authentication/pkg/project/repository.go index 9830e3129e5..07645f06506 100644 --- a/chaoscenter/authentication/pkg/project/repository.go +++ b/chaoscenter/authentication/pkg/project/repository.go @@ -6,6 +6,7 @@ import ( "log" "time" + project_utils "github.com/litmuschaos/litmus/chaoscenter/authentication/api/utils" "github.com/litmuschaos/litmus/chaoscenter/authentication/pkg/entities" "github.com/litmuschaos/litmus/chaoscenter/authentication/pkg/utils" @@ -18,7 +19,7 @@ import ( type Repository interface { GetProjectByProjectID(projectID string) (*entities.Project, error) GetProjects(query bson.D) ([]*entities.Project, error) - GetProjectsByUserID(uid string, isOwner bool) ([]*entities.Project, error) + GetProjectsByUserID(request *entities.ListProjectRequest) (*entities.ListProjectResponse, error) GetProjectStats() ([]*entities.ProjectStats, error) CreateProject(project *entities.Project) error AddMember(projectID string, member *entities.Member) error @@ -69,51 +70,82 @@ func (r repository) GetProjects(query bson.D) ([]*entities.Project, error) { } // GetProjectsByUserID returns a project based on the userID -func (r repository) GetProjectsByUserID(userID string, isOwner bool) ([]*entities.Project, error) { +func (r repository) GetProjectsByUserID(request *entities.ListProjectRequest) (*entities.ListProjectResponse, error) { var projects []*entities.Project - query := bson.D{} + ctx := context.TODO() - if isOwner { - query = bson.D{ - {"members", bson.D{ - {"$elemMatch", bson.D{ - {"user_id", userID}, - {"role", bson.D{ - {"$eq", entities.RoleOwner}, - }}, - }}, - }}} - } else { - query = bson.D{ - {"is_removed", false}, - {"members", bson.D{ - {"$elemMatch", bson.D{ - {"user_id", userID}, - {"$and", bson.A{ - bson.D{{"invitation", bson.D{ - {"$ne", entities.PendingInvitation}, - }}}, - bson.D{{"invitation", bson.D{ - {"$ne", entities.DeclinedInvitation}, - }}}, - bson.D{{"invitation", bson.D{ - {"$ne", entities.ExitedProject}, - }}}, - }}, - }}, - }}} + // Construct the pipeline + var pipeline mongo.Pipeline + + // Match stage + pipeline = append(pipeline, project_utils.CreateMatchStage(request.UserID)) + + // Filter stage + if request.Filter != nil { + filterStages := project_utils.CreateFilterStages(request.Filter, request.UserID) + pipeline = append(pipeline, filterStages...) } - result, err := r.Collection.Find(context.TODO(), query) - if err != nil { - return nil, err + // Sort stage + sortStage := project_utils.CreateSortStage(request.Sort) + if len(sortStage) > 0 { + pipeline = append(pipeline, sortStage) } - err = result.All(context.TODO(), &projects) + + // Pagination stages + paginationStages := project_utils.CreatePaginationStage(request.Pagination) + + // Facet stage to count total projects and paginate results + facetStage := bson.D{ + {"$facet", bson.D{ + {"totalCount", bson.A{ + bson.D{{"$count", "totalNumberOfProjects"}}, + }}, + {"projects", append(mongo.Pipeline{}, paginationStages...)}, + }}, + } + pipeline = append(pipeline, facetStage) + + // Execute the aggregate pipeline + cursor, err := r.Collection.Aggregate(ctx, pipeline) if err != nil { return nil, err } + defer cursor.Close(ctx) + + // Extract results + var result struct { + TotalCount []struct { + TotalNumberOfProjects int64 `bson:"totalNumberOfProjects"` + } `bson:"totalCount"` + Projects []*entities.Project `bson:"projects"` + } + + if cursor.Next(ctx) { + if err := cursor.Decode(&result); err != nil { + return nil, err + } + } + + var totalNumberOfProjects int64 + if len(result.TotalCount) > 0 { + totalNumberOfProjects = result.TotalCount[0].TotalNumberOfProjects + } else { + zero := int64(0) + return &entities.ListProjectResponse{ + Projects: projects, + TotalNumberOfProjects: &zero, + }, nil + } + + projects = result.Projects + + response := entities.ListProjectResponse{ + Projects: projects, + TotalNumberOfProjects: &totalNumberOfProjects, + } - return projects, err + return &response, nil } // GetProjectStats returns stats related to projects in the DB diff --git a/chaoscenter/authentication/pkg/services/project_service.go b/chaoscenter/authentication/pkg/services/project_service.go index 3c5316b091f..53d00f61581 100644 --- a/chaoscenter/authentication/pkg/services/project_service.go +++ b/chaoscenter/authentication/pkg/services/project_service.go @@ -13,7 +13,7 @@ import ( type projectService interface { GetProjectByProjectID(projectID string) (*entities.Project, error) GetProjects(query bson.D) ([]*entities.Project, error) - GetProjectsByUserID(uid string, isOwner bool) ([]*entities.Project, error) + GetProjectsByUserID(request *entities.ListProjectRequest) (*entities.ListProjectResponse, error) GetProjectStats() ([]*entities.ProjectStats, error) CreateProject(project *entities.Project) error AddMember(projectID string, member *entities.Member) error @@ -39,8 +39,8 @@ func (a applicationService) GetProjects(query bson.D) ([]*entities.Project, erro return a.projectRepository.GetProjects(query) } -func (a applicationService) GetProjectsByUserID(uid string, isOwner bool) ([]*entities.Project, error) { - return a.projectRepository.GetProjectsByUserID(uid, isOwner) +func (a applicationService) GetProjectsByUserID(request *entities.ListProjectRequest) (*entities.ListProjectResponse, error) { + return a.projectRepository.GetProjectsByUserID(request) } func (a applicationService) GetProjectStats() ([]*entities.ProjectStats, error) { diff --git a/chaoscenter/web/src/api/auth/hooks/useCreateProjectMutation.ts b/chaoscenter/web/src/api/auth/hooks/useCreateProjectMutation.ts index 16cc061509b..1543f63e702 100644 --- a/chaoscenter/web/src/api/auth/hooks/useCreateProjectMutation.ts +++ b/chaoscenter/web/src/api/auth/hooks/useCreateProjectMutation.ts @@ -7,7 +7,9 @@ import type { Project } from '../schemas/Project'; import { fetcher, FetcherOptions } from 'services/fetcher'; export type CreateProjectRequestBody = { + description?: string; projectName: string; + tags?: string[]; }; export type CreateProjectOkResponse = { diff --git a/chaoscenter/web/src/api/auth/hooks/useDeleteProjectMutation.ts b/chaoscenter/web/src/api/auth/hooks/useDeleteProjectMutation.ts new file mode 100644 index 00000000000..3145c8e4472 --- /dev/null +++ b/chaoscenter/web/src/api/auth/hooks/useDeleteProjectMutation.ts @@ -0,0 +1,47 @@ +/* eslint-disable */ +// This code is autogenerated using @harnessio/oats-cli. +// Please do not modify this code directly. +import { useMutation, UseMutationOptions } from '@tanstack/react-query'; + +import { fetcher, FetcherOptions } from 'services/fetcher'; + +export interface DeleteProjectMutationPathParams { + project_id: string; +} + +export type DeleteProjectOkResponse = { + message?: string; +}; + +export type DeleteProjectErrorResponse = unknown; + +export interface DeleteProjectProps + extends DeleteProjectMutationPathParams, + Omit, 'url'> {} + +export function deleteProject(props: DeleteProjectProps): Promise { + return fetcher({ + url: `/auth/delete_project/${props.project_id}`, + method: 'POST', + ...props + }); +} + +export type DeleteProjectMutationProps = Omit & + Partial>; + +/** + * This API is used to delete project, which helps owners to delete their projects. + */ +export function useDeleteProjectMutation( + props: Pick, T>, + options?: Omit< + UseMutationOptions>, + 'mutationKey' | 'mutationFn' + > +) { + return useMutation>( + (mutateProps: DeleteProjectMutationProps) => deleteProject({ ...props, ...mutateProps } as DeleteProjectProps), + options + ); +} diff --git a/chaoscenter/web/src/api/auth/hooks/useListProjectsQuery.ts b/chaoscenter/web/src/api/auth/hooks/useListProjectsQuery.ts index ab93f23e48e..2f531485085 100644 --- a/chaoscenter/web/src/api/auth/hooks/useListProjectsQuery.ts +++ b/chaoscenter/web/src/api/auth/hooks/useListProjectsQuery.ts @@ -6,16 +6,30 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query'; import type { Project } from '../schemas/Project'; import { fetcher, FetcherOptions } from 'services/fetcher'; +export interface ListProjectsQueryQueryParams { + sortField?: 'name' | 'time'; + sortAscending?: boolean; + createdByMe?: boolean; + projectName?: string; + page?: number; + limit?: number; +} + export type ListProjectsOkResponse = { - data?: Project[]; + data?: { + projects?: Project[]; + totalNumberOfProjects?: number; + }; }; export type ListProjectsErrorResponse = unknown; -export interface ListProjectsProps extends Omit, 'url'> {} +export interface ListProjectsProps extends Omit, 'url'> { + queryParams: ListProjectsQueryQueryParams; +} export function listProjects(props: ListProjectsProps): Promise { - return fetcher({ + return fetcher({ url: `/auth/list_projects`, method: 'GET', ...props @@ -31,7 +45,7 @@ export function useListProjectsQuery( options?: Omit, 'queryKey' | 'queryFn'> ) { return useQuery( - ['listProjects'], + ['listProjects', props.queryParams], ({ signal }) => listProjects({ ...props, signal }), options ); diff --git a/chaoscenter/web/src/api/auth/index.ts b/chaoscenter/web/src/api/auth/index.ts index 0e25ae9cf21..0be362c1e7f 100644 --- a/chaoscenter/web/src/api/auth/index.ts +++ b/chaoscenter/web/src/api/auth/index.ts @@ -38,6 +38,14 @@ export type { DeclineInvitationRequestBody } from './hooks/useDeclineInvitationMutation'; export { declineInvitation, useDeclineInvitationMutation } from './hooks/useDeclineInvitationMutation'; +export type { + DeleteProjectErrorResponse, + DeleteProjectMutationPathParams, + DeleteProjectMutationProps, + DeleteProjectOkResponse, + DeleteProjectProps +} from './hooks/useDeleteProjectMutation'; +export { deleteProject, useDeleteProjectMutation } from './hooks/useDeleteProjectMutation'; export type { GetApiTokensErrorResponse, GetApiTokensOkResponse, @@ -123,7 +131,8 @@ export { listInvitations, useListInvitationsQuery } from './hooks/useListInvitat export type { ListProjectsErrorResponse, ListProjectsOkResponse, - ListProjectsProps + ListProjectsProps, + ListProjectsQueryQueryParams } from './hooks/useListProjectsQuery'; export { listProjects, useListProjectsQuery } from './hooks/useListProjectsQuery'; export type { diff --git a/chaoscenter/web/src/api/auth/schemas/Project.ts b/chaoscenter/web/src/api/auth/schemas/Project.ts index 22b97f0a748..b89c8b847b3 100644 --- a/chaoscenter/web/src/api/auth/schemas/Project.ts +++ b/chaoscenter/web/src/api/auth/schemas/Project.ts @@ -7,11 +7,13 @@ import type { ProjectMember } from '../schemas/ProjectMember'; export interface Project { createAt?: number; createdBy?: ActionBy; + description?: string; isRemoved?: boolean; members: ProjectMember[]; name: string; projectID: string; state?: string; + tags?: string[]; updatedAt?: number; updatedBy?: ActionBy; } diff --git a/chaoscenter/web/src/components/ProjectDashboardCardContainer/ProjectDashboardCardContainer.module.scss b/chaoscenter/web/src/components/ProjectDashboardCardContainer/ProjectDashboardCardContainer.module.scss new file mode 100644 index 00000000000..8df4e75bcd7 --- /dev/null +++ b/chaoscenter/web/src/components/ProjectDashboardCardContainer/ProjectDashboardCardContainer.module.scss @@ -0,0 +1,35 @@ +.wrapper { + padding: '20px 40px'; +} + +.cardsMainContainer { + align-items: flex-start; + align-content: flex-start; + display: grid; + grid-template-columns: repeat(auto-fill, 245px); + gap: 2rem 1.5rem; +} + +.card { + min-height: 200px; + gap: 1rem; +} + +.projectDashboardCard { + position: relative; + + .tagsList { + display: flex; + align-items: center; + gap: 0.3rem; + + .tag { + padding: 0.25rem 0.5rem; + border-radius: 5px; + } + } +} + +.deleteProjectDialog { + padding-bottom: 0 !important; +} diff --git a/chaoscenter/web/src/components/ProjectDashboardCardContainer/ProjectDashboardCardContainer.module.scss.d.ts b/chaoscenter/web/src/components/ProjectDashboardCardContainer/ProjectDashboardCardContainer.module.scss.d.ts new file mode 100644 index 00000000000..7972381f184 --- /dev/null +++ b/chaoscenter/web/src/components/ProjectDashboardCardContainer/ProjectDashboardCardContainer.module.scss.d.ts @@ -0,0 +1,15 @@ +declare namespace ProjectDashboardCardContainerModuleScssNamespace { + export interface IProjectDashboardCardContainerModuleScss { + card: string; + cardsMainContainer: string; + deleteProjectDialog: string; + projectDashboardCard: string; + tag: string; + tagsList: string; + wrapper: string; + } +} + +declare const ProjectDashboardCardContainerModuleScssModule: ProjectDashboardCardContainerModuleScssNamespace.IProjectDashboardCardContainerModuleScss; + +export = ProjectDashboardCardContainerModuleScssModule; diff --git a/chaoscenter/web/src/components/ProjectDashboardCardContainer/ProjectDashboardCardContainer.tsx b/chaoscenter/web/src/components/ProjectDashboardCardContainer/ProjectDashboardCardContainer.tsx new file mode 100644 index 00000000000..1851b869388 --- /dev/null +++ b/chaoscenter/web/src/components/ProjectDashboardCardContainer/ProjectDashboardCardContainer.tsx @@ -0,0 +1,148 @@ +import { Button, ButtonVariation, Card, Container, Layout, Popover, Text } from '@harnessio/uicore'; +import React, { useState } from 'react'; +import { Color, FontVariation } from '@harnessio/design-system'; +import { Classes, PopoverInteractionKind, Position, Menu, Dialog } from '@blueprintjs/core'; +import { QueryObserverResult, RefetchOptions, RefetchQueryFilters } from '@tanstack/react-query'; +import { useHistory } from 'react-router-dom'; +import { ListProjectsOkResponse, Project } from '@api/auth'; +import CustomTagsPopover from '@components/CustomTagsPopover'; +import { useStrings } from '@strings'; +import ProjectDashboardCardMenuController from '@controllers/ProjectDashboardCardMenu'; +import { setUserDetails, toSentenceCase } from '@utils'; +import { useAppStore } from '@context'; +import { useRouteWithBaseUrl } from '@hooks'; +import css from './ProjectDashboardCardContainer.module.scss'; + +interface ProjectDashboardCardProps { + projects: Project[] | undefined; + listProjectRefetch: ( + options?: (RefetchOptions & RefetchQueryFilters) | undefined + ) => Promise>; +} + +export default function ProjectDashboardCardContainer(props: ProjectDashboardCardProps): React.ReactElement { + const { projects, listProjectRefetch } = props; + const [projectIdToDelete, setProjectIdToDelete] = useState(); + const { getString } = useStrings(); + const history = useHistory(); + const { updateAppStore } = useAppStore(); + + const paths = useRouteWithBaseUrl(); + + const handleProjectSelect = (project: Project): void => { + updateAppStore({ projectID: project.projectID, projectName: project.name }); + setUserDetails({ + projectID: project.projectID + }); + history.push(paths.toRoot()); + }; + + return ( + + + {projects?.map(project => { + return ( + handleProjectSelect(project)} + className={css.projectDashboardCard} + key={project.projectID} + interactive + > + {/* ProjectMenu */} + { + e.stopPropagation(); + e.preventDefault(); + }} + flex={{ justifyContent: 'center', alignItems: 'flex-end' }} + > + +