Skip to content

Commit

Permalink
feat(occurrence page): add bookmark button to occurrence page to allo…
Browse files Browse the repository at this point in the history
…w users to bookmark occurrences, closes #36

feat(bookmark button): add bookmark button component to allow users to bookmark or remove bookmark from an occurrence

feat(occurrence actions): add functions to create and remove occurrence bookmarks and revalidate bookmarks page after bookmarking or removing bookmark from an occurrence

refactor(occurrenceBookmarks.ts): rename interface OccurrenceBookmarkWithOccurrenceAndProject to OccurrenceBookmarkWithAssociations to improve semantics

feat(occurrenceBookmarks.ts): add checkOccurrenceBookmarkExistence function to check if a bookmark exists for a given user and occurrence ID
  • Loading branch information
masterkain committed May 27, 2023
1 parent 2e2b964 commit e5b8f61
Show file tree
Hide file tree
Showing 5 changed files with 119 additions and 91 deletions.
24 changes: 19 additions & 5 deletions app/occurrences/[occurrence_id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { createOccurrenceBookmark, removeOccurrenceBookmark } from '@/app/_actions';
import Breadcrumbs from '@/components/Breadcrumbs';
import OccurrenceCounterLabel from '@/components/CounterLabel';
import EnvironmentLabel from '@/components/EnvironmentLabel';
import OccurrenceChartWrapper from '@/components/OccurrenceChartWrapper';
import SidebarDesktop from '@/components/SidebarDesktop';
import SidebarMobile from '@/components/SidebarMobile';
import Backtrace from '@/components/occurrence/Backtrace';
import BookmarkButton from '@/components/occurrence/BookmarkButton';
import Context from '@/components/occurrence/Context';
import Environment from '@/components/occurrence/Environment';
import Params from '@/components/occurrence/Params';
import Session from '@/components/occurrence/Session';
import Toolbox from '@/components/occurrence/Toolbox';
import ProjectActionsMenu from '@/components/project/ActionsMenu';
import { authOptions } from '@/lib/auth';
import classNames from '@/lib/classNames';
import { checkOccurrenceBookmarkExistence } from '@/lib/queries/occurrenceBookmarks';
import { getOccurrenceById } from '@/lib/queries/occurrences';
import type { Route } from 'next';
import { getServerSession } from 'next-auth';
import Link from 'next/link';
import { FaCarCrash } from 'react-icons/fa';
import { SlCompass, SlGlobe, SlGraph, SlLink, SlList, SlUser, SlWrench } from 'react-icons/sl';
Expand All @@ -27,15 +32,18 @@ export default async function Occurrence({
params: { occurrence_id: string };
searchParams: Record<string, string>;
}) {
const tabKeys: OccurrenceTabKeys[] = ['backtrace', 'context', 'environment', 'session', 'params', 'chart', 'toolbox'];
const currentTab = tabKeys.includes(searchParams.tab as OccurrenceTabKeys)
? (searchParams.tab as OccurrenceTabKeys)
: 'backtrace';

const session = await getServerSession(authOptions);
const userId = session?.user?.id;
const occurrence = await getOccurrenceById(params.occurrence_id);
if (!occurrence) {
throw new Error('Occurrence not found');
}
const isBookmarked = await checkOccurrenceBookmarkExistence(userId, occurrence.id);

const tabKeys: OccurrenceTabKeys[] = ['backtrace', 'context', 'environment', 'session', 'params', 'chart', 'toolbox'];
const currentTab = tabKeys.includes(searchParams.tab as OccurrenceTabKeys)
? (searchParams.tab as OccurrenceTabKeys)
: 'backtrace';

const tabs = [
{ id: 'backtrace', name: 'Backtrace', current: currentTab === 'backtrace', icon: SlList },
Expand Down Expand Up @@ -97,6 +105,12 @@ export default async function Occurrence({
</div>
<div className="ml-3">
<div className="flex items-center space-x-3">
<BookmarkButton
serverAction={isBookmarked ? removeOccurrenceBookmark : createOccurrenceBookmark}
isBookmarked={isBookmarked}
occurrenceId={occurrence.id}
/>

<h3 className="text-sm font-semibold text-indigo-400">
<Link href={`/notices/${occurrence.notice_id}`}>{occurrence.notice.kind}</Link>
</h3>
Expand Down
5 changes: 3 additions & 2 deletions components/SidebarDesktop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import logo from '@/public/logo.svg';
import { getServerSession } from 'next-auth';
import Image from 'next/image';
import Link from 'next/link';
import { SlPin, SlPlus } from 'react-icons/sl';
import { BsBookmarksFill } from 'react-icons/bs';
import { SlPlus } from 'react-icons/sl';
import { Gravatar } from './Gravatar';
import { ProviderIcon } from './ProviderIcon';

Expand Down Expand Up @@ -35,7 +36,7 @@ async function SidebarDesktop({ selectedProjectId }: SidebarDesktopProps) {
>
<div className="flex w-full justify-between">
<div className="flex items-center gap-x-3 font-semibold ">
<SlPin className="h-6 w-6 shrink-0" aria-hidden="true" />
<BsBookmarksFill className="h-6 w-6 shrink-0" aria-hidden="true" />
<span className="truncate">Bookmarks</span>
</div>
</div>
Expand Down
88 changes: 35 additions & 53 deletions components/occurrence/BookmarkButton.tsx
Original file line number Diff line number Diff line change
@@ -1,72 +1,54 @@
'use client';

import { useEffect, useState } from 'react';
import { useState } from 'react';
import { BsBookmarkPlus, BsBookmarkStarFill } from 'react-icons/bs';

interface BookmarkButtonProps {
projectId: string;
noticeId: string;
occurrenceId: string;
isBookmarked: boolean;
serverAction: (occurrenceId: string) => Promise<void>;
}

export default function BookmarkButton({ projectId, noticeId, occurrenceId }: BookmarkButtonProps) {
const [isBookmarked, setIsBookmarked] = useState<boolean>(false);
export default function BookmarkButton({ occurrenceId, isBookmarked, serverAction }: BookmarkButtonProps) {
const [isHovered, setIsHovered] = useState(false);

useEffect(() => {
const storedBookmarks = localStorage.getItem('bookmarks');
if (storedBookmarks) {
const bookmarks = JSON.parse(storedBookmarks, (key, value) =>
key === '' ? value : typeof value === 'string' ? BigInt(value) : value
);
setIsBookmarked(
bookmarks.some((bookmark: any) => {
return (
bookmark.projectId === projectId && bookmark.noticeId === noticeId && bookmark.occurrenceId === occurrenceId
);
})
);
}
}, [projectId, noticeId, occurrenceId]);

const handleBookmark = () => {
const storedBookmarks = localStorage.getItem('bookmarks');
let bookmarks = [];
if (storedBookmarks) {
bookmarks = JSON.parse(storedBookmarks, (key, value) =>
key === '' ? value : typeof value === 'string' ? BigInt(value) : value
);
}

const bookmarkId = {
projectId: String(projectId),
noticeId: String(noticeId),
occurrenceId: String(occurrenceId),
};

const bookmarkIndex = bookmarks.findIndex((bookmark: any) => {
return (
bookmark.projectId === projectId && bookmark.noticeId === noticeId && bookmark.occurrenceId === occurrenceId
);
});
const handleToggleBookmark = () => {
serverAction(occurrenceId);
};

if (bookmarkIndex !== -1) {
bookmarks.splice(bookmarkIndex, 1);
} else {
bookmarks.push(bookmarkId);
}
const handleMouseEnter = () => {
setIsHovered(true);
};

localStorage.setItem('bookmarks', JSON.stringify(bookmarks));
setIsBookmarked(!isBookmarked);
const handleMouseLeave = () => {
setIsHovered(false);
};

return (
<button
type="button"
onClick={handleBookmark}
className={`inline-flex items-center gap-x-1.5 rounded-md px-3 py-2 text-sm font-semibold ${
isBookmarked ? 'bg-indigo-900 text-white' : 'bg-indigo-200 text-indigo-900'
} hover:bg-indigo-800 hover:text-white`}
onClick={handleToggleBookmark}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className="inline-flex items-center gap-x-1.5 rounded-md py-2 text-sm font-semibold text-indigo-900"
>
{isBookmarked ? 'Bookmarked' : 'Bookmark'}
{isBookmarked ? (
<>
{isHovered ? (
<BsBookmarkPlus className="h-5 w-5 text-indigo-400" aria-hidden="true" />
) : (
<BsBookmarkStarFill className="h-5 w-5 text-indigo-400" aria-hidden="true" />
)}
</>
) : (
<>
{isHovered ? (
<BsBookmarkStarFill className="h-5 w-5 text-indigo-400" aria-hidden="true" />
) : (
<BsBookmarkPlus className="h-5 w-5 text-indigo-400" aria-hidden="true" />
)}
</>
)}
</button>
);
}
69 changes: 42 additions & 27 deletions lib/actions/occurrenceActions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
'use server';

import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/db';
import { Context } from '@/types/airbroke';
import type { OccurrenceBookmark } from '@prisma/client';
import { getServerSession as originalGetServerSession } from 'next-auth';
import { revalidatePath } from 'next/cache';
import { cookies, headers } from 'next/headers';

export const getServerSession = async () => {
const req = {
headers: Object.fromEntries(headers() as Headers),
cookies: Object.fromEntries(
cookies()
.getAll()
.map((c) => [c.name, c.value]),
),
};
const res = { getHeader() { }, setCookie() { }, setHeader() { } };

// @ts-ignore - The type used in next-auth for the req object doesn't match, but it still works
const session = await originalGetServerSession(req, res, authOptions);
return session;
};

export async function performReplay(context: Context): Promise<string> {
const { headers, httpMethod, url } = context;
Expand Down Expand Up @@ -32,34 +51,30 @@ export async function performReplay(context: Context): Promise<string> {
}

// Function to create a bookmark for a user
export async function createOccurrenceBookmark(userId: string, occurrenceId: string): Promise<OccurrenceBookmark> {
try {
const bookmark = await prisma.occurrenceBookmark.create({
data: {
user_id: userId,
occurrence_id: occurrenceId,
},
});
export async function createOccurrenceBookmark(occurrenceId: string) {
const session = await getServerSession();

return bookmark;
} catch (error) {
console.error(`Error occurred during OccurrenceBookmark creation: ${error}`);
throw error;
}
await prisma.occurrenceBookmark.create({
data: {
user_id: session?.user?.id!,
occurrence_id: occurrenceId,
},
});

revalidatePath('/bookmarks')
}

export async function removeOccurrenceBookmark(userId: string, occurrenceId: string): Promise<void> {
try {
await prisma.occurrenceBookmark.delete({
where: {
user_id_occurrence_id: {
user_id: userId,
occurrence_id: occurrenceId,
},
export async function removeOccurrenceBookmark(occurrenceId: string) {
const session = await getServerSession();

await prisma.occurrenceBookmark.delete({
where: {
user_id_occurrence_id: {
user_id: session?.user?.id!,
occurrence_id: occurrenceId,
},
});
} catch (error) {
console.error(`Error occurred during OccurrenceBookmark deletion: ${error}`);
throw error;
}
},
});

revalidatePath('/bookmarks')
}
24 changes: 20 additions & 4 deletions lib/queries/occurrenceBookmarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { prisma } from '@/lib/db';
import type { Notice, Occurrence, OccurrenceBookmark, Project } from '@prisma/client';
import { cache } from 'react';

interface OccurrenceBookmarkWithOccurrenceAndProject extends OccurrenceBookmark {
export interface OccurrenceBookmarkWithAssociations extends OccurrenceBookmark {
occurrence: Occurrence & { notice: Notice & { project: Project } };
}
// Cached function to fetch occurrence bookmarks from the database
const fetchOccurrenceBookmarks = cache(async (whereObject?: any, orderByObject?: any): Promise<OccurrenceBookmarkWithOccurrenceAndProject[]> => {
const results: OccurrenceBookmarkWithOccurrenceAndProject[] = await prisma.occurrenceBookmark.findMany({
const fetchOccurrenceBookmarks = cache(async (whereObject?: any, orderByObject?: any): Promise<OccurrenceBookmarkWithAssociations[]> => {
const results: OccurrenceBookmarkWithAssociations[] = await prisma.occurrenceBookmark.findMany({
where: whereObject,
orderBy: orderByObject,
include: {
Expand All @@ -26,7 +26,7 @@ const fetchOccurrenceBookmarks = cache(async (whereObject?: any, orderByObject?:
});

// Function to get occurrence bookmarks based on provided search parameters
export async function getOccurrenceBookmarks(userId?: string, searchQuery?: string): Promise<OccurrenceBookmarkWithOccurrenceAndProject[]> {
export async function getOccurrenceBookmarks(userId?: string, searchQuery?: string): Promise<OccurrenceBookmarkWithAssociations[]> {
if (!userId) {
return [];
}
Expand Down Expand Up @@ -63,3 +63,19 @@ export async function getOccurrenceBookmarks(userId?: string, searchQuery?: stri

return occurrenceBookmarks;
}

// Function to get a single occurrence bookmark by user ID and occurrence ID
export const checkOccurrenceBookmarkExistence = async (
userId: string | undefined,
occurrenceId: string
): Promise<boolean> => {
if (!userId || !occurrenceId) {
return false;
}

const bookmark = await prisma.occurrenceBookmark.findFirst({
where: { user_id: userId, occurrence_id: occurrenceId },
});

return Boolean(bookmark);
};

0 comments on commit e5b8f61

Please sign in to comment.