Skip to content

Commit

Permalink
Add user settings and refactor user formatting
Browse files Browse the repository at this point in the history
This commit adds a new 'settings' field to user related GraphQL types, queries and mutations on both frontend and backend, while maintaining the same response shape by using a helper function. In addition, 'js-confetti' and 'react-canvas-confetti' were added to the frontend package.json and a new migrations file was created to update the user table in the database. Lastly, a small visual style adjustment was made on the avatar upload component.
  • Loading branch information
claygorman committed Jan 13, 2024
1 parent d3470a0 commit 436ca4c
Show file tree
Hide file tree
Showing 20 changed files with 230 additions and 94 deletions.
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
},
"scripts": {
"dev": "tsx watch src/index.ts",
"dev:migrate": "pnpm tsx src/umzug-runner.ts up",
"lint": "pnpm oxlint && pnpm eslint && tsc --noEmit",
"start": "node dist",
"format": "prettier --check --ignore-path ../.gitignore .",
Expand Down
3 changes: 3 additions & 0 deletions backend/src/__generated__/resolvers-types.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions backend/src/db/migrations/20240112143058-add-settings-to-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
'use strict';

const TABLE_NAME = 'users';
const COLUMN_NAME = 'settings';

/** @type {import('sequelize-cli').Migration} */
export default {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn(TABLE_NAME, COLUMN_NAME, {
type: Sequelize.JSONB,
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn(TABLE_NAME, COLUMN_NAME);
},
};
1 change: 1 addition & 0 deletions backend/src/db/models/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export class User extends Model<InferAttributes<User>, InferCreationAttributes<U
declare lastName: CreationOptional<string | null>;
declare email: CreationOptional<string | null>;
declare avatarAssetId: CreationOptional<number | null>;
declare settings: CreationOptional<object | object[] | null>; // JSONB

declare static associate?: Associate;

Expand Down
4 changes: 4 additions & 0 deletions backend/src/db/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export default (sequelize: Sequelize) =>
key: 'id',
},
},
settings: {
type: DataTypes.JSONB,
field: 'settings',
},
createdAt: {
type: DataTypes.DATE,
field: 'created_at',
Expand Down
11 changes: 3 additions & 8 deletions backend/src/resolvers/issue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '../../__generated__/resolvers-types.js';
import { Issue as IssueModel } from '../../db/models/types.js';
import { websocketBroadcast } from '../../services/ws-server.js';
import { formatUserForGraphql } from '../user/helpers';

const Query: QueryResolvers = {
issues: async (parent, { input: { projectId, id, search, searchOperator } }, { db, dataLoaderContext }) => {
Expand Down Expand Up @@ -404,10 +405,7 @@ const Issue: IssueResolvers = {

if (!reporterUser) return null;

return {
...reporterUser.toJSON(),
id: `${reporterUser.id}`,
};
return formatUserForGraphql(reporterUser);
},
assignee: async (parent, args, { db, dataLoaderContext, EXPECTED_OPTIONS_KEY }) => {
const databaseIssue = await db.Issue.findByPk(Number(parent.id), {
Expand All @@ -420,10 +418,7 @@ const Issue: IssueResolvers = {
[EXPECTED_OPTIONS_KEY]: dataLoaderContext,
});

return {
...user.toJSON(),
id: `${user.id}`,
};
return formatUserForGraphql(user);
},
project: async (parent, __, { db, dataLoaderContext, EXPECTED_OPTIONS_KEY }) => {
if (parent.project) return parent.project;
Expand Down
5 changes: 2 additions & 3 deletions backend/src/resolvers/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type Resolvers,
type ViewState,
} from '../../__generated__/resolvers-types.js';
import { formatUserForGraphql } from '../user/helpers';

const Query: QueryResolvers = {
projects: async (parent, args, { db, dataLoaderContext }) => {
Expand Down Expand Up @@ -74,9 +75,7 @@ const Query: QueryResolvers = {
id: `${databaseProject.id}`,
visibility: databaseProject.visibility as ProjectVisibility,
boards: undefined,
users: databaseProject.users
? databaseProject.users.map((user) => ({ ...user.toJSON(), id: `${user.id}` }))
: undefined,
users: databaseProject.users ? databaseProject.users.map(formatUserForGraphql) : undefined,
};
},
projectTags: async (parent, { input: { projectId, id, name } }, { db, dataLoaderContext }) => {
Expand Down
9 changes: 9 additions & 0 deletions backend/src/resolvers/user/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { User } from '../../db/models/types.js';

export const formatUserForGraphql = (user: User) => {
return {
...user.toJSON(),
id: `${user.id}`,
settings: user.settings ? JSON.stringify(user.settings) : null,
};
};
26 changes: 14 additions & 12 deletions backend/src/resolvers/user/index.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
import { isUndefined } from 'lodash-es';

import type { MutationResolvers, QueryResolvers, UserResolvers } from '../../__generated__/resolvers-types.js';
import { Asset as AssetModel, User as UserModel } from '../../db/models/types.js';
import { Asset as AssetModel } from '../../db/models/types.js';
import { BUCKET_NAME } from '../../services/config.js';
import { minioClient } from '../../services/minio-client.js';

const formatUserResponse = (user: UserModel) => ({
...user.toJSON(),
id: `${user.id}`,
});
import { formatUserForGraphql } from './helpers.js';

const Query: QueryResolvers = {
users: async (parent, args, { db, dataLoaderContext }) => {
const dbUsers = await db.User.findAll();

dataLoaderContext.prime(dbUsers);

return dbUsers.map(formatUserResponse);
return dbUsers.map(formatUserForGraphql);
},
user: (parent, __, { db }) => {
// if (externalId) {
Expand All @@ -33,24 +29,30 @@ const Query: QueryResolvers = {
[EXPECTED_OPTIONS_KEY]: dataLoaderContext,
});

return formatUserResponse(dbUser);
return formatUserForGraphql(dbUser);
},
};

const Mutation: MutationResolvers = {
updateMe: async (parent, { input }, { db, user, dataLoaderContext }) => {
const { firstName, lastName } = input;
// We reload here in cases where the cached user is out of date from fastify auth cache
await user.reload();

const { firstName, lastName, settings } = input;
if (!user.id) throw new Error('User not found');

if (!isUndefined(firstName)) user.firstName = firstName;
if (!isUndefined(lastName)) user.lastName = lastName;
if (!isUndefined(settings)) user.settings = JSON.parse(settings);

await user.save();

dataLoaderContext.prime(user);

return formatUserResponse(user);
return formatUserForGraphql(user);
},
assignAssetAsAvatar: async (parent, { input }, { db, user, dataLoaderContext, EXPECTED_OPTIONS_KEY }) => {
// We reload here in cases where the cached user is out of date from fastify auth cache
await user.reload();

const { assetId } = input;
let findOldAvatarAsset: AssetModel;

Expand Down
5 changes: 3 additions & 2 deletions backend/src/server/fastify-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { GraphQLError } from 'graphql';
import processRequest from 'graphql-upload/processRequest.mjs';

import db from '../db/index.js';
import { User as UserModel } from '../db/models/types.js';
import { cache } from '../services/cache.js';
import {
ALLOW_LOGIN_DOMAINS_LIST,
Expand All @@ -31,10 +32,10 @@ const addUserToRequest = async (request: CustomFastifyRequest, reply: FastifyRep

const cacheKey = hash(token);

const cachedUser = cache.get(cacheKey);
const cachedUser = <UserModel>cache.get(cacheKey);

if (cachedUser) {
request.user = db.User.build(cachedUser);
request.user = cachedUser;
return;
}

Expand Down
2 changes: 2 additions & 0 deletions backend/src/type-defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const typeDefs = gql`
name: String
email: String
avatarUrl: String
settings: String
}
type ProjectTag {
Expand Down Expand Up @@ -339,6 +340,7 @@ const typeDefs = gql`
input UpdateMeInput {
firstName: String
lastName: String
settings: String
}
input CreateIssueLinkInput {
Expand Down
42 changes: 11 additions & 31 deletions frontend/app/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
'use client';

import React from 'react';
import { useMutation } from '@apollo/client';
import React from 'react';

import { AvatarUploadComponent } from '@/components/Avatar';
import { UserInfo } from '@/components/Profile/UserInfo';
import {
ASSIGN_ASSET_AS_AVATAR_MUTATION,
UPLOAD_ASSET_MUTATION,
} from '@/gql/gql-queries-mutations';
import { UserInfo } from '@/components/Profile/UserInfo';
import { AvatarUploadComponent } from '@/components/Avatar';
import { base64toBlob } from '@/services/utils';

export default function Profile() {
Expand Down Expand Up @@ -68,50 +69,29 @@ export default function Profile() {
<div className='md:flex md:items-center md:justify-between'>
<div className='space-y-12'>
<div className='border-b border-gray-900/10 pb-12'>
<div className='col-span-1 col-start-1 text-3xl font-extrabold text-black dark:text-white'>
<div className='col-span-1 col-start-1 text-3xl font-extrabold text-primary'>
Profile
</div>
<p className='mt-1 text-sm leading-6 text-gray-600'>
<p className='mt-1 text-sm leading-6 text-primary/60'>
This information will be displayed publicly so be careful what you
share.
</p>

<div className='mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6'>
<div className='sm:col-span-4'>
<label
htmlFor='username'
className='block text-sm font-medium leading-6 text-gray-900'
>
Username
</label>
<div className='mt-2'>
<div className='flex rounded-md shadow-sm ring-1 ring-inset ring-gray-300 focus-within:ring-2 focus-within:ring-inset focus-within:ring-indigo-600 sm:max-w-md'>
<span className='flex select-none items-center pl-3 text-gray-500 sm:text-sm'>
user/
</span>
<input
type='text'
name='username'
id='username'
autoComplete='username'
className='block flex-1 border-0 bg-transparent py-1.5 pl-1 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6'
placeholder='janesmith'
/>
</div>
</div>
</div>

<div className='col-span-full'>
<h2 className='text-primary font-semibold leading-7 pb-4'>
Avatar
</h2>
<AvatarUploadComponent onAvatarSave={onAvatarSave} />
</div>
</div>
</div>

<div className='border-b border-gray-900/10 pb-12'>
<h2 className='text-base font-semibold leading-7 text-gray-900'>
<h2 className='text-primary font-semibold leading-7'>
Personal Information
</h2>
<p className='mt-1 text-sm leading-6 text-gray-600'>
<p className='mt-1 text-sm leading-6 text-primary/60'>
Use a permanent address where you can receive mail.
</p>

Expand Down
9 changes: 6 additions & 3 deletions frontend/components/Avatar/UploadComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import React, { useState } from 'react';
import dynamic from 'next/dynamic';
const Avatar = dynamic(() => import('react-avatar-edit'), { ssr: false });
import React, { useState } from 'react';

import { Button } from '@/components/Button';

const Avatar = dynamic(() => import('react-avatar-edit'), { ssr: false });

export const AvatarUploadComponent = ({
onAvatarSave,
}: {
Expand All @@ -24,14 +26,15 @@ export const AvatarUploadComponent = ({
}

return (
<div className='flex flex-row space-x-1'>
<div className='flex flex-row space-x-1 text-primary hover:cursor-pointer'>
<Avatar
width={600}
height={300}
onCrop={onCrop}
onClose={onClose}
onBeforeFileLoad={onBeforeFileLoad}
src={undefined}
labelStyle={{ color: undefined }}
/>
<br />
{preview && (
Expand Down
Loading

0 comments on commit 436ca4c

Please sign in to comment.