fix: add-filter popover renders via portal to avoid stacking context

- Popover now renders via createPortal into document.body with z-index 9999
- This avoids the header backdrop-blur stacking context trapping it
- Add + button in Dashboards sidebar section to create dashboards
- Inline input on Dashboards section header, Enter to create/Escape to cancel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 13:19:22 +02:00
parent f7e34f1690
commit 35b7f49518
2 changed files with 161 additions and 190 deletions

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { import {
ArrowDownAZIcon, ArrowDownAZIcon,
@@ -677,185 +678,6 @@ function TicketWorkbenchContent() {
<PlusIcon className="h-3 w-3" /> <PlusIcon className="h-3 w-3" />
Add filter Add filter
</button> </button>
{addFilterOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => {
setAddFilterOpen(false);
setAddFilterField(null);
}}
/>
<div
id="add-filter-popover"
className="fixed z-50 w-52 rounded-md border border-border bg-popover p-1 shadow-lg"
>
{!addFilterField ? (
/* Step 1: choose field */
<>
<button
type="button"
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
onClick={() => {
if (!filters.find((f) => f.field === "queue")) {
setAddFilterField("queue");
setAddFilterOperator("is");
setAddFilterValue("");
} else {
setAddFilterOpen(false);
}
}}
>
Queue
</button>
<button
type="button"
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
onClick={() => {
if (!filters.find((f) => f.field === "owner")) {
setAddFilterField("owner");
setAddFilterOperator("is");
setAddFilterValue("");
} else {
setAddFilterOpen(false);
}
}}
>
Owner
</button>
{customFields.map((cf) => (
<button
key={`cf-field-${cf.id}`}
type="button"
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
onClick={() => {
setAddFilterField(`cf.${cf.key}`);
setAddFilterOperator("is");
setAddFilterValue("");
}}
>
{cf.name}
</button>
))}
</>
) : (
/* Step 2: operator + value */
<div className="space-y-2 p-1">
<div className="flex items-center gap-1 text-xs">
<button
type="button"
onClick={() => {
setAddFilterField(null);
}}
className="text-muted-foreground hover:text-foreground"
>
</button>
<span className="font-medium text-foreground">
{addFilterField.startsWith("cf.") ? addFilterField.slice(3) : addFilterField}
</span>
</div>
<select
value={addFilterOperator}
onChange={(e) => setAddFilterOperator(e.target.value)}
className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none"
>
<option value="is">is</option>
<option value="is_not">is not</option>
</select>
{addFilterField === "queue" ? (
<select
value={addFilterValue}
onChange={(e) => setAddFilterValue(e.target.value)}
className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none"
>
<option value="">Select queue...</option>
{queues.map((q) => (
<option key={q.id} value={q.id}>{q.name}</option>
))}
</select>
) : addFilterField === "owner" ? (
<select
value={addFilterValue}
onChange={(e) => setAddFilterValue(e.target.value)}
className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none"
>
<option value="">Select owner...</option>
<option value="unassigned">Unassigned</option>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.username}</option>
))}
</select>
) : (
<input
value={addFilterValue}
onChange={(e) => setAddFilterValue(e.target.value)}
placeholder="Value"
className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none"
onKeyDown={(e) => {
if (e.key === "Enter" && addFilterValue.trim()) {
const field = addFilterField;
const value = addFilterValue;
let valueLabel = value;
setFilters((prev) => [...prev, {
id: crypto.randomUUID(),
field,
operator: addFilterOperator,
value,
label: buildFilterLabel(field, addFilterOperator, valueLabel),
}]);
setAddFilterField(null);
setAddFilterOpen(false);
}
}}
/>
)}
<div className="flex items-center justify-end gap-1 pt-1">
<button
type="button"
onClick={() => {
setAddFilterField(null);
setAddFilterOpen(false);
}}
className="rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
>
Cancel
</button>
<button
type="button"
disabled={!addFilterValue.trim()}
onClick={() => {
if (!addFilterValue.trim()) return;
const field = addFilterField;
const value = addFilterValue;
const operator = addFilterOperator;
let valueLabel = value;
if (field === "queue") {
valueLabel = queues.find((q) => q.id === value)?.name ?? value;
} else if (field === "owner") {
valueLabel = value === "unassigned" ? "Unassigned"
: users.find((u) => u.id === value)?.username ?? value;
}
setFilters((prev) => [...prev, {
id: crypto.randomUUID(),
field,
operator,
value,
label: buildFilterLabel(field, operator, valueLabel),
}]);
setAddFilterField(null);
setAddFilterOpen(false);
}}
className="rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Apply
</button>
</div>
</div>
)}
</div>
</>
)}
</div> </div>
</div> </div>
@@ -1217,6 +1039,116 @@ function TicketWorkbenchContent() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{typeof document !== "undefined" && addFilterOpen && createPortal(
<>
<div
className="fixed inset-0 z-[9998]"
onClick={() => {
setAddFilterOpen(false);
setAddFilterField(null);
}}
/>
<div
id="add-filter-popover"
className="fixed z-[9999] w-52 rounded-md border border-border bg-popover p-1 shadow-lg"
>
{!addFilterField ? (
<>
<button
type="button"
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
onClick={() => {
if (!filters.find((f) => f.field === "queue")) {
setAddFilterField("queue");
setAddFilterOperator("is");
setAddFilterValue("");
} else {
setAddFilterOpen(false);
}
}}
>Queue</button>
<button
type="button"
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
onClick={() => {
if (!filters.find((f) => f.field === "owner")) {
setAddFilterField("owner");
setAddFilterOperator("is");
setAddFilterValue("");
} else {
setAddFilterOpen(false);
}
}}
>Owner</button>
{customFields.map((cf) => (
<button
key={`cf-portal-${cf.id}`}
type="button"
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
onClick={() => {
setAddFilterField(`cf.${cf.key}`);
setAddFilterOperator("is");
setAddFilterValue("");
}}
>{cf.name}</button>
))}
</>
) : (
<div className="space-y-2 p-1">
<div className="flex items-center gap-1 text-xs">
<button type="button" onClick={() => setAddFilterField(null)} className="text-muted-foreground hover:text-foreground"></button>
<span className="font-medium text-foreground">{addFilterField.startsWith("cf.") ? addFilterField.slice(3) : addFilterField}</span>
</div>
<select value={addFilterOperator} onChange={(e) => setAddFilterOperator(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
<option value="is">is</option>
<option value="is_not">is not</option>
</select>
{addFilterField === "queue" ? (
<select value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
<option value="">Select queue...</option>
{queues.map((q) => (<option key={q.id} value={q.id}>{q.name}</option>))}
</select>
) : addFilterField === "owner" ? (
<select value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
<option value="">Select owner...</option>
<option value="unassigned">Unassigned</option>
{users.map((u) => (<option key={u.id} value={u.id}>{u.username}</option>))}
</select>
) : (
<input value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} placeholder="Value" className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none"
onKeyDown={(e) => {
if (e.key === "Enter" && addFilterValue.trim()) {
setFilters((prev) => [...prev, { id: crypto.randomUUID(), field: addFilterField, operator: addFilterOperator, value: addFilterValue, label: buildFilterLabel(addFilterField, addFilterOperator, addFilterValue) }]);
setAddFilterField(null);
setAddFilterOpen(false);
}
}}
/>
)}
<div className="flex items-center justify-end gap-1 pt-1">
<button type="button" onClick={() => { setAddFilterField(null); setAddFilterOpen(false); }} className="rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground">Cancel</button>
<button type="button" disabled={!addFilterValue.trim()}
onClick={() => {
if (!addFilterValue.trim()) return;
const field = addFilterField;
const value = addFilterValue;
let valueLabel = value;
if (field === "queue") valueLabel = queues.find((q) => q.id === value)?.name ?? value;
else if (field === "owner") valueLabel = value === "unassigned" ? "Unassigned" : users.find((u) => u.id === value)?.username ?? value;
setFilters((prev) => [...prev, { id: crypto.randomUUID(), field, operator: addFilterOperator, value, label: buildFilterLabel(field, addFilterOperator, valueLabel) }]);
setAddFilterField(null);
setAddFilterOpen(false);
}}
className="rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>Apply</button>
</div>
</div>
)}
</div>
</>,
document.body
)}
</div> </div>
); );
} }

View File

@@ -6,6 +6,7 @@ import Link from "next/link";
import { import {
ChevronRightIcon, ChevronRightIcon,
LayoutGridIcon, LayoutGridIcon,
PlusIcon,
UserIcon, UserIcon,
InboxIcon, InboxIcon,
ClockIcon, ClockIcon,
@@ -14,7 +15,7 @@ import {
PanelLeftIcon, PanelLeftIcon,
CommandIcon, CommandIcon,
} from "lucide-react"; } from "lucide-react";
import { getTickets, getQueues, getViews, getDashboards, getUsers } from "@/lib/api"; import { getTickets, getQueues, getViews, getDashboards, getUsers, createDashboard } from "@/lib/api";
import type { Dashboard, Queue, SavedView, User } from "@/lib/types"; import type { Dashboard, Queue, SavedView, User } from "@/lib/types";
import { CommandPalette } from "@/components/command-palette"; import { CommandPalette } from "@/components/command-palette";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
@@ -95,6 +96,8 @@ function SidebarNav() {
queues: true, queues: true,
views: true, views: true,
}); });
const [newDashboardName, setNewDashboardName] = useState("");
const [addingDashboard, setAddingDashboard] = useState(false);
useEffect(() => { useEffect(() => {
// Find current user and compute view counts // Find current user and compute view counts
@@ -199,16 +202,52 @@ function SidebarNav() {
{dashboards.length > 0 && ( {dashboards.length > 0 && (
<div className="mt-4"> <div className="mt-4">
{!collapsed && ( {!collapsed && (
<button <div className="flex items-center justify-between px-2 py-1.5">
type="button" <button
onClick={() => setExpanded((e) => ({ ...e, dashboards: !e.dashboards }))} type="button"
className="flex w-full items-center gap-1 px-2 py-1.5 text-[11px] font-semibold text-sidebar-foreground/45 uppercase hover:text-sidebar-foreground/70" onClick={() => setExpanded((e) => ({ ...e, dashboards: !e.dashboards }))}
> className="flex items-center gap-1 text-[11px] font-semibold text-sidebar-foreground/45 uppercase hover:text-sidebar-foreground/70"
<ChevronRightIcon >
className={cn("h-3 w-3 transition-transform", expanded.dashboards && "rotate-90")} <ChevronRightIcon
/> className={cn("h-3 w-3 transition-transform", expanded.dashboards && "rotate-90")}
Dashboards />
</button> Dashboards
</button>
{addingDashboard ? (
<div className="flex items-center gap-1">
<input
value={newDashboardName}
onChange={(e) => setNewDashboardName(e.target.value)}
placeholder="Name"
className="h-6 w-24 rounded border border-sidebar-border bg-sidebar-accent px-1.5 text-[11px] text-sidebar-foreground outline-none"
autoFocus
onKeyDown={async (e) => {
if (e.key === "Enter" && newDashboardName.trim()) {
const { data } = await createDashboard({ name: newDashboardName.trim(), is_default: false });
if (data) {
setDashboards((prev) => [...prev, data]);
setNewDashboardName("");
setAddingDashboard(false);
window.location.href = `/dashboards/${data.id}`;
}
} else if (e.key === "Escape") {
setNewDashboardName("");
setAddingDashboard(false);
}
}}
/>
</div>
) : (
<button
type="button"
onClick={() => setAddingDashboard(true)}
className="text-sidebar-foreground/35 hover:text-sidebar-foreground/70"
title="New dashboard"
>
<PlusIcon className="h-3 w-3" />
</button>
)}
</div>
)} )}
{expanded.dashboards && dashboards.map((dash) => { {expanded.dashboards && dashboards.map((dash) => {
const active = const active =