Skip to content

Commit 364061b

Browse files
authored
Move plugin configuration into a drawer (#694)
* Add plugin configuration drawer tests * Move plugin configuration into a drawer * Refine plugin drawer hierarchy * Keep focused input borders inside their controls * Add plugin drawer lifecycle coverage * Improve plugin drawer input behavior
1 parent 984a7e7 commit 364061b

11 files changed

Lines changed: 561 additions & 264 deletions

apps/web/src/app.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ html {
3838
outline-offset: 2px;
3939
}
4040

41+
/* Inputs in side drawers already have a visible control boundary. Color that
42+
edge on focus so the focus treatment reads as one control, not a second
43+
outline outside it. */
44+
[data-slot="dialog-content"][data-side="right"]
45+
[data-slot="input"]:focus-visible {
46+
outline: none;
47+
border-color: var(--accent-rail, var(--primary));
48+
}
49+
4150
/* Brand type: Red Hat Display is loaded in index.html but nothing applied
4251
it — without this the whole app silently renders in the system fallback.
4352
Space Mono covers the places code/mono content asks for monospace. */

apps/web/src/pages/plugins-page.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
PluginsGallery,
2121
PluginConnectPanel,
2222
type PluginsGalleryTab,
23+
type PluginPanelSubject,
2324
} from "@corbits/plugins-ui";
2425
import type { ResolvedPlugin } from "@corbits/connections/plugins";
2526
import { listPluginsForTenant } from "@corbits/connections/plugins";
@@ -81,7 +82,7 @@ export function PluginsRoute({
8182
const [skillsState, setSkillsState] = useState<SkillsState>({
8283
status: "loading",
8384
});
84-
const [openPlugin, setOpenPlugin] = useState<ResolvedPlugin | null>(null);
85+
const [openPlugin, setOpenPlugin] = useState<PluginPanelSubject | null>(null);
8586
const [createSkillOpen, setCreateSkillOpen] = useState(false);
8687
const [activeTab, setActiveTab] = useState<PluginsGalleryTab>("plugins");
8788
const [galleryQuery, setGalleryQuery] = useState("");
@@ -100,7 +101,7 @@ export function PluginsRoute({
100101
const clearPendingConnectProvider = useClearPendingConnectProvider();
101102
const requestPluginsConnect = useRequestPluginsConnect();
102103
const openPluginPanel = useCallback((plugin: ResolvedPlugin) => {
103-
setOpenPlugin(plugin);
104+
setOpenPlugin({ kind: "connector", plugin });
104105
setConnectDeepLinkNotFound(false);
105106
}, []);
106107

@@ -373,9 +374,12 @@ export function PluginsRoute({
373374
</PageShell>
374375
<PluginConnectPanel
375376
tenantId={tenantId}
376-
plugin={openPlugin}
377+
subject={openPlugin}
377378
onClose={() => setOpenPlugin(null)}
378-
onChanged={reloadPlugins}
379+
onChanged={() => {
380+
reloadPlugins();
381+
setOpenPlugin(null);
382+
}}
379383
/>
380384
<CreateSkillDialog
381385
open={createSkillOpen}

apps/web/test/plugins-page.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,7 @@ describe("PluginsRoute", () => {
654654

655655
// The delete resolved and `onChanged` fired `reloadPlugins`; its fetch
656656
// is now the deferred one above, still pending.
657+
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
657658
expect(el.textContent).not.toContain("Loading plugins…");
658659
expect(el.textContent).toContain("GitHub");
659660

packages/plugins-ui/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ export { PluginCard } from "./plugin-card";
55
export { SkillCard } from "./skill-card";
66
export type { SkillCardData } from "./skill-card";
77
export { InstalledStrip } from "./installed-strip";
8-
export { PluginConnectPanel } from "./plugin-connect-panel";
8+
export {
9+
PluginConnectPanel,
10+
type PluginPanelSubject,
11+
} from "./plugin-connect-panel";
912
export { McpServersSection } from "./mcp-servers-section";
1013

1114
export { PLUGINS_STRINGS } from "./strings";

packages/plugins-ui/src/mcp-preset-cards.tsx

Lines changed: 17 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// connected custom servers share the same server-side store.
44

55
import { reportError } from "@corbits/error-sink";
6-
import { Button, ConfirmButton, Input, toast } from "@corbits/react-ui";
6+
import { Button, toast } from "@corbits/react-ui";
77
import {
88
CONNECTOR_REGISTRY,
99
MCP_PRESETS,
@@ -12,13 +12,11 @@ import { useEffect, useState } from "react";
1212

1313
import {
1414
connectMcpPreset,
15-
disconnectMcpServer,
1615
listMcpPresets,
1716
mcpOAuthStartPath,
1817
type McpPreset,
1918
} from "./mcp-servers-api";
2019
import { PluginLogo } from "./plugin-logo";
21-
import { PLUGINS_STRINGS } from "./strings";
2220

2321
function messageOf(cause: unknown): string {
2422
return cause instanceof Error ? cause.message : String(cause);
@@ -71,57 +69,42 @@ export function McpPresetCard({
7169
preset,
7270
toolCount,
7371
onChanged,
72+
onOpen,
7473
}: {
7574
readonly tenantId: string;
7675
readonly preset: McpPreset;
7776
readonly toolCount: number | undefined;
7877
readonly onChanged: (toolCount?: number) => void;
78+
readonly onOpen: (trigger: HTMLButtonElement) => void;
7979
}) {
8080
const [busy, setBusy] = useState(false);
8181
const [error, setError] = useState<string | null>(() =>
8282
mcpOauthReturnError(preset.slug),
8383
);
84-
const [tokenFieldOpen, setTokenFieldOpen] = useState(false);
85-
const [token, setToken] = useState("");
86-
87-
function submitConnect(pastedToken: string | undefined) {
84+
function submitConnect() {
8885
setBusy(true);
8986
setError(null);
90-
connectMcpPreset(tenantId, preset.slug, pastedToken)
87+
connectMcpPreset(tenantId, preset.slug, undefined)
9188
.then((result) => {
9289
toast(
9390
`Connected — ${result.toolCount} tool${result.toolCount === 1 ? "" : "s"} available.`,
9491
);
95-
setTokenFieldOpen(false);
96-
setToken("");
9792
onChanged(result.toolCount);
9893
})
9994
.catch((cause: unknown) => setError(messageOf(cause)))
10095
.finally(() => setBusy(false));
10196
}
10297

103-
function handleConnect() {
98+
function handleConnect(trigger: HTMLButtonElement) {
10499
if (preset.connectionMode === "oauth") {
105100
window.location.href = mcpOAuthStartPath(tenantId, preset.slug);
106101
return;
107102
}
108103
if (preset.connectionMode === "token") {
109-
setTokenFieldOpen(true);
104+
onOpen(trigger);
110105
return;
111106
}
112-
submitConnect(undefined);
113-
}
114-
115-
function handleDisconnect() {
116-
setBusy(true);
117-
setError(null);
118-
disconnectMcpServer(tenantId, preset.slug)
119-
.then(() => {
120-
toast(`${preset.displayName} disconnected.`);
121-
onChanged();
122-
})
123-
.catch(() => setError(PLUGINS_STRINGS.disconnectError))
124-
.finally(() => setBusy(false));
107+
submitConnect();
125108
}
126109

127110
const presetDefinition = MCP_PRESETS.find(
@@ -137,8 +120,6 @@ export function McpPresetCard({
137120
: `${toolCount} tool${toolCount === 1 ? "" : "s"}`
138121
: "Not connected";
139122

140-
const tokenFieldId = `mcp-preset-token-${preset.slug}`;
141-
142123
return (
143124
<div
144125
className="min-w-0 px-2 py-2.5"
@@ -167,91 +148,30 @@ export function McpPresetCard({
167148
<div className="flex flex-none items-center gap-2">
168149
<span className="text-xs text-muted-foreground">{status}</span>
169150
{preset.connected ? (
170-
<ConfirmButton
171-
variant="ghost"
151+
<Button
152+
type="button"
172153
size="sm"
173-
confirmLabel={
174-
<>
175-
Disconnect
176-
<span className="sr-only"> {preset.displayName}</span>
177-
</>
178-
}
179-
disabled={busy}
180-
onConfirm={handleDisconnect}
154+
variant="ghost"
155+
aria-label={`Manage ${preset.displayName}`}
156+
onClick={(event) => onOpen(event.currentTarget)}
181157
>
182-
{busy ? "Disconnecting…" : "Manage"}
158+
Manage
183159
<span className="sr-only"> {preset.displayName}</span>
184-
</ConfirmButton>
185-
) : tokenFieldOpen ? null : (
160+
</Button>
161+
) : (
186162
<Button
187163
type="button"
188164
size="sm"
189165
variant="ghost"
190166
disabled={busy}
191167
aria-label={`Connect ${preset.displayName}`}
192-
onClick={handleConnect}
168+
onClick={(event) => handleConnect(event.currentTarget)}
193169
>
194170
{busy ? "Connecting…" : "Connect"}
195171
</Button>
196172
)}
197173
</div>
198174
</div>
199-
{tokenFieldOpen && !preset.connected ? (
200-
<div className="mt-2 flex flex-col gap-2 pl-11">
201-
<ol className="list-decimal space-y-1 pl-4 text-xs text-muted-foreground">
202-
{(preset.tokenSteps ?? []).map((step) => (
203-
<li key={step}>{step}</li>
204-
))}
205-
</ol>
206-
<a
207-
href={preset.docsUrl}
208-
target="_blank"
209-
rel="noreferrer"
210-
className="text-xs underline underline-offset-2"
211-
>
212-
Create your token
213-
</a>
214-
<label className="sr-only" htmlFor={tokenFieldId}>
215-
{`${preset.displayName} access token`}
216-
</label>
217-
<Input
218-
id={tokenFieldId}
219-
type="password"
220-
value={token}
221-
placeholder="Paste your access token"
222-
disabled={busy}
223-
onChange={(event) => {
224-
setToken(event.target.value);
225-
}}
226-
/>
227-
<div className="flex items-center gap-2">
228-
<Button
229-
type="button"
230-
size="sm"
231-
disabled={busy || token.trim() === ""}
232-
aria-label={`Connect ${preset.displayName}`}
233-
onClick={() => {
234-
submitConnect(token.trim());
235-
}}
236-
>
237-
{busy ? "Connecting…" : "Connect"}
238-
</Button>
239-
<Button
240-
type="button"
241-
size="sm"
242-
variant="ghost"
243-
disabled={busy}
244-
onClick={() => {
245-
setTokenFieldOpen(false);
246-
setToken("");
247-
setError(null);
248-
}}
249-
>
250-
Cancel
251-
</Button>
252-
</div>
253-
</div>
254-
) : null}
255175
</div>
256176
);
257177
}

packages/plugins-ui/src/plugin-card.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import { Button } from "@corbits/react-ui";
99
import type { ResolvedPlugin } from "@corbits/connections/plugins";
10+
import { oauthStartHref } from "@corbits/settings-ui";
1011

1112
import { pluginIcon, pluginOutcome } from "./plugin-meta";
1213
import { PluginLogo } from "./plugin-logo";
@@ -25,9 +26,11 @@ const PROVENANCE_LABEL: Record<"this-workbench" | "inherited", string> = {
2526
};
2627

2728
export function PluginCard({
29+
tenantId,
2830
plugin,
2931
onOpen,
3032
}: {
33+
readonly tenantId: string;
3134
readonly plugin: ResolvedPlugin;
3235
readonly onOpen: () => void;
3336
}) {
@@ -36,6 +39,10 @@ export function PluginCard({
3639
plugin.provenance !== null
3740
? `${STATUS_CAPTION[plugin.status]} · ${PROVENANCE_LABEL[plugin.provenance]}`
3841
: STATUS_CAPTION[plugin.status];
42+
const isDirectOAuthConnect =
43+
plugin.status === "not_connected" &&
44+
(plugin.descriptor.authKind === "oauth-pkce" ||
45+
plugin.descriptor.authKind === "oauth-code");
3946

4047
return (
4148
<div
@@ -58,7 +65,16 @@ export function PluginCard({
5865
</div>
5966
<div className="flex flex-none items-center gap-2">
6067
<span className="text-xs text-muted-foreground">{caption}</span>
61-
{plugin.status === "not_connected" ? (
68+
{isDirectOAuthConnect ? (
69+
<Button size="sm" variant="ghost" asChild>
70+
<a
71+
href={oauthStartHref(tenantId, plugin.descriptor.id, "/plugins")}
72+
aria-label={`Connect ${plugin.descriptor.displayName}`}
73+
>
74+
Connect
75+
</a>
76+
</Button>
77+
) : plugin.status === "not_connected" ? (
6278
<Button
6379
type="button"
6480
size="sm"

0 commit comments

Comments
 (0)