Tabler's form-check-single modifier zeroes margin on .form-check-input, which cancels the -2rem margin-inline-start the base .form-check rule relies on to offset the checkbox into its label's padding gutter. That left checkboxes floating almost flush against their label text. Removed the form-check-single class from all 9 affected checkboxes: 6 in NotificationSettings, 1 each in DnsProviderForm, IntegrationForm, and IntegrationEditForm. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
174 lines
5.8 KiB
TypeScript
174 lines
5.8 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { api, type IntegrationField, type IntegrationType } from "../api/client";
|
|
|
|
const TYPE_LABELS: Record<IntegrationType, string> = {
|
|
tailscale: "Tailscale",
|
|
proxmox: "Proxmox",
|
|
synology: "Synology",
|
|
semaphore: "Semaphore",
|
|
gitea: "Gitea",
|
|
dockhand: "Dockhand",
|
|
};
|
|
|
|
export default function IntegrationForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
|
|
const [allFields, setAllFields] = useState<Partial<Record<IntegrationType, IntegrationField[]>> | null>(null);
|
|
const [type, setType] = useState<IntegrationType | null>(null);
|
|
const [name, setName] = useState("");
|
|
const [values, setValues] = useState<Record<string, string | boolean>>({});
|
|
const [testResult, setTestResult] = useState<{ ok: boolean; latencyMs?: number; error?: string } | null>(null);
|
|
const [testing, setTesting] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
api.integrations.fields().then((res) => {
|
|
setAllFields(res.fields);
|
|
const firstType = Object.keys(res.fields)[0] as IntegrationType | undefined;
|
|
if (firstType) setType(firstType);
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setValues({});
|
|
setTestResult(null);
|
|
}, [type]);
|
|
|
|
const availableTypes = allFields ? (Object.keys(allFields) as IntegrationType[]) : [];
|
|
const fields = (type && allFields?.[type]) ?? [];
|
|
|
|
function setField(key: string, value: string | boolean) {
|
|
setValues((v) => ({ ...v, [key]: value }));
|
|
}
|
|
|
|
async function testConnection() {
|
|
if (!type) return;
|
|
setTesting(true);
|
|
setTestResult(null);
|
|
try {
|
|
const result = await api.integrations.test({ type, config: values });
|
|
setTestResult(result);
|
|
} catch (err) {
|
|
setTestResult({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
}
|
|
|
|
async function submit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!type) return;
|
|
setError(null);
|
|
setSaving(true);
|
|
try {
|
|
await api.integrations.create({ type, name, config: values });
|
|
onCreated();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
if (allFields && availableTypes.length === 0) {
|
|
return (
|
|
<div className="card mb-3">
|
|
<div className="card-body text-secondary">
|
|
No integration types are implemented yet.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="card mb-3">
|
|
<div className="card-header">
|
|
<h3 className="card-title">Add an integration</h3>
|
|
</div>
|
|
<form onSubmit={submit}>
|
|
<div className="card-body row g-3">
|
|
{error && (
|
|
<div className="col-12">
|
|
<div className="alert alert-danger">{error}</div>
|
|
</div>
|
|
)}
|
|
<div className="col-md-4">
|
|
<label className="form-label">Integration</label>
|
|
<select
|
|
className="form-select"
|
|
value={type ?? ""}
|
|
onChange={(e) => setType(e.target.value as IntegrationType)}
|
|
>
|
|
{availableTypes.map((t) => (
|
|
<option key={t} value={t}>
|
|
{TYPE_LABELS[t]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<label className="form-label">Display name</label>
|
|
<input
|
|
className="form-control"
|
|
required
|
|
placeholder="e.g. Home tailnet"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{fields.map((field) => (
|
|
<div className="col-md-4" key={field.key}>
|
|
{field.type === "checkbox" ? (
|
|
<label className="form-check mt-4">
|
|
<input
|
|
type="checkbox"
|
|
className="form-check-input"
|
|
checked={Boolean(values[field.key])}
|
|
onChange={(e) => setField(field.key, e.target.checked)}
|
|
/>
|
|
<span className="form-check-label">{field.label}</span>
|
|
</label>
|
|
) : (
|
|
<>
|
|
<label className="form-label">{field.label}</label>
|
|
<input
|
|
type={field.type === "password" ? "password" : "text"}
|
|
className="form-control"
|
|
placeholder={field.placeholder}
|
|
required
|
|
value={(values[field.key] as string) ?? ""}
|
|
onChange={(e) => setField(field.key, e.target.value)}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{testResult && (
|
|
<div className="col-12">
|
|
{testResult.ok ? (
|
|
<div className="alert alert-success mb-0">
|
|
Connection OK{testResult.latencyMs !== undefined ? ` (${testResult.latencyMs}ms)` : ""}.
|
|
</div>
|
|
) : (
|
|
<div className="alert alert-danger mb-0">Connection failed: {testResult.error}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="card-footer d-flex gap-2">
|
|
<button type="button" className="btn btn-outline-secondary" onClick={testConnection} disabled={testing || !type}>
|
|
{testing ? "Testing…" : "Test connection"}
|
|
</button>
|
|
<button type="submit" className="btn btn-primary" disabled={saving || !type}>
|
|
Add integration
|
|
</button>
|
|
<button type="button" className="btn" onClick={onCancel}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|