fix: replace broken add-filter button with stepped filter builder
- Fixed popover z-index: uses fixed positioning with z-50 above backdrop - Stepped flow: select field → set operator (is/is_not) → choose/write value → Apply - Removed old inline CF value inputs (handled inline in the new flow) - Fixed filter persistence: clear filters when navigating away from saved view - Fixed home redirect: check for default dashboard on load Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -159,6 +159,9 @@ function TicketWorkbenchContent() {
|
|||||||
const [saveViewOpen, setSaveViewOpen] = useState(false);
|
const [saveViewOpen, setSaveViewOpen] = useState(false);
|
||||||
const [saveViewName, setSaveViewName] = useState("");
|
const [saveViewName, setSaveViewName] = useState("");
|
||||||
const [addFilterOpen, setAddFilterOpen] = useState(false);
|
const [addFilterOpen, setAddFilterOpen] = useState(false);
|
||||||
|
const [addFilterField, setAddFilterField] = useState<string | null>(null); // which field type is selected
|
||||||
|
const [addFilterOperator, setAddFilterOperator] = useState("is");
|
||||||
|
const [addFilterValue, setAddFilterValue] = useState("");
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [newSubject, setNewSubject] = useState("");
|
const [newSubject, setNewSubject] = useState("");
|
||||||
@@ -653,10 +656,18 @@ function TicketWorkbenchContent() {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<div className="relative">
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAddFilterOpen((prev) => !prev)}
|
onClick={(event) => {
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const popover = document.getElementById("add-filter-popover");
|
||||||
|
if (popover) {
|
||||||
|
popover.style.left = `${rect.left}px`;
|
||||||
|
popover.style.top = `${rect.bottom + 4}px`;
|
||||||
|
}
|
||||||
|
setAddFilterOpen((prev) => !prev);
|
||||||
|
}}
|
||||||
className="inline-flex h-7 items-center gap-1 rounded border border-dashed border-border px-2 text-xs font-medium text-muted-foreground hover:border-ring hover:text-foreground transition-colors"
|
className="inline-flex h-7 items-center gap-1 rounded border border-dashed border-border px-2 text-xs font-medium text-muted-foreground hover:border-ring hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-3 w-3" />
|
<PlusIcon className="h-3 w-3" />
|
||||||
@@ -664,100 +675,179 @@ function TicketWorkbenchContent() {
|
|||||||
</button>
|
</button>
|
||||||
{addFilterOpen && (
|
{addFilterOpen && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setAddFilterOpen(false)} />
|
<div
|
||||||
<div className="absolute left-0 top-full z-20 mt-1 w-52 rounded-md border border-border bg-card p-1 shadow-lg">
|
className="fixed inset-0 z-40"
|
||||||
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase text-muted-foreground">Queue</div>
|
onClick={() => {
|
||||||
{queues.map((q) => (
|
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
|
<button
|
||||||
key={`q:${q.id}`}
|
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!filters.find((f) => f.field === "queue")) {
|
if (!filters.find((f) => f.field === "queue")) {
|
||||||
setFilters((prev) => [...prev, {
|
setAddFilterField("queue");
|
||||||
id: crypto.randomUUID(),
|
setAddFilterOperator("is");
|
||||||
field: "queue",
|
setAddFilterValue("");
|
||||||
operator: "is",
|
} else {
|
||||||
value: q.id,
|
|
||||||
label: buildFilterLabel("queue", "is", q.name),
|
|
||||||
}]);
|
|
||||||
}
|
|
||||||
setAddFilterOpen(false);
|
setAddFilterOpen(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{q.name}
|
Queue
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
<div className="mt-0.5 border-t border-border pt-0.5" />
|
|
||||||
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase text-muted-foreground">Owner</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!filters.find((f) => f.field === "owner")) {
|
if (!filters.find((f) => f.field === "owner")) {
|
||||||
setFilters((prev) => [...prev, {
|
setAddFilterField("owner");
|
||||||
id: crypto.randomUUID(),
|
setAddFilterOperator("is");
|
||||||
field: "owner",
|
setAddFilterValue("");
|
||||||
operator: "is",
|
} else {
|
||||||
value: "unassigned",
|
|
||||||
label: buildFilterLabel("owner", "is", "Unassigned"),
|
|
||||||
}]);
|
|
||||||
}
|
|
||||||
setAddFilterOpen(false);
|
setAddFilterOpen(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Unassigned
|
Owner
|
||||||
</button>
|
</button>
|
||||||
{users.map((u) => (
|
|
||||||
<button
|
|
||||||
key={`o:${u.id}`}
|
|
||||||
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")) {
|
|
||||||
setFilters((prev) => [...prev, {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
field: "owner",
|
|
||||||
operator: "is",
|
|
||||||
value: u.id,
|
|
||||||
label: buildFilterLabel("owner", "is", u.username),
|
|
||||||
}]);
|
|
||||||
}
|
|
||||||
setAddFilterOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{u.username}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{customFields.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="mt-0.5 border-t border-border pt-0.5" />
|
|
||||||
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase text-muted-foreground">Custom field</div>
|
|
||||||
{customFields.map((cf) => (
|
{customFields.map((cf) => (
|
||||||
<button
|
<button
|
||||||
key={`cf:${cf.id}`}
|
key={`cf-field-${cf.id}`}
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const cfFilter: Filter = {
|
setAddFilterField(`cf.${cf.key}`);
|
||||||
id: crypto.randomUUID(),
|
setAddFilterOperator("is");
|
||||||
field: `cf.${cf.key}`,
|
setAddFilterValue("");
|
||||||
operator: "is",
|
|
||||||
value: "",
|
|
||||||
label: `${cf.name} is ...`,
|
|
||||||
};
|
|
||||||
setFilters((prev) => [...prev, cfFilter]);
|
|
||||||
setAddFilterOpen(false);
|
|
||||||
setTimeout(() => {
|
|
||||||
const input = document.getElementById(`cf-value-${cfFilter.id}`) as HTMLInputElement;
|
|
||||||
input?.focus();
|
|
||||||
}, 50);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cf.name}
|
{cf.name}
|
||||||
</button>
|
</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>
|
||||||
</>
|
</>
|
||||||
@@ -765,72 +855,6 @@ function TicketWorkbenchContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inline value inputs for custom field filters */}
|
|
||||||
{filters
|
|
||||||
.filter((f) => f.field.startsWith("cf.") && f.value === "")
|
|
||||||
.map((f) => {
|
|
||||||
const cf = customFields.find((x) => x.key === f.field.slice(3));
|
|
||||||
const options = Array.isArray(cf?.values)
|
|
||||||
? cf.values.map((v) => String(v))
|
|
||||||
: [];
|
|
||||||
return (
|
|
||||||
<div key={`cf-input-${f.id}`} className="flex items-center gap-1.5">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">{cf?.name ?? f.field.slice(3)}:</span>
|
|
||||||
{options.length > 0 ? (
|
|
||||||
<select
|
|
||||||
id={`cf-value-${f.id}`}
|
|
||||||
value=""
|
|
||||||
onChange={(event) => {
|
|
||||||
const val = event.target.value;
|
|
||||||
if (!val) return;
|
|
||||||
setFilters((prev) =>
|
|
||||||
prev.map((x) =>
|
|
||||||
x.id === f.id
|
|
||||||
? { ...x, value: val, label: buildFilterLabel(x.field, "is", val) }
|
|
||||||
: x
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="h-7 rounded border border-input bg-card px-2 text-xs outline-none"
|
|
||||||
>
|
|
||||||
<option value="">Select value...</option>
|
|
||||||
{options.map((opt) => (
|
|
||||||
<option key={opt} value={opt}>{opt}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
id={`cf-value-${f.id}`}
|
|
||||||
placeholder="value"
|
|
||||||
className="h-7 w-32 rounded border border-input bg-card px-2 text-xs outline-none focus:border-ring"
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
const val = event.currentTarget.value.trim();
|
|
||||||
if (val) {
|
|
||||||
setFilters((prev) =>
|
|
||||||
prev.map((x) =>
|
|
||||||
x.id === f.id
|
|
||||||
? { ...x, value: val, label: buildFilterLabel(x.field, "is", val) }
|
|
||||||
: x
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFilters((prev) => prev.filter((x) => x.id !== f.id))}
|
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
Reference in New Issue
Block a user