feat(service-credential-tester): slice 2 — Test button + gating (shared ServiceTestPanel)

Presentational ServiceTestPanel (props-driven, no internal hooks) wired into
both CreateServiceDialog (ServicesPage.tsx) and ServiceConfigEditor
(Settings.tsx). Parent owns testResult + saveAnyway state; store-previous
pattern resets on input change (avoids setState-in-effect). Create/Save
button gated on testPassed || saveAnyway. 7 panel tests (button states,
success/failure pills, checkbox toggle). All gates: 158 vitest, build exit 0,
lint 0 errors, 362 backend pytest (regression).
This commit is contained in:
Developer
2026-07-09 22:55:55 +00:00
parent 3391fbc85d
commit f6c67bd3ff
7 changed files with 314 additions and 6 deletions
+51 -5
View File
@@ -30,6 +30,7 @@ import {
useDeleteServiceInstance,
useSaveServiceInstance,
useServiceInstances,
useTestServiceInstance,
} from "../hooks/useServices";
import { useServiceTypes } from "../hooks/useServices";
import {
@@ -41,12 +42,14 @@ import type {
SecretFieldInfo,
ServiceInstance,
ServiceInstanceInput,
ServiceTestResult,
ServiceTypeInfo,
} from "../types";
import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { DialogFooter } from "../components/DialogFooter";
import { getServiceBinding } from "../integrations/registry";
import { ServiceTestPanel } from "../components/ServiceTestPanel";
import { serviceLinkTarget } from "../components/PinnedServiceLink";
import type { NamedDashboardInput } from "../api/dashboards";
@@ -174,12 +177,48 @@ function CreateServiceDialog({
}) {
const { data: types = [] } = useServiceTypes();
const saveService = useSaveServiceInstance();
const testService = useTestServiceInstance();
const [draft, setDraft] = useState<CreateDraft | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [testResult, setTestResult] = useState<ServiceTestResult | null>(null);
const [saveAnyway, setSaveAnyway] = useState(false);
// Reset test state when draft changes (React "store previous" pattern —
// avoids setState-in-effect). Any field edit creates a new draft object.
const [prevDraft, setPrevDraft] = useState(draft);
if (draft !== prevDraft) {
setPrevDraft(draft);
setTestResult(null);
setSaveAnyway(false);
}
const testPassed = (testResult?.ok ?? false) || saveAnyway;
function reset() {
setDraft(null);
setSubmitError(null);
setTestResult(null);
setSaveAnyway(false);
}
async function handleTest() {
if (!draft) return;
try {
const res = await testService.mutateAsync({
service_type: draft.serviceType,
name: draft.name.trim(),
config: draft.config,
secrets: draft.secrets,
enabled: draft.enabled,
});
setTestResult(res);
} catch (err) {
setTestResult({
ok: false,
detail: err instanceof Error ? err.message : String(err),
evidence: null,
});
}
}
async function save() {
@@ -196,7 +235,7 @@ function CreateServiceDialog({
try {
await saveService.mutateAsync(input);
reset();
onClose();
onClose();
} catch (err) {
setSubmitError(err instanceof Error ? err.message : String(err));
}
@@ -265,9 +304,16 @@ function CreateServiceDialog({
onCheckedChange={(checked) =>
setDraft({ ...draft, enabled: checked })
}
/>
<Label htmlFor="service-enabled">Enabled</Label>
</div>
/>
<Label htmlFor="service-enabled">Enabled</Label>
</div>
<ServiceTestPanel
result={testResult}
isPending={testService.isPending}
saveAnyway={saveAnyway}
onTest={handleTest}
onSaveAnywayChange={setSaveAnyway}
/>
</>
)}
</div>
@@ -281,7 +327,7 @@ function CreateServiceDialog({
onCancel={reset}
onConfirm={save}
confirmLabel="Create service"
confirmDisabled={!draft.name.trim() || saveService.isPending}
confirmDisabled={!draft.name.trim() || saveService.isPending || !testPassed}
/>
) : null}
</DialogContent>