Make search results open the actual entry, not just its list page

Previously a search result for a secret/IP/integration/DNS record
only filtered or scrolled to the right page — you still had to find
and click it yourself. Now:
- Secrets/IPAM results pass ?editId= and the page auto-opens that
  row's existing edit form on load (viewers still just get the ?q=
  filter, since they can't edit).
- Integration results now link to /integrations?editId= (which
  auto-opens IntegrationEditForm for that row) instead of the type's
  live dashboard page (/proxmox, /tailscale, etc.) — the dashboard
  can't distinguish between two integrations of the same type anyway,
  so it wasn't landing on "the" entry when more than one existed.
- DNS record results now also pass recordType/recordName/recordContent
  so Dns.tsx, after auto-selecting the right zone, finds that exact
  record by (type, name, content) and opens its edit form too. That
  triple was used instead of an id because the records-list endpoint
  returns both the cache table's internal integer id and the
  provider's own string record id under different field names, and
  matching by content sidesteps that ambiguity entirely rather than
  risking picking the wrong one.

Servers and DNS zones/providers already landed on their real entry
(server detail page; zone/provider auto-selected) so those are
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-20 19:16:09 +02:00
1 parent 009bb3e027
commit 5c1376cf1c
5 files changed
+45 -18

No files matched your search

+11 -15
View File
@@ -19,17 +19,6 @@ const INTEGRATION_TYPE_LABELS: Record<IntegrationType, string> = {
dockhand: "Dockhand",
};
// No per-integration-instance route exists yet — every integration of a
// given type links to that type's single dashboard page.
const INTEGRATION_TYPE_PATH: Record<IntegrationType, string> = {
tailscale: "/tailscale",
proxmox: "/proxmox",
synology: "/synology",
semaphore: "/semaphore",
gitea: "/gitea",
dockhand: "/docker",
};
const DNS_PROVIDER_LABELS: Record<DnsProviderType, string> = {
cloudflare: "Cloudflare",
loopia: "Loopia",
@@ -58,7 +47,7 @@ function buildItems(res: SearchResponse): PaletteItem[] {
category: "Secrets",
title: s.name,
subtitle: SECRET_TYPE_LABELS[s.type],
path: `/secrets?q=${encodeURIComponent(s.name)}`,
path: `/secrets?q=${encodeURIComponent(s.name)}&editId=${s.id}`,
});
}
for (const i of res.ipam) {
@@ -67,7 +56,7 @@ function buildItems(res: SearchResponse): PaletteItem[] {
category: "IP Addresses",
title: i.ipAddress,
subtitle: i.label ?? "",
path: `/ipam?q=${encodeURIComponent(i.ipAddress)}`,
path: `/ipam?q=${encodeURIComponent(i.ipAddress)}&editId=${i.id}`,
});
}
for (const z of res.dnsZones) {
@@ -80,12 +69,19 @@ function buildItems(res: SearchResponse): PaletteItem[] {
});
}
for (const r of res.dnsRecords) {
const params = new URLSearchParams({
providerId: String(r.providerId),
zoneId: r.zoneId,
recordType: r.type,
recordName: r.name,
recordContent: r.content,
});
items.push({
key: `record-${r.providerId}-${r.zoneId}-${r.type}-${r.name}-${r.content}`,
category: "DNS records",
title: `${r.type} ${r.name}`,
subtitle: `${r.content} · zone ${r.zoneName} · ${r.providerName}`,
path: `/dns?providerId=${r.providerId}&zoneId=${encodeURIComponent(r.zoneId)}`,
path: `/dns?${params.toString()}`,
});
}
for (const p of res.dnsProviders) {
@@ -103,7 +99,7 @@ function buildItems(res: SearchResponse): PaletteItem[] {
category: "Integrations",
title: i.name,
subtitle: INTEGRATION_TYPE_LABELS[i.type],
path: INTEGRATION_TYPE_PATH[i.type],
path: `/integrations?editId=${i.id}`,
});
}
return items;
+11
View File
@@ -104,6 +104,17 @@ export default function Dns({ user }: { user: CurrentUser }) {
.then((res) => {
setRecords(res.records);
setSyncedAt(res.syncedAt);
// Deep-link from global search (?recordType=&recordName=&recordContent=) — jump
// straight to that record's edit form. Matched by (type, name, content) rather
// than an id, since a record's identifier isn't reliably exposed to the frontend
// for this purpose, but that triple is unambiguous within one zone.
const linkedType = searchParams.get("recordType");
const linkedName = searchParams.get("recordName");
const linkedContent = searchParams.get("recordContent");
if (linkedType && linkedName && linkedContent && canEdit) {
const match = res.records.find((r) => r.type === linkedType && r.name === linkedName && r.content === linkedContent);
if (match) startEditRecord(match);
}
})
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}
+9 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, type CSSProperties } from "react";
import { useSearchParams } from "react-router-dom";
import { api, type CurrentUser, type IntegrationSummary, type IntegrationType } from "../api/client";
import IntegrationForm from "../components/IntegrationForm";
import IntegrationEditForm from "../components/IntegrationEditForm";
@@ -23,6 +24,7 @@ function typeBadgeStyle(colors: Record<string, string>, type: IntegrationType):
export default function Integrations({ user }: { user: CurrentUser }) {
const isAdmin = user.role === "admin";
const [searchParams] = useSearchParams();
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
const [adding, setAdding] = useState(false);
@@ -39,7 +41,13 @@ export default function Integrations({ user }: { user: CurrentUser }) {
function loadIntegrations() {
api.integrations
.list()
.then((res) => setIntegrations(res.integrations))
.then((res) => {
setIntegrations(res.integrations);
// Deep-link from global search (?editId=) — jump straight to that integration's edit form.
const editId = Number(searchParams.get("editId"));
const target = res.integrations.find((i) => i.id === editId);
if (target && isAdmin) setEditingIntegration(target);
})
.catch((err) => setError(String(err)));
}
+7 -1
View File
@@ -29,7 +29,13 @@ export default function Ipam({ user }: { user: CurrentUser }) {
function load() {
api.ipam
.list()
.then((res) => setEntries(res.entries))
.then((res) => {
setEntries(res.entries);
// Deep-link from global search (?editId=) — jump straight to that entry's edit form.
const editId = Number(searchParams.get("editId"));
const target = res.entries.find((e) => e.id === editId);
if (target && canEdit) startEdit(target);
})
.catch((err) => setError(String(err)));
}
+7 -1
View File
@@ -43,7 +43,13 @@ export default function Secrets({ user }: { user: CurrentUser }) {
function load() {
api.secrets
.list()
.then((res) => setSecrets(res.secrets))
.then((res) => {
setSecrets(res.secrets);
// Deep-link from global search (?editId=) — jump straight to that secret's edit form.
const editId = Number(searchParams.get("editId"));
const target = res.secrets.find((s) => s.id === editId);
if (target && canEdit) startEdit(target);
})
.catch((err) => setError(String(err)));
}