diff --git a/api/graph/generated.go b/api/graph/generated.go index 97b0880..edbfc13 100644 --- a/api/graph/generated.go +++ b/api/graph/generated.go @@ -137,6 +137,7 @@ type ComplexityRoot struct { Me func(childComplexity int) int Projects func(childComplexity int, input *ProjectsFilter) int TaskGroups func(childComplexity int) int + Teams func(childComplexity int) int Users func(childComplexity int) int } @@ -184,6 +185,11 @@ type ComplexityRoot struct { Task func(childComplexity int) int } + UpdateTaskLocationPayload struct { + PreviousTaskGroupID func(childComplexity int) int + Task func(childComplexity int) int + } + UserAccount struct { CreatedAt func(childComplexity int) int Email func(childComplexity int) int @@ -217,7 +223,7 @@ type MutationResolver interface { ToggleTaskLabel(ctx context.Context, input ToggleTaskLabelInput) (*ToggleTaskLabelPayload, error) CreateTask(ctx context.Context, input NewTask) (*pg.Task, error) UpdateTaskDescription(ctx context.Context, input UpdateTaskDescriptionInput) (*pg.Task, error) - UpdateTaskLocation(ctx context.Context, input NewTaskLocation) (*pg.Task, error) + UpdateTaskLocation(ctx context.Context, input NewTaskLocation) (*UpdateTaskLocationPayload, error) UpdateTaskName(ctx context.Context, input UpdateTaskName) (*pg.Task, error) DeleteTask(ctx context.Context, input DeleteTaskInput) (*DeleteTaskPayload, error) AssignTask(ctx context.Context, input *AssignTaskInput) (*pg.Task, error) @@ -245,6 +251,7 @@ type QueryResolver interface { FindProject(ctx context.Context, input FindProject) (*pg.Project, error) FindTask(ctx context.Context, input FindTask) (*pg.Task, error) Projects(ctx context.Context, input *ProjectsFilter) ([]pg.Project, error) + Teams(ctx context.Context) ([]pg.Team, error) LabelColors(ctx context.Context) ([]pg.LabelColor, error) TaskGroups(ctx context.Context) ([]pg.TaskGroup, error) Me(ctx context.Context) (*pg.UserAccount, error) @@ -840,6 +847,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.TaskGroups(childComplexity), true + case "Query.teams": + if e.complexity.Query.Teams == nil { + break + } + + return e.complexity.Query.Teams(childComplexity), true + case "Query.users": if e.complexity.Query.Users == nil { break @@ -1029,6 +1043,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.ToggleTaskLabelPayload.Task(childComplexity), true + case "UpdateTaskLocationPayload.previousTaskGroupID": + if e.complexity.UpdateTaskLocationPayload.PreviousTaskGroupID == nil { + break + } + + return e.complexity.UpdateTaskLocationPayload.PreviousTaskGroupID(childComplexity), true + + case "UpdateTaskLocationPayload.task": + if e.complexity.UpdateTaskLocationPayload.Task == nil { + break + } + + return e.complexity.UpdateTaskLocationPayload.Task(childComplexity), true + case "UserAccount.createdAt": if e.complexity.UserAccount.CreatedAt == nil { break @@ -1254,6 +1282,7 @@ type Query { findProject(input: FindProject!): Project! findTask(input: FindTask!): Task! projects(input: ProjectsFilter): [Project!]! + teams: [Team!]! labelColors: [LabelColor!]! taskGroups: [TaskGroup!]! me: UserAccount! @@ -1297,8 +1326,8 @@ input NewTask { position: Float! } input NewTaskLocation { - taskID: String! - taskGroupID: String! + taskID: UUID! + taskGroupID: UUID! position: Float! } @@ -1394,6 +1423,10 @@ input UpdateProjectName { name: String! } +type UpdateTaskLocationPayload { + previousTaskGroupID: UUID! + task: Task! +} type Mutation { createRefreshToken(input: NewRefreshToken!): RefreshToken! @@ -1420,7 +1453,7 @@ type Mutation { createTask(input: NewTask!): Task! updateTaskDescription(input: UpdateTaskDescriptionInput!): Task! - updateTaskLocation(input: NewTaskLocation!): Task! + updateTaskLocation(input: NewTaskLocation!): UpdateTaskLocationPayload! updateTaskName(input: UpdateTaskName!): Task! deleteTask(input: DeleteTaskInput!): DeleteTaskPayload! assignTask(input: AssignTaskInput): Task! @@ -2924,9 +2957,9 @@ func (ec *executionContext) _Mutation_updateTaskLocation(ctx context.Context, fi } return graphql.Null } - res := resTmp.(*pg.Task) + res := resTmp.(*UpdateTaskLocationPayload) fc.Result = res - return ec.marshalNTask2ᚖgithubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋpgᚐTask(ctx, field.Selections, res) + return ec.marshalNUpdateTaskLocationPayload2ᚖgithubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋgraphᚐUpdateTaskLocationPayload(ctx, field.Selections, res) } func (ec *executionContext) _Mutation_updateTaskName(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { @@ -3966,6 +3999,40 @@ func (ec *executionContext) _Query_projects(ctx context.Context, field graphql.C return ec.marshalNProject2ᚕgithubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋpgᚐProjectᚄ(ctx, field.Selections, res) } +func (ec *executionContext) _Query_teams(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: "Query", + 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.Query().Teams(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.Team) + fc.Result = res + return ec.marshalNTeam2ᚕgithubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋpgᚐTeamᚄ(ctx, field.Selections, res) +} + func (ec *executionContext) _Query_labelColors(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -5018,6 +5085,74 @@ func (ec *executionContext) _ToggleTaskLabelPayload_task(ctx context.Context, fi return ec.marshalNTask2ᚖgithubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋpgᚐTask(ctx, field.Selections, res) } +func (ec *executionContext) _UpdateTaskLocationPayload_previousTaskGroupID(ctx context.Context, field graphql.CollectedField, obj *UpdateTaskLocationPayload) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "UpdateTaskLocationPayload", + 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.PreviousTaskGroupID, 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.(uuid.UUID) + fc.Result = res + return ec.marshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, field.Selections, res) +} + +func (ec *executionContext) _UpdateTaskLocationPayload_task(ctx context.Context, field graphql.CollectedField, obj *UpdateTaskLocationPayload) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "UpdateTaskLocationPayload", + 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.Task, 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.(*pg.Task) + fc.Result = res + return ec.marshalNTask2ᚖgithubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋpgᚐTask(ctx, field.Selections, res) +} + func (ec *executionContext) _UserAccount_id(ctx context.Context, field graphql.CollectedField, obj *pg.UserAccount) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -6655,13 +6790,13 @@ func (ec *executionContext) unmarshalInputNewTaskLocation(ctx context.Context, o switch k { case "taskID": var err error - it.TaskID, err = ec.unmarshalNString2string(ctx, v) + it.TaskID, err = ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v) if err != nil { return it, err } case "taskGroupID": var err error - it.TaskGroupID, err = ec.unmarshalNString2string(ctx, v) + it.TaskGroupID, err = ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v) if err != nil { return it, err } @@ -7583,6 +7718,20 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } return res }) + case "teams": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_teams(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) case "labelColors": field := field out.Concurrently(i, func() (res graphql.Marshaler) { @@ -8007,6 +8156,38 @@ func (ec *executionContext) _ToggleTaskLabelPayload(ctx context.Context, sel ast return out } +var updateTaskLocationPayloadImplementors = []string{"UpdateTaskLocationPayload"} + +func (ec *executionContext) _UpdateTaskLocationPayload(ctx context.Context, sel ast.SelectionSet, obj *UpdateTaskLocationPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, updateTaskLocationPayloadImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("UpdateTaskLocationPayload") + case "previousTaskGroupID": + out.Values[i] = ec._UpdateTaskLocationPayload_previousTaskGroupID(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "task": + out.Values[i] = ec._UpdateTaskLocationPayload_task(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + var userAccountImplementors = []string{"UserAccount"} func (ec *executionContext) _UserAccount(ctx context.Context, sel ast.SelectionSet, obj *pg.UserAccount) graphql.Marshaler { @@ -8868,6 +9049,43 @@ func (ec *executionContext) marshalNTeam2githubᚗcomᚋjordanknottᚋprojectᚑ return ec._Team(ctx, sel, &v) } +func (ec *executionContext) marshalNTeam2ᚕgithubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋpgᚐTeamᚄ(ctx context.Context, sel ast.SelectionSet, v []pg.Team) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNTeam2githubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋpgᚐTeam(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + return ret +} + func (ec *executionContext) marshalNTeam2ᚖgithubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋpgᚐTeam(ctx context.Context, sel ast.SelectionSet, v *pg.Team) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { @@ -8940,6 +9158,20 @@ func (ec *executionContext) unmarshalNUpdateTaskDescriptionInput2githubᚗcomᚋ return ec.unmarshalInputUpdateTaskDescriptionInput(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) +} + +func (ec *executionContext) marshalNUpdateTaskLocationPayload2ᚖgithubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋgraphᚐUpdateTaskLocationPayload(ctx context.Context, sel ast.SelectionSet, v *UpdateTaskLocationPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._UpdateTaskLocationPayload(ctx, sel, v) +} + func (ec *executionContext) unmarshalNUpdateTaskName2githubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋgraphᚐUpdateTaskName(ctx context.Context, v interface{}) (UpdateTaskName, error) { return ec.unmarshalInputUpdateTaskName(ctx, v) } diff --git a/api/graph/models_gen.go b/api/graph/models_gen.go index 70dc61b..ae5b771 100644 --- a/api/graph/models_gen.go +++ b/api/graph/models_gen.go @@ -89,9 +89,9 @@ type NewTaskGroupLocation struct { } type NewTaskLocation struct { - TaskID string `json:"taskID"` - TaskGroupID string `json:"taskGroupID"` - Position float64 `json:"position"` + TaskID uuid.UUID `json:"taskID"` + TaskGroupID uuid.UUID `json:"taskGroupID"` + Position float64 `json:"position"` } type NewTeam struct { @@ -169,6 +169,11 @@ type UpdateTaskDescriptionInput struct { Description string `json:"description"` } +type UpdateTaskLocationPayload struct { + PreviousTaskGroupID uuid.UUID `json:"previousTaskGroupID"` + Task *pg.Task `json:"task"` +} + type UpdateTaskName struct { TaskID string `json:"taskID"` Name string `json:"name"` diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls index 164ed5e..3878225 100644 --- a/api/graph/schema.graphqls +++ b/api/graph/schema.graphqls @@ -110,6 +110,7 @@ type Query { findProject(input: FindProject!): Project! findTask(input: FindTask!): Task! projects(input: ProjectsFilter): [Project!]! + teams: [Team!]! labelColors: [LabelColor!]! taskGroups: [TaskGroup!]! me: UserAccount! @@ -153,8 +154,8 @@ input NewTask { position: Float! } input NewTaskLocation { - taskID: String! - taskGroupID: String! + taskID: UUID! + taskGroupID: UUID! position: Float! } @@ -250,6 +251,10 @@ input UpdateProjectName { name: String! } +type UpdateTaskLocationPayload { + previousTaskGroupID: UUID! + task: Task! +} type Mutation { createRefreshToken(input: NewRefreshToken!): RefreshToken! @@ -276,7 +281,7 @@ type Mutation { createTask(input: NewTask!): Task! updateTaskDescription(input: UpdateTaskDescriptionInput!): Task! - updateTaskLocation(input: NewTaskLocation!): Task! + updateTaskLocation(input: NewTaskLocation!): UpdateTaskLocationPayload! updateTaskName(input: UpdateTaskName!): Task! deleteTask(input: DeleteTaskInput!): DeleteTaskPayload! assignTask(input: AssignTaskInput): Task! diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go index 08462d5..e32b8d2 100644 --- a/api/graph/schema.resolvers.go +++ b/api/graph/schema.resolvers.go @@ -215,18 +215,14 @@ func (r *mutationResolver) UpdateTaskDescription(ctx context.Context, input Upda return &task, err } -func (r *mutationResolver) UpdateTaskLocation(ctx context.Context, input NewTaskLocation) (*pg.Task, error) { - taskID, err := uuid.Parse(input.TaskID) +func (r *mutationResolver) UpdateTaskLocation(ctx context.Context, input NewTaskLocation) (*UpdateTaskLocationPayload, error) { + previousTask, err := r.Repository.GetTaskByID(ctx, input.TaskID) if err != nil { - return &pg.Task{}, err + return &UpdateTaskLocationPayload{}, err } - taskGroupID, err := uuid.Parse(input.TaskGroupID) - if err != nil { - return &pg.Task{}, err - } - task, err := r.Repository.UpdateTaskLocation(ctx, pg.UpdateTaskLocationParams{taskID, taskGroupID, input.Position}) + task, err := r.Repository.UpdateTaskLocation(ctx, pg.UpdateTaskLocationParams{input.TaskID, input.TaskGroupID, input.Position}) - return &task, err + return &UpdateTaskLocationPayload{Task: &task, PreviousTaskGroupID: previousTask.TaskGroupID}, err } func (r *mutationResolver) UpdateTaskName(ctx context.Context, input UpdateTaskName) (*pg.Task, error) { @@ -405,6 +401,10 @@ func (r *queryResolver) Projects(ctx context.Context, input *ProjectsFilter) ([] return r.Repository.GetAllProjects(ctx) } +func (r *queryResolver) Teams(ctx context.Context) ([]pg.Team, error) { + return r.Repository.GetAllTeams(ctx) +} + func (r *queryResolver) LabelColors(ctx context.Context) ([]pg.LabelColor, error) { return r.Repository.GetLabelColors(ctx) } diff --git a/web/package.json b/web/package.json index 83460c1..474f066 100644 --- a/web/package.json +++ b/web/package.json @@ -29,6 +29,7 @@ "@types/react-dom": "^16.9.5", "@types/react-router": "^5.1.4", "@types/react-router-dom": "^5.1.3", + "@types/react-select": "^3.0.13", "@types/styled-components": "^5.0.0", "@welldone-software/why-did-you-render": "^4.2.2", "apollo-cache-inmemory": "^1.6.5", @@ -57,6 +58,7 @@ "react-router": "^5.1.2", "react-router-dom": "^5.1.2", "react-scripts": "3.4.0", + "react-select": "^3.1.0", "styled-components": "^5.0.1", "typescript": "~3.7.2" }, diff --git a/web/src/App/TopNavbar.tsx b/web/src/App/TopNavbar.tsx index e0db757..400a936 100644 --- a/web/src/App/TopNavbar.tsx +++ b/web/src/App/TopNavbar.tsx @@ -1,9 +1,11 @@ import React, { useState, useContext } from 'react'; import TopNavbar from 'shared/components/TopNavbar'; import DropdownMenu from 'shared/components/DropdownMenu'; +import ProjectSettings from 'shared/components/ProjectSettings'; import { useHistory } from 'react-router'; import UserIDContext from 'App/context'; import { useMeQuery } from 'shared/generated/graphql'; +import { usePopup, Popup } from 'shared/components/PopupMenu'; type GlobalTopNavbarProps = { name: string | null; @@ -12,6 +14,7 @@ type GlobalTopNavbarProps = { }; const GlobalTopNavbar: React.FC = ({ name, projectMembers, onSaveProjectName }) => { const { loading, data } = useMeQuery(); + const { showPopup } = usePopup(); const history = useHistory(); const { userID, setUserID } = useContext(UserIDContext); const [menu, setMenu] = useState({ @@ -27,6 +30,16 @@ const GlobalTopNavbar: React.FC = ({ name, projectMembers, }); }; + const onOpenSettings = ($target: React.RefObject) => { + showPopup( + $target, + + + , + 185, + ); + }; + const onLogout = () => { fetch('http://localhost:3333/auth/logout', { method: 'POST', @@ -54,6 +67,7 @@ const GlobalTopNavbar: React.FC = ({ name, projectMembers, projectMembers={projectMembers} onProfileClick={onProfileClick} onSaveProjectName={onSaveProjectName} + onOpenSettings={onOpenSettings} /> {menu.isOpen && ( { const cacheData: any = client.readQuery({ @@ -249,7 +251,31 @@ const Project = () => { const [updateTaskDescription] = useUpdateTaskDescriptionMutation(); const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState); - const [updateTaskLocation] = useUpdateTaskLocationMutation(); + const [updateTaskLocation] = useUpdateTaskLocationMutation({ + update: (client, newTask) => { + const cacheData = getCacheData(client, projectID); + console.log(cacheData); + console.log(newTask); + + const newTaskGroups = produce(cacheData.findProject.taskGroups, (draftState: Array) => { + const { previousTaskGroupID, task } = newTask.data.updateTaskLocation; + if (previousTaskGroupID !== task.taskGroup.id) { + const oldTaskGroupIdx = draftState.findIndex((t: TaskGroup) => t.id === previousTaskGroupID); + const newTaskGroupIdx = draftState.findIndex((t: TaskGroup) => t.id === task.taskGroup.id); + if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) { + draftState[oldTaskGroupIdx].tasks = draftState[oldTaskGroupIdx].tasks.filter((t: Task) => t.id !== task.id); + draftState[newTaskGroupIdx].tasks = [...draftState[newTaskGroupIdx].tasks, { ...task }]; + } + } + }); + + const newData = { + ...cacheData.findProject, + taskGroups: newTaskGroups, + }; + writeCacheData(client, projectID, cacheData, newData); + }, + }); const [updateTaskGroupLocation] = useUpdateTaskGroupLocationMutation({}); const [deleteTaskGroup] = useDeleteTaskGroupMutation({ @@ -351,23 +377,6 @@ const Project = () => { } }; - const onCardDrop = (droppedTask: Task) => { - updateTaskLocation({ - variables: { - taskID: droppedTask.id, - taskGroupID: droppedTask.taskGroup.id, - position: droppedTask.position, - }, - optimisticResponse: { - updateTaskLocation: { - name: droppedTask.name, - id: droppedTask.id, - position: droppedTask.position, - createdAt: '', - }, - }, - }); - }; const onListDrop = (droppedColumn: TaskGroup) => { console.log(`list drop ${droppedColumn.id}`); const cacheData = getCacheData(client, projectID); @@ -399,10 +408,21 @@ const Project = () => { }; const [assignTask] = useAssignTaskMutation(); + const [unassignTask] = useUnassignTaskMutation(); - const [updateProjectName] = useUpdateProjectNameMutation(); + const [updateProjectName] = useUpdateProjectNameMutation({ + update: (client, newName) => { + const cacheData = getCacheData(client, projectID); + const newData = { + ...cacheData.findProject, + name: newName.data.updateProjectName.name, + }; + writeCacheData(client, projectID, cacheData, newData); + }, + }); const client = useApolloClient(); + const { userID } = useContext(UserIDContext); const { showPopup, hidePopup } = usePopup(); const $labelsRef = useRef(null); @@ -482,7 +502,7 @@ const Project = () => { onTaskClick={task => { history.push(`${match.url}/c/${task.id}`); }} - onTaskDrop={droppedTask => { + onTaskDrop={(droppedTask, previousTaskGroupID) => { updateTaskLocation({ variables: { taskID: droppedTask.id, @@ -492,11 +512,18 @@ const Project = () => { optimisticResponse: { __typename: 'Mutation', updateTaskLocation: { - name: droppedTask.name, - id: droppedTask.id, - position: droppedTask.position, - createdAt: '', - __typename: 'Task', + previousTaskGroupID, + task: { + name: droppedTask.name, + id: droppedTask.id, + position: droppedTask.position, + taskGroup: { + id: droppedTask.taskGroup.id, + __typename: 'TaskGroup', + }, + createdAt: '', + __typename: 'Task', + }, }, }, }); @@ -558,6 +585,42 @@ const Project = () => { onEditCard={(_listId: string, cardId: string, cardName: string) => { updateTaskName({ variables: { taskID: cardId, name: cardName } }); }} + onOpenMembersPopup={($targetRef, task) => { + showPopup( + $targetRef, + {}}> + { + if (isActive) { + assignTask({ variables: { taskID: task.id, userID: userID ?? '' } }); + } else { + unassignTask({ variables: { taskID: task.id, userID: userID ?? '' } }); + } + }} + /> + , + ); + }} + onCardMemberClick={($targetRef, taskID, memberID) => { + const member = data.findProject.members.find(m => m.id === memberID); + const profileIcon = member ? member.profileIcon : null; + showPopup( + $targetRef, + {}} tab={0}> + { + /* unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } }); */ + }} + /> + , + ); + }} onOpenLabelsPopup={($targetRef, task) => { taskLabelsRef.current = task.labels; showPopup( diff --git a/web/src/Projects/index.tsx b/web/src/Projects/index.tsx index 865d69c..c592530 100644 --- a/web/src/Projects/index.tsx +++ b/web/src/Projects/index.tsx @@ -1,11 +1,13 @@ -import React, { useState } from 'react'; +import React, { useState, useContext } from 'react'; import styled from 'styled-components/macro'; import GlobalTopNavbar from 'App/TopNavbar'; -import { useGetProjectsQuery } from 'shared/generated/graphql'; +import { useGetProjectsQuery, useCreateProjectMutation, GetProjectsDocument } from 'shared/generated/graphql'; -import ProjectGridItem from 'shared/components/ProjectGridItem'; +import ProjectGridItem, { AddProjectItem } from 'shared/components/ProjectGridItem'; import { Link } from 'react-router-dom'; import Navbar from 'App/Navbar'; +import NewProject from 'shared/components/NewProject'; +import UserIDContext from 'App/context'; const MainContent = styled.div` padding: 0 0 50px 80px; @@ -17,19 +19,37 @@ const ProjectGrid = styled.div` width: 60%; max-width: 780px; margin: 25px auto; - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; + display: grid; + grid-template-columns: 240px 240px 240px; + gap: 20px 10px; `; -const ProjectLink = styled(Link)` - flex: 1 0 33%; - margin-bottom: 20px; -`; +const ProjectLink = styled(Link)``; const Projects = () => { const { loading, data } = useGetProjectsQuery(); + const [createProject] = useCreateProjectMutation({ + update: (client, newProject) => { + const cacheData: any = client.readQuery({ + query: GetProjectsDocument, + }); + + console.log(cacheData); + console.log(newProject); + + const newData = { + ...cacheData, + projects: [...cacheData.projects, { ...newProject.data.createProject }], + }; + console.log(newData); + client.writeQuery({ + query: GetProjectsDocument, + data: newData, + }); + }, + }); + const [showNewProject, setShowNewProject] = useState(false); + const { userID, setUserID } = useContext(UserIDContext); if (loading) { return ( <> @@ -38,7 +58,7 @@ const Projects = () => { ); } if (data) { - const { projects } = data; + const { projects, teams } = data; return ( <> {}} name={null} /> @@ -50,7 +70,26 @@ const Projects = () => { /> ))} + { + setShowNewProject(true); + }} + /> + {showNewProject && ( + { + if (userID) { + createProject({ variables: { teamID, name, userID } }); + setShowNewProject(false); + } + }} + onClose={() => { + setShowNewProject(false); + }} + teams={teams} + /> + )} ); } diff --git a/web/src/projects.d.ts b/web/src/projects.d.ts index a8e0e14..93dcfc7 100644 --- a/web/src/projects.d.ts +++ b/web/src/projects.d.ts @@ -54,8 +54,9 @@ type Organization = { }; type Team = { + id: string; name: string; - projects: Project[]; + createdAt: string; }; type ProjectLabel = { diff --git a/web/src/shared/components/Card/Styles.ts b/web/src/shared/components/Card/Styles.ts index 78c6a62..14f4cd7 100644 --- a/web/src/shared/components/Card/Styles.ts +++ b/web/src/shared/components/Card/Styles.ts @@ -128,28 +128,3 @@ export const CardMembers = styled.div` margin: 0 -2px 0 0; `; -export const CardMember = styled.div<{ bgColor: string; ref: any }>` - height: 28px; - width: 28px; - float: right; - margin: 0 0 4px 4px; - - background-color: ${props => props.bgColor}; - color: #fff; - border-radius: 25em; - cursor: pointer; - display: block; - overflow: visible; - position: relative; - text-decoration: none; - z-index: 0; -`; - -export const CardMemberInitials = styled.div` - height: 28px; - width: 28px; - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; -`; diff --git a/web/src/shared/components/Card/index.tsx b/web/src/shared/components/Card/index.tsx index d8c19f3..191d964 100644 --- a/web/src/shared/components/Card/index.tsx +++ b/web/src/shared/components/Card/index.tsx @@ -2,6 +2,7 @@ import React, { useState, useRef } from 'react'; import { DraggableProvidedDraggableProps } from 'react-beautiful-dnd'; import PropTypes from 'prop-types'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import Member from 'shared/components/Member'; import { faPencilAlt, faList } from '@fortawesome/free-solid-svg-icons'; import { faClock, faCheckSquare, faEye } from '@fortawesome/free-regular-svg-icons'; import { @@ -19,8 +20,6 @@ import { ListCardOperation, CardTitle, CardMembers, - CardMember, - CardMemberInitials, } from './Styles'; type DueDate = { @@ -33,31 +32,6 @@ type Checklist = { total: number; }; -type MemberProps = { - onCardMemberClick?: OnCardMemberClick; - taskID: string; - member: TaskUser; -}; - -const Member: React.FC = ({ onCardMemberClick, taskID, member }) => { - const $targetRef = useRef(); - return ( - { - if (onCardMemberClick) { - e.stopPropagation(); - onCardMemberClick($targetRef, taskID, member.id); - } - }} - key={member.id} - bgColor={member.profileIcon.bgColor ?? '#7367F0'} - > - {member.profileIcon.initials} - - ); -}; - type Props = { title: string; description: string; diff --git a/web/src/shared/components/Lists/index.tsx b/web/src/shared/components/Lists/index.tsx index ec3b7d9..f1187e4 100644 --- a/web/src/shared/components/Lists/index.tsx +++ b/web/src/shared/components/Lists/index.tsx @@ -15,7 +15,7 @@ import { Container, BoardWrapper } from './Styles'; interface SimpleProps { taskGroups: Array; - onTaskDrop: (task: Task) => void; + onTaskDrop: (task: Task, previousTaskGroupID: string) => void; onTaskGroupDrop: (taskGroup: TaskGroup) => void; onTaskClick: (task: Task) => void; @@ -110,7 +110,7 @@ const SimpleLists: React.FC = ({ id: destination.droppableId, }, }; - onTaskDrop(newTask); + onTaskDrop(newTask, droppedTask.taskGroup.id); } } }; diff --git a/web/src/shared/components/Member/index.tsx b/web/src/shared/components/Member/index.tsx new file mode 100644 index 0000000..5f5ecf7 --- /dev/null +++ b/web/src/shared/components/Member/index.tsx @@ -0,0 +1,55 @@ +import React, { useRef } from 'react'; +import styled from 'styled-components'; + +const CardMember = styled.div<{ bgColor: string; ref: any }>` + height: 28px; + width: 28px; + float: right; + margin: 0 0 4px 4px; + + background-color: ${props => props.bgColor}; + color: #fff; + border-radius: 25em; + cursor: pointer; + display: block; + overflow: visible; + position: relative; + text-decoration: none; + z-index: 0; +`; + +const CardMemberInitials = styled.div` + height: 28px; + width: 28px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; +`; + +type MemberProps = { + onCardMemberClick?: OnCardMemberClick; + taskID: string; + member: TaskUser; +}; + +const Member: React.FC = ({ onCardMemberClick, taskID, member }) => { + const $targetRef = useRef(); + return ( + { + if (onCardMemberClick) { + e.stopPropagation(); + onCardMemberClick($targetRef, taskID, member.id); + } + }} + key={member.id} + bgColor={member.profileIcon.bgColor ?? '#7367F0'} + > + {member.profileIcon.initials} + + ); +}; + +export default Member; diff --git a/web/src/shared/components/NewProject/NewProject.stories.tsx b/web/src/shared/components/NewProject/NewProject.stories.tsx new file mode 100644 index 0000000..fe71860 --- /dev/null +++ b/web/src/shared/components/NewProject/NewProject.stories.tsx @@ -0,0 +1,32 @@ +import React, { useState, useRef, createRef } from 'react'; +import { action } from '@storybook/addon-actions'; +import styled from 'styled-components'; +import NormalizeStyles from 'App/NormalizeStyles'; +import BaseStyles from 'App/BaseStyles'; + +import NewProject from '.'; + +export default { + component: NewProject, + title: 'NewProject', + parameters: { + backgrounds: [ + { name: 'white', value: '#ffffff', default: true }, + { name: 'gray', value: '#f8f8f8' }, + ], + }, +}; + +export const Default = () => { + return ( + <> + + + {}} + /> + + ); +}; diff --git a/web/src/shared/components/NewProject/index.tsx b/web/src/shared/components/NewProject/index.tsx new file mode 100644 index 0000000..0505375 --- /dev/null +++ b/web/src/shared/components/NewProject/index.tsx @@ -0,0 +1,280 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { mixin } from 'shared/utils/styles'; +import Select from 'react-select'; +import { ArrowLeft, Cross } from 'shared/icons'; + +const Overlay = styled.div` + z-index: 10000; + background: #262c49; + bottom: 0; + color: #fff; + left: 0; + position: fixed; + right: 0; + top: 0; +`; + +const Content = styled.div` + display: flex; + flex-direction: column; + height: 100%; +`; +const Header = styled.div` + height: 64px; + padding: 0 24px; + align-items: center; + display: flex; + flex: 0 0 auto; + justify-content: space-between; + transition: box-shadow 250ms; +`; + +const HeaderLeft = styled.div` + align-items: center; + display: flex; + cursor: pointer; +`; +const HeaderRight = styled.div` + cursor: pointer; + align-items: center; + display: flex; +`; + +const Container = styled.div` + padding: 32px 0; + align-items: center; + display: flex; + flex-direction: column; +`; + +const ContainerContent = styled.div` + width: 520px; + display: flex; + flex-direction: column; +`; + +const Title = styled.h1` + font-size: 24px; + font-weight: 500; + color: #c2c6dc; + margin-bottom: 25px; +`; + +const ProjectName = styled.input` + margin: 0 0 12px; + width: 100%; + box-sizing: border-box; + display: block; + line-height: 20px; + margin-bottom: 12px; + padding: 8px 12px; + background: #262c49; + outline: none; + color: #c2c6dc; + + border-radius: 3px; + border-width: 1px; + border-style: solid; + border-color: transparent; + border-image: initial; + border-color: #414561; + + font-size: 16px; + font-weight: 400; + + &:focus { + background: ${mixin.darken('#262c49', 0.15)}; + box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px; + } +`; +const ProjectNameLabel = styled.label` + color: #c2c6dc; + font-size: 12px; + margin-bottom: 4px; +`; +const ProjectInfo = styled.div` + display: flex; +`; + +const ProjectField = styled.div` + display: flex; + flex-direction: column; + margin-right: 15px; + flex-grow: 1; +`; +const ProjectTeamField = styled.div` + display: flex; + flex-direction: column; + flex-grow: 1; +`; + +const colourStyles = { + control: (styles: any, data: any) => { + return { + ...styles, + backgroundColor: data.isMenuOpen ? mixin.darken('#262c49', 0.15) : '#262c49', + boxShadow: data.menuIsOpen ? 'rgb(115, 103, 240) 0px 0px 0px 1px' : 'none', + borderRadius: '3px', + borderWidth: '1px', + borderStyle: 'solid', + borderImage: 'initial', + borderColor: '#414561', + ':hover': { + boxShadow: 'rgb(115, 103, 240) 0px 0px 0px 1px', + borderRadius: '3px', + borderWidth: '1px', + borderStyle: 'solid', + borderImage: 'initial', + borderColor: '#414561', + }, + ':active': { + boxShadow: 'rgb(115, 103, 240) 0px 0px 0px 1px', + borderRadius: '3px', + borderWidth: '1px', + borderStyle: 'solid', + borderImage: 'initial', + borderColor: 'rgb(115, 103, 240)', + }, + }; + }, + menu: (styles: any) => { + return { + ...styles, + backgroundColor: mixin.darken('#262c49', 0.15), + }; + }, + dropdownIndicator: (styles: any) => ({ ...styles, color: '#c2c6dc', ':hover': { color: '#c2c6dc' } }), + indicatorSeparator: (styles: any) => ({ ...styles, color: '#c2c6dc' }), + option: (styles: any, { data, isDisabled, isFocused, isSelected }: any) => { + return { + ...styles, + backgroundColor: isDisabled + ? null + : isSelected + ? mixin.darken('#262c49', 0.25) + : isFocused + ? mixin.darken('#262c49', 0.15) + : null, + color: isDisabled ? '#ccc' : isSelected ? '#fff' : '#c2c6dc', + cursor: isDisabled ? 'not-allowed' : 'default', + ':active': { + ...styles[':active'], + backgroundColor: !isDisabled && (isSelected ? mixin.darken('#262c49', 0.25) : '#fff'), + }, + ':hover': { + ...styles[':hover'], + backgroundColor: !isDisabled && (isSelected ? 'rgb(115, 103, 240)' : 'rgb(115, 103, 240)'), + }, + }; + }, + placeholder: (styles: any) => ({ ...styles, color: '#c2c6dc' }), + clearIndicator: (styles: any) => ({ ...styles, color: '#c2c6dc', ':hover': { color: '#c2c6dc' } }), + input: (styles: any) => ({ + ...styles, + color: '#fff', + }), + singleValue: (styles: any) => { + return { + ...styles, + color: '#fff', + }; + }, +}; +const CreateButton = styled.button` + outline: none; + border: none; + width: 100%; + line-height: 20px; + padding: 6px 12px; + background-color: none; + text-align: center; + color: #c2c6dc; + font-size: 14px; + cursor: pointer; + + border-radius: 3px; + border-width: 1px; + border-style: solid; + border-color: transparent; + border-image: initial; + border-color: #414561; + + &:hover { + color: #fff; + background: rgb(115, 103, 240); + border-color: rgb(115, 103, 240); + } +`; +type NewProjectProps = { + teams: Array; + onClose: () => void; + onCreateProject: (projectName: string, teamID: string) => void; +}; + +const NewProject: React.FC = ({ teams, onClose, onCreateProject }) => { + const [projectName, setProjectName] = useState(''); + const [team, setTeam] = useState(null); + const options = teams.map(t => ({ label: t.name, value: t.id })); + return ( + + +
+ { + onClose(); + }} + > + + + { + onClose(); + }} + > + + +
+ + + Add project details + + + Project name + { + setProjectName(e.currentTarget.value); + }} + /> + + + Team +