History

Give per-service integrations their own service icons

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.

Rhys Sullivan committed 4 months ago

Commit a0f4e381 parent b14985d5 files · +103 −2Browse files

Unified · Split

5 files
  1. packages/core/sdk/src/client.ts +8 −0
  2. packages/plugins/google/src/react/source-plugin.ts +13 −1
  3. packages/plugins/microsoft/src/react/source-plugin.ts +12 −1
  4. packages/react/src/components/integration-favicon.test.tsx +62 −0
  5. packages/react/src/components/integration-favicon.tsx +8 −0
packages/core/sdk/src/client.ts+8−0
@@ -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 the209 * 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. */
packages/plugins/google/src/react/source-plugin.ts+13−1
@@ -1,16 +1,28 @@
1import { lazy } from "react";1import { lazy } from "react";
2import type { IntegrationPlugin } from "@executor-js/sdk/client";2import type { IntegrationPlugin } from "@executor-js/sdk/client";
3import { googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset } from "../sdk/presets";3import {
4 googleOpenApiBundlePreset,
5 googleOpenApiPresets,
6 googlePhotosOpenApiBundlePreset,
7 googleServiceSlug,
8} from "../sdk/presets";
4 9
5const importAdd = () => import("./AddGoogleSource");10const importAdd = () => import("./AddGoogleSource");
6const importAccounts = () => import("./GoogleAccountsPanel");11const 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.
15const googleServiceIcons = googleOpenApiPresets.flatMap((preset) =>
16 preset.icon ? [{ slug: googleServiceSlug(preset.id), icon: preset.icon }] : [],
17);
18
8export const googleIntegrationPlugin: IntegrationPlugin = {19export 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();
packages/plugins/microsoft/src/react/source-plugin.ts+12−1
@@ -1,16 +1,27 @@
1import { lazy } from "react";1import { lazy } from "react";
2import type { IntegrationPlugin } from "@executor-js/sdk/client";2import type { IntegrationPlugin } from "@executor-js/sdk/client";
3import { microsoftGraphPreset } from "../sdk/presets";3import {
4 microsoftGraphPreset,
5 microsoftGraphScopePresets,
6 microsoftServiceSlug,
7} from "../sdk/presets";
4 8
5const importAdd = () => import("./AddMicrosoftSource");9const importAdd = () => import("./AddMicrosoftSource");
6const importAccounts = () => import("./MicrosoftAccountsPanel");10const 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.
14const microsoftServiceIcons = microsoftGraphScopePresets.flatMap((preset) =>
15 preset.icon ? [{ slug: microsoftServiceSlug(preset.id), icon: preset.icon }] : [],
16);
17
8export const microsoftIntegrationPlugin: IntegrationPlugin = {18export 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();
packages/react/src/components/integration-favicon.test.tsx+62−0
@@ -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(
packages/react/src/components/integration-favicon.tsx+8−0
@@ -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);