feat(ai-chat): linkify variables/resources/triggers + inline drawer

- Workspace item registry now also lists variables, resources, schedules,
  and all 10 trigger kinds; resource wins over variable on path collisions
  (Windmill auto-creates a companion variable for every resource).
- Pill icons delegated to the canonical RowIcon component so each kind
  matches the home-page styling (script blue, flow teal, app orange,
  resource boxes, schedule calendar, etc.).
- Pill href includes the hash fragment each list page already consumes
  (#/resource/<path>, #<path> for variables/schedules/triggers), so
  opening the link puts the user on the list page with the matching
  editor drawer already open.
- For variable and resource pills, a hover-revealed side-panel button
  opens (or toggles closed) the editor drawer inline next to the chat,
  without navigating away. VariableEditor and ResourceEditorDrawer gain
  a closeDrawer() export and forward their close event so the host can
  drive toggling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-05-15 10:44:30 +02:00
parent d4223789e8
commit 83fcc8457a
9 changed files with 405 additions and 95 deletions
@@ -31,6 +31,10 @@
drawer?.openDrawer?.()
}
export function closeDrawer(): void {
drawer?.closeDrawer?.()
}
export async function initNew(
resourceType: string,
nDefaultValues?: Record<string, any>
@@ -45,10 +49,10 @@
let mode: 'edit' | 'new' = $derived(!path ? 'new' : 'edit')
</script>
<Drawer bind:this={drawer} size="50rem" {disableChatOffset}>
<Drawer bind:this={drawer} size="50rem" {disableChatOffset} on:close>
<DrawerContent
title={mode == 'edit' ? 'Edit ' + path : 'Add a resource'}
on:close={drawer?.closeDrawer}
on:close={() => drawer?.closeDrawer()}
>
{#await import('./ResourceEditor.svelte')}
<Loader2 class="animate-spin" />
@@ -136,6 +136,10 @@
drawer?.openDrawer()
}
export function closeDrawer(): void {
drawer?.closeDrawer()
}
async function loadSecret(): Promise<void> {
if (!editPath || !selected) return
const getV = await VariableService.getVariable({
@@ -196,10 +200,10 @@
}
</script>
<Drawer bind:this={drawer} size="50rem">
<Drawer bind:this={drawer} size="50rem" on:close>
<DrawerContent
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
on:close={drawer?.closeDrawer}
on:close={() => drawer?.closeDrawer()}
>
<div class="flex flex-col gap-8">
{#if !can_write}
@@ -146,6 +146,53 @@ class AIChatManager {
/** Cached datatables for app context (fetched asynchronously) */
cachedDatatables = $state<AppDatatableElement[]>([])
/**
* Inline-drawer request for a workspace item referenced from a chat message.
*
* Consumed by the drawer host mounted in AiChatLayout, which calls the matching
* editor's open method.
*
* `version` increments on every request so re-clicking after the drawer was closed
* via the X button (which doesn't reset this state) still re-triggers the effect.
*/
workspaceItemDrawer = $state<
| {
kind: WorkspaceItemEntry['kind']
path: string
version: number
open: boolean
}
| undefined
>(undefined)
/**
* Toggle the inline drawer for a workspace item.
*
* Behavior:
* - If a drawer for the same kind+path is currently open, close it.
* - Otherwise, set up state to open (or re-open) the drawer.
*/
toggleWorkspaceItemDrawer(target: { kind: WorkspaceItemEntry['kind']; path: string }): void {
const cur = this.workspaceItemDrawer
const sameTarget = cur?.kind === target.kind && cur.path === target.path
if (cur?.open && sameTarget) {
this.workspaceItemDrawer = { ...cur, open: false, version: cur.version + 1 }
return
}
this.workspaceItemDrawer = {
kind: target.kind,
path: target.path,
version: (cur?.version ?? 0) + 1,
open: true
}
}
/** Mark the inline drawer as closed (called by the host when the drawer's own X is hit). */
markWorkspaceItemDrawerClosed(): void {
if (!this.workspaceItemDrawer?.open) return
this.workspaceItemDrawer = { ...this.workspaceItemDrawer, open: false }
}
private confirmationCallback = $state<((value: boolean) => void) | undefined>(undefined)
private appDatatablesRefreshTimeout: ReturnType<typeof setTimeout> | undefined = undefined
@@ -11,6 +11,7 @@
import Button from '$lib/components/common/button/Button.svelte'
import { Menu } from 'lucide-svelte'
import CreatedResourceActionDrawers from './CreatedResourceActionDrawers.svelte'
import WorkspaceItemDrawerHost from './WorkspaceItemDrawerHost.svelte'
interface Props {
noPadding?: boolean
@@ -50,6 +51,7 @@
{#if !disableAi}
<CreatedResourceActionDrawers />
<WorkspaceItemDrawerHost />
<Splitpanes horizontal={false} class="flex-1 min-h-0">
<Pane size={100 - chatState.size} minSize={50} class="flex flex-col grow min-h-0 ">
<div
@@ -1,8 +1,9 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { Code2, ExternalLink, LayoutDashboard } from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import type { WindmillItemKind } from './workspaceItems.svelte'
import { ExternalLink, PanelRight } from 'lucide-svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import { aiChatManager } from './AIChatManager.svelte'
import { hasInlineDrawer, type WindmillItemKind } from './workspaceItems.svelte'
type Props = {
href?: string
@@ -11,10 +12,10 @@
'data-wm-path'?: string
title?: string
}
let { href, children, 'data-wm-kind': wmKind, title }: Props = $props()
let { href, children, 'data-wm-kind': wmKind, 'data-wm-path': wmPath, title }: Props = $props()
// Fallback to URL-based detection if the data attribute didn't make it through
// (hast/rehype can rename custom properties on some pipelines).
// Fallback to URL-based detection for scripts/flows/apps. Other kinds rely on the
// data attribute set by the remark plugin.
const kind = $derived.by((): WindmillItemKind | undefined => {
if (wmKind) return wmKind
if (!href) return undefined
@@ -23,33 +24,41 @@
if (href.startsWith('/apps/get/')) return 'app'
return undefined
})
const drawerable = $derived(kind ? hasInlineDrawer(kind) : false)
</script>
{#if href}
{#if kind}
<a
{href}
target="_blank"
rel="noopener noreferrer"
title={title || href}
class="group inline-flex items-baseline gap-1 px-1 rounded hover:bg-surface-hover text-primary no-underline font-mono text-[0.9em] align-baseline"
>
<span class="inline-flex self-center shrink-0">
{#if kind === 'script'}
<Code2 size={12} class="text-blue-500" />
{:else if kind === 'flow'}
<BarsStaggered size={12} style="" class="!fill-current text-teal-500" />
{:else if kind === 'app'}
<LayoutDashboard size={12} class="text-orange-500" />
{/if}
</span>
{@render children?.()}
<span
class="inline-flex self-center shrink-0 text-tertiary opacity-0 group-hover:opacity-100 transition-opacity"
<span class="group inline-flex items-baseline">
<a
{href}
target="_blank"
rel="noopener noreferrer"
title={title || wmPath || href}
class="inline-flex items-baseline gap-1 px-1 rounded hover:bg-surface-hover text-primary no-underline font-mono text-[0.9em] align-baseline"
>
<ExternalLink size={10} />
</span>
</a>
<span class="inline-flex self-center shrink-0">
<RowIcon {kind} size={12} />
</span>
{@render children?.()}
<span
class="inline-flex self-center shrink-0 text-tertiary opacity-0 group-hover:opacity-100 transition-opacity"
>
<ExternalLink size={10} />
</span>
</a>
{#if drawerable && wmPath}
<button
type="button"
onclick={() => aiChatManager.toggleWorkspaceItemDrawer({ kind, path: wmPath })}
title="Open in drawer"
aria-label="Open {wmPath} in drawer"
class="ml-0.5 inline-flex self-center shrink-0 rounded p-0.5 text-tertiary hover:bg-surface-hover opacity-0 group-hover:opacity-100 transition-opacity"
>
<PanelRight size={11} />
</button>
{/if}
</span>
{:else}
<a {href} target="_blank" rel="noopener noreferrer" {title}>
{@render children?.()}
@@ -5,9 +5,8 @@
ChevronRight,
XCircle,
Play,
Code2,
LayoutDashboard,
ExternalLink
ExternalLink,
PanelRight
} from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { aiChatManager } from './AIChatManager.svelte'
@@ -15,10 +14,11 @@
import { twMerge } from 'tailwind-merge'
import ToolContentDisplay from './ToolContentDisplay.svelte'
import ToolMessageActions from './ToolMessageActions.svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import { workspaceStore } from '$lib/stores'
import {
extractCandidatePaths,
hasInlineDrawer,
itemHref,
workspaceItemRegistry,
type WorkspaceItemEntry
@@ -100,15 +100,17 @@
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden font-mono text-xs"
>
<!-- Collapsible Header -->
<button
<div
class={twMerge(
'w-full p-2 bg-surface-secondary hover:bg-surface-hover transition-colors flex items-center justify-between text-left border-b border-gray-200 dark:border-gray-700',
'border-b border-gray-200 dark:border-gray-700 bg-surface-secondary',
message.needsConfirmation ? 'opacity-80' : ''
)}
onclick={() => (isExpanded = !isExpanded)}
disabled={!message.showDetails && !message.isStreamingArguments}
>
<div class="flex items-start gap-2 flex-1 min-w-0">
<button
class="w-full p-2 hover:bg-surface-hover transition-colors flex items-start gap-2 text-left"
onclick={() => (isExpanded = !isExpanded)}
disabled={!message.showDetails && !message.isStreamingArguments}
>
{#if message.showDetails || message.isStreamingArguments}
<span class="shrink-0 mt-0.5">
{#if isExpanded}
@@ -128,41 +130,51 @@
<span class="text-green-500"></span>
{/if}
</span>
<div class="flex flex-col gap-1 min-w-0 flex-1">
<span class="text-primary font-medium text-2xs">
{message.content}
</span>
{#if referencedItems.length > 0}
<div class="flex flex-row flex-wrap items-center gap-1 min-w-0">
{#each referencedItems as item (item.path)}
<a
href={itemHref(item, $workspaceStore ?? undefined)}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
title={item.summary || item.path}
class="group inline-flex items-center gap-1 px-1 py-0.5 rounded hover:bg-surface-hover text-primary no-underline font-mono text-2xs max-w-full min-w-0"
<span class="text-primary font-medium text-2xs flex-1 min-w-0">
{message.content}
</span>
</button>
{#if referencedItems.length > 0}
<!-- Chip row lives outside the toggle button so we can include real <button>s
for the "open in drawer" affordance without nesting interactive elements. -->
<div class="flex flex-row flex-wrap items-center gap-1 px-2 pb-2 -mt-1 min-w-0">
{#each referencedItems as item (item.path)}
<span class="group inline-flex items-center min-w-0 max-w-full">
<a
href={itemHref(item, $workspaceStore ?? undefined)}
target="_blank"
rel="noopener noreferrer"
title={item.summary || item.path}
class="inline-flex items-center gap-1 px-1 py-0.5 rounded hover:bg-surface-hover text-primary no-underline font-mono text-2xs min-w-0"
>
<span class="inline-flex shrink-0">
<RowIcon kind={item.kind} size={12} />
</span>
<span class="truncate">{item.path}</span>
<ExternalLink
class="w-2.5 h-2.5 shrink-0 text-tertiary opacity-0 group-hover:opacity-100 transition-opacity"
/>
</a>
{#if hasInlineDrawer(item.kind)}
<button
type="button"
onclick={() =>
aiChatManager.toggleWorkspaceItemDrawer({
kind: item.kind,
path: item.path
})}
title="Open in drawer"
aria-label="Open {item.path} in drawer"
class="ml-0.5 inline-flex self-center shrink-0 rounded p-0.5 text-tertiary hover:bg-surface-hover opacity-0 group-hover:opacity-100 transition-opacity"
>
<span class="inline-flex shrink-0">
{#if item.kind === 'script'}
<Code2 class="w-3 h-3 text-blue-500" />
{:else if item.kind === 'flow'}
<BarsStaggered size={12} style="" class="!fill-current text-teal-500" />
{:else}
<LayoutDashboard class="w-3 h-3 text-orange-500" />
{/if}
</span>
<span class="truncate">{item.path}</span>
<ExternalLink
class="w-2.5 h-2.5 shrink-0 text-tertiary opacity-0 group-hover:opacity-100 transition-opacity"
/>
</a>
{/each}
</div>
{/if}
<PanelRight class="w-2.5 h-2.5" />
</button>
{/if}
</span>
{/each}
</div>
</div>
</button>
{/if}
</div>
<!-- Expanded Content -->
{#if isExpanded}
@@ -0,0 +1,51 @@
<script lang="ts">
import VariableEditor from '$lib/components/VariableEditor.svelte'
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
import { aiChatManager } from './AIChatManager.svelte'
/**
* Hosts the workspace-item drawers next to the chat. When a chat pill asks to open
* one, the host calls the matching editor's open method; when the pill is clicked
* again (toggle) or the chat manager wants to close, the host calls `closeDrawer`.
*
* Both editors are mounted unconditionally on purpose. Their internal `<Drawer>`
* wires close handling at mount time, and the resources page (which has worked for
* a long time) uses the same always-mounted pattern. Conditional mounting via
* `{#if}` was racy — the `on:close` handler captured an undefined reference and the
* X button stopped closing the drawer. The drawer markup stays hidden until
* `openDrawer()` is called, and the heavy editor body is still loaded lazily via
* `import()` inside `ResourceEditorDrawer`, so this isn't expensive.
*/
let variableEditor: VariableEditor | undefined = $state()
let resourceEditor: ResourceEditorDrawer | undefined = $state()
let lastVersion = -1
$effect(() => {
const t = aiChatManager.workspaceItemDrawer
if (!t) return
// React on every version bump (covers open, re-open, and toggle-close).
if (t.version === lastVersion) return
lastVersion = t.version
if (!t.open) {
variableEditor?.closeDrawer()
resourceEditor?.closeDrawer()
return
}
if (t.kind === 'variable' && variableEditor) {
variableEditor.editVariable(t.path)
} else if (t.kind === 'resource' && resourceEditor) {
resourceEditor.initEdit(t.path)
}
})
</script>
<VariableEditor
bind:this={variableEditor}
on:close={() => aiChatManager.markWorkspaceItemDrawerClosed()}
/>
<ResourceEditorDrawer
bind:this={resourceEditor}
on:close={() => aiChatManager.markWorkspaceItemDrawerClosed()}
/>
@@ -1,9 +1,42 @@
import { AppService, FlowService, ScriptService } from '$lib/gen'
import {
AppService,
AzureTriggerService,
EmailTriggerService,
FlowService,
GcpTriggerService,
HttpTriggerService,
KafkaTriggerService,
MqttTriggerService,
NatsTriggerService,
PostgresTriggerService,
ResourceService,
ScheduleService,
ScriptService,
SqsTriggerService,
VariableService,
WebsocketTriggerService
} from '$lib/gen'
import { findAndReplace } from 'mdast-util-find-and-replace'
import { visit } from 'unist-util-visit'
import type { Root, InlineCode, Link } from 'mdast'
export type WindmillItemKind = 'script' | 'flow' | 'app'
export type WindmillItemKind =
| 'script'
| 'flow'
| 'app'
| 'variable'
| 'resource'
| 'schedule'
| 'http_trigger'
| 'websocket_trigger'
| 'kafka_trigger'
| 'nats_trigger'
| 'postgres_trigger'
| 'mqtt_trigger'
| 'sqs_trigger'
| 'gcp_trigger'
| 'azure_trigger'
| 'email_trigger'
export interface WorkspaceItemEntry {
kind: WindmillItemKind
@@ -30,16 +63,60 @@ export const WINDMILL_PATH_REGEX =
*/
const WINDMILL_PATH_EXACT_REGEX = /^[uf]\/[A-Za-z0-9_.\-]+\/[A-Za-z0-9_./\-]*[A-Za-z0-9_\-]$/
const itemKindToRoute: Record<WindmillItemKind, string> = {
script: '/scripts/get',
flow: '/flows/get',
app: '/apps/get'
/**
* URL builder per kind.
*
* - For scripts/flows/apps we link to the dedicated `/get/{path}` page.
* - For everything else, link to the relevant list page with a hash fragment that the
* list page reads on mount to pop the editor drawer open. Hash format mirrors what
* each list page already expects (`#<path>` for most, `#/resource/<path>` for the
* resources page).
*/
const itemKindToHref: Record<WindmillItemKind, (path: string) => string> = {
script: (p) => `/scripts/get/${p}`,
flow: (p) => `/flows/get/${p}`,
app: (p) => `/apps/get/${p}`,
variable: (p) => `/variables#${p}`,
resource: (p) => `/resources#/resource/${p}`,
schedule: (p) => `/schedules#${p}`,
http_trigger: (p) => `/routes#${p}`,
websocket_trigger: (p) => `/websocket_triggers/#${p}`,
kafka_trigger: (p) => `/kafka_triggers/#${p}`,
nats_trigger: (p) => `/nats_triggers/#${p}`,
postgres_trigger: (p) => `/postgres_triggers/#${p}`,
mqtt_trigger: (p) => `/mqtt_triggers/#${p}`,
sqs_trigger: (p) => `/sqs_triggers/#${p}`,
gcp_trigger: (p) => `/gcp_triggers/#${p}`,
azure_trigger: (p) => `/azure_triggers/#${p}`,
email_trigger: (p) => `/email_triggers/#${p}`
}
/** Build the in-app URL for a resolved workspace item. */
/** Kinds whose items can be opened in an inline drawer from the chat. */
const DRAWERABLE_KINDS = new Set<WindmillItemKind>(['variable', 'resource'])
/** Whether the chat pill should expose an "open in drawer" affordance for this kind. */
export function hasInlineDrawer(kind: WindmillItemKind): boolean {
return DRAWERABLE_KINDS.has(kind)
}
/**
* Build the in-app URL for a resolved workspace item.
*
* The workspace query param goes before the hash so the SvelteKit router still applies
* it; the hash fragment is consumed client-side by the list page on mount to open the
* matching drawer.
*/
export function itemHref(entry: WorkspaceItemEntry, workspace?: string): string {
const base = `${itemKindToRoute[entry.kind]}/${entry.path}`
return workspace ? `${base}?workspace=${workspace}` : base
const raw = itemKindToHref[entry.kind](entry.path)
if (!workspace) return raw
const hashIdx = raw.indexOf('#')
if (hashIdx === -1) {
return raw.includes('?') ? `${raw}&workspace=${workspace}` : `${raw}?workspace=${workspace}`
}
const pathPart = raw.slice(0, hashIdx)
const hashPart = raw.slice(hashIdx)
const sep = pathPart.includes('?') ? '&' : '?'
return `${pathPart}${sep}workspace=${workspace}${hashPart}`
}
/**
@@ -54,22 +131,78 @@ class WorkspaceItemRegistry {
#inflight: Map<string, Promise<void>> = new Map()
private async load(workspace: string): Promise<void> {
const [scripts, flows, apps] = await Promise.all([
// Fire every list endpoint in parallel. `.catch(() => [])` keeps a single failing
// endpoint from poisoning the whole snapshot — the registry just records empty
// for that kind, the rest still resolve.
const [
scripts,
flows,
apps,
variables,
resources,
schedules,
httpTriggers,
wsTriggers,
kafkaTriggers,
natsTriggers,
pgTriggers,
mqttTriggers,
sqsTriggers,
gcpTriggers,
azureTriggers,
emailTriggers
] = await Promise.all([
ScriptService.listScripts({ workspace }).catch(() => []),
FlowService.listFlows({ workspace }).catch(() => []),
AppService.listApps({ workspace }).catch(() => [])
AppService.listApps({ workspace }).catch(() => []),
VariableService.listVariable({ workspace }).catch(() => []),
ResourceService.listResource({ workspace }).catch(() => []),
ScheduleService.listSchedules({ workspace }).catch(() => []),
HttpTriggerService.listHttpTriggers({ workspace }).catch(() => []),
WebsocketTriggerService.listWebsocketTriggers({ workspace }).catch(() => []),
KafkaTriggerService.listKafkaTriggers({ workspace }).catch(() => []),
NatsTriggerService.listNatsTriggers({ workspace }).catch(() => []),
PostgresTriggerService.listPostgresTriggers({ workspace }).catch(() => []),
MqttTriggerService.listMqttTriggers({ workspace }).catch(() => []),
SqsTriggerService.listSqsTriggers({ workspace }).catch(() => []),
GcpTriggerService.listGcpTriggers({ workspace }).catch(() => []),
AzureTriggerService.listAzureTriggers({ workspace }).catch(() => []),
EmailTriggerService.listEmailTriggers({ workspace }).catch(() => [])
])
const map = new Map<string, WorkspaceItemEntry>()
for (const s of scripts) {
map.set(s.path, { kind: 'script', path: s.path, summary: s.summary })
}
for (const f of flows) {
map.set(f.path, { kind: 'flow', path: f.path, summary: f.summary })
}
for (const a of apps) {
map.set(a.path, { kind: 'app', path: a.path, summary: a.summary })
const add = (
items: Array<{ path: string; summary?: string | null }>,
kind: WindmillItemKind
) => {
for (const it of items) {
// First writer wins — scripts/flows/apps are checked first so they win over
// triggers if a path collides (extremely unlikely but possible across kinds).
if (!map.has(it.path)) {
map.set(it.path, { kind, path: it.path, summary: it.summary ?? undefined })
}
}
}
// Order matters because of first-writer-wins on path collisions. Resources are
// added before variables: Windmill auto-creates a companion variable at the same
// path for every resource, and a path written by the user almost always means the
// resource, not the hidden variable.
add(scripts, 'script')
add(flows, 'flow')
add(apps, 'app')
add(resources, 'resource')
add(variables, 'variable')
add(schedules, 'schedule')
add(httpTriggers, 'http_trigger')
add(wsTriggers, 'websocket_trigger')
add(kafkaTriggers, 'kafka_trigger')
add(natsTriggers, 'nats_trigger')
add(pgTriggers, 'postgres_trigger')
add(mqttTriggers, 'mqtt_trigger')
add(sqsTriggers, 'sqs_trigger')
add(gcpTriggers, 'gcp_trigger')
add(azureTriggers, 'azure_trigger')
add(emailTriggers, 'email_trigger')
// Build a new outer map to trigger reactivity on consumers using $derived.
const next = new Map(this.#byWorkspace)
@@ -7,7 +7,20 @@ import type { Root as MdastRoot, Link, Text } from 'mdast'
vi.mock('$lib/gen', () => ({
ScriptService: { listScripts: vi.fn() },
FlowService: { listFlows: vi.fn() },
AppService: { listApps: vi.fn() }
AppService: { listApps: vi.fn() },
VariableService: { listVariable: vi.fn() },
ResourceService: { listResource: vi.fn() },
ScheduleService: { listSchedules: vi.fn() },
HttpTriggerService: { listHttpTriggers: vi.fn() },
WebsocketTriggerService: { listWebsocketTriggers: vi.fn() },
KafkaTriggerService: { listKafkaTriggers: vi.fn() },
NatsTriggerService: { listNatsTriggers: vi.fn() },
PostgresTriggerService: { listPostgresTriggers: vi.fn() },
MqttTriggerService: { listMqttTriggers: vi.fn() },
SqsTriggerService: { listSqsTriggers: vi.fn() },
GcpTriggerService: { listGcpTriggers: vi.fn() },
AzureTriggerService: { listAzureTriggers: vi.fn() },
EmailTriggerService: { listEmailTriggers: vi.fn() }
}))
import {
@@ -62,7 +75,7 @@ describe('WINDMILL_PATH_REGEX', () => {
})
describe('itemHref', () => {
it('routes by kind and appends ?workspace when provided', () => {
it('routes script/flow/app to /get/{path}', () => {
expect(itemHref({ kind: 'script', path: 'f/a/b' })).toBe('/scripts/get/f/a/b')
expect(itemHref({ kind: 'flow', path: 'f/a/b' }, 'admins')).toBe(
'/flows/get/f/a/b?workspace=admins'
@@ -71,6 +84,41 @@ describe('itemHref', () => {
'/apps/get/u/me/dash?workspace=ws1'
)
})
it('routes variable / resource / schedule to list page with hash to pop the drawer', () => {
expect(itemHref({ kind: 'variable', path: 'u/me/secret' })).toBe('/variables#u/me/secret')
expect(itemHref({ kind: 'resource', path: 'u/me/db' }, 'ws1')).toBe(
'/resources?workspace=ws1#/resource/u/me/db'
)
expect(itemHref({ kind: 'schedule', path: 'f/etl/daily' })).toBe('/schedules#f/etl/daily')
})
it('routes each trigger kind with hash for drawer auto-open', () => {
const cases: Array<[WorkspaceItemEntry['kind'], string]> = [
['http_trigger', '/routes#f/a/b'],
['websocket_trigger', '/websocket_triggers/#f/a/b'],
['kafka_trigger', '/kafka_triggers/#f/a/b'],
['nats_trigger', '/nats_triggers/#f/a/b'],
['postgres_trigger', '/postgres_triggers/#f/a/b'],
['mqtt_trigger', '/mqtt_triggers/#f/a/b'],
['sqs_trigger', '/sqs_triggers/#f/a/b'],
['gcp_trigger', '/gcp_triggers/#f/a/b'],
['azure_trigger', '/azure_triggers/#f/a/b'],
['email_trigger', '/email_triggers/#f/a/b']
]
for (const [kind, expected] of cases) {
expect(itemHref({ kind, path: 'f/a/b' })).toBe(expected)
}
})
it('puts workspace query param before the hash so the router applies it', () => {
expect(itemHref({ kind: 'variable', path: 'u/me/secret' }, 'ws1')).toBe(
'/variables?workspace=ws1#u/me/secret'
)
expect(itemHref({ kind: 'http_trigger', path: 'f/a/b' }, 'ws1')).toBe(
'/routes?workspace=ws1#f/a/b'
)
})
})
const SAMPLE_ENTRIES: Record<string, WorkspaceItemEntry> = {