Skip to content

Commit

Permalink
Add favourites functionality (#68)
Browse files Browse the repository at this point in the history
Still WIP. Closes #1
  • Loading branch information
aberonni authored Sep 2, 2024
1 parent fba28df commit 8613efa
Show file tree
Hide file tree
Showing 49 changed files with 274 additions and 11 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ jobs:
run: |
npx prisma db push
# Make sure there are no pending migrations
echo "Checking for pending migrations..."
npx prisma migrate diff --from-migrations ./prisma/migrations --to-schema-datamodel ./prisma/schema --exit-code --shadow-database-url=${{ env.DATABASE_URL }}
echo "Seeding database..."
npx prisma db seed
- name: Install Playwright Browsers
run: npx playwright install --with-deps
Expand Down
14 changes: 14 additions & 0 deletions prisma/migrations/20240902121412_favourite_resources/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "UserFavourites" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"resourceId" TEXT NOT NULL,

CONSTRAINT "UserFavourites_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "UserFavourites_userId_idx" ON "UserFavourites"("userId");

-- CreateIndex
CREATE INDEX "UserFavourites_resourceId_idx" ON "UserFavourites"("resourceId");
1 change: 1 addition & 0 deletions prisma/schema/resource.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ model Resource {
editProposalAuthorId String?
lessonPlanItems LessonPlanItem[]
favouritedBy UserFavourites[]
@@index([createdById])
@@index([editProposalAuthorId])
Expand Down
13 changes: 13 additions & 0 deletions prisma/schema/user.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ enum UserRole {
USER
}

model UserFavourites {
id String @id @default(cuid())
userId String
resourceId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
resource Resource @relation(fields: [resourceId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([resourceId])
}

model User {
id String @id @default(cuid())
name String?
Expand All @@ -45,6 +56,8 @@ model User {
resources Resource[] @relation("Resources")
proposals Resource[] @relation("Proposals")
lessonPlans LessonPlan[]
favourites UserFavourites[]
}

model VerificationToken {
Expand Down
5 changes: 4 additions & 1 deletion src/components/data-table/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,10 @@ export function DataTable<TData, TValue = unknown>({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={() => row.toggleSelected(!row.getIsSelected())}
onClick={() =>
onSelectionChange &&
row.toggleSelected(!row.getIsSelected())
}
className={cn(onSelectionChange && "cursor-pointer")}
>
{row.getVisibleCells().map((cell) => (
Expand Down
2 changes: 1 addition & 1 deletion src/components/lesson-plan-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const columns = [
cell: ({ getValue, row, column: { getFilterValue } }) => (
<Link
href={`/lesson-plan/${row.original.id}`}
className="hover:underline"
className="ml-2 hover:underline"
>
<TitleCellContent
title={getValue()}
Expand Down
89 changes: 89 additions & 0 deletions src/components/resource-favourite-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { HeartFilledIcon, HeartIcon } from "@radix-ui/react-icons";
import { useMemo } from "react";

import { Button, type ButtonProps } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";

export const ResourceFavouriteButton = ({
resourceId,
favouriteCount,
showLabel,
...buttonProps
}: {
resourceId: string;
favouriteCount?: number;
showLabel?: boolean;
} & ButtonProps) => {
const { toast } = useToast();
const { data: user, isLoading } = api.user.getUser.useQuery();
const utils = api.useUtils();

const isFavourite = useMemo(() => {
return user?.favourites.some(
(favourite) => favourite.resourceId === resourceId,
);
}, [user, resourceId]);

const { mutate: setFavourite, isLoading: isSaving } =
api.user.setFavourite.useMutation({
onSuccess: async () => {
await utils.user.getUser.invalidate();
void utils.resource.invalidate();
},
onError: (e) => {
const errorMessage =
e.message ?? e.data?.zodError?.fieldErrors.content?.[0];

toast({
title: "Uh oh! Something went wrong.",
variant: "destructive",
description:
errorMessage ??
"Failed to update favourite! Please try again later.",
});
},
});

const label = useMemo(
() => (isFavourite ? "Remove from favourites" : "Add to favourites"),
[isFavourite],
);

return (
<Button
{...buttonProps}
title={label}
onClick={() => {
if (!user) {
return toast({
title: "You must be logged in to favourite a resource.",
variant: "destructive",
});
}

setFavourite({
resourceId,
favourite: !isFavourite,
});
}}
disabled={isSaving || isLoading}
variant={buttonProps.variant ?? "outline"}
className={cn(buttonProps.className, "group")}
data-testid="resource-favourite-button"
>
{isFavourite ? (
<HeartFilledIcon className="h-4 w-4 text-red-500 group-hover:text-black dark:group-hover:text-white" />
) : (
<HeartIcon className="h-4 w-4 text-muted-foreground group-hover:text-red-500" />
)}
{favouriteCount !== undefined && (
<span className="ml-0.5 text-xs text-muted-foreground">
({favouriteCount})
</span>
)}
{showLabel && <span className="ml-2">{label}</span>}
</Button>
);
};
35 changes: 33 additions & 2 deletions src/components/resource-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
ResourceConfigurationLabels,
ResourceTypeLabels,
} from "@/components/resource";
import { ResourceFavouriteButton } from "@/components/resource-favourite-button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
Expand All @@ -33,17 +34,23 @@ function getColumns({
showPublishedStatus,
showEditProposals,
showSelection,
showFavourites,
useFilters,
}: {
showPublishedStatus: boolean;
showEditProposals: boolean;
showSelection: boolean;
showFavourites: boolean;
useFilters: boolean;
}) {
const columns = [
columnHelper.accessor("type", {
header: ({ column }) => (
<DataTableColumnHeader column={column} title={column.id} />
<DataTableColumnHeader
column={column}
title={column.id}
className="w-40"
/>
),
cell: (props) => ResourceTypeLabels[props.getValue()],
filterFn: "arrIncludesSome",
Expand Down Expand Up @@ -198,7 +205,7 @@ function getColumns({
cell: ({ getValue, row, column: { getFilterValue } }) => (
<Link
href={`/resource/${row.original.id}`}
className="hover:underline"
className="ml-2 inline-block hover:underline"
>
<TitleCellContent
title={getValue()}
Expand All @@ -208,6 +215,27 @@ function getColumns({
),
}) as ColumnDef<SingleResourceType, unknown>,
);

if (showFavourites) {
columns.unshift(
columnHelper.display({
id: "select",
header: () => <></>,
cell: ({ row }) => (
<div className="flex items-center">
<ResourceFavouriteButton
resourceId={row.original.id}
className="-mr-4 h-full px-2 py-0"
variant="link"
// favouriteCount={row.original._count?.favouritedBy}
/>
</div>
),
enableSorting: false,
enableHiding: false,
}),
);
}
}

return useFilters
Expand All @@ -225,13 +253,15 @@ export const ResourceList = ({
queryResult,
showPublishedStatus = false,
showEditProposals = false,
showFavourites = false,
onSelectionChange,
}: {
useFilters?: boolean;
usePagination?: boolean;
queryResult: UseTRPCQueryResult<RouterOutputs["resource"]["getAll"], unknown>;
showPublishedStatus?: boolean;
showEditProposals?: boolean;
showFavourites?: boolean;
onSelectionChange?: (
selectedRows: Row<RouterOutputs["resource"]["getAll"][0]>[],
) => void;
Expand All @@ -253,6 +283,7 @@ export const ResourceList = ({
const columns = getColumns({
showPublishedStatus,
showEditProposals,
showFavourites,
showSelection: !!onSelectionChange,
useFilters,
});
Expand Down
9 changes: 8 additions & 1 deletion src/components/resource.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type ResourceConfiguration, ResourceType } from "@prisma/client";
import { uniqBy } from "lodash";
import Link from "next/link";
import { useMemo } from "react";
import ReactMarkdown from "react-markdown";
Expand All @@ -9,12 +10,12 @@ import {
SplitPageLayoutContent,
SplitPageLayoutSidebar,
} from "@/components/page-layout";
import { ResourceFavouriteButton } from "@/components/resource-favourite-button";
import { buttonVariants } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import type { RouterOutputs } from "@/utils/api";
import { type resourceCreateSchema } from "@/utils/zod";
import { uniqBy } from "lodash";

export const ResourceTypeLabels: Record<ResourceType, string> = {
EXERCISE: "🚀 Warm-up / Exercise",
Expand Down Expand Up @@ -80,6 +81,12 @@ export function SingleResourceComponent({
<SplitPageLayout>
<SplitPageLayoutSidebar>
<div className="space-y-6 md:flex md:min-h-full md:flex-col">
<ResourceFavouriteButton
resourceId={resource.id}
showLabel
className="w-full"
/>

<h4 className="tracking-tight text-muted-foreground">
{ResourceTypeLabels[resource.type]}
<br />
Expand Down
3 changes: 2 additions & 1 deletion src/components/site-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ export type SiteHeaderLinks = {
}[];

const userNavigation = [
{ name: "My Profile", href: "/user/my-profile" },
{ name: "My Favourite Resources", href: "/user/my-favourite-resources" },
{ name: "My Lesson Plans", href: "/user/my-lesson-plans" },
{ name: "My Proposed Resources", href: "/user/my-proposed-resources" },
{ name: "My Profile", href: "/user/my-profile" },
{ name: "Propose Resource", href: "/resource/create" },
];

Expand Down
6 changes: 4 additions & 2 deletions src/components/user-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ const columns = [
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" className="ml-2" />
),
cell: ({ getValue }) => getValue() ?? "Anonymous User",
cell: ({ getValue }) => (
<span className="ml-2">{getValue() ?? "Anonymous User"}</span>
),
}),
columnHelper.accessor("_count.resources", {
header: ({ column }) => (
Expand Down Expand Up @@ -65,7 +67,7 @@ export const UserList = ({
isLoading={isLoading}
filters={useFilters ? ["title"] : undefined}
usePagination={usePagination}
data-testid="lesson-plan-list"
data-testid="user-list"
/>
);
};
2 changes: 1 addition & 1 deletion src/pages/resource/browse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default function BrowseResources() {
<title>Browse Resources - ImprovDB</title>
</Head>
<PageLayout title="Browse Resources">
<ResourceList queryResult={queryResult} useFilters />
<ResourceList queryResult={queryResult} useFilters showFavourites />
<p className="mt-6 text-center text-sm text-muted-foreground">
See something missing? You can{" "}
<Link href="/resource/create" className="underline">
Expand Down
20 changes: 20 additions & 0 deletions src/pages/user/my-favourite-resources.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Head from "next/head";

import { PageLayout } from "@/components/page-layout";
import { ResourceList } from "@/components/resource-list";
import { api } from "@/utils/api";

export default function MyFavouriteResources() {
const queryResult = api.resource.getMyFavouriteResources.useQuery();

return (
<>
<Head>
<title>My Favourite Resources - ImprovDB</title>
</Head>
<PageLayout title="My Favourite Resources" authenticatedOnly>
<ResourceList queryResult={queryResult} useFilters showFavourites />
</PageLayout>
</>
);
}
Loading

0 comments on commit 8613efa

Please sign in to comment.