mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 16:02:36 +00:00
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:
co-authored by
Claude Opus 5
parent
b50de89479
commit
7f47a2fd3e
@@ -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
|
||||
|
||||
|
||||
@@ -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}✗{/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', () => {
|
||||
|
||||
Reference in New Issue
Block a user