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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
<div className="flex items-center justify-between px-2 py-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded((e) => ({ ...e, dashboards: !e.dashboards }))}
|
onClick={() => setExpanded((e) => ({ ...e, dashboards: !e.dashboards }))}
|
||||||
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"
|
className="flex items-center gap-1 text-[11px] font-semibold text-sidebar-foreground/45 uppercase hover:text-sidebar-foreground/70"
|
||||||
>
|
>
|
||||||
<ChevronRightIcon
|
<ChevronRightIcon
|
||||||
className={cn("h-3 w-3 transition-transform", expanded.dashboards && "rotate-90")}
|
className={cn("h-3 w-3 transition-transform", expanded.dashboards && "rotate-90")}
|
||||||
/>
|
/>
|
||||||
Dashboards
|
Dashboards
|
||||||
</button>
|
</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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user