Provider plugins that fan out into per-service integrations (Google -> Calendar, Gmail, Drive; Microsoft -> Mail, Calendar, Teams) now declare a per-service slug->icon map. The catalog favicon resolver consults it by the integration slug before the URL/token preset cascade, so a Gmail source shows the Gmail glyph rather than a generic Google logo. Kept separate from presets so the connect picker's popular list is untouched.
Unified · Split
| @@ -197,6 +197,14 @@ export interface IntegrationPlugin { · 196 unmodified lines | |||
| 197 | readonly accountHandoff?: IntegrationAccountHandoff | null; | 197 | readonly accountHandoff?: IntegrationAccountHandoff | null; |
| 198 | }>; | 198 | }>; |
| 199 | readonly presets?: readonly IntegrationPreset[]; | 199 | readonly presets?: readonly IntegrationPreset[]; |
| 200 | /** Per-service icon overrides for provider plugins that fan out into several | ||
| 201 | * per-service integrations (e.g. Google → google_calendar, google_gmail). | ||
| 202 | * Keyed by the integration slug the fan-out registers; the catalog favicon | ||
| 203 | * resolver consults this before falling back to a URL-derived favicon, so a | ||
| 204 | * Gmail integration shows the Gmail glyph rather than a generic Google logo. | ||
| 205 | * Kept separate from `presets` so the connect picker's popular-integration | ||
| 206 | * list is not affected. */ | ||
| 207 | readonly serviceIcons?: readonly { readonly slug: string; readonly icon: string }[]; | ||
| 200 | /** Trigger early download of the plugin's lazy component chunks (add/edit/etc.). | 208 | /** Trigger early download of the plugin's lazy component chunks (add/edit/etc.). |
| 201 | * Call from the host on intent (hover/focus) so the chunks land before the | 209 | * Call from the host on intent (hover/focus) so the chunks land before the |
| 202 | * user navigates into the add page. Idempotent. */ | 210 | * user navigates into the add page. Idempotent. */ |
| @@ -1,16 +1,28 @@ | |||
| 1 | import { lazy } from "react"; | 1 | import { lazy } from "react"; |
| 2 | import type { IntegrationPlugin } from "@executor-js/sdk/client"; | 2 | import type { IntegrationPlugin } from "@executor-js/sdk/client"; |
| 3 | import { googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset } from "../sdk/presets"; | 3 | import { |
| 4 | googleOpenApiBundlePreset, | ||
| 5 | googleOpenApiPresets, | ||
| 6 | googlePhotosOpenApiBundlePreset, | ||
| 7 | googleServiceSlug, | ||
| 8 | } from "../sdk/presets"; | ||
| 4 | 9 | ||
| 5 | const importAdd = () => import("./AddGoogleSource"); | 10 | const importAdd = () => import("./AddGoogleSource"); |
| 6 | const importAccounts = () => import("./GoogleAccountsPanel"); | 11 | const importAccounts = () => import("./GoogleAccountsPanel"); |
| 7 | 12 | ||
| 13 | // Each per-service integration (google_calendar, google_gmail, …) is keyed by | ||
| 14 | // the slug the fan-out registers, mapped to the preset's own product glyph. | ||
| 15 | const googleServiceIcons = googleOpenApiPresets.flatMap((preset) => | ||
| 16 | preset.icon ? [{ slug: googleServiceSlug(preset.id), icon: preset.icon }] : [], | ||
| 17 | ); | ||
| 18 | |||
| 8 | export const googleIntegrationPlugin: IntegrationPlugin = { | 19 | export const googleIntegrationPlugin: IntegrationPlugin = { |
| 9 | key: "google", | 20 | key: "google", |
| 10 | label: "Google", | 21 | label: "Google", |
| 11 | add: lazy(importAdd), | 22 | add: lazy(importAdd), |
| 12 | accounts: lazy(importAccounts), | 23 | accounts: lazy(importAccounts), |
| 13 | presets: [googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset], | 24 | presets: [googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset], |
| 25 | serviceIcons: googleServiceIcons, | ||
| 14 | preload: () => { | 26 | preload: () => { |
| 15 | void importAdd(); | 27 | void importAdd(); |
| 16 | void importAccounts(); | 28 | void importAccounts(); |
| @@ -1,16 +1,27 @@ | |||
| 1 | import { lazy } from "react"; | 1 | import { lazy } from "react"; |
| 2 | import type { IntegrationPlugin } from "@executor-js/sdk/client"; | 2 | import type { IntegrationPlugin } from "@executor-js/sdk/client"; |
| 3 | import { microsoftGraphPreset } from "../sdk/presets"; | 3 | import { |
| 4 | microsoftGraphPreset, | ||
| 5 | microsoftGraphScopePresets, | ||
| 6 | microsoftServiceSlug, | ||
| 7 | } from "../sdk/presets"; | ||
| 4 | 8 | ||
| 5 | const importAdd = () => import("./AddMicrosoftSource"); | 9 | const importAdd = () => import("./AddMicrosoftSource"); |
| 6 | const importAccounts = () => import("./MicrosoftAccountsPanel"); | 10 | const importAccounts = () => import("./MicrosoftAccountsPanel"); |
| 7 | 11 | ||
| 12 | // Each per-workload integration (microsoft_mail, microsoft_calendar, …) is | ||
| 13 | // keyed by the slug the fan-out registers, mapped to the workload's own glyph. | ||
| 14 | const microsoftServiceIcons = microsoftGraphScopePresets.flatMap((preset) => | ||
| 15 | preset.icon ? [{ slug: microsoftServiceSlug(preset.id), icon: preset.icon }] : [], | ||
| 16 | ); | ||
| 17 | |||
| 8 | export const microsoftIntegrationPlugin: IntegrationPlugin = { | 18 | export const microsoftIntegrationPlugin: IntegrationPlugin = { |
| 9 | key: "microsoft", | 19 | key: "microsoft", |
| 10 | label: "Microsoft", | 20 | label: "Microsoft", |
| 11 | add: lazy(importAdd), | 21 | add: lazy(importAdd), |
| 12 | accounts: lazy(importAccounts), | 22 | accounts: lazy(importAccounts), |
| 13 | presets: [microsoftGraphPreset], | 23 | presets: [microsoftGraphPreset], |
| 24 | serviceIcons: microsoftServiceIcons, | ||
| 14 | preload: () => { | 25 | preload: () => { |
| 15 | void importAdd(); | 26 | void importAdd(); |
| 16 | void importAccounts(); | 27 | void importAccounts(); |
| @@ -70,6 +70,68 @@ describe("IntegrationFavicon", () => { · 69 unmodified lines | |||
| 70 | expect(integrationFaviconSrc({ icon, sourceId: slug, url, size: 16 })).toBe("/favicon-32.png"); | 70 | expect(integrationFaviconSrc({ icon, sourceId: slug, url, size: 16 })).toBe("/favicon-32.png"); |
| 71 | }); | 71 | }); |
| 72 | 72 | ||
| 73 | it("prefers a plugin's per-service icon (keyed by slug) over the preset cascade", () => { | ||
| 74 | // A provider that fans out into per-service integrations declares a | ||
| 75 | // slug->icon map; a Gmail integration resolves to the Gmail glyph even | ||
| 76 | // though every Google service shares the same displayUrl host. | ||
| 77 | expect( | ||
| 78 | integrationPresetIconUrl( | ||
| 79 | { | ||
| 80 | id: "google_gmail", | ||
| 81 | kind: "google", | ||
| 82 | name: "Gmail", | ||
| 83 | url: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", | ||
| 84 | }, | ||
| 85 | [ | ||
| 86 | { | ||
| 87 | key: "google", | ||
| 88 | label: "Google", | ||
| 89 | add: () => null, | ||
| 90 | edit: () => null, | ||
| 91 | presets: [ | ||
| 92 | { id: "google", name: "Google", summary: "Bundle.", icon: "https://x/g.svg" }, | ||
| 93 | ], | ||
| 94 | serviceIcons: [ | ||
| 95 | { slug: "google_gmail", icon: "https://x/gmail.png" }, | ||
| 96 | { slug: "google_calendar", icon: "https://x/calendar.svg" }, | ||
| 97 | ], | ||
| 98 | }, | ||
| 99 | ], | ||
| 100 | ), | ||
| 101 | ).toBe("https://x/gmail.png"); | ||
| 102 | }); | ||
| 103 | |||
| 104 | it("falls through to the preset cascade when no per-service icon matches the slug", () => { | ||
| 105 | expect( | ||
| 106 | integrationPresetIconUrl( | ||
| 107 | { | ||
| 108 | id: "google_sheets", | ||
| 109 | kind: "google", | ||
| 110 | name: "Google Sheets", | ||
| 111 | url: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest", | ||
| 112 | }, | ||
| 113 | [ | ||
| 114 | { | ||
| 115 | key: "google", | ||
| 116 | label: "Google", | ||
| 117 | add: () => null, | ||
| 118 | edit: () => null, | ||
| 119 | presets: [ | ||
| 120 | { | ||
| 121 | id: "google-sheets", | ||
| 122 | name: "Google Sheets", | ||
| 123 | summary: "Spreadsheets.", | ||
| 124 | url: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest", | ||
| 125 | icon: "https://x/sheets.svg", | ||
| 126 | }, | ||
| 127 | ], | ||
| 128 | serviceIcons: [{ slug: "google_gmail", icon: "https://x/gmail.png" }], | ||
| 129 | }, | ||
| 130 | ], | ||
| 131 | ), | ||
| 132 | ).toBe("https://x/sheets.svg"); | ||
| 133 | }); | ||
| 134 | |||
| 73 | it("finds preset icons from a source URL", () => { | 135 | it("finds preset icons from a source URL", () => { |
| 74 | expect( | 136 | expect( |
| 75 | integrationPresetIconUrl( | 137 | integrationPresetIconUrl( |
| @@ -156,6 +156,14 @@ export function integrationPresetIconUrl( · 155 unmodified lines | |||
| 156 | ): string | null { | 156 | ): string | null { |
| 157 | const pluginKey = KIND_TO_PLUGIN_KEY[source.kind] ?? source.kind; | 157 | const pluginKey = KIND_TO_PLUGIN_KEY[source.kind] ?? source.kind; |
| 158 | const plugin = integrationPlugins.find((p) => p.key === pluginKey); | 158 | const plugin = integrationPlugins.find((p) => p.key === pluginKey); |
| 159 | |||
| 160 | // Per-service integrations (google_calendar, microsoft_mail, …) carry their | ||
| 161 | // own product glyph, keyed by the exact slug the fan-out registered. This is | ||
| 162 | // the most specific match, so it wins over the URL/token preset cascade below | ||
| 163 | // (a provider's per-service integrations often share one displayUrl). | ||
| 164 | const serviceIcon = plugin?.serviceIcons?.find((entry) => entry.slug === source.id); | ||
| 165 | if (serviceIcon) return serviceIcon.icon; | ||
| 166 | |||
| 159 | const presets = plugin?.presets ?? []; | 167 | const presets = plugin?.presets ?? []; |
| 160 | const sourceUrl = normalizeUrl(source.url); | 168 | const sourceUrl = normalizeUrl(source.url); |
| 161 | const sourceGoogleService = googleApiServiceFromUrl(source.url); | 169 | const sourceGoogleService = googleApiServiceFromUrl(source.url); |