feat: card filters

This commit is contained in:
Henry
2024-09-24 22:49:44 +01:00
parent 233e19276e
commit 028239d63b
12 changed files with 324 additions and 68 deletions

View File

@@ -3,10 +3,12 @@ import { twMerge } from "tailwind-merge";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger";
isLoading?: boolean;
icon?: React.ReactNode;
}
const Button = ({
children,
icon,
isLoading,
variant = "primary",
...props
@@ -14,7 +16,7 @@ const Button = ({
return (
<button
className={twMerge(
"inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none",
"inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-semibold text-light-1000 shadow-sm focus-visible:outline-none",
variant === "primary" &&
"bg-light-1000 dark:bg-dark-1000 dark:text-dark-50",
variant === "secondary" &&
@@ -51,7 +53,15 @@ const Button = ({
</svg>
</span>
)}
<span className={isLoading ? "invisible" : "visible"}>{children}</span>
<div
className={twMerge(
"flex items-center",
isLoading ? "invisible" : "visible",
)}
>
{icon && <span className="mr-2">{icon}</span>}
{children}
</div>
</span>
</button>
);

View File

@@ -1,27 +1,50 @@
import { Fragment } from "react";
import { Fragment, useState } from "react";
import { Menu, Transition } from "@headlessui/react";
import { twMerge } from "tailwind-merge";
interface Item {
key: string;
value: string;
selected: boolean;
leftIcon?: React.ReactNode;
}
interface Group {
key: string;
label: string;
icon: React.ReactNode;
items: Item[];
}
interface CheckboxDropdownProps {
children: React.ReactNode;
items: {
key: string;
value: string | null;
selected: boolean;
}[];
handleSelect: (item: { key: string }) => void;
items?: Item[];
groups?: Group[];
menuSpacing?: "sm" | "md" | "lg";
handleSelect: (groupKey: string | null, item: { key: string }) => void;
}
export default function CheckboxDropdown({
children,
items,
groups,
menuSpacing = "sm",
handleSelect,
}: CheckboxDropdownProps) {
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const menuSpacingClass = {
sm: "top-[26px]",
md: "top-[32px]",
lg: "top-[38px]",
};
return (
<>
<Menu
as="div"
className="relative flex w-full flex-wrap items-center text-left"
>
<Menu
as="div"
className="relative flex w-full flex-wrap items-center text-left"
>
<>
<Menu.Button className="focus-visible:outline-none">
{children}
</Menu.Button>
@@ -34,41 +57,110 @@ export default function CheckboxDropdown({
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
afterLeave={() => setSelectedGroup(null)}
>
<Menu.Items className="absolute left-0 top-[26px] z-10 mt-2 w-56 origin-top-left rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
<Menu.Items
className={twMerge(
"absolute left-0 z-50 mt-2 w-56 origin-top-left rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200",
menuSpacingClass[menuSpacing],
)}
>
<div className="p-1">
{items?.map((item) => (
<Menu.Item key={item.key}>
<div
key={item.key}
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleSelect({ key: item.key });
}}
>
<input
id={item.key}
name={item.key}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
onChange={() => handleSelect({ key: item.key })}
checked={item.selected}
/>
<label
htmlFor={item.key}
className="ml-3 text-sm text-dark-900"
>
{item.value}
</label>
</div>
</Menu.Item>
))}
{!selectedGroup ? (
<>
{items?.map((item) => (
<Menu.Item key={item.key}>
<div
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleSelect(null, { key: item.key });
}}
>
<input
id={item.key}
name={item.key}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
onChange={() => handleSelect(null, { key: item.key })}
checked={item.selected}
/>
{item.leftIcon && (
<span className="ml-3 flex items-center">
{item.leftIcon}
</span>
)}
<label
htmlFor={item.key}
className="ml-3 text-[12px] text-dark-900"
>
{item.value}
</label>
</div>
</Menu.Item>
))}
{groups?.map((group) => (
<Menu.Item key={group.key}>
<div
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
setSelectedGroup(group.key);
}}
>
<span className="mr-2 text-dark-900">{group.icon}</span>
<span className="pointer-events-none text-[12px] text-dark-900">
{group.label}
</span>
</div>
</Menu.Item>
))}
</>
) : (
<>
{groups
?.find((g) => g.key === selectedGroup)
?.items.map((item) => (
<Menu.Item key={item.key}>
<div
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleSelect(selectedGroup, { key: item.key });
}}
>
<input
id={item.key}
name={item.key}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
onChange={() =>
handleSelect(selectedGroup, { key: item.key })
}
checked={item.selected}
/>
{item.leftIcon && (
<span className="ml-3 flex items-center">
{item.leftIcon}
</span>
)}
<label
htmlFor={item.key}
className="ml-3 text-[12px] text-dark-900"
>
{item.value}
</label>
</div>
</Menu.Item>
))}
</>
)}
</div>
</Menu.Items>
</Transition>
</Menu>
</>
</>
</Menu>
);
}

View File

@@ -56,6 +56,10 @@ export const BoardProvider: React.FC<{ children: ReactNode }> = ({
try {
const data = await utils.board.byId.fetch({
boardPublicId: boardData.publicId,
filters: {
members: [],
labels: [],
},
});
if (data) setBoardData(data);
} catch (e) {

View File

@@ -28,9 +28,23 @@ export const boardRouter = createTRPCRouter({
return result;
}),
byId: protectedProcedure
.input(z.object({ boardPublicId: z.string().min(12) }))
.input(
z.object({
boardPublicId: z.string().min(12),
filters: z.object({
members: z.array(z.string().min(12)),
labels: z.array(z.string().min(12)),
}),
}),
)
.query(async ({ ctx, input }) => {
const result = await boardRepo.getByPublicId(ctx.db, input.boardPublicId);
const result = await boardRepo.getByPublicId(
ctx.db,
input.boardPublicId,
input.filters,
);
console.log(result);
return result;
}),

View File

@@ -18,8 +18,12 @@ export const getAllByWorkspaceId = async (
export const getByPublicId = async (
db: SupabaseClient<Database>,
boardPublicId: string,
filters: {
members: string[];
labels: string[];
},
) => {
const { data } = await db
let query = db
.from("board")
.select(
`
@@ -50,12 +54,12 @@ export const getByPublicId = async (
description,
listId,
index,
labels:label (
labels:label${filters.labels.length > 0 ? "!inner" : ""} (
publicId,
name,
colourCode
),
members:workspace_members (
members:workspace_members${filters.members.length > 0 ? "!inner" : ""} (
publicId,
user (
name
@@ -68,12 +72,24 @@ export const getByPublicId = async (
.eq("publicId", boardPublicId)
.is("deletedAt", null)
.is("lists.deletedAt", null)
.is("lists.cards.deletedAt", null)
.is("lists.cards.deletedAt", null);
if (filters.labels.length > 0) {
query = query.in("lists.cards.labels.publicId", filters.labels);
}
if (filters.members.length > 0) {
query = query.in("lists.cards.members.publicId", filters.members);
}
const { data, error } = await query
.order("index", { foreignTable: "list", ascending: true })
.order("index", { foreignTable: "list.card", ascending: true })
.limit(1)
.single();
console.log(error);
return data;
};

View File

@@ -43,7 +43,7 @@ export const update = async (
workspacePublicId: string,
name: string,
) => {
const { data, error } = await db
const { data } = await db
.from("workspace")
.update({ name })
.eq("publicId", workspacePublicId)

View File

@@ -18,7 +18,13 @@ export function DeleteListConfirmation({
const refetchBoard = async () => {
if (boardData?.publicId) {
try {
await utils.board.byId.refetch({ boardPublicId: boardData.publicId });
await utils.board.byId.refetch({
boardPublicId: boardData.publicId,
filters: {
members: [],
labels: [],
},
});
} catch (e) {
console.error(e);
}

View File

@@ -0,0 +1,105 @@
import { IoFilterOutline } from "react-icons/io5";
import { HiOutlineUserCircle, HiOutlineTag } from "react-icons/hi2";
import { useRouter } from "next/router";
import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import { useBoard } from "~/providers/board";
const LabelIcon = ({ colourCode }: { colourCode: string | null }) => (
<svg
fill={colourCode ?? "#3730a3"}
className="h-2 w-2"
viewBox="0 0 6 6"
aria-hidden="true"
>
<circle cx={3} cy={3} r={3} />
</svg>
);
const Avatar = ({ name }: { name: string }) => (
<span className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 ring-1 ring-light-200 dark:ring-dark-500">
<span className="text-[8px] font-medium leading-none text-white">
{name
?.split(" ")
.map((namePart) => namePart.charAt(0).toUpperCase())
.join("")}
</span>
</span>
);
const Filters = () => {
const { boardData } = useBoard();
const router = useRouter();
const formattedMembers =
boardData?.workspace?.members?.map((member) => ({
key: member.publicId,
value: member.user?.name ?? "",
selected: !!router.query.members?.includes(member.publicId),
leftIcon: <Avatar name={member.user?.name ?? ""} />,
})) ?? [];
const formattedLabels =
boardData?.labels.map((label) => ({
key: label.publicId,
value: label.name,
selected: !!router.query.labels?.includes(label.publicId),
leftIcon: <LabelIcon colourCode={label.colourCode} />,
})) ?? [];
const groups = [
{
key: "members",
label: "Members",
icon: <HiOutlineUserCircle size={16} />,
items: formattedMembers,
},
{
key: "labels",
label: "Labels",
icon: <HiOutlineTag size={16} />,
items: formattedLabels,
},
];
const handleSelect = async (
groupKey: string | null,
item: { key: string },
) => {
if (groupKey === null) return;
const currentQuery = router.query[groupKey] ?? [];
const formattedCurrentQuery = Array.isArray(currentQuery)
? currentQuery
: [currentQuery];
const updatedQuery = formattedCurrentQuery.includes(item.key)
? formattedCurrentQuery.filter((key) => key !== item.key)
: [...formattedCurrentQuery, item.key];
try {
await router.push({
pathname: router.pathname,
query: { ...router.query, [groupKey]: updatedQuery },
});
} catch (error) {
console.error(error);
}
};
return (
<div className="relative">
<CheckboxDropdown
groups={groups}
handleSelect={handleSelect}
menuSpacing="md"
>
<Button variant="secondary" icon={<IoFilterOutline />}>
Filter
</Button>
</CheckboxDropdown>
</div>
);
};
export default Filters;

View File

@@ -64,7 +64,7 @@ export default function List({
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
className="dark-text-dark-1000 z-0 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
>
<div className="flex justify-between">
<form

View File

@@ -178,9 +178,7 @@ export function NewCardForm({ listPublicId }: NewCardFormProps) {
<div className="w-fit">
<CheckboxDropdown
items={formattedLists}
handleSelect={(list: { key: string }) =>
handleSelectList(list.key)
}
handleSelect={(_groupKey, item) => handleSelectList(item.key)}
>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
{selectedList?.value}
@@ -190,9 +188,7 @@ export function NewCardForm({ listPublicId }: NewCardFormProps) {
<div className="w-fit">
<CheckboxDropdown
items={formattedMembers}
handleSelect={(list: { key: string }) =>
handleSelectMembers(list.key)
}
handleSelect={(_groupKey, item) => handleSelectMembers(item.key)}
>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
{!memberPublicIds.length ? (
@@ -228,9 +224,7 @@ export function NewCardForm({ listPublicId }: NewCardFormProps) {
<div className="w-fit">
<CheckboxDropdown
items={formattedLabels}
handleSelect={(list: { key: string }) =>
handleSelectLabels(list.key)
}
handleSelect={(_groupKey, item) => handleSelectLabels(item.key)}
>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
{!labelPublicIds.length ? (

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useParams } from "next/navigation";
import { HiOutlinePlusSmall } from "react-icons/hi2";
import {
@@ -24,13 +25,22 @@ import List from "./components/List";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { NewCardForm } from "./components/NewCardForm";
import { NewListForm } from "./components/NewListForm";
import Filters from "./components/Filters";
import { type UpdateBoardInput } from "~/types/router.types";
type PublicListId = string;
const formatToArray = (value: string | string[] | undefined): string[] => {
if (Array.isArray(value)) {
return value.filter((item) => item !== undefined);
}
return value ? [value] : [];
};
export default function BoardPage() {
const params = useParams();
const router = useRouter();
const { boardData, setBoardData, updateCard, updateList } = useBoard();
const { openModal, modalContentType } = useModal();
const [selectedPublicListId, setSelectedPublicListId] =
@@ -55,7 +65,13 @@ export default function BoardPage() {
};
const { data, isSuccess, isLoading } = api.board.byId.useQuery(
{ boardPublicId: boardId ?? "" },
{
boardPublicId: boardId ?? "",
filters: {
members: formatToArray(router.query.members),
labels: formatToArray(router.query.labels),
},
},
{
enabled: !!boardId,
},
@@ -150,7 +166,8 @@ export default function BoardPage() {
</form>
)}
<div className="flex items-center">
<div className="flex items-center space-x-2">
<Filters />
<button
type="button"
className="mr-2 inline-flex items-center gap-x-1.5 rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:bg-dark-1000 dark:text-dark-50"
@@ -166,7 +183,7 @@ export default function BoardPage() {
</div>
</div>
<div className="scrollbar-w-none z-10 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain pb-5 scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
<div className="scrollbar-w-none z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain pb-5 scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
{isLoading ? (
<div className="ml-[2rem] flex">
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />

View File

@@ -1,5 +1,3 @@
import { useForm } from "react-hook-form";
import Modal from "~/components/modal";
import Button from "~/components/Button";