Skip to content

Commit

Permalink
feat: add Add to list dropdown to user profile (#1843)
Browse files Browse the repository at this point in the history
  • Loading branch information
OgDev-01 authored Oct 10, 2023
1 parent 2ea3989 commit 4d81040
Show file tree
Hide file tree
Showing 3 changed files with 178 additions and 67 deletions.
106 changes: 52 additions & 54 deletions components/atoms/Select/multi-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@ import { Command, CommandGroup, CommandInput, CommandItem } from "../Cmd/command
export type OptionKeys = Record<"value" | "label", string>;

interface MultiSelectProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
options: OptionKeys[];
selected: OptionKeys[];
setSelected?: React.Dispatch<React.SetStateAction<OptionKeys[]>>;
handleSelect: (value: OptionKeys) => void;
placeholder?: string;
inputPlaceholder?: string;
className?: string;
handleKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
emptyState?: React.ReactNode;
}

const MultiSelect = ({
Expand All @@ -28,30 +32,20 @@ const MultiSelect = ({
placeholder,
handleKeyDown,
inputPlaceholder,
setSelected,
open,
setOpen,
emptyState,
}: MultiSelectProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = useState("");
const [dummySelected, setDummySelected] = useState<OptionKeys[]>([]);

// For testing purposes, this component is meant to be stateless.

const toggleFramework = (option: OptionKeys) => {
const isOptionSelected = dummySelected.some((s) => s.value === option.value);
if (isOptionSelected) {
setDummySelected((prev) => prev.filter((s) => s.value !== option.value));
} else {
setDummySelected((prev) => [...prev, option]);
}
inputRef?.current?.focus();
};

return (
<Popover open={open} onOpenChange={(value) => setOpen(value)}>
<div className="min-w-[250px] max-w-[100px] ">
<div className="">
<PopoverTrigger
asChild
className="p-1.5 border rounded-md bg-white data-[state=open]:border-orange-500 min-w-[250px] "
className={clsx("p-1.5 border rounded-md bg-white data-[state=open]:border-orange-500 min-w-max", className)}
>
<button
aria-controls="select-menu-list"
Expand All @@ -62,7 +56,7 @@ const MultiSelect = ({
{selected.length > 0 ? (
<span className="truncate">
{selected[0].label}
{selected.length > 1 ? `, +${dummySelected.length - 1}` : null}
{selected.length > 1 ? `, +${selected.length - 1}` : null}
</span>
) : (
<span className="opacity-50">{placeholder ?? "Select Items"}</span>
Expand All @@ -73,7 +67,7 @@ const MultiSelect = ({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDummySelected([]);
setSelected?.([]);
}}
>
<IoMdCloseCircle className="text-red-600" />
Expand All @@ -83,42 +77,46 @@ const MultiSelect = ({
)}
</button>
</PopoverTrigger>
<PopoverContent className="!w-full !min-w-[250px] bg-white p-0 max-w-sm">
<Command loop onKeyDown={handleKeyDown} className="w-full px-0 bg-transparent">
<CommandInput
ref={inputRef}
placeholder={placeholder ?? "Search Items"}
value={inputValue}
onValueChange={setInputValue}
/>
<CommandGroup className="flex flex-col !px-0 overflow-scroll max-h-48">
{open && options.length > 0
? options.map((option) => (
<CommandItem
key={option.value}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value) => {
setInputValue("");
toggleFramework(option);
}}
onClick={() => toggleFramework(option)}
className={clsx(
"!cursor-pointer flex justify-between items-center !px-1 rounded-md truncate break-words w-full",
selected.some((s) => s.value === option.value) && "bg-gray-100"
)}
>
{option.label}
{selected.some((s) => s.value === option.value) && (
<IoCheckmarkSharp className="w-5 h-5 ml-2 text-sauced-orange shrink-0" />
)}
</CommandItem>
))
: null}
</CommandGroup>
</Command>
<PopoverContent align="end" className="!w-full bg-white p-0 max-w-sm">
{options.length > 0 && (
<Command loop onKeyDown={handleKeyDown} className="w-full px-0 bg-transparent">
<CommandInput
ref={inputRef}
placeholder={inputPlaceholder ?? "Search Items"}
value={inputValue}
onValueChange={setInputValue}
/>
<CommandGroup className="flex flex-col !px-0 overflow-scroll max-h-48">
{open && options.length > 0
? options.map((option) => (
<CommandItem
key={option.value}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value) => {
setInputValue("");
// toggleFramework(option);
handleSelect(option);
}}
onClick={() => handleSelect(option)}
className={clsx(
"!cursor-pointer flex justify-between items-center !px-1 rounded-md truncate break-words w-full",
selected.some((s) => s.value === option.value) && ""
)}
>
{option.label}
{selected.some((s) => s.value === option.value) && (
<IoCheckmarkSharp className="w-5 h-5 ml-2 text-sauced-orange shrink-0" />
)}
</CommandItem>
))
: null}
</CommandGroup>
</Command>
)}
{options.length === 0 && emptyState ? emptyState : null}
</PopoverContent>
</div>
</Popover>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import Image from "next/image";

import { TfiMoreAlt } from "react-icons/tfi";
import { HiUserAdd } from "react-icons/hi";
import { FaIdCard } from "react-icons/fa";
import { SignInWithOAuthCredentials, User } from "@supabase/supabase-js";
import { usePostHog } from "posthog-js/react";
import { clsx } from "clsx";

import dynamic from "next/dynamic";
import {
DropdownMenu,
DropdownMenuContent,
Expand All @@ -23,9 +24,13 @@ import Text from "components/atoms/Typography/text";
import { Textarea } from "components/atoms/Textarea/text-area";
import { useUserConnections } from "lib/hooks/useUserConnections";
import { useToast } from "lib/hooks/useToast";
import { cardPageUrl } from "lib/utils/urls";
import { OptionKeys } from "components/atoms/Select/multi-select";
import { addListContributor, useFetchAllLists } from "lib/hooks/useList";
import { useFetchUser } from "lib/hooks/useFetchUser";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../Dialog/dialog";

const MultiSelect = dynamic(() => import("components/atoms/Select/multi-select"), { ssr: false });

interface ContributorProfileHeaderProps {
avatarUrl?: string;
githubName: string;
Expand Down Expand Up @@ -156,7 +161,7 @@ const ContributorProfileHeader = ({
</div>
{isConnected && (
<div className="flex flex-col items-center gap-3 translate-y-24 md:translate-y-0 md:flex-row">
<div className="flex justify-center items-center gap-2 mb-10 md:gap-6 flex-wrap">
<div className="flex flex-wrap items-center justify-center gap-2 mb-10 md:gap-6">
{user ? (
!isOwner && (
<>
Expand All @@ -166,8 +171,8 @@ const ContributorProfileHeader = ({
variant="primary"
className="group w-[6.25rem] justify-center items-center"
>
<span className="text-center hidden sm:block group-hover:hidden">Following</span>
<span className="text-center block sm:hidden group-hover:block">Unfollow</span>
<span className="hidden text-center sm:block group-hover:hidden">Following</span>
<span className="block text-center sm:hidden group-hover:block">Unfollow</span>
</Button>
) : (
<Button variant="primary" className="w-[6.25rem] text-center" onClick={handleFollowClick}>
Expand Down Expand Up @@ -199,14 +204,10 @@ const ContributorProfileHeader = ({
</>
)}

<Button className="sm:hidden bg-white" variant="text" href={cardPageUrl(username!)}>
<FaIdCard className="" />
</Button>
<Button className="hidden sm:inline-flex text-black" variant="default" href={cardPageUrl(username!)}>
<FaIdCard className="mt-1 mr-1" /> Get Card
</Button>
{user && !isOwner && <AddToListDropdown username={username ?? ""} />}

<DropdownMenu modal={false}>
<div className="items-center gap-2 md:gap-6 flex-wrap">
<div className="flex-wrap items-center gap-2 md:gap-6">
{!isOwner && (
<DropdownMenuTrigger
title="More options"
Expand Down Expand Up @@ -327,4 +328,79 @@ const ContributorProfileHeader = ({
);
};

// Making this dropdown seperate to optimize for performance and not fetch certain data until the dropdown is rendered
const AddToListDropdown = ({ username }: { username: string }) => {
const [selectListOpen, setSelectListOpen] = useState(false);
const [selectedList, setSelectedList] = useState<OptionKeys[]>([]);
const { data } = useFetchAllLists();
const { data: contributor } = useFetchUser(username ?? "");
const { toast } = useToast();

const listOptions = data ? data.map((list) => ({ label: list.name, value: list.id })) : [];

const handleSelectList = (value: OptionKeys) => {
const isOptionSelected = selectedList.some((s) => s.value === value.value);
if (isOptionSelected) {
setSelectedList((prev) => prev.filter((s) => s.value !== value.value));
} else {
setSelectedList((prev) => [...prev, value]);
}
};

const handleAddToList = async () => {
if (selectedList.length > 0 && contributor) {
const listIds = selectedList.map((list) => list.value);
const response = Promise.all(listIds.map((listIds) => addListContributor(listIds, [contributor.id])));

response
.then((res) => {
toast({
description: `
You've added ${username} to ${selectedList.length} list${selectedList.length > 1 ? "s" : ""}!`,
variant: "success",
});
})
.catch((res) => {
const failedList = listOptions.filter((list) => res.some((r: any) => r.error?.list_id === list.value));
toast({
description: `
Failed to add ${username} to ${failedList[0].label} ${
failedList.length > 1 && `and ${failedList.length - 1} other lists`
} !
`,
variant: "danger",
});
});
}
};

useEffect(() => {
if (!selectListOpen && selectedList.length > 0) {
handleAddToList();
setSelectedList([]);
}
}, [selectListOpen]);

return (
<MultiSelect
open={selectListOpen}
setOpen={setSelectListOpen}
emptyState={
<div className="">
You have no lists. <br />
<Link className="text-sauced-orange" href="/hub/lists/new">
Create a list
</Link>
</div>
}
className="w-10 px-4"
placeholder="Add to list"
options={listOptions}
selected={selectedList}
setSelected={setSelectedList}
handleSelect={(option) => handleSelectList(option)}
/>
);
};

export default ContributorProfileHeader;
39 changes: 38 additions & 1 deletion lib/hooks/useList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import useSWR, { Fetcher } from "swr";
import { useState } from "react";

import publicApiFetcher from "lib/utils/public-api-fetcher";
import { supabase } from "lib/utils/supabase";

interface PaginatedListResponse {
data: DbUserList[];
Expand Down Expand Up @@ -69,6 +70,42 @@ const useFetchListContributors = (id: string, range = 30) => {
};
};

const addListContributor = async (listId: string, contributors: number[]) => {
const sessionResponse = await supabase.auth.getSession();
const sessionToken = sessionResponse?.data.session?.access_token;

try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/lists/${listId}/contributors`, {
method: "POST",
body: JSON.stringify({ contributors }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${sessionToken}`,
},
});

if (response.ok) {
const data = await response.json();
return {
data,
error: null,
};
} else {
const error = await response.json();
return {
data: null,
error: { message: error.message, listId },
};
}
} catch (error: any) {
console.log(error);
return {
data: null,
error: { message: error.message, listId },
};
}
};

const useList = (listId: string) => {
const { data, error, mutate } = useSWR<any>(`lists/${listId}`, publicApiFetcher as Fetcher<any, Error>);

Expand All @@ -80,4 +117,4 @@ const useList = (listId: string) => {
};
};

export { useList, useFetchAllLists, useFetchListContributors };
export { useList, useFetchAllLists, useFetchListContributors, addListContributor };

0 comments on commit 4d81040

Please sign in to comment.