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
+56 -1
View File
@@ -55,10 +55,13 @@ import {
useSaveServiceInstance,
useServiceInstances,
useServiceTypes,
useTestServiceInstance,
} from "../hooks/useServices";
import { ServiceTestPanel } from "../components/ServiceTestPanel";
import type {
ServiceInstance,
ServiceInstanceInput,
ServiceTestResult,
ServiceTypeInfo,
} from "../types";
@@ -1435,6 +1438,7 @@ function ServiceConfigEditor({
}) {
const saveService = useSaveServiceInstance();
const deleteService = useDeleteServiceInstance();
const testService = useTestServiceInstance();
const [name, setName] = useState(instance.name);
const [enabled, setEnabled] = useState(instance.enabled);
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({
@@ -1442,6 +1446,49 @@ function ServiceConfigEditor({
});
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
const [deleteOpen, setDeleteOpen] = useState(false);
const [testResult, setTestResult] = useState<ServiceTestResult | null>(null);
const [saveAnyway, setSaveAnyway] = useState(false);
// Build a test input from the current editor state. For the test, include
// ALL typed secrets (unfiltered) so the backend can authenticate.
function buildTestInput(): ServiceInstanceInput {
return {
id: instance.id,
service_type: instance.service_type,
name,
config: draftConfig,
secrets: draftSecrets,
enabled,
};
}
const testInput = buildTestInput();
// Reset test state when the test input changes (store-previous pattern).
const [prevTestInput, setPrevTestInput] = useState(testInput);
if (
testInput !== prevTestInput &&
JSON.stringify(testInput) !== JSON.stringify(prevTestInput)
) {
setPrevTestInput(testInput);
setTestResult(null);
setSaveAnyway(false);
}
const testPassed = (testResult?.ok ?? false) || saveAnyway;
async function handleTest() {
try {
const res = await testService.mutateAsync(testInput);
setTestResult(res);
} catch (err) {
setTestResult({
ok: false,
detail: err instanceof Error ? err.message : String(err),
evidence: null,
});
}
}
const properties =
(
@@ -1560,10 +1607,18 @@ function ServiceConfigEditor({
</FormField>
))}
<ServiceTestPanel
result={testResult}
isPending={testService.isPending}
saveAnyway={saveAnyway}
onTest={handleTest}
onSaveAnywayChange={setSaveAnyway}
/>
<div className="flex justify-between">
<Button
onClick={handleSave}
disabled={saveService.isPending}
disabled={saveService.isPending || !testPassed}
className="mobile-touch-target"
>
Save