feat: add Honcho Cloud connection preset
Adds a "choose-type" step to the settings flow so users can pick between Honcho Cloud (https://api.honcho.dev, API key required) and Self-Hosted (URL + optional token) when creating a connection. Multi-instance support already exists in the data layer, so cloud and self-hosted instances can coexist. - new HONCHO_CLOUD_URL constant and isCloudInstance helper in config.ts - SettingsForm accepts a preset prop; cloud variant locks the endpoint and enforces an API key - InstancesManager gains a ConnectionTypeChooser entry point and renders a Cloud icon for cloud instances in the list - unit tests for both preset paths and cloud edit-mode detection
This commit is contained in:
@@ -1,14 +1,18 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Check, Pencil, Plus, Server, Trash2 } from "lucide-react";
|
import { Check, ChevronRight, Cloud, Pencil, Plus, Server, Trash2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SettingsForm } from "@/components/settings/SettingsForm";
|
import { type ConnectionPreset, SettingsForm } from "@/components/settings/SettingsForm";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Muted } from "@/components/ui/typography";
|
import { Muted } from "@/components/ui/typography";
|
||||||
import { useInstances } from "@/hooks/useInstances";
|
import { useInstances } from "@/hooks/useInstances";
|
||||||
import type { Instance } from "@/lib/config";
|
import { HONCHO_CLOUD_URL, type Instance, isCloudInstance } from "@/lib/config";
|
||||||
import { COLOR } from "@/lib/constants";
|
import { COLOR } from "@/lib/constants";
|
||||||
|
|
||||||
type Mode = { kind: "list" } | { kind: "create" } | { kind: "edit"; id: string };
|
type Mode =
|
||||||
|
| { kind: "list" }
|
||||||
|
| { kind: "choose-type" }
|
||||||
|
| { kind: "create"; preset: ConnectionPreset }
|
||||||
|
| { kind: "edit"; id: string };
|
||||||
|
|
||||||
interface InstancesManagerProps {
|
interface InstancesManagerProps {
|
||||||
onActivated?: () => void;
|
onActivated?: () => void;
|
||||||
@@ -16,18 +20,32 @@ interface InstancesManagerProps {
|
|||||||
|
|
||||||
export function InstancesManager({ onActivated }: InstancesManagerProps) {
|
export function InstancesManager({ onActivated }: InstancesManagerProps) {
|
||||||
const { instances, activeId, activate, remove } = useInstances();
|
const { instances, activeId, activate, remove } = useInstances();
|
||||||
const [mode, setMode] = useState<Mode>({ kind: "list" });
|
const isFirstRun = instances.length === 0;
|
||||||
|
const [mode, setMode] = useState<Mode>(isFirstRun ? { kind: "choose-type" } : { kind: "list" });
|
||||||
|
|
||||||
|
const backFromCreate = () => setMode(isFirstRun ? { kind: "choose-type" } : { kind: "list" });
|
||||||
|
|
||||||
|
if (mode.kind === "choose-type") {
|
||||||
|
return (
|
||||||
|
<ConnectionTypeChooser
|
||||||
|
onPick={(preset) => setMode({ kind: "create", preset })}
|
||||||
|
onCancel={isFirstRun ? undefined : () => setMode({ kind: "list" })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (mode.kind === "create") {
|
if (mode.kind === "create") {
|
||||||
return (
|
return (
|
||||||
<SettingsForm
|
<SettingsForm
|
||||||
instance={null}
|
instance={null}
|
||||||
|
preset={mode.preset}
|
||||||
onSaved={() => {
|
onSaved={() => {
|
||||||
setMode({ kind: "list" });
|
setMode({ kind: "list" });
|
||||||
onActivated?.();
|
onActivated?.();
|
||||||
}}
|
}}
|
||||||
onCancel={instances.length > 0 ? () => setMode({ kind: "list" }) : undefined}
|
onCancel={backFromCreate}
|
||||||
hideCancel={instances.length === 0}
|
hideCancel={false}
|
||||||
|
submitLabel={isFirstRun ? "Save Connection" : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -44,17 +62,6 @@ export function InstancesManager({ onActivated }: InstancesManagerProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (instances.length === 0) {
|
|
||||||
return (
|
|
||||||
<SettingsForm
|
|
||||||
instance={null}
|
|
||||||
onSaved={() => onActivated?.()}
|
|
||||||
hideCancel
|
|
||||||
submitLabel="Save Connection"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -76,7 +83,7 @@ export function InstancesManager({ onActivated }: InstancesManagerProps) {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setMode({ kind: "create" })}
|
onClick={() => setMode({ kind: "choose-type" })}
|
||||||
className="w-full py-2.5 px-4 rounded-xl flex items-center justify-center gap-2"
|
className="w-full py-2.5 px-4 rounded-xl flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" strokeWidth={1.5} />
|
<Plus className="w-4 h-4" strokeWidth={1.5} />
|
||||||
@@ -86,6 +93,109 @@ export function InstancesManager({ onActivated }: InstancesManagerProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ConnectionTypeChooserProps {
|
||||||
|
onPick: (preset: ConnectionPreset) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionTypeChooser({ onPick, onCancel }: ConnectionTypeChooserProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-6 space-y-3"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-2">
|
||||||
|
<h2 className="text-base font-medium" style={{ color: "var(--text-1)" }}>
|
||||||
|
How do you want to connect?
|
||||||
|
</h2>
|
||||||
|
<Muted className="text-xs mt-1">
|
||||||
|
You can add more connections later — Cloud, self-hosted, or both.
|
||||||
|
</Muted>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConnectionTypeButton
|
||||||
|
icon={Cloud}
|
||||||
|
title="Honcho Cloud"
|
||||||
|
description={`Hosted at ${HONCHO_CLOUD_URL.replace(/^https?:\/\//, "")} — sign in with your API key`}
|
||||||
|
accent
|
||||||
|
onClick={() => onPick("cloud")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConnectionTypeButton
|
||||||
|
icon={Server}
|
||||||
|
title="Self-Hosted"
|
||||||
|
description="Connect to your own Honcho deployment"
|
||||||
|
onClick={() => onPick("self-hosted")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{onCancel && (
|
||||||
|
<div className="pt-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="w-full py-2 px-4 rounded-xl"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectionTypeButtonProps {
|
||||||
|
icon: typeof Cloud;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
accent?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionTypeButton({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
accent,
|
||||||
|
onClick,
|
||||||
|
}: ConnectionTypeButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="w-full rounded-xl p-4 flex items-center gap-3 text-left transition-colors"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: `1px solid ${accent ? "var(--accent-border)" : "var(--border)"}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0"
|
||||||
|
style={{
|
||||||
|
background: accent ? "var(--accent)" : "var(--bg-2)",
|
||||||
|
color: accent ? "white" : "var(--text-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<Muted className="text-xs mt-0.5">{description}</Muted>
|
||||||
|
</div>
|
||||||
|
<ChevronRight
|
||||||
|
className="w-4 h-4 shrink-0"
|
||||||
|
style={{ color: "var(--text-3)" }}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface InstanceRowProps {
|
interface InstanceRowProps {
|
||||||
instance: Instance;
|
instance: Instance;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -96,6 +206,7 @@ interface InstanceRowProps {
|
|||||||
|
|
||||||
function InstanceRow({ instance, active, onActivate, onEdit, onDelete }: InstanceRowProps) {
|
function InstanceRow({ instance, active, onActivate, onEdit, onDelete }: InstanceRowProps) {
|
||||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||||
|
const cloud = isCloudInstance(instance);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -122,6 +233,8 @@ function InstanceRow({ instance, active, onActivate, onEdit, onDelete }: Instanc
|
|||||||
>
|
>
|
||||||
{active ? (
|
{active ? (
|
||||||
<Check className="w-4 h-4" strokeWidth={2} />
|
<Check className="w-4 h-4" strokeWidth={2} />
|
||||||
|
) : cloud ? (
|
||||||
|
<Cloud className="w-4 h-4" strokeWidth={1.5} />
|
||||||
) : (
|
) : (
|
||||||
<Server className="w-4 h-4" strokeWidth={1.5} />
|
<Server className="w-4 h-4" strokeWidth={1.5} />
|
||||||
)}
|
)}
|
||||||
@@ -134,7 +247,7 @@ function InstanceRow({ instance, active, onActivate, onEdit, onDelete }: Instanc
|
|||||||
{instance.name}
|
{instance.name}
|
||||||
</p>
|
</p>
|
||||||
<Muted className="text-xs font-mono truncate">
|
<Muted className="text-xs font-mono truncate">
|
||||||
{instance.baseUrl.replace(/^https?:\/\//, "")}
|
{cloud ? "Honcho Cloud" : instance.baseUrl.replace(/^https?:\/\//, "")}
|
||||||
</Muted>
|
</Muted>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,17 +1,37 @@
|
|||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { AlertCircle, CheckCircle, Loader, Lock, LockOpen, Wifi, WifiOff } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Cloud,
|
||||||
|
Loader,
|
||||||
|
Lock,
|
||||||
|
LockOpen,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input, Textarea } from "@/components/ui/input";
|
import { Input, Textarea } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Muted } from "@/components/ui/typography";
|
import { Muted } from "@/components/ui/typography";
|
||||||
import { useInstances } from "@/hooks/useInstances";
|
import { useInstances } from "@/hooks/useInstances";
|
||||||
import { checkConnection, type HealthStatus, type Instance, instanceSchema } from "@/lib/config";
|
import {
|
||||||
|
checkConnection,
|
||||||
|
type HealthStatus,
|
||||||
|
HONCHO_CLOUD_URL,
|
||||||
|
type Instance,
|
||||||
|
instanceSchema,
|
||||||
|
isCloudInstance,
|
||||||
|
} from "@/lib/config";
|
||||||
import { COLOR } from "@/lib/constants";
|
import { COLOR } from "@/lib/constants";
|
||||||
|
|
||||||
|
export type ConnectionPreset = "cloud" | "self-hosted";
|
||||||
|
|
||||||
interface SettingsFormProps {
|
interface SettingsFormProps {
|
||||||
/** Instance to edit; pass `null` to create a new one. */
|
/** Instance to edit; pass `null` to create a new one. */
|
||||||
instance: Instance | null;
|
instance: Instance | null;
|
||||||
|
/** Whether this form is for a Cloud or Self-Hosted connection. Inferred from `instance` in edit mode. */
|
||||||
|
preset?: ConnectionPreset;
|
||||||
/** Called after a successful save. Receives the saved instance id. */
|
/** Called after a successful save. Receives the saved instance id. */
|
||||||
onSaved?: (id: string) => void;
|
onSaved?: (id: string) => void;
|
||||||
/** Called when the user cancels (only meaningful when there's something to cancel back to). */
|
/** Called when the user cancels (only meaningful when there's something to cancel back to). */
|
||||||
@@ -31,6 +51,7 @@ const statusConfig = {
|
|||||||
|
|
||||||
export function SettingsForm({
|
export function SettingsForm({
|
||||||
instance,
|
instance,
|
||||||
|
preset,
|
||||||
onSaved,
|
onSaved,
|
||||||
onCancel,
|
onCancel,
|
||||||
hideCancel,
|
hideCancel,
|
||||||
@@ -38,8 +59,17 @@ export function SettingsForm({
|
|||||||
}: SettingsFormProps) {
|
}: SettingsFormProps) {
|
||||||
const { add, update, activate } = useInstances();
|
const { add, update, activate } = useInstances();
|
||||||
|
|
||||||
const [name, setName] = useState(instance?.name ?? "");
|
const resolvedPreset: ConnectionPreset =
|
||||||
const [baseUrl, setBaseUrl] = useState(instance?.baseUrl ?? "http://localhost:8000");
|
preset ?? (instance && isCloudInstance(instance) ? "cloud" : "self-hosted");
|
||||||
|
const isCloud = resolvedPreset === "cloud";
|
||||||
|
|
||||||
|
const initialName = instance?.name ?? (isCloud ? "Honcho Cloud" : "");
|
||||||
|
const initialBaseUrl = isCloud
|
||||||
|
? HONCHO_CLOUD_URL
|
||||||
|
: (instance?.baseUrl ?? "http://localhost:8000");
|
||||||
|
|
||||||
|
const [name, setName] = useState(initialName);
|
||||||
|
const [baseUrl, setBaseUrl] = useState(initialBaseUrl);
|
||||||
const [token, setToken] = useState(instance?.token ?? "");
|
const [token, setToken] = useState(instance?.token ?? "");
|
||||||
const [errors, setErrors] = useState<Partial<Record<keyof Instance, string>>>({});
|
const [errors, setErrors] = useState<Partial<Record<keyof Instance, string>>>({});
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
@@ -64,8 +94,8 @@ export function SettingsForm({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const candidate = {
|
const candidate = {
|
||||||
id: instance?.id ?? "placeholder",
|
id: instance?.id ?? "placeholder",
|
||||||
name: name.trim() || "Default",
|
name: name.trim() || (isCloud ? "Honcho Cloud" : "Default"),
|
||||||
baseUrl,
|
baseUrl: isCloud ? HONCHO_CLOUD_URL : baseUrl,
|
||||||
token,
|
token,
|
||||||
};
|
};
|
||||||
const result = instanceSchema.safeParse(candidate);
|
const result = instanceSchema.safeParse(candidate);
|
||||||
@@ -78,6 +108,10 @@ export function SettingsForm({
|
|||||||
setErrors(fieldErrors);
|
setErrors(fieldErrors);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isCloud && !token.trim()) {
|
||||||
|
setErrors({ token: "API key is required for Honcho Cloud" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setErrors({});
|
setErrors({});
|
||||||
|
|
||||||
let id: string;
|
let id: string;
|
||||||
@@ -136,8 +170,25 @@ export function SettingsForm({
|
|||||||
|
|
||||||
{/* Base URL */}
|
{/* Base URL */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 text-sm">Honcho Base URL</Label>
|
<Label className="mb-1.5 text-sm">{isCloud ? "Endpoint" : "Honcho Base URL"}</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
{isCloud ? (
|
||||||
|
<div
|
||||||
|
className="flex-1 font-mono rounded-xl px-3 py-2 text-sm flex items-center gap-2"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Cloud
|
||||||
|
className="w-4 h-4 shrink-0"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{HONCHO_CLOUD_URL}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Input
|
<Input
|
||||||
type="url"
|
type="url"
|
||||||
value={baseUrl}
|
value={baseUrl}
|
||||||
@@ -148,6 +199,7 @@ export function SettingsForm({
|
|||||||
placeholder="http://localhost:8000"
|
placeholder="http://localhost:8000"
|
||||||
className="flex-1 font-mono rounded-xl"
|
className="flex-1 font-mono rounded-xl"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="accent"
|
variant="accent"
|
||||||
@@ -168,12 +220,16 @@ export function SettingsForm({
|
|||||||
<span className="hidden sm:block">Test</span>
|
<span className="hidden sm:block">Test</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{errors.baseUrl && (
|
{errors.baseUrl && !isCloud && (
|
||||||
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
|
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
|
||||||
{errors.baseUrl}
|
{errors.baseUrl}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Muted className="text-xs mt-1.5">URL of your self-hosted Honcho instance</Muted>
|
<Muted className="text-xs mt-1.5">
|
||||||
|
{isCloud
|
||||||
|
? "Hosted Honcho service — endpoint is fixed"
|
||||||
|
: "URL of your self-hosted Honcho instance"}
|
||||||
|
</Muted>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Health status */}
|
{/* Health status */}
|
||||||
@@ -225,27 +281,39 @@ export function SettingsForm({
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
API Token
|
{isCloud ? "API key" : "API Token"}
|
||||||
<span
|
<span
|
||||||
className="ml-1 text-xs font-normal px-1.5 py-0.5 rounded-full"
|
className="ml-1 text-xs font-normal px-1.5 py-0.5 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--surface)",
|
background: "var(--surface)",
|
||||||
border: "1px solid var(--border)",
|
border: `1px solid ${isCloud ? COLOR.accentText : "var(--border)"}`,
|
||||||
color: "var(--text-3)",
|
color: isCloud ? COLOR.accentText : "var(--text-3)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
optional
|
{isCloud ? "required" : "optional"}
|
||||||
</span>
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="honcho-token"
|
id="honcho-token"
|
||||||
value={token}
|
value={token}
|
||||||
onChange={(e) => setToken(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setToken(e.target.value);
|
||||||
|
if (errors.token) setErrors({ ...errors, token: undefined });
|
||||||
|
}}
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="eyJ... (required only if your instance has auth enabled)"
|
placeholder={
|
||||||
|
isCloud
|
||||||
|
? "Paste your Honcho Cloud API key"
|
||||||
|
: "eyJ... (required only if your instance has auth enabled)"
|
||||||
|
}
|
||||||
className="font-mono rounded-xl"
|
className="font-mono rounded-xl"
|
||||||
/>
|
/>
|
||||||
{health?.status === "auth-required" && !token && (
|
{errors.token && (
|
||||||
|
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
|
||||||
|
{errors.token}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!errors.token && health?.status === "auth-required" && !token && (
|
||||||
<motion.p
|
<motion.p
|
||||||
initial={{ opacity: 0, y: -4 }}
|
initial={{ opacity: 0, y: -4 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
@@ -274,7 +342,10 @@ export function SettingsForm({
|
|||||||
className="flex-1 py-2.5 px-4 rounded-xl"
|
className="flex-1 py-2.5 px-4 rounded-xl"
|
||||||
style={saved ? { background: "#059669" } : undefined}
|
style={saved ? { background: "#059669" } : undefined}
|
||||||
>
|
>
|
||||||
{saved ? "✓ Saved" : (submitLabel ?? (isCreate ? "Add Instance" : "Save Changes"))}
|
{saved
|
||||||
|
? "✓ Saved"
|
||||||
|
: (submitLabel ??
|
||||||
|
(isCreate ? (isCloud ? "Connect to Honcho Cloud" : "Add Instance") : "Save Changes"))}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import { httpFetch } from "@/lib/http";
|
|||||||
const LEGACY_KEY = "openconcho:config";
|
const LEGACY_KEY = "openconcho:config";
|
||||||
const STORE_KEY = "openconcho:instances";
|
const STORE_KEY = "openconcho:instances";
|
||||||
|
|
||||||
|
export const HONCHO_CLOUD_URL = "https://api.honcho.dev";
|
||||||
|
|
||||||
|
function normalizeBaseUrl(url: string): string {
|
||||||
|
return url.trim().replace(/\/+$/, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCloudInstance(instance: Pick<Instance, "baseUrl">): boolean {
|
||||||
|
return normalizeBaseUrl(instance.baseUrl) === HONCHO_CLOUD_URL;
|
||||||
|
}
|
||||||
|
|
||||||
export const configSchema = z.object({
|
export const configSchema = z.object({
|
||||||
baseUrl: z.string().url({ message: "Must be a valid URL" }),
|
baseUrl: z.string().url({ message: "Must be a valid URL" }),
|
||||||
token: z.string().optional().default(""),
|
token: z.string().optional().default(""),
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function SettingsPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm mt-1" style={{ color: "var(--text-3)" }}>
|
<p className="text-sm mt-1" style={{ color: "var(--text-3)" }}>
|
||||||
{isFirstRun
|
{isFirstRun
|
||||||
? "Connect to your self-hosted Honcho instance"
|
? "Connect to Honcho Cloud or your self-hosted instance"
|
||||||
: "Manage your Honcho connections"}
|
: "Manage your Honcho connections"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ describe("first load with no config", () => {
|
|||||||
// Should be visible immediately — bug 1: RootLayout returns null while
|
// Should be visible immediately — bug 1: RootLayout returns null while
|
||||||
// a useEffect-driven navigate fires, leaving a blank screen.
|
// a useEffect-driven navigate fires, leaving a blank screen.
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(/Connect to your self-hosted Honcho instance/i),
|
await screen.findByText(/Connect to Honcho Cloud or your self-hosted instance/i),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
72
packages/web/src/test/settings-form.test.tsx
Normal file
72
packages/web/src/test/settings-form.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { SettingsForm } from "@/components/settings/SettingsForm";
|
||||||
|
import { HONCHO_CLOUD_URL, loadStore } from "@/lib/config";
|
||||||
|
|
||||||
|
function renderForm(ui: React.ReactElement) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SettingsForm — cloud preset", () => {
|
||||||
|
it("does not render the editable Base URL input", () => {
|
||||||
|
renderForm(<SettingsForm instance={null} preset="cloud" />);
|
||||||
|
expect(screen.queryByPlaceholderText("http://localhost:8000")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the API key as required", () => {
|
||||||
|
renderForm(<SettingsForm instance={null} preset="cloud" />);
|
||||||
|
expect(screen.getByText("required")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks save when the API key is empty", async () => {
|
||||||
|
const onSaved = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderForm(<SettingsForm instance={null} preset="cloud" onSaved={onSaved} />);
|
||||||
|
await user.click(screen.getByRole("button", { name: /Connect to Honcho Cloud/i }));
|
||||||
|
expect(screen.getByText(/API key is required for Honcho Cloud/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves with the Honcho Cloud URL when a token is provided", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderForm(<SettingsForm instance={null} preset="cloud" />);
|
||||||
|
await user.type(screen.getByPlaceholderText(/Paste your Honcho Cloud API key/i), "sk-test-1");
|
||||||
|
await user.click(screen.getByRole("button", { name: /Connect to Honcho Cloud/i }));
|
||||||
|
const store = loadStore();
|
||||||
|
expect(store.instances[0]?.baseUrl).toBe(HONCHO_CLOUD_URL);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SettingsForm — self-hosted preset", () => {
|
||||||
|
it("renders the editable Base URL input", () => {
|
||||||
|
renderForm(<SettingsForm instance={null} preset="self-hosted" />);
|
||||||
|
expect(screen.getByPlaceholderText("http://localhost:8000")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows saving without a token", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderForm(<SettingsForm instance={null} preset="self-hosted" />);
|
||||||
|
await user.click(screen.getByRole("button", { name: /Add Instance/i }));
|
||||||
|
const store = loadStore();
|
||||||
|
expect(store.instances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SettingsForm — edit mode auto-detects cloud", () => {
|
||||||
|
it("renders the cloud variant when editing an instance with the cloud URL", () => {
|
||||||
|
renderForm(
|
||||||
|
<SettingsForm
|
||||||
|
instance={{
|
||||||
|
id: "id-1",
|
||||||
|
name: "My Cloud",
|
||||||
|
baseUrl: HONCHO_CLOUD_URL,
|
||||||
|
token: "sk-existing",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByPlaceholderText("http://localhost:8000")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("required")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user