Skip to content

Commit

Permalink
feat(facets): add persistence for selected items
Browse files Browse the repository at this point in the history
  • Loading branch information
cesconix committed Jul 6, 2024
1 parent 83ec3e0 commit dceefa3
Show file tree
Hide file tree
Showing 13 changed files with 356 additions and 55 deletions.
1 change: 1 addition & 0 deletions packages/pinorama-studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"debounce": "^2.1.0",
"fastify": "^4.27.0",
"lucide-react": "^0.390.0",
"minimist": "^1.2.8",
Expand Down
36 changes: 33 additions & 3 deletions packages/pinorama-studio/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Search } from "lucide-react"
import { useState } from "react"
import { PinoramaDocsTable } from "./components/pinorama-docs-table"
import { PinoramaFacets } from "./components/pinorama-facets"
import type { SearchFilters } from "./components/pinorama-facets/types"
import { Button } from "./components/ui/button"
import { Input } from "./components/ui/input"
import {
ResizableHandle,
Expand All @@ -9,12 +12,37 @@ import {
} from "./components/ui/resizable"

function App() {
const [searchText, setSearchText] = useState("")
const [filters, setFilters] = useState<SearchFilters>({})

const handleResetFilters = () => {
setFilters({})
setSearchText("")
}

const hasFilters = Object.keys(filters).length > 0 || searchText.length > 0

return (
<ResizablePanelGroup direction="horizontal" className="min-h-screen w-full">
<ResizablePanel defaultSize={20}>
<div className="flex flex-col h-screen p-3 overflow-auto">
<div className="py-2 px-2 text-sm mt-1">🌀 Pinorama</div>
<PinoramaFacets />
<div className="flex text-sm whitespace-nowrap justify-between items-center mb-0.5 h-[40px]">
<div className="font-medium">🌀 Pinorama</div>
{hasFilters ? (
<Button
variant="outline"
className="text-muted-foreground"
onClick={handleResetFilters}
>
Reset Filters
</Button>
) : null}
</div>
<PinoramaFacets
searchText={searchText}
filters={filters}
onFiltersChange={setFilters}
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
Expand All @@ -27,10 +55,12 @@ function App() {
type="text"
placeholder="Search logs..."
className="pl-9"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</div>
</div>
<PinoramaDocsTable />
<PinoramaDocsTable searchText={searchText} filters={filters} />
</div>
</ResizablePanel>
</ResizablePanelGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,33 @@
import type { SearchFilters } from "@/components/pinorama-facets/types"
import { usePinoramaClient } from "@/contexts"
import { useQuery } from "@tanstack/react-query"

export const useDocs = () => {
export const useDocs = (searchText?: string, filters?: SearchFilters) => {
const client = usePinoramaClient()

const query = useQuery({
queryKey: ["docs"],
queryFn: async () => {
const response: any = await client?.search({
limit: 100
})
queryKey: ["docs", searchText, filters],
queryFn: async ({ signal }) => {
await new Promise((resolve) => setTimeout(resolve, 500))

if (signal.aborted) {
return
}

const payload: any = {
limit: 10000
}

if (searchText) {
payload.term = searchText
}

if (filters) {
payload.where = filters
}

const response: any = await client?.search(payload)

return response.hits.map((hit: { document: unknown }) => hit.document)
}
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import { useRef } from "react"
import { useMemo, useRef } from "react"

import { getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { useVirtualizer } from "@tanstack/react-virtual"
import { LoaderIcon } from "lucide-react"
import type { SearchFilters } from "../pinorama-facets/types"
import { TableBody } from "./components/tbody"
import { TableHead } from "./components/thead"
import { useColumns } from "./hooks/use-columns"
import { useDocs } from "./hooks/use-docs"

export function PinoramaDocsTable() {
type PinoramaDocsTableProps = {
searchText: string
filters: SearchFilters
}

export function PinoramaDocsTable(props: PinoramaDocsTableProps) {
const columns = useColumns()
const { data } = useDocs()
const { data, status } = useDocs(props.searchText, props.filters)

const docs = useMemo(() => data ?? [], [data])

const table = useReactTable({
data: data || [],
data: docs,
columns,
enableColumnResizing: true,
columnResizeMode: "onChange",
Expand All @@ -37,7 +46,21 @@ export function PinoramaDocsTable() {
>
<table className="text-sm w-full">
<TableHead table={table} />
<TableBody virtualizer={virtualizer} rows={rows} />
{status === "pending" ? (
<tbody>
<tr>
<td
colSpan={columns.length}
className="h-10 px-3 text-muted-foreground flex items-center"
>
<LoaderIcon className="w-4 h-4 mr-2 animate-spin" />
Loading...
</td>
</tr>
</tbody>
) : (
<TableBody virtualizer={virtualizer} rows={rows} />
)}
</table>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Checkbox } from "@/components/ui/checkbox"
import { facetFilterOperationsFactory } from "../lib/operations"
import type { OramaPropType, SearchFilters } from "../types"

export function FacetFactoryInput(props: {
id: string
type: OramaPropType
name: string
value: string | number
filters: SearchFilters
onFiltersChange: (filters: SearchFilters) => void
}) {
const operations: any = facetFilterOperationsFactory(props.type)

const criteria = props.filters[props.name] || operations.create()
const checked = operations.exists(props.value, criteria)

const handleCheckedChange = (checked: boolean) => {
const newCriteria = checked
? operations.add(criteria, props.value)
: operations.remove(criteria, props.value)

const filters = { ...props.filters, [props.name]: newCriteria }

if (operations.length(newCriteria) === 0) {
delete filters[props.name]
}

props.onFiltersChange(filters)
}

return (
<Checkbox
id={props.id}
className="hover:bg-muted border-muted-foreground"
checked={checked}
onCheckedChange={handleCheckedChange}
/>
)
}
Original file line number Diff line number Diff line change
@@ -1,59 +1,136 @@
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { ChevronDown, ChevronRight } from "lucide-react"
import { useState } from "react"
import { ChevronDown, ChevronRight, CircleX, LoaderIcon } from "lucide-react"
import { useCallback, useMemo, useState } from "react"
import { useFacet } from "../hooks/use-facet"
import { facetFilterOperationsFactory } from "../lib/operations"
import type {
FacetFilter,
FacetValue,
OramaPropType,
SearchFilters
} from "../types"
import { FacetFactoryInput } from "./facet-factory-input"

type FacetProps = {
propertyName: string
name: string
type: OramaPropType
searchText: string
filters: SearchFilters
onFiltersChange: (filters: SearchFilters) => void
}

export function Facet(props: FacetProps) {
const { data: facet, isLoading } = useFacet(props.propertyName)
const [open, setOpen] = useState(true)
const { data: facet, fetchStatus } = useFacet(
props.name,
props.searchText,
props.filters
)

const operations: any = facetFilterOperationsFactory(props.type)
const criteria = props.filters[props.name] || operations.create()
const selelectedOptionCount = operations.length(criteria)

const handleReset = useCallback(() => {
const filters = { ...props.filters }
delete filters[props.name]
props.onFiltersChange(filters)
}, [props.onFiltersChange, props.name, props.filters])

const selected: FacetValue[] = useMemo(() => {
return operations
.values(props.filters[props.name] as FacetFilter)
.map((value: string | number) => {
return {
value,
count: facet?.values[value] || 0
}
})
}, [facet?.values, props.filters, props.name, operations.values])

const handleClick = () => {
setOpen((prev) => !prev)
}
const unselected: FacetValue[] = useMemo(() => {
return Object.entries(facet?.values || {})
.map(([value, count]) => {
// If the value is a number of type string,
// convert it to a number.
let parsedValue: string | number = value
if (props.type === "enum" && Number.isFinite(+value)) {
parsedValue = Number(value)
}
return { value: parsedValue, count }
})
.filter(
({ value }) => !selected.some((selected) => selected.value === value)
)
}, [facet?.values, props.type, selected])

const values = useMemo(
() => selected.concat(unselected),
[selected, unselected]
)

const ChevronIcon = open ? ChevronDown : ChevronRight

return (
<div className="">
<div>
<Button
variant={"ghost"}
onClick={handleClick}
className={`w-full text-left justify-start text-sm font-normal px-2 ${open ? "hover:bg-transparent" : "text-muted-foreground"}`}
onClick={() => setOpen((value) => !value)}
className={`w-full text-left justify-between text-sm font-normal px-2 ${open ? "hover:bg-transparent" : "text-muted-foreground"}`}
>
<ChevronIcon className="mr-2 w-5 h-5" />
{props.propertyName}
<div className="flex items-center">
<ChevronIcon className="mr-2 w-5 h-5" />
{props.name}
{fetchStatus === "fetching" ? (
<LoaderIcon className="w-4 h-4 ml-2 animate-spin text-muted-foreground" />
) : null}
</div>
{selelectedOptionCount > 0 ? (
<div>
<Button
variant={"outline"}
size={"badge"}
className="flex text-muted-foreground"
onClick={handleReset}
>
<CircleX className="w-4 h-4" />
<div className="px-1.5 text-xs">
{selelectedOptionCount as string}
</div>
</Button>
</div>
) : null}
</Button>
{open && !isLoading ? (
{open ? (
<div className="my-2">
<div className="border border-muted rounded-md overflow-auto max-h-[246px] my-2">
{Object.entries(facet?.values || {}).map(([label, count]) => {
<div className="border border-muted rounded-md overflow-auto max-h-[241px] my-2">
{values?.map(({ value, count }) => {
return (
<div
key={label}
className="flex items-center space-x-3 py-[6px] px-3 border-b last:border-b-0"
key={value}
className="flex items-center space-x-3 h-10 px-3 border-b last:border-b-0"
>
<Checkbox
id={label}
className="hover:bg-muted border-muted-foreground"
<FacetFactoryInput
id={value as string}
type={props.type}
name={props.name}
value={value}
filters={props.filters}
onFiltersChange={props.onFiltersChange}
/>
<Label
htmlFor={label}
htmlFor={value as string}
className="whitespace-nowrap text-muted-foreground font-normal cursor-pointer flex-grow text-ellipsis w-full overflow-hidden leading-tight"
>
{label}
{value}
</Label>
<Badge
variant={"secondary"}
className="font-normal text-muted-foreground cursor-pointer"
>
{count as string}
{count}
</Badge>
</div>
)
Expand Down
Loading

0 comments on commit dceefa3

Please sign in to comment.