Skip to content

Commit

Permalink
29-feature/swearjar/[id]/view-page (#31)
Browse files Browse the repository at this point in the history
* feat(rest pkg): Create `GetUserIdFromCookie` method and implemented in `GetSwearJarsByUserId` method

* feat(backend): Add functionality to get a Swear Jar by it's ID via GET `/swearjar`, provided a query parameter is provided

- rest pkg: Update `swearjar` route and add `GetSwearJarById` method in handler
- swearjar pkg: Update Service and Repository interfaces to include `GetSwearJarById` method
- mongodb pkg: Implement `GetSwearJarById` method in MongoRepository

* feat(api): Update GET `api/swearJar` endpoint to handle requests dynamically based on the presence of a `swearJarId` query parameter

- Previously, the endpoint did not check for an `id` query parameter.
- Now, if an `id` is provided, the endpoint calls `/swearjar?id={id}` to retrieve a specific swear jar
- If no `id` is provided, it calls `/swearjar` to retrieve swear jars by user ID
- Corresponding backend logic differentiates handling based on the presence of `swearJarId`

* refactor: Update endpoint used to get list of swearjars

* style: Update style of Button's outline variant

* chore: Install shadcn charts and move shadcn components to `ui/shadcn`

* feat/fix: Add patch-package and postinstall-postinstall to apply a patch-fix to recharts resolve disappearing grid line issue

* refactor: Update ErrorAlert to accept className prop

* feat: Create SwearJarProp type and update `swearjar/list` to use it

* feat: Update chart colours in globals.css

* feat: Prefetch and render SwearJarData on `swearjar/[id]/view` page

- Added error handling with `ErrorAlert` to display messages when data fetching fails

* feat: Create SwearJarInfo and SwearJarTrends components

* feat: Implement grid layout for `swearjar/[id]/view` mainContent and render BreadcrumbHeader, SwearJarTrends, and SwearJarInfo components

* fix: Update POST endpoint to new `/api/swearjar`

* feat: Add hoverOnlyWhenSupported to resolve sticky-hover issue

* style: Add new "plain" variant to shadcn's button and update SwearJarInfo and SwearJarTrends buttons

* style: Add mb-7 to swearjar/layout

* style: Re-order components on `/swearjar/[id]/view` page

* feat: Create SwearJarRecent component and update `swearjar/[id]/view` page to include it

* style: add active state for SwearJarCard

* feat: Update loading state for `swearjar/[id]/view`
  • Loading branch information
MikeyTheOng authored Sep 8, 2024
1 parent 183a656 commit 995f8df
Show file tree
Hide file tree
Showing 33 changed files with 1,327 additions and 71 deletions.
19 changes: 19 additions & 0 deletions backend/pkg/database/mongodb/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ func (r *MongoRepository) GetSwearJarsByUserId(userId string) ([]swearJar.SwearJ
return swearJars, nil
}

func (r *MongoRepository) GetSwearJarById(swearJarId string) (swearJar.SwearJar, error) {
swearJarIdHex, err := primitive.ObjectIDFromHex(swearJarId)
if err != nil {
return swearJar.SwearJar{}, fmt.Errorf("invalid SwearJarId: %v", err)
}

var sj swearJar.SwearJar
filter := bson.M{"_id": swearJarIdHex}
err = r.swearJars.FindOne(context.TODO(), filter).Decode(&sj)
if err != nil {
if err == mongo.ErrNoDocuments {
return swearJar.SwearJar{}, fmt.Errorf("swear jar not found: %v", err)
}
return swearJar.SwearJar{}, fmt.Errorf("error fetching swear jar: %v", err)
}

return sj, nil
}

func (r *MongoRepository) CreateSwearJar(sj swearJar.SwearJar) (swearJar.SwearJar, error) {
// Convert []string to []primitive.ObjectID in one go
ownerIDs := make([]primitive.ObjectID, len(sj.Owners))
Expand Down
43 changes: 31 additions & 12 deletions backend/pkg/http/rest/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,16 @@ func (h *Handler) RegisterRoutes() http.Handler {
}
})

// Wrap the /swear route with the ProtectedRouteMiddleware middleware
// Wrap the /swearjar route with the ProtectedRouteMiddleware middleware
mux.Handle("/swearjar", ProtectedRouteMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.GetSwearJarsByUserId(w, r)
swearJarId := r.URL.Query().Get("id")
if swearJarId == "" {
h.GetSwearJarsByUserId(w, r)
} else {
h.GetSwearJarById(w, r)
}
case http.MethodPost:
h.CreateSwearJar(w, r)
default:
Expand Down Expand Up @@ -266,34 +271,48 @@ func (h *Handler) GetTopClosestEmails(w http.ResponseWriter, r *http.Request) {
}

func (h *Handler) GetSwearJarsByUserId(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("jwt")
userId, err := GetUserIdFromCookie(w, r)
if err != nil {
RespondWithError(w, http.StatusUnauthorized, "Unauthorized")
return
}

claims, err := authentication.DecodeJWT(cookie.Value)
swearJars, err := h.sjService.GetSwearJarsByUserId(userId)
if err != nil {
log.Printf("Error decoding JWT: %v", err)
RespondWithError(w, http.StatusUnauthorized, "Error decoding JWT")
RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}

userId, ok := claims["UserId"].(string)
if !ok {
RespondWithError(w, http.StatusUnauthorized, "UserId not found in token")
response := map[string]interface{}{
"msg": "fetch successful",
"swearJars": swearJars,
}

w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(response)
if err != nil {
RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}
}

swearJars, err := h.sjService.GetSwearJarsByUserId(userId)
func (h *Handler) GetSwearJarById(w http.ResponseWriter, r *http.Request) {
swearJarId := r.URL.Query().Get("id")
userId, err := GetUserIdFromCookie(w, r)
if err != nil {
RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}

swearJar, err := h.sjService.GetSwearJarById(swearJarId, userId)
if err != nil {
RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}

response := map[string]interface{}{
"msg": "fetch successful",
"swearJars": swearJars,
"msg": "fetch successful",
"swearJar": swearJar,
}

w.WriteHeader(http.StatusOK)
Expand Down
21 changes: 21 additions & 0 deletions backend/pkg/http/rest/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"os"
"strconv"
"time"

"github.com/mikeytheong/swearjar/backend/pkg/authentication"
)

// SetCookie sets a cookie with the provided name and value.
Expand Down Expand Up @@ -35,3 +37,22 @@ func RespondWithError(w http.ResponseWriter, statusCode int, message string) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

func GetUserIdFromCookie(w http.ResponseWriter, r *http.Request) (string, error) {
cookie, err := r.Cookie("jwt")
if err != nil {
return "", err
}
claims, err := authentication.DecodeJWT(cookie.Value)
if err != nil {
log.Printf("Error decoding JWT: %v", err)
return "", err
}

userId, ok := claims["UserId"].(string)
if !ok {
return "", err
}

return userId, nil
}
23 changes: 23 additions & 0 deletions backend/pkg/swearJar/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import (

type Service interface {
GetSwearJarsByUserId(userId string) ([]SwearJar, error)
GetSwearJarById(swearJarId string, userId string) (SwearJar, error)
CreateSwearJar(Name string, Desc string, Owners []string) (SwearJar, error)
AddSwear(Swear) error
// TODO: GetSwears() []Swear
}

type Repository interface {
GetSwearJarsByUserId(swearJarId string) ([]SwearJar, error)
GetSwearJarById(swearJarId string) (SwearJar, error)
CreateSwearJar(SwearJar) (SwearJar, error)
AddSwear(Swear) error
GetSwearJarOwners(swearJarId string) (owners []string, err error)
Expand Down Expand Up @@ -65,3 +67,24 @@ func (s *service) AddSwear(swear Swear) error {
func (s *service) GetSwearJarsByUserId(userId string) ([]SwearJar, error) {
return s.r.GetSwearJarsByUserId(userId)
}

func (s *service) GetSwearJarById(swearJarId string, userId string) (SwearJar, error) {
swearJar, err := s.r.GetSwearJarById(swearJarId)
if err != nil {
return SwearJar{}, err
}

// Only allow the user to access the SwearJar if they are an owner
isOwner := false
for _, ownerID := range swearJar.Owners {
if ownerID == userId {
isOwner = true
break
}
}
if !isOwner {
return SwearJar{}, fmt.Errorf("User ID: %s is not an owner of SwearJar ID: %s", userId, swearJarId)
}

return swearJar, nil
}
11 changes: 7 additions & 4 deletions frontend/app/api/swearJar/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,20 @@ const swearJarPropsSchema = z.object({
additionalOwners: z.array(userSchema).optional(),
});

// GET /api/swear - Retrieve all swear jars by userId
// GET /api/swearjar - Retrieve swear jars by userId
// or GET /api/swearjar?id={swearJarId} - Retrieve a specific swear jar by SwearJar Id
export const GET = auth(async function GET(req) {
try {
const session = req.auth;

if (!session) {
if (!session) {
return new Response(JSON.stringify({ status: 'error', message: 'User not authenticated' }), { status: 401 });
}

const swearJarId = req.nextUrl.searchParams.get('id');
const route = swearJarId ? `/swearjar?id=${swearJarId}` : `/swearjar`;

const { data, status } = await apiRequest({
route: `/swearjar`,
route:route,
method: 'GET',
});
return new Response(JSON.stringify(data), { status: status });
Expand Down
20 changes: 10 additions & 10 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@
--input: 180 60% 5%;
--ring: 180 60% 5%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--chart-1: 176 61% 55%;
--chart-2: 337 61% 72%;
--chart-3: 32 60% 64%;
--chart-4: 6 100% 36%;
--chart-5: 315 97% 26%;
}

.dark {
Expand All @@ -55,11 +55,11 @@
--input: 180 60% 95%;
--ring: 180 60% 95%;
--radius: 0.5rem;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--chart-1: 176 61% 55%;
--chart-2: 337 61% 72%;
--chart-3: 32 60% 64%;
--chart-4: 6 100% 36%;
--chart-5: 315 97% 26%;
}
}

Expand Down
43 changes: 43 additions & 0 deletions frontend/app/swearjar/[id]/view/mainContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client"
import { fetcher } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";

import BreadcrumbHeader from "@/components/layout/header/breadcrumbHeader";
import { SwearJar } from "@/lib/types";
import SwearJarInfo from "@/components/app/swearjar/view/SwearJarInfo";
import SwearJarTrends from "@/components/app/swearjar/view/SwearJarTrends";
import SwearJarRecent from "@/components/app/swearjar/view/SwearJarRecent";
interface SwearJarApiResponse {
msg: string;
swearJar: SwearJar;
}

export default function MainContent({ swearJarId }: { swearJarId: string }) {
const { data, isLoading } = useQuery<SwearJarApiResponse>({
queryKey: [`swearjar?id=${swearJarId}`],
queryFn: () => fetcher<SwearJarApiResponse>(`/api/swearjar?id=${swearJarId}`),
refetchOnWindowFocus: "always",
});
if (isLoading) return <span className="daisy-loading daisy-loading-dots daisy-loading-lg text-primary"></span>;
if (!data?.swearJar) return <p>Swear Jar does not exist</p>;
return (
<div className="grid grid-cols-1 md:grid-cols-5 gap-y-3 gap-x-4">
<div className="col-span-1 md:col-span-5 order-1">
<BreadcrumbHeader title={data.swearJar.Name} subtitle={data.swearJar.Desc} />
</div>
<div className="col-span-1 md:col-span-3 order-3 md:order-2">
<SwearJarTrends />
</div>
<div className="col-span-1 md:col-span-2 order-2 md:order-3 space-y-2">
<SwearJarInfo {...data.swearJar} />
<span className="hidden md:block">
<SwearJarRecent />
</span>
</div>
{/* <div className="col-span-0 block md:hidden md:col-span-1 md:order-4"></div> */}
<div className="col-span-1 block md:hidden order-4">
<SwearJarRecent />
</div>
</div>
)
}
44 changes: 44 additions & 0 deletions frontend/app/swearjar/[id]/view/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query'
import { fetcher } from "@/lib/utils";
import { SwearJar } from '@/lib/types';


import DefaultContentLayout from "@/components/layout/content";
import MainContent from "./mainContent";
import ErrorAlert from '@/components/shared/ErrorAlert';

interface SwearJarApiResponse {
msg: string;
swearJar: SwearJar;
}

export default async function SwearJarPage({ params }: { params: { id: string } }) {
const queryClient = new QueryClient()
let errorMessage: string | null = null;
try {
await queryClient.prefetchQuery<SwearJarApiResponse>({
queryKey: [`swearjar?id=${params.id}`],
queryFn: () => fetcher<SwearJarApiResponse>(`/api/swearjar?id=${params.id}`),
})
} catch (error) {
console.error("Error during prefetch:", error);
errorMessage = "Error fetching Swear Jar data"
}

if (errorMessage) {
return <ErrorAlert message={errorMessage} className='h-10' />
}
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<section className="w-full md:w-[768px] lg:w-[864px]">
<DefaultContentLayout>
<MainContent swearJarId={params.id} />
</DefaultContentLayout>
</section>
</HydrationBoundary>
);
}
2 changes: 1 addition & 1 deletion frontend/app/swearjar/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default async function CreateSJLayout({
return (
<section className="h-dvh flex flex-col">
<Navbar session={session} />
<main className="flex-grow flex justify-center px-4 mt-7">
<main className="flex-grow flex justify-center px-4 mt-7 mb-7">
{children}
</main>
<Footer />
Expand Down
20 changes: 10 additions & 10 deletions frontend/app/swearjar/list/mainContent.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,31 @@
"use client"

import { fetcher } from "@/lib/utils";
import { SwearJar } from "@/lib/types";
import { SwearJarProp } from "@/lib/types";
import { useQuery } from "@tanstack/react-query";
import { useState, useEffect } from "react";

import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/shadcn/button";
import ErrorAlert from "@/components/shared/ErrorAlert";
import { GoPlus } from "react-icons/go";
import { Input } from "@/components/ui/input";
import { Input } from "@/components/ui/shadcn/input";
import Link from "next/link";
import SwearJarCard from "@/components/app/swearjar/listing/SwearJarCard";

interface SwearJarApiResponse {
msg: string;
swearJars: SwearJar[];
swearJars: SwearJarProp[];
}

export default function MainContent() {
const { data, error, isLoading } = useQuery<SwearJarApiResponse>({
queryKey: ['swearJars'],
queryFn: () => fetcher<SwearJarApiResponse>('/api/swearJar'),
queryKey: ['swearjar'],
queryFn: () => fetcher<SwearJarApiResponse>('/api/swearjar'),
refetchOnWindowFocus: "always",
});

const [searchQuery, setSearchQuery] = useState("");
const [filteredSwearJars, setFilteredSwearJars] = useState<SwearJar[]>([]);
const [filteredSwearJars, setFilteredSwearJars] = useState<SwearJarProp[]>([]);

useEffect(() => {
if (data?.swearJars) {
Expand Down Expand Up @@ -68,10 +68,10 @@ export default function MainContent() {
</div>
) : (
<div className="grid grid-cols-1 gap-1 md:gap-x-5 md:gap-y-3 md:grid-cols-2 lg:grid-cols-3 lg:gap-3">
{filteredSwearJars.map((swearJar: SwearJar) => (
<div key={swearJar.Name} className="col-span-1">
{filteredSwearJars.map((swearJar: SwearJarProp) => (
<Link key={swearJar.SwearJarId} href={`/swearjar/${swearJar.SwearJarId}/view`} className="col-span-1">
<SwearJarCard swearJar={swearJar} />
</div>
</Link>
))}
</div>
)}
Expand Down
Loading

0 comments on commit 995f8df

Please sign in to comment.