Skip to content

Commit

Permalink
feat: add the ability to save filter params as views
Browse files Browse the repository at this point in the history
  • Loading branch information
nainglinnkhant committed Jul 7, 2024
1 parent f1e5438 commit 6235588
Show file tree
Hide file tree
Showing 13 changed files with 315 additions and 41 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"date-fns": "^3.6.0",
"drizzle-orm": "^0.31.0",
"geist": "^1.3.0",
"lodash.isequal": "^4.5.0",
"nanoid": "^5.0.7",
"next": "14.2.4",
"next-themes": "^0.3.0",
Expand All @@ -64,6 +65,7 @@
"@ianvs/prettier-plugin-sort-imports": "^4.2.1",
"@total-typescript/ts-reset": "^0.5.1",
"@types/eslint": "^8.56.10",
"@types/lodash.isequal": "^4.5.8",
"@types/node": "^20.14.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
Expand Down
20 changes: 20 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions src/app/_lib/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const createViewSchema = z.object({
columns: z.string().array().optional(),
filterParams: z.object({
operator: z.enum(["and", "or"]).optional(),
sort: z.string(),
sort: z.string().optional(),
filters: z
.object({
field: z.enum(["title", "status", "priority"]),
Expand All @@ -69,6 +69,7 @@ export const deleteViewSchema = z.object({

export type DeleteViewSchema = z.infer<typeof deleteViewSchema>

export type Filter = NonNullable<
CreateViewSchema["filterParams"]["filters"]
>[number]
export type FilterParams = NonNullable<CreateViewSchema["filterParams"]>
export type Operator = FilterParams["operator"]
export type Sort = FilterParams["sort"]
export type Filter = NonNullable<FilterParams["filters"]>[number]
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export default function RootLayout({ children }: React.PropsWithChildren) {
</div>
<TailwindIndicator />
</ThemeProvider>
<Toaster />
<Toaster richColors />
</body>
</html>
)
Expand Down
55 changes: 51 additions & 4 deletions src/components/data-table/advanced/data-table-advanced-toolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"use client"

import * as React from "react"
import { useSearchParams } from "next/navigation"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import type { View } from "@/db/schema"
import type { DataTableFilterField, DataTableFilterOption } from "@/types"
import { CaretSortIcon, PlusIcon } from "@radix-ui/react-icons"
import type { Table } from "@tanstack/react-table"
import isEqual from "lodash.isequal"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
Expand All @@ -15,7 +16,13 @@ import type { SearchParams } from "@/app/_lib/validations"

import { DataTableFilterItem } from "./data-table-filter-item"
import { DataTableMultiFilter } from "./data-table-multi-filter"
import { DataTableViewsDialog } from "./views/data-table-views-dropdown"
import { CreateViewPopover } from "./views/create-view-popover"
import { DataTableViewsDropdown } from "./views/data-table-views-dropdown"
import {
calcFilterParams,
calcViewSearchParamsURL,
getIsFiltered,
} from "./views/utils"

interface DataTableAdvancedToolbarProps<TData>
extends React.HTMLAttributes<HTMLDivElement> {
Expand All @@ -32,6 +39,8 @@ export function DataTableAdvancedToolbar<TData>({
className,
...props
}: DataTableAdvancedToolbarProps<TData>) {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()

const options = React.useMemo<DataTableFilterOption<TData>[]>(() => {
Expand Down Expand Up @@ -87,6 +96,23 @@ export function DataTableAdvancedToolbar<TData>({
)
)

const isFiltered = getIsFiltered(searchParams)

const currentView = views.find(
(view) => view.id === searchParams.get("viewId")
)

const filterParams = calcFilterParams(selectedOptions, searchParams)

const isUpdated = !isEqual(currentView?.filterParams, filterParams)

function resetToCurrentView() {
if (!currentView) return

const searchParamsURL = calcViewSearchParamsURL(currentView)
router.push(`${pathname}?${searchParamsURL}`)
}

// Update table state when search params are changed
React.useEffect(() => {
const searchParamsObj = Object.fromEntries(searchParams)
Expand All @@ -109,6 +135,9 @@ export function DataTableAdvancedToolbar<TData>({
}

setSelectedOptions(newSelectedOptions)
if (newSelectedOptions.length > 0) {
setOpenFilterBuilder(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams])

Expand All @@ -121,7 +150,7 @@ export function DataTableAdvancedToolbar<TData>({
{...props}
>
<div className="flex items-center justify-between">
<DataTableViewsDialog views={views} />
<DataTableViewsDropdown views={views} filterParams={filterParams} />

<div className="flex items-center gap-2">
{children}
Expand Down Expand Up @@ -151,7 +180,7 @@ export function DataTableAdvancedToolbar<TData>({
</div>
<div
className={cn(
"flex items-center gap-2",
"flex h-8 items-center gap-2",
!openFilterBuilder && "hidden"
)}
>
Expand Down Expand Up @@ -194,6 +223,24 @@ export function DataTableAdvancedToolbar<TData>({
</Button>
</DataTableFilterCombobox>
) : null}

<div className="ml-auto flex items-center gap-2">
{isUpdated && currentView && (
<Button variant="ghost" size="sm" onClick={resetToCurrentView}>
Reset
</Button>
)}

{isFiltered && !currentView && (
<CreateViewPopover selectedOptions={selectedOptions} />
)}

{isUpdated && currentView && (
<Button variant="outline" size="sm">
Update view
</Button>
)}
</div>
</div>
</div>
)
Expand Down
65 changes: 48 additions & 17 deletions src/components/data-table/advanced/views/create-view-form.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect } from "react"
import { useEffect, useRef } from "react"
import { ChevronLeftIcon } from "@radix-ui/react-icons"
import { useFormState, useFormStatus } from "react-dom"
import { toast } from "sonner"
Expand All @@ -8,45 +8,76 @@ import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { LoaderIcon } from "@/components/loader-icon"
import { createView } from "@/app/_lib/actions"
import type { FilterParams } from "@/app/_lib/validations"

interface CreateViewFormProps {
setIsCreateViewFormOpen: React.Dispatch<React.SetStateAction<boolean>>
backButton?: true
onBack?: () => void
onSuccess?: () => void
filterParams?: FilterParams
}

export function CreateViewForm({
setIsCreateViewFormOpen,
backButton,
filterParams,
onBack,
onSuccess,
}: CreateViewFormProps) {
const nameInputRef = useRef<HTMLInputElement>(null)

const [state, formAction] = useFormState(createView, {
message: "",
})

useEffect(() => {
nameInputRef.current?.focus()
}, [])

useEffect(() => {
if (state.status === "success") {
onSuccess?.()
toast.success(state.message)
} else if (state.status === "error") {
toast.error(state.message)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state])

return (
<div>
<div className="flex items-center gap-1 px-1 py-1.5">
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => setIsCreateViewFormOpen(false)}
>
<span className="sr-only">Close create view form</span>
<ChevronLeftIcon aria-hidden="true" className="size-4" />
</Button>
<span className="text-sm">Create view</span>
</div>
{backButton && (
<>
<div className="flex items-center gap-1 px-1 py-1.5">
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => onBack?.()}
>
<span className="sr-only">Close create view form</span>
<ChevronLeftIcon aria-hidden="true" className="size-4" />
</Button>

<Separator />
<span className="text-sm">Create view</span>
</div>

<Separator />
</>
)}

<form action={formAction} className="flex flex-col gap-2 p-2">
<Input type="text" name="name" placeholder="Name" autoComplete="off" />
<input
type="hidden"
name="filterParams"
value={JSON.stringify(filterParams)}
/>
<Input
ref={nameInputRef}
type="text"
name="name"
placeholder="Name"
autoComplete="off"
/>
<SubmitButton />
</form>
</div>
Expand Down
43 changes: 43 additions & 0 deletions src/components/data-table/advanced/views/create-view-popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useState } from "react"
import { useSearchParams } from "next/navigation"
import type { DataTableFilterOption } from "@/types"

import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"

import { CreateViewForm } from "./create-view-form"
import { calcFilterParams } from "./utils"

interface CreateViewPopoverProps<T> {
selectedOptions: DataTableFilterOption<T>[]
}

export function CreateViewPopover<T>({
selectedOptions,
}: CreateViewPopoverProps<T>) {
const searchParams = useSearchParams()

const [open, setOpen] = useState(false)

const filterParams = calcFilterParams(selectedOptions, searchParams)

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
Save as new view
</Button>
</PopoverTrigger>
<PopoverContent className="w-[12.5rem] p-0" align="end">
<CreateViewForm
filterParams={filterParams}
onSuccess={() => setOpen(false)}
/>
</PopoverContent>
</Popover>
)
}
Loading

0 comments on commit 6235588

Please sign in to comment.