Skip to content

Commit fe28a00

Browse files
committed
refactor: move source add flows to proper subpage routes
- Add /sources/add/$pluginKey route with optional ?url= search param - New SourcesAddPage reads plugin key from URL, renders plugin's add component - Remove useReducer state machine from SourcesPage (list/detecting/adding) - SourcesPage now only shows the list view; detect navigates to add route - Manual add buttons and presets are now <Link> elements to add routes - onComplete/onCancel in add flow navigate back to / with fresh state - Clicking 'Sources' in sidebar always returns to clean list view
1 parent aa72429 commit fe28a00

3 files changed

Lines changed: 136 additions & 108 deletions

File tree

‎apps/web/src/pages/sources-add.tsx‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Suspense } from "react";
2+
import { Link, useNavigate } from "@tanstack/react-router";
3+
import type { SourcePlugin } from "@executor/react";
4+
import { useAtomRefresh, sourcesAtom } from "@executor/react";
5+
import { useScope } from "../lib/use-scope";
6+
import { openApiSourcePlugin } from "@executor/plugin-openapi/react";
7+
import { mcpSourcePlugin } from "@executor/plugin-mcp/react";
8+
import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react";
9+
import { graphqlSourcePlugin } from "@executor/plugin-graphql/react";
10+
11+
// ---------------------------------------------------------------------------
12+
// Registered source plugins
13+
// ---------------------------------------------------------------------------
14+
15+
const sourcePlugins: SourcePlugin[] = [
16+
openApiSourcePlugin,
17+
mcpSourcePlugin,
18+
googleDiscoverySourcePlugin,
19+
graphqlSourcePlugin,
20+
];
21+
22+
// ---------------------------------------------------------------------------
23+
// Page
24+
// ---------------------------------------------------------------------------
25+
26+
export function SourcesAddPage(props: {
27+
pluginKey: string;
28+
url?: string;
29+
}) {
30+
const { pluginKey, url } = props;
31+
const scopeId = useScope();
32+
const refreshSources = useAtomRefresh(sourcesAtom(scopeId));
33+
const navigate = useNavigate();
34+
35+
const plugin = sourcePlugins.find((p) => p.key === pluginKey);
36+
37+
if (!plugin) {
38+
return (
39+
<div className="min-h-0 flex-1 overflow-y-auto">
40+
<div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
41+
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20">
42+
<p className="text-[14px] font-medium text-foreground/70 mb-1">
43+
Unknown source type: {pluginKey}
44+
</p>
45+
<p className="text-[13px] text-muted-foreground/60 mb-5">
46+
This source plugin is not registered.
47+
</p>
48+
<Link
49+
to="/"
50+
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
51+
>
52+
Back to sources
53+
</Link>
54+
</div>
55+
</div>
56+
</div>
57+
);
58+
}
59+
60+
const AddComponent = plugin.add;
61+
62+
return (
63+
<div className="min-h-0 flex-1 overflow-y-auto">
64+
<div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
65+
<Suspense fallback={<p className="text-sm text-muted-foreground">Loading…</p>}>
66+
<AddComponent
67+
initialUrl={url}
68+
onComplete={() => {
69+
refreshSources();
70+
void navigate({ to: "/" });
71+
}}
72+
onCancel={() => {
73+
void navigate({ to: "/" });
74+
}}
75+
/>
76+
</Suspense>
77+
</div>
78+
</div>
79+
);
80+
}

‎apps/web/src/pages/sources.tsx‎

Lines changed: 41 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { useReducer, useCallback, useMemo, Suspense } from "react";
2-
import { Link } from "@tanstack/react-router";
3-
import { Result, useAtomValue, useAtomRefresh, useAtomSet, sourcesAtom, detectSource } from "@executor/react";
1+
import { useState, useCallback, useMemo } from "react";
2+
import { Link, useNavigate } from "@tanstack/react-router";
3+
import { Result, useAtomValue, useAtomSet, sourcesAtom, detectSource } from "@executor/react";
44
import { useScope } from "../lib/use-scope";
55
import type { SourcePlugin, SourcePreset } from "@executor/react";
66
import { openApiSourcePlugin } from "@executor/plugin-openapi/react";
@@ -27,123 +27,51 @@ const KIND_TO_PLUGIN_KEY: Record<string, string> = {
2727
googleDiscovery: "googleDiscovery",
2828
};
2929

30-
// ---------------------------------------------------------------------------
31-
// State machine
32-
// ---------------------------------------------------------------------------
33-
34-
type State = {
35-
step: "list" | "detecting" | "adding";
36-
url: string;
37-
error: string | null;
38-
pluginKey: string | null;
39-
initialUrl: string | undefined;
40-
};
41-
42-
type Action =
43-
| { type: "set-url"; url: string }
44-
| { type: "detect-start" }
45-
| { type: "detect-ok"; pluginKey: string; url: string }
46-
| { type: "detect-no-match" }
47-
| { type: "detect-unknown-kind"; kind: string }
48-
| { type: "detect-fail" }
49-
| { type: "add-manual"; pluginKey: string }
50-
| { type: "add-preset"; pluginKey: string; url: string }
51-
| { type: "back" };
52-
53-
const init: State = { step: "list", url: "", error: null, pluginKey: null, initialUrl: undefined };
54-
55-
function reducer(state: State, action: Action): State {
56-
switch (action.type) {
57-
case "set-url":
58-
return { ...state, step: "list", url: action.url, error: null };
59-
case "detect-start":
60-
return { ...state, step: "detecting", error: null };
61-
case "detect-ok":
62-
return { ...state, step: "adding", pluginKey: action.pluginKey, initialUrl: action.url };
63-
case "detect-no-match":
64-
return { ...state, step: "list", error: "Could not detect a source type from this URL. Try adding manually." };
65-
case "detect-unknown-kind":
66-
return { ...state, step: "list", error: `Detected source type "${action.kind}" but no plugin is available for it.` };
67-
case "detect-fail":
68-
return { ...state, step: "list", error: "Detection failed. Try adding a source manually." };
69-
case "add-manual":
70-
return { ...state, step: "adding", pluginKey: action.pluginKey, initialUrl: undefined };
71-
case "add-preset":
72-
return { ...state, step: "adding", pluginKey: action.pluginKey, initialUrl: action.url };
73-
case "back":
74-
return init;
75-
default:
76-
return state;
77-
}
78-
}
79-
8030
// ---------------------------------------------------------------------------
8131
// Page
8232
// ---------------------------------------------------------------------------
8333

8434
export function SourcesPage() {
85-
const [state, dispatch] = useReducer(reducer, init);
35+
const [url, setUrl] = useState("");
36+
const [detecting, setDetecting] = useState(false);
37+
const [error, setError] = useState<string | null>(null);
38+
8639
const scopeId = useScope();
8740
const sources = useAtomValue(sourcesAtom(scopeId));
88-
const refreshSources = useAtomRefresh(sourcesAtom(scopeId));
8941
const doDetect = useAtomSet(detectSource, { mode: "promise" });
42+
const navigate = useNavigate();
9043

9144
const handleDetect = useCallback(async () => {
92-
const trimmed = state.url.trim();
45+
const trimmed = url.trim();
9346
if (!trimmed) return;
94-
dispatch({ type: "detect-start" });
47+
setDetecting(true);
48+
setError(null);
9549
try {
9650
const results = await doDetect({
9751
path: { scopeId },
9852
payload: { url: trimmed },
9953
});
10054
if (results.length === 0) {
101-
dispatch({ type: "detect-no-match" });
55+
setError("Could not detect a source type from this URL. Try adding manually.");
56+
setDetecting(false);
10257
return;
10358
}
10459
const pluginKey = KIND_TO_PLUGIN_KEY[results[0].kind];
10560
if (pluginKey) {
106-
dispatch({ type: "detect-ok", pluginKey, url: trimmed });
61+
void navigate({
62+
to: "/sources/add/$pluginKey",
63+
params: { pluginKey },
64+
search: { url: trimmed },
65+
});
10766
} else {
108-
dispatch({ type: "detect-unknown-kind", kind: results[0].kind });
67+
setError(`Detected source type "${results[0].kind}" but no plugin is available for it.`);
10968
}
11069
} catch {
111-
dispatch({ type: "detect-fail" });
70+
setError("Detection failed. Try adding a source manually.");
71+
} finally {
72+
setDetecting(false);
11273
}
113-
}, [state.url, doDetect]);
114-
115-
// ---------------------------------------------------------------------------
116-
// Adding view
117-
// ---------------------------------------------------------------------------
118-
119-
if (state.step === "adding" && state.pluginKey) {
120-
const plugin = sourcePlugins.find((p) => p.key === state.pluginKey);
121-
if (!plugin) return null;
122-
const AddComponent = plugin.add;
123-
return (
124-
<div className="min-h-0 flex-1 overflow-y-auto">
125-
<div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
126-
<Suspense fallback={<p className="text-sm text-muted-foreground">Loading…</p>}>
127-
<AddComponent
128-
initialUrl={state.initialUrl}
129-
onComplete={() => {
130-
dispatch({ type: "back" });
131-
refreshSources();
132-
}}
133-
onCancel={() => dispatch({ type: "back" })}
134-
/>
135-
</Suspense>
136-
</div>
137-
</div>
138-
);
139-
}
140-
141-
// ---------------------------------------------------------------------------
142-
// List view
143-
// ---------------------------------------------------------------------------
144-
145-
const isDetecting = state.step === "detecting";
146-
const error = state.step === "list" ? state.error : null;
74+
}, [url, doDetect, navigate, scopeId]);
14775

14876
return (
14977
<div className="min-h-0 flex-1 overflow-y-auto">
@@ -166,19 +94,22 @@ export function SourcesPage() {
16694
<div className="flex gap-2">
16795
<input
16896
type="url"
169-
value={state.url}
170-
onChange={(e) => dispatch({ type: "set-url", url: e.target.value })}
97+
value={url}
98+
onChange={(e) => {
99+
setUrl(e.target.value);
100+
setError(null);
101+
}}
171102
onKeyDown={(e) => { if (e.key === "Enter") handleDetect(); }}
172103
placeholder="Paste a URL to auto-detect source type..."
173-
disabled={isDetecting}
104+
disabled={detecting}
174105
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
175106
/>
176107
<button
177108
onClick={handleDetect}
178-
disabled={isDetecting || !state.url.trim()}
109+
disabled={detecting || !url.trim()}
179110
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:pointer-events-none"
180111
>
181-
{isDetecting ? "Detecting..." : "Detect"}
112+
{detecting ? "Detecting..." : "Detect"}
182113
</button>
183114
</div>
184115
{error && (
@@ -187,21 +118,22 @@ export function SourcesPage() {
187118
<div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
188119
<span>Or add manually:</span>
189120
{sourcePlugins.map((p) => (
190-
<button
121+
<Link
191122
key={p.key}
192-
onClick={() => dispatch({ type: "add-manual", pluginKey: p.key })}
123+
to="/sources/add/$pluginKey"
124+
params={{ pluginKey: p.key }}
193125
className="rounded-md border border-border px-2 py-1 text-xs font-medium transition-colors hover:bg-muted"
194126
>
195127
{p.label}
196-
</button>
128+
</Link>
197129
))}
198130
</div>
199131
</div>
200132
</div>
201133

202134
<McpInstallCard className="mb-8 rounded-2xl border border-border bg-card/80 p-5" />
203135

204-
<PresetGrid plugins={sourcePlugins} dispatch={dispatch} />
136+
<PresetGrid plugins={sourcePlugins} />
205137

206138
{Result.match(sources, {
207139
onInitial: () => (
@@ -272,7 +204,6 @@ export function SourcesPage() {
272204

273205
function PresetGrid(props: {
274206
plugins: readonly SourcePlugin[];
275-
dispatch: React.Dispatch<Action>;
276207
}) {
277208
const allPresets = useMemo(() => {
278209
const out: { preset: SourcePreset; pluginKey: string; pluginLabel: string }[] = [];
@@ -296,9 +227,11 @@ function PresetGrid(props: {
296227
</div>
297228
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
298229
{allPresets.map(({ preset, pluginKey, pluginLabel }) => (
299-
<button
230+
<Link
300231
key={`${pluginKey}-${preset.id}`}
301-
onClick={() => props.dispatch({ type: "add-preset", pluginKey, url: preset.url })}
232+
to="/sources/add/$pluginKey"
233+
params={{ pluginKey }}
234+
search={{ url: preset.url }}
302235
className="flex items-start gap-3 rounded-xl border border-border bg-card px-4 py-3 text-left transition-colors hover:border-primary/25 hover:bg-card/90"
303236
>
304237
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground overflow-hidden">
@@ -319,7 +252,7 @@ function PresetGrid(props: {
319252
</div>
320253
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">{preset.summary}</p>
321254
</div>
322-
</button>
255+
</Link>
323256
))}
324257
</div>
325258
</section>

‎apps/web/src/router.tsx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
import { ExecutorProvider } from "@executor/react";
77
import { ToolsPage } from "./pages/tools";
88
import { SourcesPage } from "./pages/sources";
9+
import { SourcesAddPage } from "./pages/sources-add";
910
import { SourceDetailPage } from "./pages/source-detail";
1011
import { SecretsPage } from "./pages/secrets";
1112
import { Shell } from "./shell";
@@ -38,6 +39,19 @@ const toolsRoute = createRoute({
3839
component: ToolsPage,
3940
});
4041

42+
const sourcesAddRoute = createRoute({
43+
getParentRoute: () => rootRoute,
44+
path: "/sources/add/$pluginKey",
45+
validateSearch: (search: Record<string, unknown>): { url?: string } => ({
46+
url: typeof search.url === "string" ? search.url : undefined,
47+
}),
48+
component: () => {
49+
const { pluginKey } = sourcesAddRoute.useParams();
50+
const { url } = sourcesAddRoute.useSearch();
51+
return <SourcesAddPage pluginKey={pluginKey} url={url} />;
52+
},
53+
});
54+
4155
const sourceDetailRoute = createRoute({
4256
getParentRoute: () => rootRoute,
4357
path: "/sources/$namespace",
@@ -60,6 +74,7 @@ const secretsRoute = createRoute({
6074
const routeTree = rootRoute.addChildren([
6175
indexRoute,
6276
toolsRoute,
77+
sourcesAddRoute,
6378
sourceDetailRoute,
6479
secretsRoute,
6580
]);

0 commit comments

Comments
 (0)