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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user