feat: label resource types and integrations with hub display names

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-14 13:06:08 +02:00
co-authored by Claude Opus 5
parent b50de89479
commit 7f47a2fd3e
11 changed files with 175 additions and 28 deletions
+7
View File
@@ -8706,6 +8706,9 @@ paths:
type: string
picks:
type: integer
display_name:
description: the label the hub curates for the resource type, absent where it names none
type: string
required:
- name
- app
@@ -8985,6 +8988,10 @@ paths:
picks:
description: how often the integration has been picked, absent on a hub that does not count picks
type: integer
display_name:
description: the label the hub curates for the integration, null or absent where it names none
type: string
nullable: true
required:
- name
+26 -8
View File
@@ -2672,6 +2672,10 @@ struct HubResourceTypeEntry {
/// fail the whole parse and take pick reporting — which needs just the id — with it.
#[serde(default)]
app: Option<String>,
/// Raw: `None` where nobody named the type. The frontend derives those labels with its own
/// word casing, which a titleised guess from the hub would override.
#[serde(default)]
display_name: Option<String>,
}
#[derive(Clone)]
@@ -2682,6 +2686,7 @@ struct HubResourceType {
/// hub knows that. Without it a workspace holding a `discord_webhook` resource looks
/// like one that has never touched Discord.
app: String,
display_name: Option<String>,
}
/// Reads the index cache, choosing the TTL by what is stored: a failure expires far sooner
@@ -2722,9 +2727,9 @@ async fn hub_resource_types(
if !response.status().is_success() {
return None;
}
// Only the id and the app are kept. That listing carries every type's schema —
// around a megabyte — and neither reporting a pick nor grouping types by
// integration needs it.
// Only the id, the app and the display name are kept. That listing carries every
// type's schema — around a megabyte — and neither reporting a pick, grouping types by
// integration nor labelling them needs it.
Some(
response
.json::<Vec<HubResourceTypeEntry>>()
@@ -2733,7 +2738,11 @@ async fn hub_resource_types(
.into_iter()
.map(|rt| {
let app = rt.app.unwrap_or_else(|| rt.name.clone());
(rt.name, HubResourceType { id: rt.id, app })
let display_name = rt
.display_name
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty());
(rt.name, HubResourceType { id: rt.id, app, display_name })
})
.collect::<HashMap<String, HubResourceType>>(),
)
@@ -2813,7 +2822,7 @@ mod hub_picks_tests {
let index = || {
Some(HashMap::from([(
"slack".to_string(),
HubResourceType { id: 1, app: "slack".to_string() },
HubResourceType { id: 1, app: "slack".to_string(), display_name: None },
)]))
};
@@ -2860,10 +2869,13 @@ struct HubResourceTypeInfo {
/// integration rather than per type.
app: String,
picks: i64,
/// The label the hub curates for the type, absent where it names none.
#[serde(skip_serializing_if = "Option::is_none")]
display_name: Option<String>,
}
/// What the hub knows about its resource types: which integration each belongs to, and how
/// often each has been picked.
/// What the hub knows about its resource types: which integration each belongs to, what it
/// names each, and how often each has been picked.
///
/// Empty rather than an error when the hub answers neither read, so the pickers treat an
/// older or private hub as "no hub signal" and fall back to what the workspace itself uses.
@@ -2917,6 +2929,7 @@ async fn list_hub_resource_type_info(
picks: picks_by_name.remove(&name).unwrap_or(0),
name,
app: rt.app,
display_name: rt.display_name,
})
.collect();
// What the index did not account for is a type the picks read knows and the listing does
@@ -2925,7 +2938,12 @@ async fn list_hub_resource_type_info(
info.extend(
picks_by_name
.into_iter()
.map(|(name, picks)| HubResourceTypeInfo { app: name.clone(), name, picks }),
.map(|(name, picks)| HubResourceTypeInfo {
app: name.clone(),
name,
picks,
display_name: None,
}),
);
Ok(Json(info))
@@ -20,6 +20,7 @@
import { OauthService } from '$lib/gen'
import { registryCcCapableFor } from '$lib/components/oauthRegistry'
import { resourceTypeDisplayName } from '$lib/components/resourceTypeDisplay'
import { loadHubResourceTypeDisplayNames } from '$lib/components/pickerPopularity'
import { applyOneMigration } from '$lib/components/workspaceSettings/projectInstall'
import { probeMigrationsApplied } from '$lib/importWizard/probe'
import {
@@ -197,6 +198,12 @@
* first half and Connect disappears on the eight such providers, where it would work.
*/
const canConnectType = (rt: string) => instanceConnects.has(rt) || registryCcCapableFor(rt)
// Row labels read the hub's curated resource type names, which arrive after first render.
$effect(() => {
loadHubResourceTypeDisplayNames(workspace)
})
let appConnect: AppConnectDrawer | undefined = $state(undefined)
const customInstanceDbs = resource([() => workspace], SettingService.listCustomInstanceDbs)
@@ -4,6 +4,7 @@
import { capitalize } from '$lib/utils'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import { APP_TO_ICON_COMPONENT } from '$lib/components/icons'
import { setHubIntegrationDisplayNames } from '$lib/components/resourceTypeDisplay'
import ListFilters from '$lib/components/home/ListFilters.svelte'
import { IntegrationService, ScriptService, type HubScriptKind } from '$lib/gen'
import { Loader2 } from 'lucide-svelte'
@@ -67,6 +68,7 @@
IntegrationService.listHubIntegrations({ kind: filterKind }),
$workspaceStore ? localCountsByIntegration($workspaceStore) : {}
])
setHubIntegrationDisplayNames(integrations)
const hubPicks = Object.fromEntries(integrations.map((x) => [x.name, x.picks ?? 0]))
popularity = byPopularity(hubPicks, local)
allApps = integrations.map((x) => x.name).sort(popularity)
@@ -1,7 +1,10 @@
<script module lang="ts">
let listHubIntegrationsCached = createCache(
({ kind }: { kind: HubScriptKind & string; refreshCount?: number }) =>
IntegrationService.listHubIntegrations({ kind }),
IntegrationService.listHubIntegrations({ kind }).then((integrations) => {
setHubIntegrationDisplayNames(integrations)
return integrations
}),
{ initial: { kind: 'script', refreshCount: 0 }, invalidateMs: 1000 * 60 }
)
@@ -40,6 +43,7 @@
import { Skeleton } from '$lib/components/common'
import { classNames, createCache } from '$lib/utils'
import { APP_TO_ICON_COMPONENT } from '$lib/components/icons'
import { setHubIntegrationDisplayNames } from '$lib/components/resourceTypeDisplay'
import { IntegrationService, ScriptService, type HubScriptKind } from '$lib/gen'
import { Circle, ExternalLink } from 'lucide-svelte'
import Popover from '$lib/components/Popover.svelte'
@@ -3,6 +3,7 @@
import { Badge } from '../common'
import type { BadgeColor, BadgeIconProps } from '../common/badge/model'
import { appIconComponent } from '../icons'
import { integrationDisplayName } from '../resourceTypeDisplay'
import { onDestroy, onMount } from 'svelte'
interface Props {
@@ -131,7 +132,7 @@
<Folder class="mr-0.5" size={14} />
{/if}
</span>
{filter}
{resourceType ? integrationDisplayName(filter) : filter}
{#if filter === selectedFilter}&cross;{/if}
</Badge>
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { Folder, User, Circle } from 'lucide-svelte'
import { appIconComponent } from '../icons'
import { integrationDisplayName } from '../resourceTypeDisplay'
import { createEventDispatcher } from 'svelte'
import { Button } from '../common'
@@ -51,7 +52,7 @@
btnClasses="justify-start"
title={filter}
>
<span class="truncate">{filter}</span>
<span class="truncate">{resourceType ? integrationDisplayName(filter) : filter}</span>
</Button>
</div>
{/each}
@@ -5,6 +5,10 @@
import Popover from '$lib/components/Popover.svelte'
import MultiSelect from '$lib/components/select/MultiSelect.svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
import {
integrationDisplayName,
setHubIntegrationDisplayNames
} from '$lib/components/resourceTypeDisplay'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { FlowService, FolderService, IntegrationService, ScriptService } from '$lib/gen'
import { mcpEndpointTools } from '$lib/mcpEndpointTools'
@@ -268,11 +272,9 @@
if (allApps.length > 0) return
try {
loadingApps = true
allApps = (
await IntegrationService.listHubIntegrations({
kind: 'script'
})
).map((x) => x.name)
const integrations = await IntegrationService.listHubIntegrations({ kind: 'script' })
setHubIntegrationDisplayNames(integrations)
allApps = integrations.map((x) => x.name)
} catch (err) {
console.error('Hub is not available')
allApps = []
@@ -602,7 +604,9 @@
<div>Error fetching apps</div>
{:else}
<MultiSelect
items={safeSelectItems(allApps)}
items={safeSelectItems(
allApps.map((app) => ({ value: app, label: integrationDisplayName(app) }))
)}
placeholder="Select apps"
bind:value={newMcpApps}
/>
@@ -2,7 +2,7 @@ import { get } from 'svelte/store'
import { ResourceService } from '$lib/gen'
import { disableHubStore } from '$lib/stores'
import { createCache } from '$lib/utils'
import { isCustomResourceTypeName } from './resourceTypeDisplay'
import { isCustomResourceTypeName, setHubResourceTypeDisplayNames } from './resourceTypeDisplay'
/**
* How often something has been picked or used, keyed by integration or resource type name.
@@ -17,12 +17,14 @@ export type PopularityCounts = Record<string, number>
*/
const CACHE_MS = 60_000
type HubResourceTypeInfo = { name: string; app: string; picks: number }
type HubResourceTypeInfo = { name: string; app: string; picks: number; display_name?: string }
const hubInfoCached = createCache(
async ({ workspace }: { workspace: string }): Promise<HubResourceTypeInfo[]> => {
try {
return await ResourceService.listHubResourceTypeInfo({ workspace })
const info = await ResourceService.listHubResourceTypeInfo({ workspace })
setHubResourceTypeDisplayNames(info)
return info
} catch {
return []
}
@@ -53,6 +55,15 @@ export async function hubResourceTypePicks(workspace: string): Promise<Popularit
return Object.fromEntries(info.map((rt) => [rt.name, rt.picks]))
}
/**
* Fill `resourceTypeDisplayName` with the hub's curated names, for a surface that shows type
* labels without ordering by picks. The pickers get them from the read they already make.
*/
export async function loadHubResourceTypeDisplayNames(workspace: string): Promise<void> {
if (get(disableHubStore)) return
await hubInfoCached({ workspace })
}
/**
* How many resources of each type this workspace holds — the only evidence about this
* particular team. Keyed by resource type, which is what the add-resource drawer lists.
@@ -1,3 +1,5 @@
import { SvelteMap } from 'svelte/reactivity'
/**
* Resource types are named by abbreviation — `gdrive`, `gcal`, `s3` — so a product's real
* name ("Google Drive", "Amazon S3") only ever appears in its description. Matching on the
@@ -179,7 +181,42 @@ const RESOURCE_TYPE_WORDS: Record<string, string> = {
woocommerce: 'WooCommerce'
}
/** Types whose label is not their name with the parts re-cased. */
/**
* Names the hub curates, for the resource types and integrations no rule names (`gsheets` ->
* Google Sheets). The hub leaves the rest unnamed on purpose, so the word table above keeps
* deciding those. Reactive because they fill in after first render: a label already on screen
* updates when they land.
*/
const hubResourceTypeNames = new SvelteMap<string, string>()
const hubIntegrationNames = new SvelteMap<string, string>()
type HubNamed = { name: string; display_name?: string | null }
/**
* Takes a complete hub listing, so an entry without a name drops any name kept for it. A hub
* predating display names sends none at all, and switching to one must revert to the inferred
* labels rather than keep the previous hub's.
*/
function recordHubNames(names: SvelteMap<string, string>, entries: HubNamed[]): void {
for (const entry of entries) {
const label = entry.display_name?.trim()
if (label) names.set(entry.name, label)
else names.delete(entry.name)
}
}
export function setHubResourceTypeDisplayNames(types: HubNamed[]): void {
recordHubNames(hubResourceTypeNames, types)
}
export function setHubIntegrationDisplayNames(integrations: HubNamed[]): void {
recordHubNames(hubIntegrationNames, integrations)
}
/**
* What the hub names these types, and the integrations that share their slugs, for an instance
* that cannot ask it: one with the hub switched off, or a hub predating display names.
*/
const RESOURCE_TYPE_NAMES: Record<string, string> = {
adobe_acrobat_sign: 'Adobe Acrobat Sign',
bamboo_hr: 'BambooHR',
@@ -206,16 +243,44 @@ export function isCustomResourceTypeName(name: string): boolean {
/**
* Display name for a resource type: `adobe_acrobat_sign` -> `Adobe Acrobat Sign`, `mysql` ->
* `MySQL`, `c_acme_api` -> `Acme API`. Inferred from the name, since nothing in the type
* carries a product name the two tables above only cover what the inference gets wrong.
* `MySQL`, `c_acme_api` -> `Acme API`. A name the hub curates wins; everything else is
* inferred from the type name, the tables above covering what the inference gets wrong.
*/
export function resourceTypeDisplayName(name: string): string {
const exact = RESOURCE_TYPE_NAMES[name]
const exact = hubResourceTypeNames.get(name) ?? wholeName(name)
if (exact) return exact
const stripped = isCustomResourceTypeName(name) ? name.slice(CUSTOM_TYPE_PREFIX.length) : name
return stripped
.split('_')
.map((word) => RESOURCE_TYPE_WORDS[word] ?? word.charAt(0).toUpperCase() + word.slice(1))
return titleize(isCustomResourceTypeName(name) ? name.slice(CUSTOM_TYPE_PREFIX.length) : name)
}
/**
* Display name for a hub integration slug, as the hub pickers label their filters:
* `activecampaign` -> `ActiveCampaign` from a hub that names it, `Activecampaign` from one
* that does not.
*/
export function integrationDisplayName(app: string): string {
return hubIntegrationNames.get(app) ?? wholeName(app) ?? titleize(app)
}
// `Object.hasOwn` throughout: a name like `constructor` would otherwise resolve up the
// prototype chain of these object literals and render as a native function.
function wholeName(name: string): string | undefined {
return Object.hasOwn(RESOURCE_TYPE_NAMES, name) ? RESOURCE_TYPE_NAMES[name] : undefined
}
/**
* Lowercases each word before the lookup and splits on `-` too: a private hub can predate the
* slug rule, and still serve `aws-ses` or `RSS`.
*/
function titleize(name: string): string {
return name
.split(/[_-]/)
.filter(Boolean)
.map((word) => {
const lower = word.toLowerCase()
return Object.hasOwn(RESOURCE_TYPE_WORDS, lower)
? RESOURCE_TYPE_WORDS[lower]
: word.charAt(0).toUpperCase() + word.slice(1)
})
.join(' ')
}
@@ -1,7 +1,10 @@
import { describe, it, expect } from 'vitest'
import {
addResourceTitle,
integrationDisplayName,
resourceTypeDisplayName,
setHubIntegrationDisplayNames,
setHubResourceTypeDisplayNames,
sortResourceTypesByMatch
} from './resourceTypeDisplay'
@@ -65,6 +68,30 @@ describe('resourceTypeDisplayName', () => {
it('capitalizes anything the tables do not cover', () => {
expect(resourceTypeDisplayName('stripe')).toBe('Stripe')
})
it("prefers the hub's curated name, and infers one where the hub names none", () => {
setHubResourceTypeDisplayNames([
{ name: 'snowflake_oauth', display_name: 'Snowflake (OAuth)' },
{ name: 'smtp', display_name: null }
])
expect(resourceTypeDisplayName('snowflake_oauth')).toBe('Snowflake (OAuth)')
expect(resourceTypeDisplayName('smtp')).toBe('SMTP')
})
})
describe('integrationDisplayName', () => {
it('reverts to the inferred name once a hub stops sending one', () => {
setHubIntegrationDisplayNames([{ name: 'activecampaign', display_name: 'ActiveCampaign' }])
expect(integrationDisplayName('activecampaign')).toBe('ActiveCampaign')
// A private hub predating display names omits the field altogether.
setHubIntegrationDisplayNames([{ name: 'activecampaign' }])
expect(integrationDisplayName('activecampaign')).toBe('Activecampaign')
})
it('cases slugs from a hub predating the slug rule', () => {
expect(integrationDisplayName('aws-lambda')).toBe('AWS Lambda')
expect(integrationDisplayName('RSS')).toBe('RSS')
})
})
describe('addResourceTitle', () => {