feature: add team creation & project deletion

This commit is contained in:
Jordan Knott 2020-06-20 17:49:11 -05:00
parent 5c3afaba7c
commit fd7c006b73
27 changed files with 1590 additions and 98 deletions

File diff suppressed because it is too large Load Diff

View File

@ -36,10 +36,29 @@ type CreateTaskChecklistItem struct {
Position float64 `json:"position"`
}
type CreateTeamMember struct {
UserID uuid.UUID `json:"userID"`
TeamID uuid.UUID `json:"teamID"`
}
type CreateTeamMemberPayload struct {
Team *pg.Team `json:"team"`
TeamMember *ProjectMember `json:"teamMember"`
}
type DeleteProject struct {
ProjectID uuid.UUID `json:"projectID"`
}
type DeleteProjectLabel struct {
ProjectLabelID uuid.UUID `json:"projectLabelID"`
}
type DeleteProjectPayload struct {
Ok bool `json:"ok"`
Project *pg.Project `json:"project"`
}
type DeleteTaskChecklistItem struct {
TaskChecklistItemID uuid.UUID `json:"taskChecklistItemID"`
}
@ -124,7 +143,7 @@ type NewTaskLocation struct {
type NewTeam struct {
Name string `json:"name"`
OrganizationID string `json:"organizationID"`
OrganizationID uuid.UUID `json:"organizationID"`
}
type NewUserAccount struct {

View File

@ -55,6 +55,7 @@ type Team {
id: ID!
createdAt: Time!
name: String!
members: [ProjectMember!]!
}
type Project {
@ -117,7 +118,13 @@ input FindTask {
taskID: UUID!
}
type Organization {
id: ID!
name: String!
}
type Query {
organizations: [Organization!]!
users: [UserAccount!]!
findUser(input: FindUser!): UserAccount!
findProject(input: FindProject!): Project!
@ -143,7 +150,7 @@ input NewUserAccount {
input NewTeam {
name: String!
organizationID: String!
organizationID: UUID!
}
input NewProject {
@ -330,6 +337,25 @@ input UpdateTaskChecklistItemName {
name: String!
}
input CreateTeamMember {
userID: UUID!
teamID: UUID!
}
type CreateTeamMemberPayload {
team: Team!
teamMember: ProjectMember!
}
input DeleteProject {
projectID: UUID!
}
type DeleteProjectPayload {
ok: Boolean!
project: Project!
}
type Mutation {
createRefreshToken(input: NewRefreshToken!): RefreshToken!
@ -338,7 +364,10 @@ type Mutation {
createTeam(input: NewTeam!): Team!
clearProfileAvatar: UserAccount!
createTeamMember(input: CreateTeamMember!): CreateTeamMemberPayload!
createProject(input: NewProject!): Project!
deleteProject(input: DeleteProject!): DeleteProjectPayload!
updateProjectName(input: UpdateProjectName): Project!
createProjectLabel(input: NewProjectLabel!): ProjectLabel!

View File

@ -41,12 +41,8 @@ func (r *mutationResolver) CreateUserAccount(ctx context.Context, input NewUserA
}
func (r *mutationResolver) CreateTeam(ctx context.Context, input NewTeam) (*pg.Team, error) {
organizationID, err := uuid.Parse(input.OrganizationID)
if err != nil {
return &pg.Team{}, err
}
createdAt := time.Now().UTC()
team, err := r.Repository.CreateTeam(ctx, pg.CreateTeamParams{organizationID, createdAt, input.Name})
team, err := r.Repository.CreateTeam(ctx, pg.CreateTeamParams{input.OrganizationID, createdAt, input.Name})
return &team, err
}
@ -65,12 +61,49 @@ func (r *mutationResolver) ClearProfileAvatar(ctx context.Context) (*pg.UserAcco
return &user, nil
}
func (r *mutationResolver) CreateTeamMember(ctx context.Context, input CreateTeamMember) (*CreateTeamMemberPayload, error) {
addedDate := time.Now().UTC()
team, err := r.Repository.GetTeamByID(ctx, input.TeamID)
if err != nil {
return &CreateTeamMemberPayload{}, err
}
_, err = r.Repository.CreateTeamMember(ctx, pg.CreateTeamMemberParams{TeamID: input.TeamID, UserID: input.UserID, Addeddate: addedDate})
user, err := r.Repository.GetUserAccountByID(ctx, input.UserID)
if err != nil {
return &CreateTeamMemberPayload{}, err
}
var url *string
if user.ProfileAvatarUrl.Valid {
url = &user.ProfileAvatarUrl.String
}
profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor}
return &CreateTeamMemberPayload{
Team: &team,
TeamMember: &ProjectMember{
ID: user.UserID,
FullName: user.FullName,
ProfileIcon: profileIcon,
}}, 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})
return &project, err
}
func (r *mutationResolver) DeleteProject(ctx context.Context, input DeleteProject) (*DeleteProjectPayload, error) {
project, err := r.Repository.GetProjectByID(ctx, input.ProjectID)
if err != nil {
return &DeleteProjectPayload{Ok: false}, err
}
err = r.Repository.DeleteProjectByID(ctx, input.ProjectID)
if err != nil {
return &DeleteProjectPayload{Ok: false}, err
}
return &DeleteProjectPayload{Project: &project, Ok: true}, err
}
func (r *mutationResolver) UpdateProjectName(ctx context.Context, input *UpdateProjectName) (*pg.Project, error) {
project, err := r.Repository.UpdateProjectNameByID(ctx, pg.UpdateProjectNameByIDParams{ProjectID: input.ProjectID, Name: input.Name})
if err != nil {
@ -409,6 +442,10 @@ func (r *mutationResolver) LogoutUser(ctx context.Context, input LogoutUser) (bo
return true, err
}
func (r *organizationResolver) ID(ctx context.Context, obj *pg.Organization) (uuid.UUID, error) {
return obj.OrganizationID, nil
}
func (r *projectResolver) ID(ctx context.Context, obj *pg.Project) (uuid.UUID, error) {
return obj.ProjectID, nil
}
@ -475,6 +512,10 @@ func (r *projectLabelResolver) Name(ctx context.Context, obj *pg.ProjectLabel) (
return name, nil
}
func (r *queryResolver) Organizations(ctx context.Context) ([]pg.Organization, error) {
return r.Repository.GetAllOrganizations(ctx)
}
func (r *queryResolver) Users(ctx context.Context) ([]pg.UserAccount, error) {
return r.Repository.GetAllUserAccounts(ctx)
}
@ -683,6 +724,31 @@ func (r *teamResolver) ID(ctx context.Context, obj *pg.Team) (uuid.UUID, error)
return obj.TeamID, nil
}
func (r *teamResolver) Members(ctx context.Context, obj *pg.Team) ([]ProjectMember, error) {
teamMembers, err := r.Repository.GetTeamMembersForTeamID(ctx, obj.TeamID)
var projectMembers []ProjectMember
if err != nil {
return projectMembers, err
}
for _, teamMember := range teamMembers {
user, err := r.Repository.GetUserAccountByID(ctx, teamMember.UserID)
if err != nil {
return projectMembers, err
}
var url *string
if user.ProfileAvatarUrl.Valid {
url = &user.ProfileAvatarUrl.String
}
profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor}
projectMembers = append(projectMembers, ProjectMember{
ID: user.UserID,
FullName: user.FullName,
ProfileIcon: profileIcon,
})
}
return projectMembers, nil
}
func (r *userAccountResolver) ID(ctx context.Context, obj *pg.UserAccount) (uuid.UUID, error) {
return obj.UserID, nil
}
@ -702,6 +768,9 @@ func (r *Resolver) LabelColor() LabelColorResolver { return &labelColorResolver{
// Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
// Organization returns OrganizationResolver implementation.
func (r *Resolver) Organization() OrganizationResolver { return &organizationResolver{r} }
// Project returns ProjectResolver implementation.
func (r *Resolver) Project() ProjectResolver { return &projectResolver{r} }
@ -737,6 +806,7 @@ func (r *Resolver) UserAccount() UserAccountResolver { return &userAccountResolv
type labelColorResolver struct{ *Resolver }
type mutationResolver struct{ *Resolver }
type organizationResolver struct{ *Resolver }
type projectResolver struct{ *Resolver }
type projectLabelResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

View File

@ -0,0 +1,8 @@
CREATE TABLE team_member (
team_member_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
team_id uuid NOT NULL REFERENCES team(team_id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES user_account(user_id) ON DELETE CASCADE,
UNIQUE(team_id, user_id),
addedDate timestamptz NOT NULL
);

View File

@ -0,0 +1,6 @@
ALTER TABLE project_label DROP CONSTRAINT project_label_project_id_fkey;
ALTER TABLE project_label
ADD CONSTRAINT project_label_project_id_fkey
FOREIGN KEY (project_id)
REFERENCES project(project_id)
ON DELETE CASCADE;

View File

@ -0,0 +1,6 @@
ALTER TABLE task_label DROP CONSTRAINT task_label_project_label_id_fkey;
ALTER TABLE task_label
ADD CONSTRAINT task_label_project_label_id_fkey
FOREIGN KEY (project_label_id)
REFERENCES project_label(project_label_id)
ON DELETE CASCADE;

View File

@ -103,6 +103,13 @@ type Team struct {
OrganizationID uuid.UUID `json:"organization_id"`
}
type TeamMember struct {
TeamMemberID uuid.UUID `json:"team_member_id"`
TeamID uuid.UUID `json:"team_id"`
UserID uuid.UUID `json:"user_id"`
Addeddate time.Time `json:"addeddate"`
}
type UserAccount struct {
UserID uuid.UUID `json:"user_id"`
CreatedAt time.Time `json:"created_at"`

View File

@ -7,11 +7,17 @@ import (
)
type Repository interface {
CreateTeamMember(ctx context.Context, arg CreateTeamMemberParams) (TeamMember, error)
DeleteTeamMemberByUserID(ctx context.Context, userID uuid.UUID) error
GetTeamMembersForTeamID(ctx context.Context, teamID uuid.UUID) ([]TeamMember, error)
CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error)
DeleteTeamByID(ctx context.Context, teamID uuid.UUID) error
GetTeamByID(ctx context.Context, teamID uuid.UUID) (Team, error)
GetAllTeams(ctx context.Context) ([]Team, error)
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) error
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
GetAllProjects(ctx context.Context) ([]Project, error)
GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error)

View File

@ -39,6 +39,15 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (P
return i, err
}
const deleteProjectByID = `-- name: DeleteProjectByID :exec
DELETE FROM project WHERE project_id = $1
`
func (q *Queries) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteProjectByID, projectID)
return err
}
const getAllProjects = `-- name: GetAllProjects :many
SELECT project_id, team_id, created_at, name, owner FROM project
`

View File

@ -21,8 +21,10 @@ type Querier interface {
CreateTaskGroup(ctx context.Context, arg CreateTaskGroupParams) (TaskGroup, error)
CreateTaskLabelForTask(ctx context.Context, arg CreateTaskLabelForTaskParams) (TaskLabel, error)
CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error)
CreateTeamMember(ctx context.Context, arg CreateTeamMemberParams) (TeamMember, error)
CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error)
DeleteExpiredTokens(ctx context.Context) error
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) error
DeleteProjectLabelByID(ctx context.Context, projectLabelID uuid.UUID) error
DeleteRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) error
DeleteRefreshTokenByUserID(ctx context.Context, userID uuid.UUID) error
@ -34,6 +36,7 @@ type Querier interface {
DeleteTaskLabelForTaskByProjectLabelID(ctx context.Context, arg DeleteTaskLabelForTaskByProjectLabelIDParams) error
DeleteTasksByTaskGroupID(ctx context.Context, taskGroupID uuid.UUID) (int64, error)
DeleteTeamByID(ctx context.Context, teamID uuid.UUID) error
DeleteTeamMemberByUserID(ctx context.Context, userID uuid.UUID) error
GetAllOrganizations(ctx context.Context) ([]Organization, error)
GetAllProjects(ctx context.Context) ([]Project, error)
GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error)
@ -59,6 +62,7 @@ type Querier interface {
GetTaskLabelsForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskLabel, error)
GetTasksForTaskGroupID(ctx context.Context, taskGroupID uuid.UUID) ([]Task, error)
GetTeamByID(ctx context.Context, teamID uuid.UUID) (Team, error)
GetTeamMembersForTeamID(ctx context.Context, teamID uuid.UUID) ([]TeamMember, error)
GetTeamsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]Team, error)
GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error)
GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error)

75
api/pg/team_member.sql.go Normal file
View File

@ -0,0 +1,75 @@
// Code generated by sqlc. DO NOT EDIT.
// source: team_member.sql
package pg
import (
"context"
"time"
"github.com/google/uuid"
)
const createTeamMember = `-- name: CreateTeamMember :one
INSERT INTO team_member (team_id, user_id, addedDate) VALUES ($1, $2, $3)
RETURNING team_member_id, team_id, user_id, addeddate
`
type CreateTeamMemberParams struct {
TeamID uuid.UUID `json:"team_id"`
UserID uuid.UUID `json:"user_id"`
Addeddate time.Time `json:"addeddate"`
}
func (q *Queries) CreateTeamMember(ctx context.Context, arg CreateTeamMemberParams) (TeamMember, error) {
row := q.db.QueryRowContext(ctx, createTeamMember, arg.TeamID, arg.UserID, arg.Addeddate)
var i TeamMember
err := row.Scan(
&i.TeamMemberID,
&i.TeamID,
&i.UserID,
&i.Addeddate,
)
return i, err
}
const deleteTeamMemberByUserID = `-- name: DeleteTeamMemberByUserID :exec
DELETE FROM team_member WHERE user_id = $1
`
func (q *Queries) DeleteTeamMemberByUserID(ctx context.Context, userID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteTeamMemberByUserID, userID)
return err
}
const getTeamMembersForTeamID = `-- name: GetTeamMembersForTeamID :many
SELECT team_member_id, team_id, user_id, addeddate FROM team_member WHERE team_id = $1
`
func (q *Queries) GetTeamMembersForTeamID(ctx context.Context, teamID uuid.UUID) ([]TeamMember, error) {
rows, err := q.db.QueryContext(ctx, getTeamMembersForTeamID, teamID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TeamMember
for rows.Next() {
var i TeamMember
if err := rows.Scan(
&i.TeamMemberID,
&i.TeamID,
&i.UserID,
&i.Addeddate,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@ -12,3 +12,6 @@ INSERT INTO project(owner, team_id, created_at, name) VALUES ($1, $2, $3, $4) RE
-- name: UpdateProjectNameByID :one
UPDATE project SET name = $2 WHERE project_id = $1 RETURNING *;
-- name: DeleteProjectByID :exec
DELETE FROM project WHERE project_id = $1;

View File

@ -0,0 +1,9 @@
-- name: CreateTeamMember :one
INSERT INTO team_member (team_id, user_id, addedDate) VALUES ($1, $2, $3)
RETURNING *;
-- name: GetTeamMembersForTeamID :many
SELECT * FROM team_member WHERE team_id = $1;
-- name: DeleteTeamMemberByUserID :exec
DELETE FROM team_member WHERE user_id = $1;

View File

@ -1,22 +1,47 @@
import React, { useState, useContext } from 'react';
import TopNavbar from 'shared/components/TopNavbar';
import DropdownMenu, { ProfileMenu } from 'shared/components/DropdownMenu';
import ProjectSettings from 'shared/components/ProjectSettings';
import ProjectSettings, { DeleteProject } from 'shared/components/ProjectSettings';
import { useHistory } from 'react-router';
import UserIDContext from 'App/context';
import { useMeQuery } from 'shared/generated/graphql';
import { useMeQuery, useDeleteProjectMutation, GetProjectsDocument } from 'shared/generated/graphql';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import produce from 'immer';
type GlobalTopNavbarProps = {
projectID: string | null;
name: string | null;
projectMembers?: null | Array<TaskUser>;
onSaveProjectName?: (projectName: string) => void;
};
const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({ name, projectMembers, onSaveProjectName }) => {
const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({ projectID, name, projectMembers, onSaveProjectName }) => {
const { loading, data } = useMeQuery();
const { showPopup, hidePopup } = usePopup();
const { showPopup, hidePopup, setTab } = usePopup();
const history = useHistory();
const { userID, setUserID } = useContext(UserIDContext);
const [deleteProject] = useDeleteProjectMutation({
update: (client, deleteData) => {
const cacheData: any = client.readQuery({
query: GetProjectsDocument,
});
console.log(cacheData);
console.log(deleteData);
const newData = produce(cacheData, (draftState: any) => {
draftState.projects = draftState.projects.filter(
(project: any) => project.id !== deleteData.data.deleteProject.project.id,
);
});
client.writeQuery({
query: GetProjectsDocument,
data: {
...newData,
},
});
},
});
const onLogout = () => {
fetch('http://localhost:3333/auth/logout', {
method: 'POST',
@ -49,9 +74,27 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({ name, projectMembers,
const onOpenSettings = ($target: React.RefObject<HTMLElement>) => {
showPopup(
$target,
<>
<Popup title={null} tab={0}>
<ProjectSettings />
</Popup>,
<ProjectSettings
onDeleteProject={() => {
setTab(1, 325);
}}
/>
</Popup>
<Popup title={`Delete the "${name}" project?`} tab={1}>
<DeleteProject
name={name ?? ''}
onDeleteProject={() => {
if (projectID) {
deleteProject({ variables: { projectID } });
hidePopup();
history.push('/projects');
}
}}
/>
</Popup>
</>,
185,
);
};

View File

@ -50,7 +50,7 @@ const Projects = () => {
}
}}
/>
<GlobalTopNavbar onSaveProjectName={() => {}} name={null} />
<GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />
{!loading && data && (
<Settings
profile={data.me.profileIcon}

View File

@ -472,7 +472,7 @@ const Project = () => {
if (loading) {
return (
<>
<GlobalTopNavbar onSaveProjectName={projectName => {}} name="" />
<GlobalTopNavbar onSaveProjectName={projectName => {}} name="" projectID={null} />
</>
);
}
@ -510,6 +510,7 @@ const Project = () => {
updateProjectName({ variables: { projectID, name: projectName } });
}}
projectMembers={data.findProject.members}
projectID={projectID}
name={data.findProject.name}
/>
<ProjectBar>

View File

@ -1,32 +1,183 @@
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';
import {
useCreateTeamMutation,
useGetProjectsQuery,
useCreateProjectMutation,
GetProjectsDocument,
} from 'shared/generated/graphql';
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';
import Button from 'shared/components/Button';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { useForm } from 'react-hook-form';
import Input from 'shared/components/Input';
const MainContent = styled.div`
padding: 0 0 50px 80px;
height: 100%;
background: #262c49;
const CreateTeamButton = styled(Button)`
width: 100%;
`;
type CreateTeamData = { teamName: string };
type CreateTeamFormProps = {
onCreateTeam: (teamName: string) => void;
};
const CreateTeamFormContainer = styled.form``;
const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => {
const { register, handleSubmit, errors } = useForm<CreateTeamData>();
const createTeam = (data: CreateTeamData) => {
onCreateTeam(data.teamName);
};
return (
<CreateTeamFormContainer onSubmit={handleSubmit(createTeam)}>
<Input
width="100%"
label="Team name"
id="teamName"
name="teamName"
variant="alternate"
ref={register({ required: 'Team name is required' })}
/>
<CreateTeamButton type="submit">Create</CreateTeamButton>
</CreateTeamFormContainer>
);
};
const ProjectAddTile = styled.div`
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.4);
background-size: cover;
background-position: 50%;
color: #fff;
line-height: 20px;
padding: 8px;
position: relative;
text-decoration: none;
border-radius: 3px;
display: block;
`;
const ProjectTile = styled(Link)<{ color: string }>`
background-color: ${props => props.color};
background-size: cover;
background-position: 50%;
color: #fff;
line-height: 20px;
padding: 8px;
position: relative;
text-decoration: none;
border-radius: 3px;
display: block;
`;
const ProjectTileFade = styled.div`
background-color: rgba(0, 0, 0, 0.15);
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
`;
const ProjectListItem = styled.li`
width: 23.5%;
padding: 0;
margin: 0 2% 2% 0;
box-sizing: border-box;
position: relative;
cursor: pointer;
&:hover ${ProjectTileFade} {
background-color: rgba(0, 0, 0, 0.25);
}
`;
const ProjectList = styled.ul`
display: flex;
flex-wrap: wrap;
& ${ProjectListItem}:nth-of-type(4n) {
margin-right: 0;
}
`;
const ProjectTileDetails = styled.div`
display: flex;
height: 80px;
position: relative;
flex-direction: column;
justify-content: space-between;
`;
const ProjectAddTileDetails = styled.div`
display: flex;
height: 80px;
position: relative;
flex-direction: column;
align-items: center;
justify-content: center;
`;
const ProjectTileName = styled.div<{ centered?: boolean }>`
flex: 0 0 auto;
font-size: 16px;
font-weight: 700;
display: inline-block;
overflow: hidden;
max-height: 40px;
width: 100%;
word-wrap: break-word;
${props => props.centered && 'text-align: center;'}
`;
const Wrapper = styled.div`
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: center;
`;
const ProjectSectionTitleWrapper = styled.div`
align-items: center;
display: flex;
height: 32px;
margin-bottom: 24px;
padding: 8px 0;
position: relative;
`;
const ProjectSectionTitle = styled.h3`
font-size: 16px;
color: rgba(${props => props.theme.colors.text.primary});
`;
const ProjectsContainer = styled.div`
margin: 40px 16px 0;
width: 100%;
max-width: 825px;
min-width: 288px;
`;
const ProjectGrid = styled.div`
width: 60%;
max-width: 780px;
margin: 25px auto;
display: grid;
grid-template-columns: 240px 240px 240px;
gap: 20px 10px;
`;
const AddTeamButton = styled(Button)`
padding: 6px 12px;
float: right;
`;
const ProjectLink = styled(Link)``;
const Projects = () => {
const { showPopup } = usePopup();
const { loading, data } = useGetProjectsQuery();
useEffect(() => {
document.title = 'Citadel';
@ -53,6 +204,7 @@ const Projects = () => {
});
const [showNewProject, setShowNewProject] = useState(false);
const { userID, setUserID } = useContext(UserIDContext);
const [createTeam] = useCreateTeamMutation();
if (loading) {
return (
<>
@ -60,25 +212,75 @@ const Projects = () => {
</>
);
}
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'];
if (data) {
const { projects, teams } = data;
const { projects, teams, organizations } = data;
const organizationID = organizations[0].id ?? null;
const projectTeams = teams.map(team => {
return {
id: team.id,
name: team.name,
projects: projects.filter(project => project.team.id === team.id),
};
});
return (
<>
<GlobalTopNavbar onSaveProjectName={() => {}} name={null} />
<ProjectGrid>
{projects.map(project => (
<ProjectLink key={project.id} to={`/projects/${project.id}`}>
<ProjectGridItem
project={{ ...project, projectID: project.id, teamTitle: project.team.name, taskGroups: [] }}
/>
</ProjectLink>
))}
<AddProjectItem
onAddProject={() => {
setShowNewProject(true);
<GlobalTopNavbar onSaveProjectName={() => {}} projectID={null} name={null} />
<Wrapper>
<ProjectsContainer>
<AddTeamButton
variant="outline"
onClick={$target => {
showPopup(
$target,
<Popup title="Create team" tab={0}>
<CreateTeamForm
onCreateTeam={teamName => {
if (organizationID) {
createTeam({ variables: { name: teamName, organizationID } });
}
}}
/>
</ProjectGrid>
</Popup>,
);
}}
>
Add Team
</AddTeamButton>
{projectTeams.map(team => {
return (
<div key={team.id}>
<ProjectSectionTitleWrapper>
<ProjectSectionTitle>{team.name}</ProjectSectionTitle>
</ProjectSectionTitleWrapper>
<ProjectList>
{team.projects.map((project, idx) => (
<ProjectListItem key={project.id}>
<ProjectTile color={colors[idx % 5]} to={`/projects/${project.id}`}>
<ProjectTileFade />
<ProjectTileDetails>
<ProjectTileName>{project.name}</ProjectTileName>
</ProjectTileDetails>
</ProjectTile>
</ProjectListItem>
))}
<ProjectListItem>
<ProjectAddTile
onClick={() => {
setShowNewProject(true);
}}
>
<ProjectTileFade />
<ProjectAddTileDetails>
<ProjectTileName centered>Create new project</ProjectTileName>
</ProjectAddTileDetails>
</ProjectAddTile>
</ProjectListItem>
</ProjectList>
</div>
);
})}
{showNewProject && (
<NewProject
onCreateProject={(name, teamID) => {
@ -93,6 +295,8 @@ const Projects = () => {
teams={teams}
/>
)}
</ProjectsContainer>
</Wrapper>
</>
);
}

View File

@ -11,7 +11,7 @@ const InputWrapper = styled.div<{ width: string }>`
justify-content: center;
margin-bottom: 2.2rem;
margin-top: 17px;
margin-top: 24px;
`;
const InputLabel = styled.span<{ width: string }>`

View File

@ -1,4 +1,5 @@
import styled from 'styled-components';
import Button from 'shared/components/Button';
export const Wrapper = styled.div`
background: #eff2f7;
@ -70,21 +71,7 @@ export const FormError = styled.span`
color: rgb(234, 84, 85);
`;
export const LoginButton = styled.input`
padding: 0.75rem 2rem;
font-size: 1rem;
border-radius: 6px;
background: var(--color-button-background);
outline: none;
border: none;
cursor: pointer;
color: var(--color-button-text-hover);
&:disabled {
opacity: 0.5;
cursor: default;
pointer-events: none;
}
`;
export const LoginButton = styled(Button)``;
export const ActionButtons = styled.div`
margin-top: 17.5px;
@ -92,15 +79,7 @@ export const ActionButtons = styled.div`
justify-content: space-between;
`;
export const RegisterButton = styled.button`
padding: 0.679rem 2rem;
border-radius: 6px;
border: 1px solid rgb(115, 103, 240);
background: transparent;
font-size: 1rem;
color: var(--color-primary);
cursor: pointer;
`;
export const RegisterButton = styled(Button)``;
export const LogoTitle = styled.div`
font-size: 24px;

View File

@ -72,8 +72,10 @@ const Login = ({ onSubmit }: LoginProps) => {
{errors.password && <FormError>{errors.password.message}</FormError>}
<ActionButtons>
<RegisterButton>Register</RegisterButton>
<LoginButton type="submit" value="Login" disabled={!isComplete} />
<RegisterButton variant="outline">Register</RegisterButton>
<LoginButton type="submit" disabled={!isComplete}>
Login
</LoginButton>
</ActionButtons>
</Form>
</LoginFormContainer>

View File

@ -16,7 +16,7 @@ import {
type PopupContextState = {
show: (target: RefObject<HTMLElement>, content: JSX.Element, width?: string | number) => void;
setTab: (newTab: number) => void;
setTab: (newTab: number, width?: number | string) => void;
getCurrentTab: () => number;
hide: () => void;
};
@ -139,12 +139,14 @@ export const PopupProvider: React.FC = ({ children }) => {
};
const portalTarget = canUseDOM ? document.body : null; // appease flow
const setTab = (newTab: number) => {
const setTab = (newTab: number, width?: number | string) => {
let newWidth = width ?? currentState.width;
setState((prevState: PopupState) => {
return {
...prevState,
previousTab: currentState.currentTab,
currentTab: newTab,
width: newWidth,
};
});
};

View File

@ -1,6 +1,7 @@
import React from 'react';
import styled from 'styled-components';
import Button from 'shared/components/Button';
export const ListActionsWrapper = styled.ul`
list-style-type: none;
@ -36,16 +37,64 @@ export const ListSeparator = styled.hr`
width: 100%;
`;
type Props = {};
const ProjectSettings: React.FC<Props> = () => {
type Props = {
onDeleteProject: () => void;
};
const ProjectSettings: React.FC<Props> = ({ onDeleteProject }) => {
return (
<>
<ListActionsWrapper>
<ListActionItemWrapper onClick={() => {}}>
<ListActionItemWrapper onClick={() => onDeleteProject()}>
<ListActionItem>Delete Project</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
</>
);
};
const ConfirmWrapper = styled.div``;
const ConfirmSubTitle = styled.h3`
font-size: 14px;
`;
const ConfirmDescription = styled.div`
font-size: 14px;
`;
const DeleteList = styled.ul`
margin-bottom: 12px;
`;
const DeleteListItem = styled.li`
padding: 6px 0;
list-style: disc;
margin-left: 12px;
`;
const ConfirmDeleteButton = styled(Button)`
width: 100%;
padding: 6px 12px;
`;
type DeleteProjectProps = {
name: string;
onDeleteProject: () => void;
};
const DeleteProject: React.FC<DeleteProjectProps> = ({ name, onDeleteProject }) => {
return (
<ConfirmWrapper>
<ConfirmDescription>
Deleting the project will also delete the following:
<DeleteList>
<DeleteListItem>Task groups and tasks</DeleteListItem>
</DeleteList>
</ConfirmDescription>
<ConfirmDeleteButton onClick={() => onDeleteProject()} color="danger">
Delete
</ConfirmDeleteButton>
</ConfirmWrapper>
);
};
export { DeleteProject };
export default ProjectSettings;

View File

@ -78,6 +78,7 @@ export type Team = {
id: Scalars['ID'];
createdAt: Scalars['Time'];
name: Scalars['String'];
members: Array<ProjectMember>;
};
export type Project = {
@ -145,8 +146,15 @@ export type FindTask = {
taskID: Scalars['UUID'];
};
export type Organization = {
__typename?: 'Organization';
id: Scalars['ID'];
name: Scalars['String'];
};
export type Query = {
__typename?: 'Query';
organizations: Array<Organization>;
users: Array<UserAccount>;
findUser: UserAccount;
findProject: Project;
@ -192,7 +200,7 @@ export type NewUserAccount = {
export type NewTeam = {
name: Scalars['String'];
organizationID: Scalars['String'];
organizationID: Scalars['UUID'];
};
export type NewProject = {
@ -390,13 +398,36 @@ export type UpdateTaskChecklistItemName = {
name: Scalars['String'];
};
export type CreateTeamMember = {
userID: Scalars['UUID'];
teamID: Scalars['UUID'];
};
export type CreateTeamMemberPayload = {
__typename?: 'CreateTeamMemberPayload';
team: Team;
teamMember: ProjectMember;
};
export type DeleteProject = {
projectID: Scalars['UUID'];
};
export type DeleteProjectPayload = {
__typename?: 'DeleteProjectPayload';
ok: Scalars['Boolean'];
project: Project;
};
export type Mutation = {
__typename?: 'Mutation';
createRefreshToken: RefreshToken;
createUserAccount: UserAccount;
createTeam: Team;
clearProfileAvatar: UserAccount;
createTeamMember: CreateTeamMemberPayload;
createProject: Project;
deleteProject: DeleteProjectPayload;
updateProjectName: Project;
createProjectLabel: ProjectLabel;
deleteProjectLabel: ProjectLabel;
@ -443,11 +474,21 @@ export type MutationCreateTeamArgs = {
};
export type MutationCreateTeamMemberArgs = {
input: CreateTeamMember;
};
export type MutationCreateProjectArgs = {
input: NewProject;
};
export type MutationDeleteProjectArgs = {
input: DeleteProject;
};
export type MutationUpdateProjectNameArgs = {
input?: Maybe<UpdateProjectName>;
};
@ -869,7 +910,10 @@ export type GetProjectsQueryVariables = {};
export type GetProjectsQuery = (
{ __typename?: 'Query' }
& { teams: Array<(
& { organizations: Array<(
{ __typename?: 'Organization' }
& Pick<Organization, 'id' | 'name'>
)>, teams: Array<(
{ __typename?: 'Team' }
& Pick<Team, 'id' | 'name' | 'createdAt'>
)>, projects: Array<(
@ -897,6 +941,23 @@ export type MeQuery = (
) }
);
export type DeleteProjectMutationVariables = {
projectID: Scalars['UUID'];
};
export type DeleteProjectMutation = (
{ __typename?: 'Mutation' }
& { deleteProject: (
{ __typename?: 'DeleteProjectPayload' }
& Pick<DeleteProjectPayload, 'ok'>
& { project: (
{ __typename?: 'Project' }
& Pick<Project, 'id'>
) }
) }
);
export type CreateTaskChecklistItemMutationVariables = {
taskChecklistID: Scalars['UUID'];
name: Scalars['String'];
@ -985,6 +1046,20 @@ export type UpdateTaskGroupNameMutation = (
) }
);
export type CreateTeamMutationVariables = {
name: Scalars['String'];
organizationID: Scalars['UUID'];
};
export type CreateTeamMutation = (
{ __typename?: 'Mutation' }
& { createTeam: (
{ __typename?: 'Team' }
& Pick<Team, 'id' | 'createdAt' | 'name'>
) }
);
export type ToggleTaskLabelMutationVariables = {
taskID: Scalars['UUID'];
projectLabelID: Scalars['UUID'];
@ -1690,6 +1765,10 @@ export type FindTaskLazyQueryHookResult = ReturnType<typeof useFindTaskLazyQuery
export type FindTaskQueryResult = ApolloReactCommon.QueryResult<FindTaskQuery, FindTaskQueryVariables>;
export const GetProjectsDocument = gql`
query getProjects {
organizations {
id
name
}
teams {
id
name
@ -1768,6 +1847,41 @@ export function useMeLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptio
export type MeQueryHookResult = ReturnType<typeof useMeQuery>;
export type MeLazyQueryHookResult = ReturnType<typeof useMeLazyQuery>;
export type MeQueryResult = ApolloReactCommon.QueryResult<MeQuery, MeQueryVariables>;
export const DeleteProjectDocument = gql`
mutation deleteProject($projectID: UUID!) {
deleteProject(input: {projectID: $projectID}) {
ok
project {
id
}
}
}
`;
export type DeleteProjectMutationFn = ApolloReactCommon.MutationFunction<DeleteProjectMutation, DeleteProjectMutationVariables>;
/**
* __useDeleteProjectMutation__
*
* To run a mutation, you first call `useDeleteProjectMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteProjectMutation` 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 [deleteProjectMutation, { data, loading, error }] = useDeleteProjectMutation({
* variables: {
* projectID: // value for 'projectID'
* },
* });
*/
export function useDeleteProjectMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<DeleteProjectMutation, DeleteProjectMutationVariables>) {
return ApolloReactHooks.useMutation<DeleteProjectMutation, DeleteProjectMutationVariables>(DeleteProjectDocument, baseOptions);
}
export type DeleteProjectMutationHookResult = ReturnType<typeof useDeleteProjectMutation>;
export type DeleteProjectMutationResult = ApolloReactCommon.MutationResult<DeleteProjectMutation>;
export type DeleteProjectMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteProjectMutation, DeleteProjectMutationVariables>;
export const CreateTaskChecklistItemDocument = gql`
mutation createTaskChecklistItem($taskChecklistID: UUID!, $name: String!, $position: Float!) {
createTaskChecklistItem(input: {taskChecklistID: $taskChecklistID, name: $name, position: $position}) {
@ -1980,6 +2094,41 @@ export function useUpdateTaskGroupNameMutation(baseOptions?: ApolloReactHooks.Mu
export type UpdateTaskGroupNameMutationHookResult = ReturnType<typeof useUpdateTaskGroupNameMutation>;
export type UpdateTaskGroupNameMutationResult = ApolloReactCommon.MutationResult<UpdateTaskGroupNameMutation>;
export type UpdateTaskGroupNameMutationOptions = ApolloReactCommon.BaseMutationOptions<UpdateTaskGroupNameMutation, UpdateTaskGroupNameMutationVariables>;
export const CreateTeamDocument = gql`
mutation createTeam($name: String!, $organizationID: UUID!) {
createTeam(input: {name: $name, organizationID: $organizationID}) {
id
createdAt
name
}
}
`;
export type CreateTeamMutationFn = ApolloReactCommon.MutationFunction<CreateTeamMutation, CreateTeamMutationVariables>;
/**
* __useCreateTeamMutation__
*
* To run a mutation, you first call `useCreateTeamMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateTeamMutation` 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 [createTeamMutation, { data, loading, error }] = useCreateTeamMutation({
* variables: {
* name: // value for 'name'
* organizationID: // value for 'organizationID'
* },
* });
*/
export function useCreateTeamMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<CreateTeamMutation, CreateTeamMutationVariables>) {
return ApolloReactHooks.useMutation<CreateTeamMutation, CreateTeamMutationVariables>(CreateTeamDocument, baseOptions);
}
export type CreateTeamMutationHookResult = ReturnType<typeof useCreateTeamMutation>;
export type CreateTeamMutationResult = ApolloReactCommon.MutationResult<CreateTeamMutation>;
export type CreateTeamMutationOptions = ApolloReactCommon.BaseMutationOptions<CreateTeamMutation, CreateTeamMutationVariables>;
export const ToggleTaskLabelDocument = gql`
mutation toggleTaskLabel($taskID: UUID!, $projectLabelID: UUID!) {
toggleTaskLabel(input: {taskID: $taskID, projectLabelID: $projectLabelID}) {

View File

@ -1,4 +1,8 @@
query getProjects {
organizations {
id
name
}
teams {
id
name

View File

@ -0,0 +1,14 @@
import gql from 'graphql-tag';
export const DELETE_PROJECT_MUTATION = gql`
mutation deleteProject($projectID: UUID!) {
deleteProject(input: { projectID: $projectID }) {
ok
project {
id
}
}
}
`;
export default DELETE_PROJECT_MUTATION;

View File

@ -0,0 +1,13 @@
import gql from 'graphql-tag';
export const CREATE_TEAM_MUTATION = gql`
mutation createTeam($name: String!, $organizationID: UUID!) {
createTeam(input: { name: $name, organizationID: $organizationID }) {
id
createdAt
name
}
}
`;
export default CREATE_TEAM_MUTATION;