add label filter column to sitesTable

This commit is contained in:
Fred KISSIE
2026-05-22 04:07:49 +02:00
parent 3b89104a59
commit 76aea311a4
5 changed files with 588 additions and 337 deletions
+2
View File
@@ -1164,6 +1164,8 @@
"siteLabelsDescription": "Manage labels associated with this site.", "siteLabelsDescription": "Manage labels associated with this site.",
"labelsNotFound": "Labels not found", "labelsNotFound": "Labels not found",
"labelSearch": "Search labels", "labelSearch": "Search labels",
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
"accessLabelFilterClear": "Clear label filters",
"selectColor": "Select color", "selectColor": "Select color",
"createNewLabel": "Create new org label \"{label}\"", "createNewLabel": "Create new org label \"{label}\"",
"inviteInvalidDescription": "The invite link is invalid.", "inviteInvalidDescription": "The invite link is invalid.",
+1 -1
View File
@@ -71,7 +71,7 @@ const listResourcesSchema = z.object({
}), }),
query: z.string().optional(), query: z.string().optional(),
sort_by: z sort_by: z
.enum(["name"]) .literal("name")
.optional() .optional()
.catch(undefined) .catch(undefined)
.openapi({ .openapi({
+50 -3
View File
@@ -187,6 +187,26 @@ const listSitesSchema = z.object({
type: "string", type: "string",
enum: ["pending", "approved"], enum: ["pending", "approved"],
description: "Filter by site status" description: "Filter by site status"
}),
labels: z
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
return undefined;
}
if (Array.isArray(val)) {
return val;
}
// the array is returned as this
if (typeof val === "string") {
return val.split(",");
}
return undefined;
}, z.array(z.string()))
.optional()
.catch([])
.openapi({
type: "array",
description: "Filter by site labels"
}) })
}); });
@@ -319,8 +339,16 @@ export async function listSites(
tierMatrix.labels tierMatrix.labels
); );
const { pageSize, page, query, sort_by, order, online, status } = const {
parsedQuery.data; pageSize,
page,
query,
sort_by,
order,
online,
status,
labels: labelFilter
} = parsedQuery.data;
const accessibleSiteIds = accessibleSites.map((site) => site.siteId); const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
@@ -337,6 +365,23 @@ export async function listSites(
if (typeof status !== "undefined") { if (typeof status !== "undefined") {
conditions.push(eq(sites.status, status)); conditions.push(eq(sites.status, status));
} }
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
conditions.push(
inArray(
sites.siteId,
db
.select({ id: siteLabels.siteId })
.from(siteLabels)
.innerJoin(
labels,
eq(labels.labelId, siteLabels.labelId)
)
.where(inArray(labels.name, labelFilter))
)
);
}
if (query) { if (query) {
const q = "%" + query.toLowerCase() + "%"; const q = "%" + query.toLowerCase() + "%";
const queryList = [ const queryList = [
@@ -366,7 +411,9 @@ export async function listSites(
// we need to add `as` so that drizzle filters the result as a subquery // we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count( const countQuery = db.$count(
querySitesBase().where(and(...conditions)).as("filtered_sites") querySitesBase()
.where(and(...conditions))
.as("filtered_sites")
); );
const siteListQuery = baseQuery const siteListQuery = baseQuery
+184
View File
@@ -0,0 +1,184 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { CheckIcon, Funnel } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
import { Badge } from "./ui/badge";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useDebounce } from "use-debounce";
type LabelColumnFilterButtonProps = {
selectedValues: string[];
onSelectedValuesChange: (values: string[]) => void;
className?: string;
label: string;
orgId: string;
};
export function LabelColumnFilterButton({
selectedValues,
onSelectedValuesChange,
className,
label,
orgId
}: LabelColumnFilterButtonProps) {
const [open, setOpen] = useState(false);
const t = useTranslations();
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
const [debouncedQuery] = useDebounce(labelSearchQuery, 150);
const { data: labels = [] } = useQuery(
orgQueries.labels({
orgId,
query: debouncedQuery,
perPage: 500
})
);
const selectedSet = useMemo(
() => new Set(selectedValues),
[selectedValues]
);
const summary = useMemo(() => {
if (selectedValues.length === 0) {
return null;
}
if (selectedValues.length === 1) {
const foundLabel = labels.find((o) => o.name === selectedValues[0]);
if (foundLabel) {
return (
<div className="inline-flex items-center gap-1">
<div
className="size-3 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": foundLabel.color
}}
/>
{foundLabel.name}
</div>
);
}
return selectedValues[0];
}
return t("accessLabelFilterCount", {
count: selectedValues.length
});
}, [selectedValues, labels, t]);
function toggle(value: string) {
const next = selectedSet.has(value)
? selectedValues.filter((v) => v !== value)
: [...selectedValues, value];
onSelectedValuesChange(next);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
aria-expanded={open}
className={cn(
"justify-between text-sm h-8 px-2",
selectedValues.length === 0 && "text-muted-foreground",
className
)}
>
<div className="flex items-center gap-2 min-w-0">
<span className="shrink-0">{label}</span>
<Funnel className="size-4 flex-none shrink-0" />
{summary && (
<Badge
className={cn(
"truncate max-w-40",
selectedValues.length === 1 &&
"pl-1.5 pr-2 h-auto"
)}
variant="secondary"
>
{summary}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder={t("labelSearch")}
value={labelSearchQuery}
onValueChange={setlabelsSearchQuery}
/>
<CommandList>
<CommandEmpty>{t("labelsNotFound")}</CommandEmpty>
<CommandGroup>
{selectedValues.length > 0 && (
<CommandItem
onSelect={() => {
onSelectedValuesChange([]);
setOpen(false);
}}
className="text-muted-foreground"
>
{t("accessLabelFilterClear")}
</CommandItem>
)}
{labels.map((label) => (
<CommandItem
key={label.name}
value={label.name}
onSelect={() => {
toggle(label.name);
}}
className="flex items-center gap-2"
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
selectedSet.has(label.name)
? "opacity-100"
: "opacity-0"
)}
/>
<div
className="size-4 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": label.color
}}
/>
{label.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+27 -9
View File
@@ -64,6 +64,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { LabelBadge } from "./label-badge"; import { LabelBadge } from "./label-badge";
import { LabelsSelector, type SelectedLabel } from "./labels-selector"; import { LabelsSelector, type SelectedLabel } from "./labels-selector";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
export type SiteRow = { export type SiteRow = {
id: number; id: number;
@@ -136,14 +137,16 @@ export default function SitesTable({
function handleFilterChange( function handleFilterChange(
column: string, column: string,
value: string | undefined | null value: string | undefined | null | string[]
) { ) {
const sp = new URLSearchParams(searchParams); const sp = new URLSearchParams(searchParams);
sp.delete(column); sp.delete(column);
sp.delete("page"); sp.delete("page");
if (value) { if (typeof value === "string") {
sp.set(column, value); sp.set(column, value);
} else if (value) {
value.forEach((val) => sp.append(column, val));
} }
startTransition(() => router.push(`${pathname}?${sp.toString()}`)); startTransition(() => router.push(`${pathname}?${sp.toString()}`));
} }
@@ -347,7 +350,9 @@ export default function SitesTable({
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<span>Newt</span> <span>Newt</span>
{originalRow.newtVersion && ( {originalRow.newtVersion && (
<span>v{originalRow.newtVersion}</span> <span>
v{originalRow.newtVersion}
</span>
)} )}
</div> </div>
</Badge> </Badge>
@@ -482,8 +487,13 @@ export default function SitesTable({
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0"> <Button
<span className="sr-only">Open menu</span> variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -509,7 +519,9 @@ export default function SitesTable({
href={`/${siteRow.orgId}/settings/resources/client?siteId=${siteRow.id}`} href={`/${siteRow.orgId}/settings/resources/client?siteId=${siteRow.id}`}
> >
<DropdownMenuItem> <DropdownMenuItem>
{t("sitesTableViewPrivateResources")} {t(
"sitesTableViewPrivateResources"
)}
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
</DropdownMenuContent> </DropdownMenuContent>
@@ -532,9 +544,15 @@ export default function SitesTable({
cols.splice(cols.length - 1, 0, { cols.splice(cols.length - 1, 0, {
accessorKey: "labels", accessorKey: "labels",
header: () => ( header: () => (
<span className="p-3 text-end w-full inline-block"> <LabelColumnFilterButton
{t("labels")} orgId={orgId}
</span> selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
), ),
cell: ({ row }: { row: { original: SiteRow } }) => ( cell: ({ row }: { row: { original: SiteRow } }) => (
<SiteLabelCell site={row.original} orgId={orgId} /> <SiteLabelCell site={row.original} orgId={orgId} />