diff --git a/api/Makefile b/api/Makefile new file mode 100644 index 0000000..ad81808 --- /dev/null +++ b/api/Makefile @@ -0,0 +1,3 @@ +start: + docker container start test-db + go run cmd/citadel/main.go diff --git a/api/graph/generated.go b/api/graph/generated.go index edbfc13..a4fdc23 100644 --- a/api/graph/generated.go +++ b/api/graph/generated.go @@ -74,6 +74,7 @@ type ComplexityRoot struct { Mutation struct { AddTaskLabel func(childComplexity int, input *AddTaskLabelInput) int AssignTask func(childComplexity int, input *AssignTaskInput) int + ClearProfileAvatar func(childComplexity int) int CreateProject func(childComplexity int, input NewProject) int CreateProjectLabel func(childComplexity int, input NewProjectLabel) int CreateRefreshToken func(childComplexity int, input NewRefreshToken) int @@ -94,6 +95,7 @@ type ComplexityRoot struct { UpdateProjectName func(childComplexity int, input *UpdateProjectName) int UpdateTaskDescription func(childComplexity int, input UpdateTaskDescriptionInput) int UpdateTaskGroupLocation func(childComplexity int, input NewTaskGroupLocation) int + UpdateTaskGroupName func(childComplexity int, input UpdateTaskGroupName) int UpdateTaskLocation func(childComplexity int, input NewTaskLocation) int UpdateTaskName func(childComplexity int, input UpdateTaskName) int } @@ -123,9 +125,8 @@ type ComplexityRoot struct { } ProjectMember struct { - FirstName func(childComplexity int) int + FullName func(childComplexity int) int ID func(childComplexity int) int - LastName func(childComplexity int) int ProfileIcon func(childComplexity int) int } @@ -193,9 +194,9 @@ type ComplexityRoot struct { UserAccount struct { CreatedAt func(childComplexity int) int Email func(childComplexity int) int - FirstName func(childComplexity int) int + FullName func(childComplexity int) int ID func(childComplexity int) int - LastName func(childComplexity int) int + Initials func(childComplexity int) int ProfileIcon func(childComplexity int) int Username func(childComplexity int) int } @@ -208,6 +209,7 @@ type MutationResolver interface { CreateRefreshToken(ctx context.Context, input NewRefreshToken) (*pg.RefreshToken, error) CreateUserAccount(ctx context.Context, input NewUserAccount) (*pg.UserAccount, error) CreateTeam(ctx context.Context, input NewTeam) (*pg.Team, error) + ClearProfileAvatar(ctx context.Context) (*pg.UserAccount, error) CreateProject(ctx context.Context, input NewProject) (*pg.Project, error) UpdateProjectName(ctx context.Context, input *UpdateProjectName) (*pg.Project, error) CreateProjectLabel(ctx context.Context, input NewProjectLabel) (*pg.ProjectLabel, error) @@ -217,6 +219,7 @@ type MutationResolver interface { UpdateProjectLabelColor(ctx context.Context, input UpdateProjectLabelColor) (*pg.ProjectLabel, error) CreateTaskGroup(ctx context.Context, input NewTaskGroup) (*pg.TaskGroup, error) UpdateTaskGroupLocation(ctx context.Context, input NewTaskGroupLocation) (*pg.TaskGroup, error) + UpdateTaskGroupName(ctx context.Context, input UpdateTaskGroupName) (*pg.TaskGroup, error) DeleteTaskGroup(ctx context.Context, input DeleteTaskGroupInput) (*DeleteTaskGroupPayload, error) AddTaskLabel(ctx context.Context, input *AddTaskLabelInput) (*pg.Task, error) RemoveTaskLabel(ctx context.Context, input *RemoveTaskLabelInput) (*pg.Task, error) @@ -381,6 +384,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.AssignTask(childComplexity, args["input"].(*AssignTaskInput)), true + case "Mutation.clearProfileAvatar": + if e.complexity.Mutation.ClearProfileAvatar == nil { + break + } + + return e.complexity.Mutation.ClearProfileAvatar(childComplexity), true + case "Mutation.createProject": if e.complexity.Mutation.CreateProject == nil { break @@ -621,6 +631,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.UpdateTaskGroupLocation(childComplexity, args["input"].(NewTaskGroupLocation)), true + case "Mutation.updateTaskGroupName": + if e.complexity.Mutation.UpdateTaskGroupName == nil { + break + } + + args, err := ec.field_Mutation_updateTaskGroupName_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.UpdateTaskGroupName(childComplexity, args["input"].(UpdateTaskGroupName)), true + case "Mutation.updateTaskLocation": if e.complexity.Mutation.UpdateTaskLocation == nil { break @@ -750,12 +772,12 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.ProjectLabel.Name(childComplexity), true - case "ProjectMember.firstName": - if e.complexity.ProjectMember.FirstName == nil { + case "ProjectMember.fullName": + if e.complexity.ProjectMember.FullName == nil { break } - return e.complexity.ProjectMember.FirstName(childComplexity), true + return e.complexity.ProjectMember.FullName(childComplexity), true case "ProjectMember.id": if e.complexity.ProjectMember.ID == nil { @@ -764,13 +786,6 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.ProjectMember.ID(childComplexity), true - case "ProjectMember.lastName": - if e.complexity.ProjectMember.LastName == nil { - break - } - - return e.complexity.ProjectMember.LastName(childComplexity), true - case "ProjectMember.profileIcon": if e.complexity.ProjectMember.ProfileIcon == nil { break @@ -1071,12 +1086,12 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.UserAccount.Email(childComplexity), true - case "UserAccount.firstName": - if e.complexity.UserAccount.FirstName == nil { + case "UserAccount.fullName": + if e.complexity.UserAccount.FullName == nil { break } - return e.complexity.UserAccount.FirstName(childComplexity), true + return e.complexity.UserAccount.FullName(childComplexity), true case "UserAccount.id": if e.complexity.UserAccount.ID == nil { @@ -1085,12 +1100,12 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.UserAccount.ID(childComplexity), true - case "UserAccount.lastName": - if e.complexity.UserAccount.LastName == nil { + case "UserAccount.initials": + if e.complexity.UserAccount.Initials == nil { break } - return e.complexity.UserAccount.LastName(childComplexity), true + return e.complexity.UserAccount.Initials(childComplexity), true case "UserAccount.profileIcon": if e.complexity.UserAccount.ProfileIcon == nil { @@ -1172,6 +1187,7 @@ func (ec *executionContext) introspectType(name string) (*introspection.Type, er var sources = []*ast.Source{ &ast.Source{Name: "graph/schema.graphqls", Input: `scalar Time scalar UUID +scalar Upload type ProjectLabel { id: ID! @@ -1201,8 +1217,7 @@ type ProfileIcon { type ProjectMember { id: ID! - firstName: String! - lastName: String! + fullName: String! profileIcon: ProfileIcon! } @@ -1217,8 +1232,8 @@ type UserAccount { id: ID! email: String! createdAt: Time! - firstName: String! - lastName: String! + fullName: String! + initials: String! username: String! profileIcon: ProfileIcon! } @@ -1295,8 +1310,8 @@ input NewRefreshToken { input NewUserAccount { username: String! email: String! - firstName: String! - lastName: String! + fullName: String! + initials: String! password: String! } @@ -1427,12 +1442,19 @@ type UpdateTaskLocationPayload { previousTaskGroupID: UUID! task: Task! } + +input UpdateTaskGroupName { + taskGroupID: UUID! + name: String! +} + type Mutation { createRefreshToken(input: NewRefreshToken!): RefreshToken! createUserAccount(input: NewUserAccount!): UserAccount! createTeam(input: NewTeam!): Team! + clearProfileAvatar: UserAccount! createProject(input: NewProject!): Project! updateProjectName(input: UpdateProjectName): Project! @@ -1445,6 +1467,7 @@ type Mutation { createTaskGroup(input: NewTaskGroup!): TaskGroup! updateTaskGroupLocation(input: NewTaskGroupLocation!): TaskGroup! + updateTaskGroupName(input: UpdateTaskGroupName!): TaskGroup! deleteTaskGroup(input: DeleteTaskGroupInput!): DeleteTaskGroupPayload! addTaskLabel(input: AddTaskLabelInput): Task! @@ -1777,6 +1800,20 @@ func (ec *executionContext) field_Mutation_updateTaskGroupLocation_args(ctx cont return args, nil } +func (ec *executionContext) field_Mutation_updateTaskGroupName_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 UpdateTaskGroupName + if tmp, ok := rawArgs["input"]; ok { + arg0, err = ec.unmarshalNUpdateTaskGroupName2githubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋgraphᚐUpdateTaskGroupName(ctx, tmp) + if err != nil { + return nil, err + } + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_updateTaskLocation_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -2306,6 +2343,40 @@ func (ec *executionContext) _Mutation_createTeam(ctx context.Context, field grap return ec.marshalNTeam2ᚖgithubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋpgᚐTeam(ctx, field.Selections, res) } +func (ec *executionContext) _Mutation_clearProfileAvatar(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().ClearProfileAvatar(rctx) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*pg.UserAccount) + fc.Result = res + return ec.marshalNUserAccount2ᚖgithubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋpgᚐUserAccount(ctx, field.Selections, res) +} + func (ec *executionContext) _Mutation_createProject(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -2675,6 +2746,47 @@ func (ec *executionContext) _Mutation_updateTaskGroupLocation(ctx context.Contex return ec.marshalNTaskGroup2ᚖgithubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋpgᚐTaskGroup(ctx, field.Selections, res) } +func (ec *executionContext) _Mutation_updateTaskGroupName(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_updateTaskGroupName_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().UpdateTaskGroupName(rctx, args["input"].(UpdateTaskGroupName)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*pg.TaskGroup) + fc.Result = res + return ec.marshalNTaskGroup2ᚖgithubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋpgᚐTaskGroup(ctx, field.Selections, res) +} + func (ec *executionContext) _Mutation_deleteTaskGroup(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -3699,7 +3811,7 @@ func (ec *executionContext) _ProjectMember_id(ctx context.Context, field graphql return ec.marshalNID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, field.Selections, res) } -func (ec *executionContext) _ProjectMember_firstName(ctx context.Context, field graphql.CollectedField, obj *ProjectMember) (ret graphql.Marshaler) { +func (ec *executionContext) _ProjectMember_fullName(ctx context.Context, field graphql.CollectedField, obj *ProjectMember) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { ec.Error(ctx, ec.Recover(ctx, r)) @@ -3716,41 +3828,7 @@ func (ec *executionContext) _ProjectMember_firstName(ctx context.Context, field ctx = graphql.WithFieldContext(ctx, fc) resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return obj.FirstName, nil - }) - if err != nil { - ec.Error(ctx, err) - return graphql.Null - } - if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } - return graphql.Null - } - res := resTmp.(string) - fc.Result = res - return ec.marshalNString2string(ctx, field.Selections, res) -} - -func (ec *executionContext) _ProjectMember_lastName(ctx context.Context, field graphql.CollectedField, obj *ProjectMember) (ret graphql.Marshaler) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = graphql.Null - } - }() - fc := &graphql.FieldContext{ - Object: "ProjectMember", - Field: field, - Args: nil, - IsMethod: false, - } - - ctx = graphql.WithFieldContext(ctx, fc) - resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { - ctx = rctx // use context from middleware stack in children - return obj.LastName, nil + return obj.FullName, nil }) if err != nil { ec.Error(ctx, err) @@ -5255,7 +5333,7 @@ func (ec *executionContext) _UserAccount_createdAt(ctx context.Context, field gr return ec.marshalNTime2timeᚐTime(ctx, field.Selections, res) } -func (ec *executionContext) _UserAccount_firstName(ctx context.Context, field graphql.CollectedField, obj *pg.UserAccount) (ret graphql.Marshaler) { +func (ec *executionContext) _UserAccount_fullName(ctx context.Context, field graphql.CollectedField, obj *pg.UserAccount) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { ec.Error(ctx, ec.Recover(ctx, r)) @@ -5272,7 +5350,7 @@ func (ec *executionContext) _UserAccount_firstName(ctx context.Context, field gr ctx = graphql.WithFieldContext(ctx, fc) resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return obj.FirstName, nil + return obj.FullName, nil }) if err != nil { ec.Error(ctx, err) @@ -5289,7 +5367,7 @@ func (ec *executionContext) _UserAccount_firstName(ctx context.Context, field gr return ec.marshalNString2string(ctx, field.Selections, res) } -func (ec *executionContext) _UserAccount_lastName(ctx context.Context, field graphql.CollectedField, obj *pg.UserAccount) (ret graphql.Marshaler) { +func (ec *executionContext) _UserAccount_initials(ctx context.Context, field graphql.CollectedField, obj *pg.UserAccount) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { ec.Error(ctx, ec.Recover(ctx, r)) @@ -5306,7 +5384,7 @@ func (ec *executionContext) _UserAccount_lastName(ctx context.Context, field gra ctx = graphql.WithFieldContext(ctx, fc) resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return obj.LastName, nil + return obj.Initials, nil }) if err != nil { ec.Error(ctx, err) @@ -6854,15 +6932,15 @@ func (ec *executionContext) unmarshalInputNewUserAccount(ctx context.Context, ob if err != nil { return it, err } - case "firstName": + case "fullName": var err error - it.FirstName, err = ec.unmarshalNString2string(ctx, v) + it.FullName, err = ec.unmarshalNString2string(ctx, v) if err != nil { return it, err } - case "lastName": + case "initials": var err error - it.LastName, err = ec.unmarshalNString2string(ctx, v) + it.Initials, err = ec.unmarshalNString2string(ctx, v) if err != nil { return it, err } @@ -7088,6 +7166,30 @@ func (ec *executionContext) unmarshalInputUpdateTaskDescriptionInput(ctx context return it, nil } +func (ec *executionContext) unmarshalInputUpdateTaskGroupName(ctx context.Context, obj interface{}) (UpdateTaskGroupName, error) { + var it UpdateTaskGroupName + var asMap = obj.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "taskGroupID": + var err error + it.TaskGroupID, err = ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v) + if err != nil { + return it, err + } + case "name": + var err error + it.Name, err = ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputUpdateTaskName(ctx context.Context, obj interface{}) (UpdateTaskName, error) { var it UpdateTaskName var asMap = obj.(map[string]interface{}) @@ -7265,6 +7367,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { invalids++ } + case "clearProfileAvatar": + out.Values[i] = ec._Mutation_clearProfileAvatar(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } case "createProject": out.Values[i] = ec._Mutation_createProject(ctx, field) if out.Values[i] == graphql.Null { @@ -7310,6 +7417,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { invalids++ } + case "updateTaskGroupName": + out.Values[i] = ec._Mutation_updateTaskGroupName(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } case "deleteTaskGroup": out.Values[i] = ec._Mutation_deleteTaskGroup(ctx, field) if out.Values[i] == graphql.Null { @@ -7607,13 +7719,8 @@ func (ec *executionContext) _ProjectMember(ctx context.Context, sel ast.Selectio if out.Values[i] == graphql.Null { invalids++ } - case "firstName": - out.Values[i] = ec._ProjectMember_firstName(ctx, field, obj) - if out.Values[i] == graphql.Null { - invalids++ - } - case "lastName": - out.Values[i] = ec._ProjectMember_lastName(ctx, field, obj) + case "fullName": + out.Values[i] = ec._ProjectMember_fullName(ctx, field, obj) if out.Values[i] == graphql.Null { invalids++ } @@ -8223,13 +8330,13 @@ func (ec *executionContext) _UserAccount(ctx context.Context, sel ast.SelectionS if out.Values[i] == graphql.Null { atomic.AddUint32(&invalids, 1) } - case "firstName": - out.Values[i] = ec._UserAccount_firstName(ctx, field, obj) + case "fullName": + out.Values[i] = ec._UserAccount_fullName(ctx, field, obj) if out.Values[i] == graphql.Null { atomic.AddUint32(&invalids, 1) } - case "lastName": - out.Values[i] = ec._UserAccount_lastName(ctx, field, obj) + case "initials": + out.Values[i] = ec._UserAccount_initials(ctx, field, obj) if out.Values[i] == graphql.Null { atomic.AddUint32(&invalids, 1) } @@ -9158,6 +9265,10 @@ func (ec *executionContext) unmarshalNUpdateTaskDescriptionInput2githubᚗcomᚋ return ec.unmarshalInputUpdateTaskDescriptionInput(ctx, v) } +func (ec *executionContext) unmarshalNUpdateTaskGroupName2githubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋgraphᚐUpdateTaskGroupName(ctx context.Context, v interface{}) (UpdateTaskGroupName, error) { + return ec.unmarshalInputUpdateTaskGroupName(ctx, v) +} + func (ec *executionContext) marshalNUpdateTaskLocationPayload2githubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋgraphᚐUpdateTaskLocationPayload(ctx context.Context, sel ast.SelectionSet, v UpdateTaskLocationPayload) graphql.Marshaler { return ec._UpdateTaskLocationPayload(ctx, sel, &v) } diff --git a/api/graph/models_gen.go b/api/graph/models_gen.go index ae5b771..03f6437 100644 --- a/api/graph/models_gen.go +++ b/api/graph/models_gen.go @@ -100,11 +100,11 @@ type NewTeam struct { } type NewUserAccount struct { - Username string `json:"username"` - Email string `json:"email"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` - Password string `json:"password"` + Username string `json:"username"` + Email string `json:"email"` + FullName string `json:"fullName"` + Initials string `json:"initials"` + Password string `json:"password"` } type ProfileIcon struct { @@ -115,8 +115,7 @@ type ProfileIcon struct { type ProjectMember struct { ID uuid.UUID `json:"id"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` + FullName string `json:"fullName"` ProfileIcon *ProfileIcon `json:"profileIcon"` } @@ -169,6 +168,11 @@ type UpdateTaskDescriptionInput struct { Description string `json:"description"` } +type UpdateTaskGroupName struct { + TaskGroupID uuid.UUID `json:"taskGroupID"` + Name string `json:"name"` +} + type UpdateTaskLocationPayload struct { PreviousTaskGroupID uuid.UUID `json:"previousTaskGroupID"` Task *pg.Task `json:"task"` diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls index 3878225..d626453 100644 --- a/api/graph/schema.graphqls +++ b/api/graph/schema.graphqls @@ -1,5 +1,6 @@ scalar Time scalar UUID +scalar Upload type ProjectLabel { id: ID! @@ -29,8 +30,7 @@ type ProfileIcon { type ProjectMember { id: ID! - firstName: String! - lastName: String! + fullName: String! profileIcon: ProfileIcon! } @@ -45,8 +45,8 @@ type UserAccount { id: ID! email: String! createdAt: Time! - firstName: String! - lastName: String! + fullName: String! + initials: String! username: String! profileIcon: ProfileIcon! } @@ -123,8 +123,8 @@ input NewRefreshToken { input NewUserAccount { username: String! email: String! - firstName: String! - lastName: String! + fullName: String! + initials: String! password: String! } @@ -255,12 +255,19 @@ type UpdateTaskLocationPayload { previousTaskGroupID: UUID! task: Task! } + +input UpdateTaskGroupName { + taskGroupID: UUID! + name: String! +} + type Mutation { createRefreshToken(input: NewRefreshToken!): RefreshToken! createUserAccount(input: NewUserAccount!): UserAccount! createTeam(input: NewTeam!): Team! + clearProfileAvatar: UserAccount! createProject(input: NewProject!): Project! updateProjectName(input: UpdateProjectName): Project! @@ -273,6 +280,7 @@ type Mutation { createTaskGroup(input: NewTaskGroup!): TaskGroup! updateTaskGroupLocation(input: NewTaskGroupLocation!): TaskGroup! + updateTaskGroupName(input: UpdateTaskGroupName!): TaskGroup! deleteTaskGroup(input: DeleteTaskGroupInput!): DeleteTaskGroupPayload! addTaskLabel(input: AddTaskLabelInput): Task! diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go index e32b8d2..09fb727 100644 --- a/api/graph/schema.resolvers.go +++ b/api/graph/schema.resolvers.go @@ -29,7 +29,14 @@ func (r *mutationResolver) CreateRefreshToken(ctx context.Context, input NewRefr func (r *mutationResolver) CreateUserAccount(ctx context.Context, input NewUserAccount) (*pg.UserAccount, error) { createdAt := time.Now().UTC() - userAccount, err := r.Repository.CreateUserAccount(ctx, pg.CreateUserAccountParams{input.FirstName, input.LastName, input.Email, input.Username, createdAt, input.Password}) + userAccount, err := r.Repository.CreateUserAccount(ctx, pg.CreateUserAccountParams{ + FullName: input.FullName, + Initials: input.Initials, + Email: input.Email, + Username: input.Username, + CreatedAt: createdAt, + PasswordHash: input.Password, + }) return &userAccount, err } @@ -43,6 +50,21 @@ func (r *mutationResolver) CreateTeam(ctx context.Context, input NewTeam) (*pg.T return &team, err } +func (r *mutationResolver) ClearProfileAvatar(ctx context.Context) (*pg.UserAccount, error) { + userID, ok := GetUserID(ctx) + if !ok { + return &pg.UserAccount{}, fmt.Errorf("internal server error") + } + log.WithFields(log.Fields{ + "userID": userID, + }).Info("getting user account") + user, err := r.Repository.UpdateUserAccountProfileAvatarURL(ctx, pg.UpdateUserAccountProfileAvatarURLParams{UserID: userID, ProfileAvatarUrl: sql.NullString{Valid: false, String: ""}}) + if err != nil { + return &pg.UserAccount{}, err + } + return &user, nil +} + func (r *mutationResolver) CreateProject(ctx context.Context, input NewProject) (*pg.Project, error) { createdAt := time.Now().UTC() project, err := r.Repository.CreateProject(ctx, pg.CreateProjectParams{input.UserID, input.TeamID, createdAt, input.Name}) @@ -120,6 +142,10 @@ func (r *mutationResolver) UpdateTaskGroupLocation(ctx context.Context, input Ne return &taskGroup, err } +func (r *mutationResolver) UpdateTaskGroupName(ctx context.Context, input UpdateTaskGroupName) (*pg.TaskGroup, error) { + panic(fmt.Errorf("not implemented")) +} + func (r *mutationResolver) DeleteTaskGroup(ctx context.Context, input DeleteTaskGroupInput) (*DeleteTaskGroupPayload, error) { deletedTasks, err := r.Repository.DeleteTasksByTaskGroupID(ctx, input.TaskGroupID) if err != nil { @@ -301,9 +327,12 @@ func (r *projectResolver) Owner(ctx context.Context, obj *pg.Project) (*ProjectM if err != nil { return &ProjectMember{}, err } - initials := string([]rune(user.FirstName)[0]) + string([]rune(user.LastName)[0]) - profileIcon := &ProfileIcon{nil, &initials, &user.ProfileBgColor} - return &ProjectMember{obj.Owner, user.FirstName, user.LastName, profileIcon}, nil + var url *string + if user.ProfileAvatarUrl.Valid { + url = &user.ProfileAvatarUrl.String + } + profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor} + return &ProjectMember{obj.Owner, user.FullName, profileIcon}, nil } func (r *projectResolver) TaskGroups(ctx context.Context, obj *pg.Project) ([]pg.TaskGroup, error) { @@ -316,9 +345,12 @@ func (r *projectResolver) Members(ctx context.Context, obj *pg.Project) ([]Proje if err != nil { return members, err } - initials := string([]rune(user.FirstName)[0]) + string([]rune(user.LastName)[0]) - profileIcon := &ProfileIcon{nil, &initials, &user.ProfileBgColor} - members = append(members, ProjectMember{obj.Owner, user.FirstName, user.LastName, profileIcon}) + var url *string + if user.ProfileAvatarUrl.Valid { + url = &user.ProfileAvatarUrl.String + } + profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor} + members = append(members, ProjectMember{obj.Owner, user.FullName, profileIcon}) return members, nil } @@ -463,9 +495,12 @@ func (r *taskResolver) Assigned(ctx context.Context, obj *pg.Task) ([]ProjectMem if err != nil { return taskMembers, err } - initials := string([]rune(user.FirstName)[0]) + string([]rune(user.LastName)[0]) - profileIcon := &ProfileIcon{nil, &initials, &user.ProfileBgColor} - taskMembers = append(taskMembers, ProjectMember{taskMemberLink.UserID, user.FirstName, user.LastName, profileIcon}) + var url *string + if user.ProfileAvatarUrl.Valid { + url = &user.ProfileAvatarUrl.String + } + profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor} + taskMembers = append(taskMembers, ProjectMember{taskMemberLink.UserID, user.FullName, profileIcon}) } return taskMembers, nil } @@ -505,8 +540,11 @@ func (r *userAccountResolver) ID(ctx context.Context, obj *pg.UserAccount) (uuid } func (r *userAccountResolver) ProfileIcon(ctx context.Context, obj *pg.UserAccount) (*ProfileIcon, error) { - initials := string([]rune(obj.FirstName)[0]) + string([]rune(obj.LastName)[0]) - profileIcon := &ProfileIcon{nil, &initials, &obj.ProfileBgColor} + var url *string + if obj.ProfileAvatarUrl.Valid { + url = &obj.ProfileAvatarUrl.String + } + profileIcon := &ProfileIcon{url, &obj.Initials, &obj.ProfileBgColor} return profileIcon, nil } @@ -554,39 +592,3 @@ type taskGroupResolver struct{ *Resolver } type taskLabelResolver struct{ *Resolver } type teamResolver struct{ *Resolver } type userAccountResolver struct{ *Resolver } - -// !!! WARNING !!! -// The code below was going to be deleted when updating resolvers. It has been copied here so you have -// one last chance to move it out of harms way if you want. There are two reasons this happens: -// - When renaming or deleting a resolver the old code will be put in here. You can safely delete -// it when you're done. -// - You have helper methods in this file. Move them out to keep these resolver files clean. -func (r *taskLabelResolver) ColorHex(ctx context.Context, obj *pg.TaskLabel) (string, error) { - projectLabel, err := r.Repository.GetProjectLabelByID(ctx, obj.ProjectLabelID) - if err != nil { - return "", err - } - labelColor, err := r.Repository.GetLabelColorByID(ctx, projectLabel.LabelColorID) - if err != nil { - return "", err - } - return labelColor.ColorHex, nil -} -func (r *taskLabelResolver) Name(ctx context.Context, obj *pg.TaskLabel) (*string, error) { - projectLabel, err := r.Repository.GetProjectLabelByID(ctx, obj.ProjectLabelID) - if err != nil { - return nil, err - } - name := projectLabel.Name - if !name.Valid { - return nil, err - } - return &name.String, err -} -func (r *projectLabelResolver) ColorHex(ctx context.Context, obj *pg.ProjectLabel) (string, error) { - labelColor, err := r.Repository.GetLabelColorByID(ctx, obj.LabelColorID) - if err != nil { - return "", err - } - return labelColor.ColorHex, nil -} diff --git a/api/migrations/0020_add-full-name-column-to-user_account-table.up.sql b/api/migrations/0020_add-full-name-column-to-user_account-table.up.sql new file mode 100644 index 0000000..17e8675 --- /dev/null +++ b/api/migrations/0020_add-full-name-column-to-user_account-table.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE user_account ADD COLUMN full_name TEXT; +UPDATE user_account SET full_name = CONCAT(first_name, ' ', last_name); +ALTER TABLE user_account ALTER COLUMN full_name SET NOT NULL; diff --git a/api/migrations/0021_drop-first-name-column-from-user_account-table.up.sql b/api/migrations/0021_drop-first-name-column-from-user_account-table.up.sql new file mode 100644 index 0000000..ff93086 --- /dev/null +++ b/api/migrations/0021_drop-first-name-column-from-user_account-table.up.sql @@ -0,0 +1 @@ +ALTER TABLE user_account DROP COLUMN first_name; diff --git a/api/migrations/0022_drop-last-name-column-from-user_account-table.up.sql b/api/migrations/0022_drop-last-name-column-from-user_account-table.up.sql new file mode 100644 index 0000000..bfde48e --- /dev/null +++ b/api/migrations/0022_drop-last-name-column-from-user_account-table.up.sql @@ -0,0 +1 @@ +ALTER TABLE user_account DROP COLUMN last_name; diff --git a/api/migrations/0023_add-initials-column-to-user_account-table.up.sql b/api/migrations/0023_add-initials-column-to-user_account-table.up.sql new file mode 100644 index 0000000..35a50b4 --- /dev/null +++ b/api/migrations/0023_add-initials-column-to-user_account-table.up.sql @@ -0,0 +1 @@ +ALTER TABLE user_account ADD COLUMN initials TEXT NOT NULL DEFAULT ''; diff --git a/api/migrations/0024_add-profile-avatar-url-column-to-user_account-table.up.sql b/api/migrations/0024_add-profile-avatar-url-column-to-user_account-table.up.sql new file mode 100644 index 0000000..c7fd227 --- /dev/null +++ b/api/migrations/0024_add-profile-avatar-url-column-to-user_account-table.up.sql @@ -0,0 +1 @@ +ALTER TABLE user_account ADD COLUMN profile_avatar_url TEXT; diff --git a/api/pg/models.go b/api/pg/models.go index 8de99af..9402a2d 100644 --- a/api/pg/models.go +++ b/api/pg/models.go @@ -85,12 +85,13 @@ type Team struct { } type UserAccount struct { - UserID uuid.UUID `json:"user_id"` - CreatedAt time.Time `json:"created_at"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Email string `json:"email"` - Username string `json:"username"` - PasswordHash string `json:"password_hash"` - ProfileBgColor string `json:"profile_bg_color"` + UserID uuid.UUID `json:"user_id"` + CreatedAt time.Time `json:"created_at"` + Email string `json:"email"` + Username string `json:"username"` + PasswordHash string `json:"password_hash"` + ProfileBgColor string `json:"profile_bg_color"` + FullName string `json:"full_name"` + Initials string `json:"initials"` + ProfileAvatarUrl sql.NullString `json:"profile_avatar_url"` } diff --git a/api/pg/pg.go b/api/pg/pg.go index 0a97df7..3e08de3 100644 --- a/api/pg/pg.go +++ b/api/pg/pg.go @@ -17,6 +17,7 @@ type Repository interface { GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error) GetProjectByID(ctx context.Context, projectID uuid.UUID) (Project, error) + UpdateUserAccountProfileAvatarURL(ctx context.Context, arg UpdateUserAccountProfileAvatarURLParams) (UserAccount, error) CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error) GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error) GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error) diff --git a/api/pg/querier.go b/api/pg/querier.go index 03cfa3d..f2753b6 100644 --- a/api/pg/querier.go +++ b/api/pg/querier.go @@ -64,6 +64,7 @@ type Querier interface { UpdateTaskGroupLocation(ctx context.Context, arg UpdateTaskGroupLocationParams) (TaskGroup, error) UpdateTaskLocation(ctx context.Context, arg UpdateTaskLocationParams) (Task, error) UpdateTaskName(ctx context.Context, arg UpdateTaskNameParams) (Task, error) + UpdateUserAccountProfileAvatarURL(ctx context.Context, arg UpdateUserAccountProfileAvatarURLParams) (UserAccount, error) } var _ Querier = (*Queries)(nil) diff --git a/api/pg/user_accounts.sql.go b/api/pg/user_accounts.sql.go index 1815f64..c346dcc 100644 --- a/api/pg/user_accounts.sql.go +++ b/api/pg/user_accounts.sql.go @@ -5,20 +5,20 @@ package pg import ( "context" + "database/sql" "time" "github.com/google/uuid" ) const createUserAccount = `-- name: CreateUserAccount :one -INSERT INTO user_account(first_name, last_name, email, username, created_at, password_hash) - VALUES ($1, $2, $3, $4, $5, $6) -RETURNING user_id, created_at, first_name, last_name, email, username, password_hash, profile_bg_color +INSERT INTO user_account(full_name, initials, email, username, created_at, password_hash) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url ` type CreateUserAccountParams struct { - FirstName string `json:"first_name"` - LastName string `json:"last_name"` + FullName string `json:"full_name"` + Initials string `json:"initials"` Email string `json:"email"` Username string `json:"username"` CreatedAt time.Time `json:"created_at"` @@ -27,8 +27,8 @@ type CreateUserAccountParams struct { func (q *Queries) CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error) { row := q.db.QueryRowContext(ctx, createUserAccount, - arg.FirstName, - arg.LastName, + arg.FullName, + arg.Initials, arg.Email, arg.Username, arg.CreatedAt, @@ -38,18 +38,19 @@ func (q *Queries) CreateUserAccount(ctx context.Context, arg CreateUserAccountPa err := row.Scan( &i.UserID, &i.CreatedAt, - &i.FirstName, - &i.LastName, &i.Email, &i.Username, &i.PasswordHash, &i.ProfileBgColor, + &i.FullName, + &i.Initials, + &i.ProfileAvatarUrl, ) return i, err } const getAllUserAccounts = `-- name: GetAllUserAccounts :many -SELECT user_id, created_at, first_name, last_name, email, username, password_hash, profile_bg_color FROM user_account +SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url FROM user_account ` func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error) { @@ -64,12 +65,13 @@ func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error) if err := rows.Scan( &i.UserID, &i.CreatedAt, - &i.FirstName, - &i.LastName, &i.Email, &i.Username, &i.PasswordHash, &i.ProfileBgColor, + &i.FullName, + &i.Initials, + &i.ProfileAvatarUrl, ); err != nil { return nil, err } @@ -85,7 +87,7 @@ func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error) } const getUserAccountByID = `-- name: GetUserAccountByID :one -SELECT user_id, created_at, first_name, last_name, email, username, password_hash, profile_bg_color FROM user_account WHERE user_id = $1 +SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url FROM user_account WHERE user_id = $1 ` func (q *Queries) GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error) { @@ -94,18 +96,19 @@ func (q *Queries) GetUserAccountByID(ctx context.Context, userID uuid.UUID) (Use err := row.Scan( &i.UserID, &i.CreatedAt, - &i.FirstName, - &i.LastName, &i.Email, &i.Username, &i.PasswordHash, &i.ProfileBgColor, + &i.FullName, + &i.Initials, + &i.ProfileAvatarUrl, ) return i, err } const getUserAccountByUsername = `-- name: GetUserAccountByUsername :one -SELECT user_id, created_at, first_name, last_name, email, username, password_hash, profile_bg_color FROM user_account WHERE username = $1 +SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url FROM user_account WHERE username = $1 ` func (q *Queries) GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error) { @@ -114,12 +117,40 @@ func (q *Queries) GetUserAccountByUsername(ctx context.Context, username string) err := row.Scan( &i.UserID, &i.CreatedAt, - &i.FirstName, - &i.LastName, &i.Email, &i.Username, &i.PasswordHash, &i.ProfileBgColor, + &i.FullName, + &i.Initials, + &i.ProfileAvatarUrl, + ) + return i, err +} + +const updateUserAccountProfileAvatarURL = `-- name: UpdateUserAccountProfileAvatarURL :one +UPDATE user_account SET profile_avatar_url = $2 WHERE user_id = $1 + RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url +` + +type UpdateUserAccountProfileAvatarURLParams struct { + UserID uuid.UUID `json:"user_id"` + ProfileAvatarUrl sql.NullString `json:"profile_avatar_url"` +} + +func (q *Queries) UpdateUserAccountProfileAvatarURL(ctx context.Context, arg UpdateUserAccountProfileAvatarURLParams) (UserAccount, error) { + row := q.db.QueryRowContext(ctx, updateUserAccountProfileAvatarURL, arg.UserID, arg.ProfileAvatarUrl) + var i UserAccount + err := row.Scan( + &i.UserID, + &i.CreatedAt, + &i.Email, + &i.Username, + &i.PasswordHash, + &i.ProfileBgColor, + &i.FullName, + &i.Initials, + &i.ProfileAvatarUrl, ) return i, err } diff --git a/api/query/task_group.sql b/api/query/task_group.sql index 23138a4..13a51f1 100644 --- a/api/query/task_group.sql +++ b/api/query/task_group.sql @@ -11,8 +11,12 @@ SELECT * FROM task_group; -- name: GetTaskGroupByID :one SELECT * FROM task_group WHERE task_group_id = $1; --- name: UpdateTaskGroupLocation :one -UPDATE task_group SET position = $2 WHERE task_group_id = $1 RETURNING *; +-- name: SetTaskGroupName :one +UPDATE task_group SET name = $2 WHERE task_group_id = $1 RETURNING *; -- name: DeleteTaskGroupByID :execrows DELETE FROM task_group WHERE task_group_id = $1; + +-- name: UpdateTaskGroupLocation :one +UPDATE task_group SET position = $2 WHERE task_group_id = $1 RETURNING *; + diff --git a/api/query/user_accounts.sql b/api/query/user_accounts.sql index 2c1b155..74427cb 100644 --- a/api/query/user_accounts.sql +++ b/api/query/user_accounts.sql @@ -8,6 +8,9 @@ SELECT * FROM user_account; SELECT * FROM user_account WHERE username = $1; -- name: CreateUserAccount :one -INSERT INTO user_account(first_name, last_name, email, username, created_at, password_hash) - VALUES ($1, $2, $3, $4, $5, $6) -RETURNING *; +INSERT INTO user_account(full_name, initials, email, username, created_at, password_hash) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; + +-- name: UpdateUserAccountProfileAvatarURL :one +UPDATE user_account SET profile_avatar_url = $2 WHERE user_id = $1 + RETURNING *; diff --git a/api/router/middleware.go b/api/router/middleware.go index a4a2ed5..2f6e823 100644 --- a/api/router/middleware.go +++ b/api/router/middleware.go @@ -24,6 +24,7 @@ func AuthenticationMiddleware(next http.Handler) http.Handler { accessClaims, err := ValidateAccessToken(accessTokenString) if err != nil { if _, ok := err.(*ErrExpiredToken); ok { + w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{ "data": {}, "errors": [ diff --git a/api/router/models.go b/api/router/models.go index 8f88204..45af546 100644 --- a/api/router/models.go +++ b/api/router/models.go @@ -30,3 +30,8 @@ type LogoutResponseData struct { type RefreshTokenResponseData struct { AccessToken string `json:"accessToken"` } + +type AvatarUploadResponseData struct { + UserID string `json:"userID"` + URL string `json:"url"` +} diff --git a/api/router/router.go b/api/router/router.go index 9563abb..aa4669b 100644 --- a/api/router/router.go +++ b/api/router/router.go @@ -1,12 +1,16 @@ package router import ( + "database/sql" + "encoding/json" + "io/ioutil" "net/http" "time" "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" "github.com/go-chi/cors" + "github.com/google/uuid" "github.com/jmoiron/sqlx" log "github.com/sirupsen/logrus" @@ -18,6 +22,45 @@ func (h *CitadelHandler) PingHandler(w http.ResponseWriter, r *http.Request) { w.Write([]byte("pong")) } +func (h *CitadelHandler) ProfileImageUpload(w http.ResponseWriter, r *http.Request) { + log.Info("preparing to upload file") + userID, ok := r.Context().Value("userID").(uuid.UUID) + if !ok { + log.Error("not a valid uuid") + w.WriteHeader(http.StatusInternalServerError) + return + } + + // Parse our multipart form, 10 << 20 specifies a maximum + // upload of 10 MB files. + r.ParseMultipartForm(10 << 20) + + file, handler, err := r.FormFile("file") + if err != nil { + log.WithError(err).Error("issue while uploading file") + return + } + defer file.Close() + log.WithFields(log.Fields{"filename": handler.Filename, "size": handler.Size, "header": handler.Header}).Info("file metadata") + + fileBytes, err := ioutil.ReadAll(file) + if err != nil { + log.WithError(err).Error("while reading file") + return + } + err = ioutil.WriteFile("uploads/"+handler.Filename, fileBytes, 0644) + if err != nil { + log.WithError(err).Error("while reading file") + return + } + + h.repo.UpdateUserAccountProfileAvatarURL(r.Context(), pg.UpdateUserAccountProfileAvatarURLParams{UserID: userID, ProfileAvatarUrl: sql.NullString{String: "http://localhost:3333/uploads/" + handler.Filename, Valid: true}}) + // return that we have successfully uploaded our file! + log.Info("file uploaded") + json.NewEncoder(w).Encode(AvatarUploadResponseData{URL: "http://localhost:3333/uploads/" + handler.Filename, UserID: userID.String()}) + +} + func NewRouter(db *sqlx.DB) (chi.Router, error) { formatter := new(log.TextFormatter) formatter.TimestampFormat = "02-01-2006 15:04:05" @@ -50,9 +93,13 @@ func NewRouter(db *sqlx.DB) (chi.Router, error) { r.Group(func(mux chi.Router) { mux.Mount("/auth", authResource{}.Routes(citadelHandler)) mux.Handle("/__graphql", graph.NewPlaygroundHandler("/graphql")) + var imgServer = http.FileServer(http.Dir("./uploads/")) + mux.Mount("/uploads/", http.StripPrefix("/uploads/", imgServer)) + }) r.Group(func(mux chi.Router) { mux.Use(AuthenticationMiddleware) + mux.Post("/users/me/avatar", citadelHandler.ProfileImageUpload) mux.Get("/ping", citadelHandler.PingHandler) mux.Handle("/graphql", graph.NewHandler(repository)) }) diff --git a/api/router/tokens.go b/api/router/tokens.go index c2f3686..cff30f6 100644 --- a/api/router/tokens.go +++ b/api/router/tokens.go @@ -43,6 +43,10 @@ func ValidateAccessToken(accessTokenString string) (AccessTokenClaims, error) { return jwtKey, nil }) + if err != nil { + return *accessClaims, nil + } + if accessToken.Valid { log.WithFields(log.Fields{ "token": accessTokenString, diff --git a/api/uploads/Ansible_logo.svg b/api/uploads/Ansible_logo.svg new file mode 100644 index 0000000..2f25933 --- /dev/null +++ b/api/uploads/Ansible_logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/api/uploads/headshot-small.png b/api/uploads/headshot-small.png new file mode 100644 index 0000000..8146718 Binary files /dev/null and b/api/uploads/headshot-small.png differ diff --git a/api/uploads/headshot.png b/api/uploads/headshot.png new file mode 100644 index 0000000..51b0e2c Binary files /dev/null and b/api/uploads/headshot.png differ diff --git a/api/uploads/logo.png b/api/uploads/logo.png new file mode 100644 index 0000000..c20defa Binary files /dev/null and b/api/uploads/logo.png differ diff --git a/api/uploads/logo.svg b/api/uploads/logo.svg new file mode 100644 index 0000000..e842bc5 --- /dev/null +++ b/api/uploads/logo.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + JK + + diff --git a/api/uploads/text4184.png b/api/uploads/text4184.png new file mode 100644 index 0000000..d1dba8f Binary files /dev/null and b/api/uploads/text4184.png differ diff --git a/web/.editorconfig b/web/.editorconfig index 11695db..66996e6 100644 --- a/web/.editorconfig +++ b/web/.editorconfig @@ -7,3 +7,7 @@ indent_size = 2 charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true + +[Makefile] +indent_style = tab +indent_size = 2 diff --git a/web/Makefile b/web/Makefile new file mode 100644 index 0000000..c51deba --- /dev/null +++ b/web/Makefile @@ -0,0 +1,3 @@ + +start: + yarn start diff --git a/web/package.json b/web/package.json index 474f066..7277d29 100644 --- a/web/package.json +++ b/web/package.json @@ -18,7 +18,9 @@ "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", + "@types/axios": "^0.14.0", "@types/color": "^3.0.1", + "@types/date-fns": "^2.6.0", "@types/jest": "^24.0.0", "@types/jwt-decode": "^2.2.1", "@types/lodash": "^4.14.149", @@ -32,6 +34,8 @@ "@types/react-select": "^3.0.13", "@types/styled-components": "^5.0.0", "@welldone-software/why-did-you-render": "^4.2.2", + "ag-grid-community": "^23.2.0", + "ag-grid-react": "^23.2.0", "apollo-cache-inmemory": "^1.6.5", "apollo-client": "^2.6.8", "apollo-link": "^1.2.13", @@ -39,7 +43,10 @@ "apollo-link-http": "^1.5.16", "apollo-link-state": "^0.4.2", "apollo-utilities": "^1.3.3", + "axios": "^0.19.2", + "axios-auth-refresh": "^2.2.7", "color": "^3.1.2", + "date-fns": "^2.14.0", "graphql": "^15.0.0", "graphql-tag": "^2.10.3", "history": "^4.10.1", diff --git a/web/src/App/Routes.tsx b/web/src/App/Routes.tsx index d28ef6b..58f4bab 100644 --- a/web/src/App/Routes.tsx +++ b/web/src/App/Routes.tsx @@ -6,7 +6,14 @@ import Dashboard from 'Dashboard'; import Projects from 'Projects'; import Project from 'Projects/Project'; import Login from 'Auth'; +import Profile from 'Profile'; +import styled from 'styled-components'; +const MainContent = styled.div` + padding: 0 0 50px 80px; + background: #262c49; + height: 100%; +`; type RoutesProps = { history: H.History; }; @@ -14,9 +21,12 @@ type RoutesProps = { const Routes = ({ history }: RoutesProps) => ( - - - + + + + + + ); diff --git a/web/src/App/TopNavbar.tsx b/web/src/App/TopNavbar.tsx index 400a936..c6f4db1 100644 --- a/web/src/App/TopNavbar.tsx +++ b/web/src/App/TopNavbar.tsx @@ -1,6 +1,6 @@ import React, { useState, useContext } from 'react'; import TopNavbar from 'shared/components/TopNavbar'; -import DropdownMenu from 'shared/components/DropdownMenu'; +import DropdownMenu, { ProfileMenu } from 'shared/components/DropdownMenu'; import ProjectSettings from 'shared/components/ProjectSettings'; import { useHistory } from 'react-router'; import UserIDContext from 'App/context'; @@ -14,21 +14,37 @@ type GlobalTopNavbarProps = { }; const GlobalTopNavbar: React.FC = ({ name, projectMembers, onSaveProjectName }) => { const { loading, data } = useMeQuery(); - const { showPopup } = usePopup(); + const { showPopup, hidePopup } = usePopup(); const history = useHistory(); const { userID, setUserID } = useContext(UserIDContext); - const [menu, setMenu] = useState({ - top: 0, - left: 0, - isOpen: false, - }); - const onProfileClick = (bottom: number, right: number) => { - setMenu({ - isOpen: !menu.isOpen, - left: right, - top: bottom, + const onLogout = () => { + fetch('http://localhost:3333/auth/logout', { + method: 'POST', + credentials: 'include', + }).then(async x => { + const { status } = x; + if (status === 200) { + history.replace('/login'); + setUserID(null); + hidePopup(); + } }); }; + const onProfileClick = ($target: React.RefObject) => { + showPopup( + $target, + + { + history.push('/profile'); + hidePopup(); + }} + /> + , + 185, + ); + }; const onOpenSettings = ($target: React.RefObject) => { showPopup( @@ -40,18 +56,6 @@ const GlobalTopNavbar: React.FC = ({ name, projectMembers, ); }; - const onLogout = () => { - fetch('http://localhost:3333/auth/logout', { - method: 'POST', - credentials: 'include', - }).then(async x => { - const { status } = x; - if (status === 200) { - history.replace('/login'); - setUserID(null); - } - }); - }; if (!userID) { return null; } @@ -59,30 +63,13 @@ const GlobalTopNavbar: React.FC = ({ name, projectMembers, <> {}} projectMembers={projectMembers} onProfileClick={onProfileClick} onSaveProjectName={onSaveProjectName} onOpenSettings={onOpenSettings} /> - {menu.isOpen && ( - { - setMenu({ - top: 0, - left: 0, - isOpen: false, - }); - }} - onLogout={onLogout} - left={menu.left} - top={menu.top} - /> - )} ); }; diff --git a/web/src/App/index.tsx b/web/src/App/index.tsx index 978df4e..557faf8 100644 --- a/web/src/App/index.tsx +++ b/web/src/App/index.tsx @@ -13,12 +13,6 @@ import { PopupProvider } from 'shared/components/PopupMenu'; const history = createBrowserHistory(); -const MainContent = styled.div` - padding: 0 0 50px 80px; - background: #262c49; - height: 100%; -`; - const App = () => { const [loading, setLoading] = useState(true); const [userID, setUserID] = useState(null); @@ -54,9 +48,7 @@ const App = () => { ) : ( <> - - - + )} diff --git a/web/src/Profile/index.tsx b/web/src/Profile/index.tsx new file mode 100644 index 0000000..26c4312 --- /dev/null +++ b/web/src/Profile/index.tsx @@ -0,0 +1,71 @@ +import React, { useRef, useEffect } from 'react'; +import styled from 'styled-components/macro'; +import GlobalTopNavbar from 'App/TopNavbar'; +import { Link } from 'react-router-dom'; +import { getAccessToken } from 'shared/utils/accessToken'; +import Navbar from 'App/Navbar'; +import Settings from 'shared/components/Settings'; +import UserIDContext from 'App/context'; +import { useMeQuery, useClearProfileAvatarMutation } from 'shared/generated/graphql'; +import axios from 'axios'; + +const MainContent = styled.div` + padding: 0 0 50px 80px; + height: 100%; + background: #262c49; +`; + +const Projects = () => { + const $fileUpload = useRef(null); + const [clearProfileAvatar] = useClearProfileAvatarMutation(); + const { loading, data, refetch } = useMeQuery(); + useEffect(() => { + document.title = 'Profile | Citadel'; + }, []); + return ( + <> + { + if (e.target.files) { + console.log(e.target.files[0]); + const fileData = new FormData(); + fileData.append('file', e.target.files[0]); + const accessToken = getAccessToken(); + axios + .post('http://localhost:3333/users/me/avatar', fileData, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + .then(res => { + if ($fileUpload && $fileUpload.current) { + $fileUpload.current.value = ''; + refetch(); + } + }); + } + }} + /> + {}} name={null} /> + {!loading && data && ( + { + if ($fileUpload && $fileUpload.current) { + $fileUpload.current.click(); + } + }} + onProfileAvatarRemove={() => { + clearProfileAvatar(); + }} + /> + )} + + ); +}; + +export default Projects; diff --git a/web/src/Projects/Project/index.tsx b/web/src/Projects/Project/index.tsx index 3f36650..368311a 100644 --- a/web/src/Projects/Project/index.tsx +++ b/web/src/Projects/Project/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useContext } from 'react'; +import React, { useState, useRef, useContext, useEffect } from 'react'; import GlobalTopNavbar from 'App/TopNavbar'; import styled from 'styled-components/macro'; import { Bolt, ToggleOn, Tags } from 'shared/icons'; @@ -428,6 +428,11 @@ const Project = () => { const $labelsRef = useRef(null); const labelsRef = useRef>([]); const taskLabelsRef = useRef>([]); + useEffect(() => { + if (data) { + document.title = `${data.findProject.name} | Citadel`; + } + }, [data]); if (loading) { return ( <> @@ -562,6 +567,7 @@ const Project = () => { , ); }} + onChangeTaskGroupName={(taskGroupID, name) => {}} onQuickEditorOpen={onQuickEditorOpen} onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => { showPopup( diff --git a/web/src/Projects/index.tsx b/web/src/Projects/index.tsx index c592530..cc7044a 100644 --- a/web/src/Projects/index.tsx +++ b/web/src/Projects/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useContext } from 'react'; +import React, { useState, useContext, useEffect } from 'react'; import styled from 'styled-components/macro'; import GlobalTopNavbar from 'App/TopNavbar'; import { useGetProjectsQuery, useCreateProjectMutation, GetProjectsDocument } from 'shared/generated/graphql'; @@ -28,6 +28,9 @@ const ProjectLink = styled(Link)``; const Projects = () => { const { loading, data } = useGetProjectsQuery(); + useEffect(() => { + document.title = 'Citadel'; + }, []); const [createProject] = useCreateProjectMutation({ update: (client, newProject) => { const cacheData: any = client.readQuery({ diff --git a/web/src/citadel.d.ts b/web/src/citadel.d.ts index d61698e..611a015 100644 --- a/web/src/citadel.d.ts +++ b/web/src/citadel.d.ts @@ -18,8 +18,7 @@ type ContextMenuEvent = { type TaskUser = { id: string; - firstName: string; - lastName: string; + fullName: string; profileIcon: ProfileIcon; }; @@ -32,6 +31,11 @@ type LoginFormData = { password: string; }; +type DueDateFormData = { + endDate: Date; + endTime: string | null; +}; + type LoginProps = { onSubmit: ( data: LoginFormData, diff --git a/web/src/index.tsx b/web/src/index.tsx index 6e8653d..f72da2f 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -9,9 +9,21 @@ import { onError } from 'apollo-link-error'; import { ApolloLink, Observable, fromPromise } from 'apollo-link'; import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken'; +import axios from 'axios'; +import createAuthRefreshInterceptor from 'axios-auth-refresh'; import App from './App'; +// Function that will be called to refresh authorization +const refreshAuthLogic = (failedRequest: any) => + axios.post('http://localhost:3333/auth/refresh_token', {}, { withCredentials: true }).then(tokenRefreshResponse => { + setAccessToken(tokenRefreshResponse.data.accessToken); + failedRequest.response.config.headers.Authorization = `Bearer ${tokenRefreshResponse.data.accessToken}`; + return Promise.resolve(); + }); + +createAuthRefreshInterceptor(axios, refreshAuthLogic); + // https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8 let forward$; diff --git a/web/src/shared/components/Admin/Admin.stories.tsx b/web/src/shared/components/Admin/Admin.stories.tsx new file mode 100644 index 0000000..a037f8c --- /dev/null +++ b/web/src/shared/components/Admin/Admin.stories.tsx @@ -0,0 +1,25 @@ +import React, { useRef } from 'react'; +import Admin from '.'; +import NormalizeStyles from 'App/NormalizeStyles'; +import BaseStyles from 'App/BaseStyles'; + +export default { + component: Admin, + title: 'Admin', + parameters: { + backgrounds: [ + { name: 'gray', value: '#f8f8f8', default: true }, + { name: 'white', value: '#ffffff' }, + ], + }, +}; + +export const Default = () => { + return ( + <> + + + + + ); +}; diff --git a/web/src/shared/components/Admin/index.tsx b/web/src/shared/components/Admin/index.tsx new file mode 100644 index 0000000..c3fb76b --- /dev/null +++ b/web/src/shared/components/Admin/index.tsx @@ -0,0 +1,323 @@ +import React, { useState, useRef } from 'react'; +import styled from 'styled-components'; +import { User, Plus } from 'shared/icons'; +import { AgGridReact } from 'ag-grid-react'; + +import 'ag-grid-community/dist/styles/ag-grid.css'; +import 'ag-grid-community/dist/styles/ag-theme-material.css'; + +const NewUserButton = styled.button` + outline: none; + border: none; + cursor: pointer; + line-height: 20px; + padding: 0.75rem; + background-color: transparent; + display: flex; + align-items: center; + justify-content: center; + color: rgba(115, 103, 240); + font-size: 14px; + + border-radius: 0.5rem; + border-width: 1px; + border-style: solid; + border-color: transparent; + border-image: initial; + border-color: rgba(115, 103, 240); + span { + padding-left: 0.5rem; + } +`; +const GridTable = styled.div` + height: 620px; +`; + +const RootWrapper = styled.div` + height: 100%; + display: flex; + position: relative; + flex-direction: column; + overflow: hidden; +`; + +const Root = styled.div` + .ag-theme-material { + --ag-foreground-color: #c2c6dc; + --ag-secondary-foreground-color: #c2c6dc; + --ag-background-color: transparent; + --ag-header-background-color: transparent; + --ag-header-foreground-color: #c2c6dc; + --ag-border-color: #414561; + + --ag-row-hover-color: #262c49; + --ag-header-cell-hover-background-color: #262c49; + --ag-checkbox-unchecked-color: #c2c6dc; + --ag-checkbox-indeterminate-color: rgba(115, 103, 240); + --ag-selected-row-background-color: #262c49; + --ag-material-primary-color: rgba(115, 103, 240); + --ag-material-accent-color: rgba(115, 103, 240); + } + .ag-theme-material ::-webkit-scrollbar { + width: 12px; + } + + .ag-theme-material ::-webkit-scrollbar-track { + background: #262c49; + border-radius: 20px; + } + + .ag-theme-material ::-webkit-scrollbar-thumb { + background: #7367f0; + border-radius: 20px; + } + .ag-header-cell-text { + color: #fff; + font-weight: 700; + } +`; + +const Header = styled.div` + border-bottom: 1px solid #e2e2e2; + flex-direction: row; + box-sizing: border-box; + display: flex; + white-space: nowrap; + width: 100%; + overflow: hidden; + background: transparent; + border-bottom-color: #414561; + color: #fff; + + height: 112px; + min-height: 112px; +`; + +const ActionButtons = () => { + return Hello!; +}; +const data = { + defaultColDef: { + resizable: true, + sortable: true, + }, + columnDefs: [ + { + minWidth: 125, + width: 125, + headerCheckboxSelection: true, + checkboxSelection: true, + headerName: 'ID', + field: 'id', + }, + { minWidth: 210, headerName: 'Username', editable: true, field: 'username' }, + { minWidth: 225, headerName: 'Email', field: 'email' }, + { minWidth: 200, headerName: 'Name', editable: true, field: 'full_name' }, + { minWidth: 200, headerName: 'Role', editable: true, field: 'role' }, + { + minWidth: 200, + headerName: 'Actions', + cellRenderer: 'actionButtons', + }, + ], + frameworkComponents: { + actionButtons: ActionButtons, + }, + rowData: [ + { id: '1', full_name: 'Jordan Knott', username: 'jordan', email: 'jordan@jordanthedev.com', role: 'Admin' }, + { id: '2', full_name: 'Jordan Test', username: 'jordantest', email: 'jordan@jordanthedev.com', role: 'Admin' }, + { id: '3', full_name: 'Jordan Other', username: 'alphatest1050', email: 'jordan@jordanthedev.com', role: 'Admin' }, + { id: '5', full_name: 'Jordan French', username: 'other', email: 'jordan@jordanthedev.com', role: 'Admin' }, + ], +}; +const ListTable = () => { + return ( + +
+ { + params.api.sizeColumnsToFit(); + }} + onGridSizeChanged={params => { + params.api.sizeColumnsToFit(); + }} + > +
+
+ ); +}; + +const Wrapper = styled.div` + background: #eff2f7; + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; +`; + +const Container = styled.div` + padding: 2.2rem; + display: flex; + width: 100%; + position: relative; +`; + +const TabNav = styled.div` + float: left; + width: 220px; + height: 100%; + display: block; + position: relative; +`; + +const TabNavContent = styled.ul` + display: block; + width: auto; + border-bottom: 0 !important; + border-right: 1px solid rgba(0, 0, 0, 0.05); +`; + +const TabNavItem = styled.li` + padding: 0.35rem 0.3rem; + display: block; + position: relative; +`; +const TabNavItemButton = styled.button<{ active: boolean }>` + cursor: pointer; + display: flex; + align-items: center; + + padding-top: 10px !important; + padding-bottom: 10px !important; + padding-left: 12px !important; + padding-right: 8px !important; + width: 100%; + position: relative; + + color: ${props => (props.active ? 'rgba(115, 103, 240)' : '#c2c6dc')}; + &:hover { + color: rgba(115, 103, 240); + } + &:hover svg { + fill: rgba(115, 103, 240); + } +`; + +const TabNavItemSpan = styled.span` + text-align: left; + padding-left: 9px; + font-size: 14px; +`; + +const TabNavLine = styled.span<{ top: number }>` + left: auto; + right: 0; + width: 2px; + height: 48px; + transform: scaleX(1); + top: ${props => props.top}px; + + background: linear-gradient(30deg, rgba(115, 103, 240), rgba(115, 103, 240)); + box-shadow: 0 0 8px 0 rgba(115, 103, 240); + display: block; + position: absolute; + transition: all 0.2s ease; +`; + +const TabContentWrapper = styled.div` + position: relative; + display: block; + overflow: hidden; + width: 100%; +`; + +const TabContent = styled.div` + position: relative; + width: 100%; + display: block; + padding: 0; + padding: 1.5rem; + background-color: #10163a; + margin-left: 1rem !important; + border-radius: 0.5rem; +`; + +const items = [ + { name: 'Insights' }, + { name: 'Members' }, + { name: 'Teams' }, + { name: 'Security' }, + { name: 'Settings' }, +]; + +type NavItemProps = { + active: boolean; + name: string; + tab: number; + onClick: (tab: number, top: number) => void; +}; +const NavItem: React.FC = ({ active, name, tab, onClick }) => { + const $item = useRef(null); + return ( + { + if ($item && $item.current) { + const pos = $item.current.getBoundingClientRect(); + onClick(tab, pos.top); + } + }} + > + + + {name} + + + ); +}; + +const Admin = () => { + const [currentTop, setTop] = useState(0); + const [currentTab, setTab] = useState(0); + const $tabNav = useRef(null); + return ( + + + + {items.map((item, idx) => ( + { + if ($tabNav && $tabNav.current) { + const pos = $tabNav.current.getBoundingClientRect(); + setTab(tab); + setTop(top - pos.top); + } + }} + name={item.name} + tab={idx} + active={idx === currentTab} + /> + ))} + + + + + + + + Add New + + + + + + ); +}; + +export default Admin; diff --git a/web/src/shared/components/Card/index.tsx b/web/src/shared/components/Card/index.tsx index 191d964..d8c6c22 100644 --- a/web/src/shared/components/Card/index.tsx +++ b/web/src/shared/components/Card/index.tsx @@ -21,6 +21,7 @@ import { CardTitle, CardMembers, } from './Styles'; +import TaskAssignee from 'shared/components/TaskAssignee'; type DueDate = { isPastDue: boolean; @@ -143,7 +144,16 @@ const Card = React.forwardRef( {members && members.map(member => ( - + { + if (onCardMemberClick) { + onCardMemberClick($target, taskID, member.id); + } + }} + /> ))} diff --git a/web/src/shared/components/DropdownMenu/index.tsx b/web/src/shared/components/DropdownMenu/index.tsx index 0fadfec..45e6038 100644 --- a/web/src/shared/components/DropdownMenu/index.tsx +++ b/web/src/shared/components/DropdownMenu/index.tsx @@ -33,4 +33,29 @@ const DropdownMenu: React.FC = ({ left, top, onLogout, onClos ); }; +type ProfileMenuProps = { + onProfile: () => void; + onLogout: () => void; +}; + +const ProfileMenu: React.FC = ({ onProfile, onLogout }) => { + return ( + <> + + + Profile + + + + + + Logout + + + + ); +}; + +export { ProfileMenu }; + export default DropdownMenu; diff --git a/web/src/shared/components/DueDateManager/DueDateManager.stories.tsx b/web/src/shared/components/DueDateManager/DueDateManager.stories.tsx index 2c66a67..b7a7b0a 100644 --- a/web/src/shared/components/DueDateManager/DueDateManager.stories.tsx +++ b/web/src/shared/components/DueDateManager/DueDateManager.stories.tsx @@ -1,7 +1,12 @@ import React, { useRef } from 'react'; import { action } from '@storybook/addon-actions'; import DueDateManager from '.'; +import { Popup } from '../PopupMenu'; +import styled from 'styled-components'; +const PopupWrapper = styled.div` + width: 300px; +`; export default { component: DueDateManager, title: 'DueDateManager', @@ -15,41 +20,44 @@ export default { export const Default = () => { return ( - + + + taskGroup: { name: 'General', id: '1', position: 1 }, + name: 'Hello, world', + position: 1, + labels: [ + { + id: 'soft-skills', + assignedDate: new Date().toString(), + projectLabel: { + createdDate: new Date().toString(), + id: 'label-soft-skills', + name: 'Soft Skills', + labelColor: { + id: '1', + name: 'white', + colorHex: '#fff', + position: 1, + }, + }, + }, + ], + description: 'hello!', + assigned: [ + { + id: '1', + profileIcon: { url: null, initials: null, bgColor: null }, + fullName: 'Jordan Knott', + }, + ], + }} + onCancel={action('cancel')} + onDueDateChange={action('due date change')} + /> + + ); }; diff --git a/web/src/shared/components/DueDateManager/Styles.ts b/web/src/shared/components/DueDateManager/Styles.ts index 2f032de..96e5800 100644 --- a/web/src/shared/components/DueDateManager/Styles.ts +++ b/web/src/shared/components/DueDateManager/Styles.ts @@ -1,8 +1,62 @@ import styled from 'styled-components'; +import { mixin } from 'shared/utils/styles'; export const Wrapper = styled.div` display: flex flex-direction: column; + & .react-datepicker { + background: #262c49; + font-family: 'Droid Sans', sans-serif; + border: none; + + } + & .react-datepicker__day-name { + color: #c2c6dc; + outline: none; + box-shadow: none; + padding: 4px; + font-size: 12px40px + line-height: 40px; + } + & .react-datepicker__day-name:hover { + background: #10163a; + } + & .react-datepicker__month { + margin: 0; + } + + & .react-datepicker__day, + & .react-datepicker__time-name { + color: #c2c6dc; + outline: none; + box-shadow: none; + padding: 4px; + font-size: 14px; + } + + & .react-datepicker__day--outside-month { + opacity: 0.6; + } + + & .react-datepicker__day:hover { + border-radius: 50%; + background: #10163a; + } + & .react-datepicker__day--selected { + border-radius: 50%; + background: rgba(115, 103, 240); + color: #fff; + } + & .react-datepicker__day--selected:hover { + border-radius: 50%; + background: rgba(115, 103, 240); + color: #fff; + } + & .react-datepicker__header { + background: none; + border: none; + } + `; export const DueDatePickerWrapper = styled.div` diff --git a/web/src/shared/components/DueDateManager/index.tsx b/web/src/shared/components/DueDateManager/index.tsx index bc3bb3b..28d46c1 100644 --- a/web/src/shared/components/DueDateManager/index.tsx +++ b/web/src/shared/components/DueDateManager/index.tsx @@ -1,23 +1,202 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; +import moment from 'moment'; +import styled from 'styled-components'; import DatePicker from 'react-datepicker'; import { Cross } from 'shared/icons'; +import _ from 'lodash'; import { Wrapper, ActionWrapper, DueDatePickerWrapper, ConfirmAddDueDate, CancelDueDate } from './Styles'; import 'react-datepicker/dist/react-datepicker.css'; +import { getYear, getMonth } from 'date-fns'; +import { useForm } from 'react-hook-form'; type DueDateManagerProps = { task: Task; onDueDateChange: (task: Task, newDueDate: Date) => void; onCancel: () => void; }; -const DueDateManager: React.FC = ({ task, onDueDateChange, onCancel }) => { - const [startDate, setStartDate] = useState(new Date()); +const HeaderSelectLabel = styled.div` + display: inline-block; + position: relative; + z-index: 9999; + border-radius: 3px; + cursor: pointer; + padding: 6px 10px; + text-decoration: underline; + margin: 6px 0; + font-size: 14px; + line-height: 16px; + margin-left: 0; + margin-right: 0; + padding-left: 4px; + padding-right: 4px; + color: #c2c6dc; + + &:hover { + background: rgba(115, 103, 240); + color: #c2c6dc; + } +`; + +const HeaderSelect = styled.select` + text-decoration: underline; + font-size: 14px; + text-align: center; + padding: 4px 6px; + background: none; + outline: none; + border: none; + border-radius: 3px; + appearance: none; + + &:hover { + background: #262c49; + border: 1px solid rgba(115, 103, 240); + outline: none !important; + box-shadow: none; + color: #c2c6dc; + } + + &::-ms-expand { + display: none; + } + + cursor: pointer; + position: absolute; + z-index: 9998; + margin: 0; + left: 0; + top: 5px; + opacity: 0; +`; + +const HeaderButton = styled.button` + cursor: pointer; + color: #c2c6dc; + text-decoration: underline; + font-size: 14px; + text-align: center; + padding: 6px 10px; + margin: 6px 0; + background: none; + outline: none; + border: none; + border-radius: 3px; + &:hover { + background: rgba(115, 103, 240); + color: #fff; + } +`; + +const HeaderActions = styled.div` + position: relative; + text-align: center; + & > button:first-child { + float: left; + } + & > button:last-child { + float: right; + } +`; + +const DueDateManager: React.FC = ({ task, onDueDateChange, onCancel }) => { + const now = moment(); + const [textStartDate, setTextStartDate] = useState(now.format('YYYY-MM-DD')); + + const [startDate, setStartDate] = useState(new Date()); + useEffect(() => { + setTextStartDate(moment(startDate).format('YYYY-MM-DD')); + }, [startDate]); + + const years = _.range(2010, getYear(new Date()) + 10, 1); + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + const { register, handleSubmit, errors, setError, formState } = useForm(); + console.log(errors); return ( +
+ { + setTextStartDate(e.currentTarget.value); + }} + value={textStartDate} + ref={register({ + required: 'End due date is required.', + validate: value => { + const isValid = moment(value, 'YYYY-MM-DD').isValid(); + console.log(`${value} - ${isValid}`); + return isValid; + }, + })} + /> +
- setStartDate(date ?? new Date())} /> + ( + + + Prev + + + {months[date.getMonth()]} + changeYear(parseInt(value))}> + {years.map(option => ( + + ))} + + + + {date.getFullYear()} + changeMonth(months.indexOf(value))} + > + {months.map(option => ( + + ))} + + + + + Next + + + )} + selected={startDate} + inline + onChange={date => setStartDate(date ?? new Date())} + /> onDueDateChange(task, startDate)}>Save diff --git a/web/src/shared/components/List/Styles.ts b/web/src/shared/components/List/Styles.ts index 7ad9640..6392e2b 100644 --- a/web/src/shared/components/List/Styles.ts +++ b/web/src/shared/components/List/Styles.ts @@ -64,6 +64,7 @@ export const HeaderEditTarget = styled.div<{ isHidden: boolean }>` export const HeaderName = styled(TextareaAutosize)` font-family: 'Droid Sans'; + font-size: 14px; border: none; resize: none; overflow: hidden; diff --git a/web/src/shared/components/Lists/Lists.stories.tsx b/web/src/shared/components/Lists/Lists.stories.tsx index e4665b4..7878d21 100644 --- a/web/src/shared/components/Lists/Lists.stories.tsx +++ b/web/src/shared/components/Lists/Lists.stories.tsx @@ -171,6 +171,7 @@ export const ListsWithManyList = () => { onCreateTask={action('card create')} onTaskDrop={onCardDrop} onTaskGroupDrop={onListDrop} + onChangeTaskGroupName={action('change group name')} onCreateTaskGroup={action('create list')} onExtraMenuOpen={action('extra menu open')} onCardMemberClick={action('card member click')} diff --git a/web/src/shared/components/Lists/index.tsx b/web/src/shared/components/Lists/index.tsx index f1187e4..83dedad 100644 --- a/web/src/shared/components/Lists/index.tsx +++ b/web/src/shared/components/Lists/index.tsx @@ -20,6 +20,7 @@ interface SimpleProps { onTaskClick: (task: Task) => void; onCreateTask: (taskGroupID: string, name: string) => void; + onChangeTaskGroupName: (taskGroupID: string, name: string) => void; onQuickEditorOpen: (e: ContextMenuEvent) => void; onCreateTaskGroup: (listName: string) => void; onExtraMenuOpen: (taskGroupID: string, $targetRef: React.RefObject) => void; @@ -29,6 +30,7 @@ interface SimpleProps { const SimpleLists: React.FC = ({ taskGroups, onTaskDrop, + onChangeTaskGroupName, onTaskGroupDrop, onTaskClick, onCreateTask, @@ -135,7 +137,7 @@ const SimpleLists: React.FC = ({ name={taskGroup.name} onOpenComposer={id => setCurrentComposer(id)} isComposerOpen={currentComposer === taskGroup.id} - onSaveName={name => {}} + onSaveName={name => onChangeTaskGroupName(taskGroup.id, name)} ref={columnDragProvided.innerRef} wrapperProps={columnDragProvided.draggableProps} headerProps={columnDragProvided.dragHandleProps} diff --git a/web/src/shared/components/Login/Styles.ts b/web/src/shared/components/Login/Styles.ts index af0e1c4..872d51b 100644 --- a/web/src/shared/components/Login/Styles.ts +++ b/web/src/shared/components/Login/Styles.ts @@ -101,3 +101,24 @@ export const RegisterButton = styled.button` color: rgba(115, 103, 240); cursor: pointer; `; + +export const LogoTitle = styled.div` + font-size: 24px; + font-weight: 600; + margin-left: 12px; + transition: visibility, opacity, transform 0.25s ease; + color: #7367f0; +`; + +export const LogoWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + flex-direction: row; + position: relative; + width: 100%; + padding-bottom: 16px; + margin-bottom: 24px; + color: rgb(222, 235, 255); + border-bottom: 1px solid rgba(65, 69, 97, 0.65); +`; diff --git a/web/src/shared/components/Login/index.tsx b/web/src/shared/components/Login/index.tsx index f4d1732..2ad0213 100644 --- a/web/src/shared/components/Login/index.tsx +++ b/web/src/shared/components/Login/index.tsx @@ -1,10 +1,12 @@ import React, { useState } from 'react'; import AccessAccount from 'shared/undraw/AccessAccount'; -import { User, Lock } from 'shared/icons'; +import { User, Lock, Citadel } from 'shared/icons'; import { useForm } from 'react-hook-form'; import { Form, + LogoWrapper, + LogoTitle, ActionButtons, RegisterButton, LoginButton, @@ -35,6 +37,10 @@ const Login = ({ onSubmit }: LoginProps) => { + + + Citadel + Login Welcome back, please login into your account.
diff --git a/web/src/shared/components/MemberManager/index.tsx b/web/src/shared/components/MemberManager/index.tsx index 3a57707..24d0c0f 100644 --- a/web/src/shared/components/MemberManager/index.tsx +++ b/web/src/shared/components/MemberManager/index.tsx @@ -39,9 +39,7 @@ const MemberManager: React.FC = ({ {availableMembers .filter( - member => - currentSearch === '' || - `${member.firstName} ${member.lastName}`.toLowerCase().startsWith(currentSearch.toLowerCase()), + member => currentSearch === '' || member.fullName.toLowerCase().startsWith(currentSearch.toLowerCase()), ) .map(member => { return ( @@ -58,7 +56,7 @@ const MemberManager: React.FC = ({ }} > JK - {`${member.firstName} ${member.lastName}`} + {member.fullName} {activeMembers.findIndex(m => m.id === member.id) !== -1 && ( diff --git a/web/src/shared/components/PopupMenu/PopupMenu.stories.tsx b/web/src/shared/components/PopupMenu/PopupMenu.stories.tsx index 491a14d..684f434 100644 --- a/web/src/shared/components/PopupMenu/PopupMenu.stories.tsx +++ b/web/src/shared/components/PopupMenu/PopupMenu.stories.tsx @@ -227,8 +227,7 @@ export const MemberManagerPopup = () => { availableMembers={[ { id: '1', - firstName: 'Jordan', - lastName: 'Knott', + fullName: 'Jordan Knott', profileIcon: { bgColor: null, url: null, initials: null }, }, ]} @@ -293,8 +292,7 @@ export const DueDateManagerPopup = () => { { id: '1', profileIcon: { bgColor: null, url: null, initials: null }, - firstName: 'Jordan', - lastName: 'Knott', + fullName: 'Jordan Knott', }, ], }} diff --git a/web/src/shared/components/ProfileIcon/index.tsx b/web/src/shared/components/ProfileIcon/index.tsx new file mode 100644 index 0000000..4a53224 --- /dev/null +++ b/web/src/shared/components/ProfileIcon/index.tsx @@ -0,0 +1,46 @@ +import React, { useRef } from 'react'; +import styled from 'styled-components'; + +export const Container = styled.div<{ size: number | string; bgColor: string | null; backgroundURL: string | null }>` + margin-left: 10px; + width: ${props => props.size}px; + height: ${props => props.size}px; + border-radius: 9999px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 700; + background: ${props => (props.backgroundURL ? `url(${props.backgroundURL})` : props.bgColor)}; + background-position: center; + background-size: contain; +`; + +type ProfileIconProps = { + user: TaskUser; + onProfileClick: ($target: React.RefObject, user: TaskUser) => void; + size: number | string; +}; + +const ProfileIcon: React.FC = ({ user, onProfileClick, size }) => { + const $profileRef = useRef(null); + return ( + { + onProfileClick($profileRef, user); + }} + size={size} + backgroundURL={user.profileIcon.url ?? null} + bgColor={user.profileIcon.bgColor ?? null} + > + {(!user.profileIcon.url && user.profileIcon.initials) ?? ''} + + ); +}; + +ProfileIcon.defaultProps = { + size: 28, +}; + +export default ProfileIcon; diff --git a/web/src/shared/components/QuickCardEditor/Styles.ts b/web/src/shared/components/QuickCardEditor/Styles.ts index 5edc9f1..d0422df 100644 --- a/web/src/shared/components/QuickCardEditor/Styles.ts +++ b/web/src/shared/components/QuickCardEditor/Styles.ts @@ -3,14 +3,14 @@ import TextareaAutosize from 'react-autosize-textarea'; import { mixin } from 'shared/utils/styles'; export const Wrapper = styled.div<{ open: boolean }>` - background: rgba(0, 0, 0, 0.4); + background: rgba(0, 0, 0, 0.55); bottom: 0; color: #fff; left: 0; position: fixed; right: 0; top: 0; - z-index: 30; + z-index: 100; visibility: ${props => (props.open ? 'show' : 'hidden')}; `; diff --git a/web/src/shared/components/Settings/Settings.stories.tsx b/web/src/shared/components/Settings/Settings.stories.tsx new file mode 100644 index 0000000..6b84d4c --- /dev/null +++ b/web/src/shared/components/Settings/Settings.stories.tsx @@ -0,0 +1,30 @@ +import React, { createRef, useState } from 'react'; +import { action } from '@storybook/addon-actions'; +import NormalizeStyles from 'App/NormalizeStyles'; +import BaseStyles from 'App/BaseStyles'; +import Settings from '.'; + +export default { + component: Settings, + title: 'Settings', + parameters: { + backgrounds: [ + { name: 'white', value: '#ffffff', default: true }, + { name: 'gray', value: '#f8f8f8' }, + ], + }, +}; +const profile = { url: 'http://localhost:3333/uploads/headshot.png', bgColor: '#000', initials: 'JK' }; +export const Default = () => { + return ( + <> + + + + + ); +}; diff --git a/web/src/shared/components/Settings/index.tsx b/web/src/shared/components/Settings/index.tsx new file mode 100644 index 0000000..cc70aa2 --- /dev/null +++ b/web/src/shared/components/Settings/index.tsx @@ -0,0 +1,362 @@ +import React, { useState, useRef } from 'react'; +import styled from 'styled-components'; +import { User } from 'shared/icons'; + +const TextFieldWrapper = styled.div` + display: flex; + align-items: flex-start; + flex-direction: column; + position: relative; + justify-content: center; + + margin-bottom: 2.2rem; + margin-top: 17px; +`; + +const TextFieldLabel = styled.span` + padding: 0.7rem !important; + color: #c2c6dc; + left: 0; + top: 0; + transition: all 0.2s ease; + position: absolute; + border-radius: 5px; + overflow: hidden; + font-size: 0.85rem; + cursor: text; + width: 100%; + font-size: 12px; + user-select: none; + pointer-events: none; +} +`; + +const TextFieldInput = styled.input` + font-size: 12px; + border: 1px solid rgba(0, 0, 0, 0.2); + background: #262c49; + padding: 0.7rem !important; + color: #c2c6dc; + position: relative; + border-radius: 5px; + transition: all 0.3s ease; + width: 100%; + &:focus { + box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15); + border: 1px solid rgba(115, 103, 240); + } + &:focus ~ ${TextFieldLabel} { + color: rgba(115, 103, 240); + transform: translate(-3px, -90%); + } +`; + +type TextFieldProps = { + label: string; +}; +const TextField: React.FC = ({ label }) => { + return ( + + + {label} + + ); +}; + +const ProfileContainer = styled.div` + display: flex; + align-items: center; + margin-bottom: 2.2rem !important; +`; + +const AvatarContainer = styled.div` + width: 70px; + height: 70px; + border-radius: 50%; + position: relative; + cursor: pointer; + display: inline-block; + margin: 5px; + margin-bottom: 1rem; + margin-right: 1rem; +`; +const AvatarMask = styled.div<{ background: string }>` + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; + background: ${props => props.background}; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +`; + +const AvatarImg = styled.img<{ src: string }>` + display: block; + width: 100%; + height: 100%; +`; + +const ActionButtons = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; +`; +const UploadButton = styled.div` + margin-right: 1rem; + padding: 0.75rem 2rem; + border: 0; + border-radius: 6px; + cursor: pointer; + position: relative; + overflow: hidden; + color: #fff; + display: inline-block; + background: rgba(115, 103, 240); +`; + +const RemoveButton = styled.button` + display: inline-block; + border: 1px solid rgba(234, 84, 85, 1); + background: transparent; + color: rgba(234, 84, 85, 1); + padding: 0.75rem 2rem; + + border-radius: 6px; + cursor: pointer; + position: relative; + overflow: hidden; +`; + +const ImgLabel = styled.p` + color: #c2c6dc; + margin-top: 0.5rem; + font-size: 12.25px; + width: 100%; +`; + +const AvatarInitials = styled.span` + font-size: 32px; + color: #fff; +`; + +type AvatarSettingsProps = { + onProfileAvatarChange: () => void; + onProfileAvatarRemove: () => void; + profile: ProfileIcon; +}; + +const AvatarSettings: React.FC = ({ profile, onProfileAvatarChange, onProfileAvatarRemove }) => { + return ( + + + onProfileAvatarChange()} + > + {profile.url ? ( + + ) : ( + {profile.initials} + )} + + + + onProfileAvatarChange()}>Upload photo + onProfileAvatarRemove()}>Remove + Allowed JPG, GIF or PNG. Max size of 800kB + + + ); +}; + +const Container = styled.div` + padding: 2.2rem; + display: flex; + width: 100%; + position: relative; +`; + +const TabNav = styled.div` + float: left; + width: 220px; + height: 100%; + display: block; + position: relative; +`; + +const TabNavContent = styled.ul` + display: block; + width: auto; + border-bottom: 0 !important; + border-right: 1px solid rgba(0, 0, 0, 0.05); +`; + +const TabNavItem = styled.li` + padding: 0.35rem 0.3rem; + display: block; + position: relative; +`; +const TabNavItemButton = styled.button<{ active: boolean }>` + cursor: pointer; + display: flex; + align-items: center; + + padding-top: 10px !important; + padding-bottom: 10px !important; + padding-left: 12px !important; + padding-right: 8px !important; + width: 100%; + position: relative; + + color: ${props => (props.active ? 'rgba(115, 103, 240)' : '#c2c6dc')}; + &:hover { + color: rgba(115, 103, 240); + } + &:hover svg { + fill: rgba(115, 103, 240); + } +`; + +const TabNavItemSpan = styled.span` + text-align: left; + padding-left: 9px; + font-size: 14px; +`; + +const TabNavLine = styled.span<{ top: number }>` + left: auto; + right: 0; + width: 2px; + height: 48px; + transform: scaleX(1); + top: ${props => props.top}px; + + background: linear-gradient(30deg, rgba(115, 103, 240), rgba(115, 103, 240)); + box-shadow: 0 0 8px 0 rgba(115, 103, 240); + display: block; + position: absolute; + transition: all 0.2s ease; +`; + +const TabContentWrapper = styled.div` + position: relative; + display: block; + overflow: hidden; + width: 100%; +`; + +const TabContent = styled.div` + position: relative; + width: 100%; + display: block; + padding: 0; + padding: 1.5rem; + background-color: #10163a; + margin-left: 1rem !important; + border-radius: 0.5rem; +`; + +const TabContentInner = styled.div``; + +const items = [{ name: 'General' }, { name: 'Change Password' }, { name: 'Info' }, { name: 'Notifications' }]; +type NavItemProps = { + active: boolean; + name: string; + tab: number; + onClick: (tab: number, top: number) => void; +}; +const NavItem: React.FC = ({ active, name, tab, onClick }) => { + const $item = useRef(null); + return ( + { + if ($item && $item.current) { + const pos = $item.current.getBoundingClientRect(); + onClick(tab, pos.top); + } + }} + > + + + {name} + + + ); +}; + +const SettingActions = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; +`; + +const SaveButton = styled.div` + margin-right: 1rem; + padding: 0.75rem 2rem; + border: 0; + border-radius: 6px; + cursor: pointer; + position: relative; + overflow: hidden; + color: #fff; + display: inline-block; + background: rgba(115, 103, 240); +`; + +type SettingsProps = { + onProfileAvatarChange: () => void; + onProfileAvatarRemove: () => void; + profile: ProfileIcon; +}; + +const Settings: React.FC = ({ onProfileAvatarRemove, onProfileAvatarChange, profile }) => { + const [currentTab, setTab] = useState(0); + const [currentTop, setTop] = useState(0); + const $tabNav = useRef(null); + return ( + + + + {items.map((item, idx) => ( + { + if ($tabNav && $tabNav.current) { + const pos = $tabNav.current.getBoundingClientRect(); + setTab(tab); + setTop(top - pos.top); + } + }} + name={item.name} + tab={idx} + active={idx === currentTab} + /> + ))} + + + + + + + + + + + + + Save Change + + + + + ); +}; + +export default Settings; diff --git a/web/src/shared/components/Tabs/Tabs.stories.tsx b/web/src/shared/components/Tabs/Tabs.stories.tsx new file mode 100644 index 0000000..10a5099 --- /dev/null +++ b/web/src/shared/components/Tabs/Tabs.stories.tsx @@ -0,0 +1,25 @@ +import React, { useRef } from 'react'; +import NormalizeStyles from 'App/NormalizeStyles'; +import BaseStyles from 'App/BaseStyles'; +import Tabs from '.'; + +export default { + component: Tabs, + title: 'Tabs', + parameters: { + backgrounds: [ + { name: 'gray', value: '#f8f8f8', default: true }, + { name: 'white', value: '#ffffff' }, + ], + }, +}; + +export const Default = () => { + return ( + <> + + + + + ); +}; diff --git a/web/src/shared/components/Tabs/index.tsx b/web/src/shared/components/Tabs/index.tsx new file mode 100644 index 0000000..36b6fdd --- /dev/null +++ b/web/src/shared/components/Tabs/index.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import styled from 'styled-components'; + +const Tabs = () => { + return HEllo!; +}; + +export default Tabs; diff --git a/web/src/shared/components/TaskAssignee/index.tsx b/web/src/shared/components/TaskAssignee/index.tsx index a911960..7a37329 100644 --- a/web/src/shared/components/TaskAssignee/index.tsx +++ b/web/src/shared/components/TaskAssignee/index.tsx @@ -8,7 +8,7 @@ const TaskDetailAssignee = styled.div` margin-right: 4px; `; -const ProfileIcon = styled.div<{ size: string | number }>` +export const Wrapper = styled.div<{ size: number | string; bgColor: string | null; backgroundURL: string | null }>` width: ${props => props.size}px; height: ${props => props.size}px; border-radius: 9999px; @@ -16,10 +16,10 @@ const ProfileIcon = styled.div<{ size: string | number }>` align-items: center; justify-content: center; color: #fff; - font-weight: 400; - background: rgb(115, 103, 240); - font-size: 14px; - cursor: pointer; + font-weight: 700; + background: ${props => (props.backgroundURL ? `url(${props.backgroundURL})` : props.bgColor)}; + background-position: center; + background-size: contain; `; type TaskAssigneeProps = { @@ -31,8 +31,17 @@ type TaskAssigneeProps = { const TaskAssignee: React.FC = ({ member, onMemberProfile, size }) => { const $memberRef = useRef(null); return ( - onMemberProfile($memberRef, member.id)} key={member.id}> - {member.profileIcon.initials ?? ''} + { + e.stopPropagation(); + onMemberProfile($memberRef, member.id); + }} + key={member.id} + > + + {(!member.profileIcon.url && member.profileIcon.initials) ?? ''} + ); }; diff --git a/web/src/shared/components/TaskDetails/TaskDetails.stories.tsx b/web/src/shared/components/TaskDetails/TaskDetails.stories.tsx index 3bd60f6..84e758e 100644 --- a/web/src/shared/components/TaskDetails/TaskDetails.stories.tsx +++ b/web/src/shared/components/TaskDetails/TaskDetails.stories.tsx @@ -55,8 +55,7 @@ export const Default = () => { { id: '1', profileIcon: { bgColor: null, url: null, initials: null }, - firstName: 'Jordan', - lastName: 'Knott', + fullName: 'Jordan Knott', }, ], }} diff --git a/web/src/shared/components/TopNavbar/Styles.ts b/web/src/shared/components/TopNavbar/Styles.ts index 3389a35..bb9af72 100644 --- a/web/src/shared/components/TopNavbar/Styles.ts +++ b/web/src/shared/components/TopNavbar/Styles.ts @@ -68,7 +68,7 @@ export const ProfileNameSecondary = styled.small` color: #c2c6dc; `; -export const ProfileIcon = styled.div<{ bgColor: string }>` +export const ProfileIcon = styled.div<{ bgColor: string | null; backgroundURL: string | null }>` margin-left: 10px; width: 40px; height: 40px; @@ -78,8 +78,9 @@ export const ProfileIcon = styled.div<{ bgColor: string }>` justify-content: center; color: #fff; font-weight: 700; - background: ${props => props.bgColor}; - cursor: pointer; + background: ${props => (props.backgroundURL ? `url(${props.backgroundURL})` : props.bgColor)}; + background-position: center; + background-size: contain; `; export const ProjectMeta = styled.div` diff --git a/web/src/shared/components/TopNavbar/TopNavbar.stories.tsx b/web/src/shared/components/TopNavbar/TopNavbar.stories.tsx index 468120b..6891795 100644 --- a/web/src/shared/components/TopNavbar/TopNavbar.stories.tsx +++ b/web/src/shared/components/TopNavbar/TopNavbar.stories.tsx @@ -21,42 +21,25 @@ export default { }; export const Default = () => { - const [menu, setMenu] = useState({ - top: 0, - left: 0, - isOpen: false, - }); - const onClick = (bottom: number, right: number) => { - setMenu({ - isOpen: !menu.isOpen, - left: right, - top: bottom, - }); - }; return ( <> - {menu.isOpen && ( - { - setMenu({ left: 0, top: 0, isOpen: false }); - }} - onLogout={action('on logout')} - left={menu.left} - top={menu.top} - /> - )} ); }; diff --git a/web/src/shared/components/TopNavbar/index.tsx b/web/src/shared/components/TopNavbar/index.tsx index ce2d052..bdc0531 100644 --- a/web/src/shared/components/TopNavbar/index.tsx +++ b/web/src/shared/components/TopNavbar/index.tsx @@ -1,6 +1,6 @@ import React, { useRef, useState, useEffect } from 'react'; import { Star, Ellipsis, Bell, Cog, AngleDown } from 'shared/icons'; - +import ProfileIcon from 'shared/components/ProfileIcon'; import { NotificationContainer, ProjectNameTextarea, @@ -18,7 +18,6 @@ import { Breadcrumbs, BreadcrumpSeparator, ProjectSettingsButton, - ProfileIcon, ProfileContainer, ProfileNameWrapper, ProfileNamePrimary, @@ -110,14 +109,11 @@ const ProjectHeading: React.FC = ({ type NavBarProps = { projectName: string | null; - onProfileClick: (bottom: number, right: number) => void; + onProfileClick: ($target: React.RefObject) => void; onSaveProjectName?: (projectName: string) => void; onNotificationClick: () => void; - bgColor: string; + user: TaskUser | null; onOpenSettings: ($target: React.RefObject) => void; - firstName: string; - lastName: string; - initials: string; projectMembers?: Array | null; }; @@ -126,17 +122,14 @@ const NavBar: React.FC = ({ onSaveProjectName, onProfileClick, onNotificationClick, - firstName, - lastName, - initials, - bgColor, + user, projectMembers, onOpenSettings, }) => { - const $profileRef: any = useRef(null); - const handleProfileClick = () => { - const boundingRect = $profileRef.current.getBoundingClientRect(); - onProfileClick(boundingRect.bottom, boundingRect.right); + const handleProfileClick = ($target: React.RefObject) => { + if ($target && $target.current) { + onProfileClick($target); + } }; const { showPopup } = usePopup(); const onMemberProfile = ($targetRef: React.RefObject, memberID: string) => { @@ -189,15 +182,16 @@ const NavBar: React.FC = ({ - - - {`${firstName} ${lastName}`} - Manager - - - {initials} - - + + {user && ( + + + {user.fullName} + Manager + + } + + )} diff --git a/web/src/shared/generated/graphql.tsx b/web/src/shared/generated/graphql.tsx index 8226f90..dbb4e20 100644 --- a/web/src/shared/generated/graphql.tsx +++ b/web/src/shared/generated/graphql.tsx @@ -11,10 +11,12 @@ export type Scalars = { Float: number; Time: any; UUID: string; + Upload: any; }; + export type ProjectLabel = { __typename?: 'ProjectLabel'; id: Scalars['ID']; @@ -48,8 +50,7 @@ export type ProfileIcon = { export type ProjectMember = { __typename?: 'ProjectMember'; id: Scalars['ID']; - firstName: Scalars['String']; - lastName: Scalars['String']; + fullName: Scalars['String']; profileIcon: ProfileIcon; }; @@ -66,8 +67,8 @@ export type UserAccount = { id: Scalars['ID']; email: Scalars['String']; createdAt: Scalars['Time']; - firstName: Scalars['String']; - lastName: Scalars['String']; + fullName: Scalars['String']; + initials: Scalars['String']; username: Scalars['String']; profileIcon: ProfileIcon; }; @@ -169,8 +170,8 @@ export type NewRefreshToken = { export type NewUserAccount = { username: Scalars['String']; email: Scalars['String']; - firstName: Scalars['String']; - lastName: Scalars['String']; + fullName: Scalars['String']; + initials: Scalars['String']; password: Scalars['String']; }; @@ -314,6 +315,7 @@ export type Mutation = { createRefreshToken: RefreshToken; createUserAccount: UserAccount; createTeam: Team; + clearProfileAvatar: UserAccount; createProject: Project; updateProjectName: Project; createProjectLabel: ProjectLabel; @@ -470,11 +472,26 @@ export type AssignTaskMutation = ( & Pick & { assigned: Array<( { __typename?: 'ProjectMember' } - & Pick + & Pick )> } ) } ); +export type ClearProfileAvatarMutationVariables = {}; + + +export type ClearProfileAvatarMutation = ( + { __typename?: 'Mutation' } + & { clearProfileAvatar: ( + { __typename?: 'UserAccount' } + & Pick + & { profileIcon: ( + { __typename?: 'ProfileIcon' } + & Pick + ) } + ) } +); + export type CreateProjectMutationVariables = { teamID: Scalars['UUID']; userID: Scalars['UUID']; @@ -541,7 +558,7 @@ export type CreateTaskMutation = ( ) } )>, assigned: Array<( { __typename?: 'ProjectMember' } - & Pick + & Pick & { profileIcon: ( { __typename?: 'ProfileIcon' } & Pick @@ -624,7 +641,7 @@ export type FindProjectQuery = ( & Pick & { members: Array<( { __typename?: 'ProjectMember' } - & Pick + & Pick & { profileIcon: ( { __typename?: 'ProfileIcon' } & Pick @@ -658,7 +675,7 @@ export type FindProjectQuery = ( ) } )>, assigned: Array<( { __typename?: 'ProjectMember' } - & Pick + & Pick & { profileIcon: ( { __typename?: 'ProfileIcon' } & Pick @@ -698,7 +715,7 @@ export type FindTaskQuery = ( ) } )>, assigned: Array<( { __typename?: 'ProjectMember' } - & Pick + & Pick & { profileIcon: ( { __typename?: 'ProfileIcon' } & Pick @@ -732,10 +749,10 @@ export type MeQuery = ( { __typename?: 'Query' } & { me: ( { __typename?: 'UserAccount' } - & Pick + & Pick & { profileIcon: ( { __typename?: 'ProfileIcon' } - & Pick + & Pick ) } ) } ); @@ -783,7 +800,7 @@ export type UnassignTaskMutation = ( & Pick & { assigned: Array<( { __typename?: 'ProjectMember' } - & Pick + & Pick )> } ) } ); @@ -831,7 +848,7 @@ export type UpdateTaskDescriptionMutation = ( { __typename?: 'Mutation' } & { updateTaskDescription: ( { __typename?: 'Task' } - & Pick + & Pick ) } ); @@ -893,8 +910,7 @@ export const AssignTaskDocument = gql` id assigned { id - firstName - lastName + fullName } } } @@ -925,6 +941,43 @@ export function useAssignTaskMutation(baseOptions?: ApolloReactHooks.MutationHoo export type AssignTaskMutationHookResult = ReturnType; export type AssignTaskMutationResult = ApolloReactCommon.MutationResult; export type AssignTaskMutationOptions = ApolloReactCommon.BaseMutationOptions; +export const ClearProfileAvatarDocument = gql` + mutation clearProfileAvatar { + clearProfileAvatar { + id + fullName + profileIcon { + initials + bgColor + url + } + } +} + `; +export type ClearProfileAvatarMutationFn = ApolloReactCommon.MutationFunction; + +/** + * __useClearProfileAvatarMutation__ + * + * To run a mutation, you first call `useClearProfileAvatarMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useClearProfileAvatarMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [clearProfileAvatarMutation, { data, loading, error }] = useClearProfileAvatarMutation({ + * variables: { + * }, + * }); + */ +export function useClearProfileAvatarMutation(baseOptions?: ApolloReactHooks.MutationHookOptions) { + return ApolloReactHooks.useMutation(ClearProfileAvatarDocument, baseOptions); + } +export type ClearProfileAvatarMutationHookResult = ReturnType; +export type ClearProfileAvatarMutationResult = ApolloReactCommon.MutationResult; +export type ClearProfileAvatarMutationOptions = ApolloReactCommon.BaseMutationOptions; export const CreateProjectDocument = gql` mutation createProject($teamID: UUID!, $userID: UUID!, $name: String!) { createProject(input: {teamID: $teamID, userID: $userID, name: $name}) { @@ -1035,8 +1088,7 @@ export const CreateTaskDocument = gql` } assigned { id - firstName - lastName + fullName profileIcon { url initials @@ -1219,8 +1271,7 @@ export const FindProjectDocument = gql` name members { id - firstName - lastName + fullName profileIcon { url initials @@ -1269,8 +1320,7 @@ export const FindProjectDocument = gql` } assigned { id - firstName - lastName + fullName profileIcon { url initials @@ -1341,8 +1391,7 @@ export const FindTaskDocument = gql` } assigned { id - firstName - lastName + fullName profileIcon { url initials @@ -1423,11 +1472,12 @@ export type GetProjectsQueryResult = ApolloReactCommon.QueryResult