Skip to content

Commit 66ec7be

Browse files
committed
feat(graphql): add iconUrl field with auto-favicon preview
Add iconUrl to GraphQL source connect input schema and form component. The form displays an auto-generated favicon preview derived from the endpoint, with manual override capability.
1 parent 6844bf0 commit 66ec7be

3 files changed

Lines changed: 91 additions & 3 deletions

File tree

plugins/graphql/react/components.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ type GraphqlToolRouteParams = {
5151

5252
const defaultGraphqlInput = (): GraphqlConnectInput => ({
5353
name: "My GraphQL Source",
54+
iconUrl: undefined,
5455
endpoint: "https://example.com/graphql",
5556
defaultHeaders: null,
5657
auth: {
@@ -61,6 +62,63 @@ const defaultGraphqlInput = (): GraphqlConnectInput => ({
6162
const DEFAULT_BEARER_HEADER_NAME = "Authorization";
6263
const DEFAULT_BEARER_PREFIX = "Bearer ";
6364

65+
const COMMON_COMPOUND_SUFFIXES = new Set([
66+
"ac.uk",
67+
"co.in",
68+
"co.jp",
69+
"co.nz",
70+
"co.uk",
71+
"com.au",
72+
"com.br",
73+
"com.mx",
74+
"net.au",
75+
"org.au",
76+
"org.uk",
77+
]);
78+
79+
const isIpv4Address = (value: string): boolean =>
80+
/^\d{1,3}(?:\.\d{1,3}){3}$/.test(value);
81+
82+
const toRegistrableDomain = (hostname: string): string | null => {
83+
const normalized = hostname.trim().toLowerCase().replace(/^\.+|\.+$/g, "");
84+
if (!normalized) {
85+
return null;
86+
}
87+
88+
if (normalized === "localhost" || isIpv4Address(normalized)) {
89+
return normalized;
90+
}
91+
92+
const parts = normalized.split(".").filter((part) => part.length > 0);
93+
if (parts.length < 2) {
94+
return null;
95+
}
96+
97+
const suffix = parts.slice(-2).join(".");
98+
if (parts.length >= 3 && COMMON_COMPOUND_SUFFIXES.has(suffix)) {
99+
return parts.slice(-3).join(".");
100+
}
101+
102+
return parts.slice(-2).join(".");
103+
};
104+
105+
const getPreviewFaviconUrl = (value: string): string | null => {
106+
const trimmed = value.trim();
107+
if (!trimmed) {
108+
return null;
109+
}
110+
111+
try {
112+
const url = new URL(trimmed);
113+
const domain = toRegistrableDomain(url.hostname);
114+
return domain
115+
? `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=32`
116+
: null;
117+
} catch {
118+
return null;
119+
}
120+
};
121+
64122
const presetString = (
65123
search: Record<string, unknown>,
66124
key: string,
@@ -196,6 +254,7 @@ function GraphqlSourceForm(props: {
196254
}) {
197255
const submitMutation = useExecutorMutation<GraphqlConnectInput, void>(props.onSubmit);
198256
const [name, setName] = useState(props.initialValue.name);
257+
const [iconUrl, setIconUrl] = useState(props.initialValue.iconUrl ?? "");
199258
const [endpoint, setEndpoint] = useState(props.initialValue.endpoint);
200259
const [headersText, setHeadersText] = useState(
201260
stringifyStringMap(props.initialValue.defaultHeaders),
@@ -211,6 +270,7 @@ function GraphqlSourceForm(props: {
211270
bearerPrefixValue(props.initialValue.auth),
212271
);
213272
const [error, setError] = useState<string | null>(null);
273+
const resolvedIconUrl = iconUrl.trim() || getPreviewFaviconUrl(endpoint);
214274

215275
return (
216276
<div className="space-y-6 rounded-lg border border-border bg-card p-6 text-sm ring-1 ring-foreground/[0.04]">
@@ -223,6 +283,22 @@ function GraphqlSourceForm(props: {
223283
/>
224284
</div>
225285

286+
<div className="grid gap-2">
287+
<Label>Icon URL</Label>
288+
<Input
289+
value={iconUrl}
290+
onChange={(event) => setIconUrl(event.target.value)}
291+
placeholder="https://cdn.example.com/icon.png"
292+
className="font-mono text-xs"
293+
/>
294+
{resolvedIconUrl && (
295+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
296+
<img src={resolvedIconUrl} alt="" className="size-4 rounded-sm object-contain" />
297+
<span>{iconUrl.trim() ? "Using override" : "Auto preview"}</span>
298+
</div>
299+
)}
300+
</div>
301+
226302
<div className="grid gap-2">
227303
<Label>Endpoint</Label>
228304
<Input
@@ -305,6 +381,7 @@ function GraphqlSourceForm(props: {
305381
try {
306382
await submitMutation.mutateAsync({
307383
name: name.trim(),
384+
...(iconUrl.trim() ? { iconUrl: iconUrl.trim() } : {}),
308385
endpoint: endpoint.trim(),
309386
defaultHeaders: parseStringMap(headersText),
310387
auth: authFromSecretValue(

plugins/graphql/sdk/index.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export type GraphqlSdk = {
9797
const GraphqlExecutorAddInputSchema = Schema.Struct({
9898
kind: Schema.Literal("graphql"),
9999
name: Schema.String,
100+
iconUrl: Schema.optional(Schema.String),
100101
endpoint: Schema.String,
101102
defaultHeaders: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.String })),
102103
auth: GraphqlConnectionAuthSchema,
@@ -318,8 +319,10 @@ const storedSourceDataFromInput = (
318319
const sourceConfigFromStored = (
319320
source: Source,
320321
stored: GraphqlStoredSourceData,
322+
configSource: { iconUrl?: string } | null,
321323
): GraphqlSourceConfigPayload => ({
322324
name: source.name,
325+
...(configSource?.iconUrl ? { iconUrl: configSource.iconUrl } : {}),
323326
endpoint: stored.endpoint,
324327
defaultHeaders: stored.defaultHeaders,
325328
auth: stored.auth,
@@ -329,6 +332,7 @@ const graphqlConnectInputFromAddInput = (
329332
input: GraphqlExecutorAddInput,
330333
): GraphqlConnectInput => ({
331334
name: input.name,
335+
...(input.iconUrl ? { iconUrl: input.iconUrl } : {}),
332336
endpoint: input.endpoint,
333337
defaultHeaders: input.defaultHeaders,
334338
auth: input.auth,
@@ -394,14 +398,20 @@ export const graphqlSdkPlugin = (options: {
394398
},
395399
stored: storedSourceDataFromInput(config),
396400
}),
397-
toConfig: ({ source, stored }) =>
398-
sourceConfigFromStored(source, normalizeStoredSourceData(stored)),
401+
toConfig: ({ source, stored, configSource }) =>
402+
sourceConfigFromStored(source, normalizeStoredSourceData(stored), configSource),
399403
},
400404
scopeConfig: {
401-
toConfigSource: ({ source, stored }) =>
405+
toConfigSource: ({ source, stored, configInput }) =>
402406
pluginScopeConfigSourceFromConfig({
403407
source,
404408
config: normalizeStoredSourceData(stored),
409+
iconUrl:
410+
configInput && typeof configInput === "object" && "iconUrl" in configInput
411+
? (typeof configInput.iconUrl === "string"
412+
? configInput.iconUrl.trim() || null
413+
: null)
414+
: null,
405415
}),
406416
recoverStored: ({ config, loadedConfig }) =>
407417
graphqlStoredSourceDataFromLocalConfig({

plugins/graphql/shared/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const GraphqlConnectionAuthSchema = Schema.Union(
2323

2424
export const GraphqlConnectInputSchema = Schema.Struct({
2525
name: Schema.String,
26+
iconUrl: Schema.optional(Schema.String),
2627
endpoint: Schema.String,
2728
defaultHeaders: Schema.NullOr(StringMapSchema),
2829
auth: GraphqlConnectionAuthSchema,

0 commit comments

Comments
 (0)