Skip to content

Commit

Permalink
Add project settings page and user management feature (#17)
Browse files Browse the repository at this point in the history
This commit introduces a project settings page in the frontend. In addition, it also adds the ability to add or remove users from a project in the backend. This includes accompanying GraphQL query and mutation setup. Now, users can be associated with a project and be managed via the settings page.
  • Loading branch information
claygorman authored Dec 24, 2023
1 parent 7aaa644 commit d608649
Show file tree
Hide file tree
Showing 19 changed files with 681 additions and 45 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use strict';

const TABLE_NAME = 'project_permissions';

/** @type {import('sequelize-cli').Migration} */
export default {
async up(queryInterface, Sequelize) {
await queryInterface.createTable(TABLE_NAME, {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER,
},
userId: {
type: Sequelize.INTEGER,
field: 'user_id',
references: {
model: {
tableName: 'users',
schema: 'public',
},
key: 'id',
},
},
projectId: {
type: Sequelize.INTEGER,
field: 'project_id',
references: {
model: {
tableName: 'projects',
schema: 'public',
},
key: 'id',
},
},
});

await queryInterface.addIndex(TABLE_NAME, {
fields: ['project_id', 'user_id'],
unique: true,
});
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable(TABLE_NAME);
},
};
2 changes: 2 additions & 0 deletions backend/src/db/models/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import ProjectTag from './project-tag.js';
import IssueTag from './issue-tag.js';
import IssueBoard from './issue-board.js';
import IssueLinks from './issue-links.js';
import ProjectPermissions from './project-permissions.js';

export const db = {};

Expand All @@ -33,6 +34,7 @@ const init = async () => {
db.IssueTag = IssueTag(db.sequelize, DataTypes);
db.IssueBoard = IssueBoard(db.sequelize, DataTypes);
db.IssueLinks = IssueLinks(db.sequelize, DataTypes);
db.ProjectPermissions = ProjectPermissions(db.sequelize, DataTypes);

Object.values(db).forEach((model) => {
if (model.associate) {
Expand Down
39 changes: 39 additions & 0 deletions backend/src/db/models/project-permissions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use strict';

export default (sequelize, DataTypes) => {
const ProjectPermission = sequelize.define(
'ProjectPermission',
{
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER,
},
projectId: {
type: DataTypes.INTEGER,
field: 'project_id',
references: {
model: 'projects',
key: 'id',
},
},
userId: {
type: DataTypes.INTEGER,
field: 'user_id',
references: {
model: 'users',
key: 'id',
},
},
},
{
sequelize,
tableName: 'project_permissions',
timestamps: false,
indexes: [{ unique: true, fields: ['project_id', 'user_id'] }],
}
);

return ProjectPermission;
};
10 changes: 9 additions & 1 deletion backend/src/db/models/project.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
'use strict';
import ProjectPermissions from './project-permissions.js';

export default (sequelize, DataTypes) => {
const Project = sequelize.define(
'Project',
Expand Down Expand Up @@ -52,8 +54,14 @@ export default (sequelize, DataTypes) => {
}
);

Project.associate = ({ ProjectTag }) => {
Project.associate = ({ ProjectTag, Users, ProjectPermissions }) => {
Project.hasMany(ProjectTag, { foreignKey: 'project_id' });
Project.belongsToMany(Users, {
through: ProjectPermissions,
foreignKey: 'project_id',
otherKey: 'user_id',
as: 'users',
});
};

return Project;
Expand Down
61 changes: 59 additions & 2 deletions backend/src/resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,43 @@ import { emitBoardUpdatedEvent, emitIssueUpdatedEvent } from './socket/events.js
const resolvers = {
Upload: GraphQLUpload,
Mutation: {
addUserToProject: async (parent, { input: { userId, projectId } }, { db }) => {
const existingPermission = await db.sequelize.models.ProjectPermission.findOne({
where: { userId, projectId },
});

if (existingPermission) {
return { message: 'User added to project', status: 'success' };
}

// Add the user to the project
await db.sequelize.models.ProjectPermission.create({
userId,
projectId,
});

return { message: 'User added to project', status: 'success' };
},
removeUserFromProject: async (parent, { input: { userId, projectId } }, { db, user }) => {
if (user.id === userId) {
throw new Error('You cannot remove yourself from the project');
}

const existingPermission = await db.sequelize.models.ProjectPermission.findOne({
where: { userId, projectId },
});

if (!existingPermission) {
throw new Error('User is not added to the project');
}

// Remove the user from the project
await db.sequelize.models.ProjectPermission.destroy({
where: { userId, projectId },
});

return { message: 'User removed from project', status: 'success' };
},
deleteIssueLink: async (parent, { input: { issueId, linkType, linkedIssueId } }, { db }) => {
await db.sequelize.models.IssueLinks.destroy({
where: {
Expand Down Expand Up @@ -319,6 +356,11 @@ const resolvers = {
visibility: input.visibility ?? 'INTERNAL',
});

await db.sequelize.models.ProjectPermission.create({
userId: input.userId,
projectId: project.id,
});

const projectId = Number(project.id);

// TODO: We should create mappings for these statuses
Expand Down Expand Up @@ -363,6 +405,7 @@ const resolvers = {
},
},
User: {
name: (parent) => `${parent?.firstName} ${parent?.lastName}`,
avatarUrl: async (parent, args, { db }) => {
const findAvatarAsset = await db.sequelize.models.Asset.findByPk(parent.avatarAssetId);

Expand Down Expand Up @@ -421,7 +464,14 @@ const resolvers = {
}
},
projects: (parent, args, { db }) => {
return db.sequelize.models.Project.findAll();
return db.sequelize.models.Project.findAll({
include: [
{
model: db.sequelize.models.User,
as: 'users',
},
],
});
},
createProjectValidation: async (parent, { input }, { db }) => {
const { name, key } = input;
Expand Down Expand Up @@ -449,7 +499,14 @@ const resolvers = {
},
project: (parent, args, { db }) => {
const { id } = args?.input;
return db.sequelize.models.Project.findByPk(id);
return db.sequelize.models.Project.findByPk(id, {
include: [
{
model: db.sequelize.models.User,
as: 'users',
},
],
});
},
projectTags: (parent, { input: { projectId, id, name } }, { db }) => {
const where = { projectId };
Expand Down
15 changes: 15 additions & 0 deletions backend/src/type-defs.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const typeDefs = gql`
externalId: String
firstName: String
lastName: String
name: String
email: String
avatarUrl: String
}
Expand All @@ -74,6 +75,7 @@ const typeDefs = gql`
issueStatuses: [IssueStatus]
issues(input: QueryIssueInput): [Issue]
tags: [ProjectTag]
users: [User]
issueCount: Int
}
Expand Down Expand Up @@ -306,10 +308,23 @@ const typeDefs = gql`
linkedIssueId: String!
}
input AddUserToProjectInput {
userId: String!
projectId: String!
}
input RemoveUserFromProjectInput {
userId: String!
projectId: String!
}
# Mutations
type Mutation {
createProject(input: CreateProjectInput): Project
addUserToProject(input: AddUserToProjectInput!): MessageAndStatus
removeUserFromProject(input: RemoveUserFromProjectInput!): MessageAndStatus
createProjectTag(input: CreateProjectTagInput!): ProjectTag
deleteProjectTag(input: DeleteProjectTagInput!): MessageAndStatus
Expand Down
39 changes: 39 additions & 0 deletions frontend/app/projects/[projectId]/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use client';
import React from 'react';
import Breadcrumb from '@/components/Breadcrumbs/Breadcrumb';
import ProjectSettings from '@/components/ProjectSettings';

const ProjectSettingsPage = ({ params }: { params: { projectId: string } }) => {
const { projectId } = params;

return (
<>
<Breadcrumb
paths={[
{
name: `Project ${projectId}`,
href: `/projects/${projectId}`,
current: false,
},
{
name: 'Settings',
href: `/projects/${projectId}/settings`,
current: true,
},
]}
/>

<div className='w-65 md:flex md:items-center md:justify-between'>
<div className='min-w-0 flex-1'>
<h2 className='text-2xl font-bold leading-7 sm:truncate sm:text-3xl sm:tracking-tight'>
Project {projectId} Settings
</h2>
</div>
</div>

<ProjectSettings projectId={projectId} />
</>
);
};

export default ProjectSettingsPage;
2 changes: 1 addition & 1 deletion frontend/components/Avatar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
// import Image from 'next/image';
import { classNames, formatUser } from '@/services/utils';
import { User } from '@/constants/types';
import { User } from '@/gql/__generated__/graphql';

const DefaultAvatar = ({ className }: { className?: string }) => (
<span
Expand Down
22 changes: 14 additions & 8 deletions frontend/components/ProjectList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Fragment, useState } from 'react';
import React, { Fragment, useState } from 'react';
import { Menu, Transition } from '@headlessui/react';

import { EllipsisHorizontalIcon } from '@heroicons/react/24/solid';
Expand All @@ -10,6 +10,7 @@ import { useRouter } from 'next/navigation';
import { useQuery } from '@apollo/client';
import { GET_PROJECTS } from '@/gql/gql-queries-mutations';
import { GetProjectsQuery, Project } from '@/gql/__generated__/graphql';
import Link from 'next/link';

const cols = [
'ID',
Expand All @@ -22,7 +23,7 @@ const cols = [
];

// TODO: Make this its own component?
const ProjectOptionsDropdown = () => {
const ProjectOptionsDropdown = ({ projectId }: { projectId: string }) => {
return (
<Menu as='div' className='relative inline-block text-left'>
<div>
Expand All @@ -48,12 +49,12 @@ const ProjectOptionsDropdown = () => {
<div className='py-1'>
<Menu.Item>
{({ active }) => (
<a
href='#'
<Link
href={`/projects/${projectId}/settings`}
className={'block px-4 py-2 text-sm hover:bg-neutral-pressed'}
>
Project settings
</a>
</Link>
)}
</Menu.Item>
<Menu.Item>
Expand Down Expand Up @@ -189,8 +190,13 @@ const ProjectList = () => {
{itemsOnPage?.map((project) => (
<tr
onClick={(e) => {
// @ts-ignore
if (e?.target?.id !== 'ProjectOptionsDropdown') {
// TODO: lets better handle this
const target = e?.target as HTMLAnchorElement;

if (
target?.id !== 'ProjectOptionsDropdown' &&
(!target?.href || target?.href.trim() === '')
) {
push(
`/projects/${project.id}/boards/${project.boards?.[0]?.id}`
);
Expand Down Expand Up @@ -220,7 +226,7 @@ const ProjectList = () => {
{project?.description}
</td>
<td className='whitespace-nowrap px-3 py-1'>
<ProjectOptionsDropdown />
<ProjectOptionsDropdown projectId={`${project.id}`} />
</td>
</tr>
))}
Expand Down
Loading

0 comments on commit d608649

Please sign in to comment.