mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
fix(ui): guard environment variable drafts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
0fcda52537
commit
c5d4d47f63
3 files changed
+69
-3
No files matched your search
@@ -201,6 +201,8 @@ async function renderForm(
|
||||
showAdapterTestEnvironmentButton?: boolean;
|
||||
configurationShell?: boolean;
|
||||
visibleConfigurationSections?: ReadonlySet<string>;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onDirtyDetailsChange?: (details: { count: number; sections: string[] }) => void;
|
||||
} = {},
|
||||
) {
|
||||
mockEnvironmentsApi.list.mockResolvedValue(environments);
|
||||
@@ -229,6 +231,8 @@ async function renderForm(
|
||||
configurationShell={options.configurationShell}
|
||||
visibleConfigurationSections={options.visibleConfigurationSections}
|
||||
sectionLayout={options.configurationShell ? "cards" : undefined}
|
||||
onDirtyChange={options.onDirtyChange}
|
||||
onDirtyDetailsChange={options.onDirtyDetailsChange}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
@@ -623,6 +627,38 @@ describe("AgentConfigForm environment selector", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reports editor-local environment drafts as unsaved configuration", async () => {
|
||||
const onDirtyChange = vi.fn();
|
||||
const onDirtyDetailsChange = vi.fn();
|
||||
const result = await renderForm([
|
||||
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
||||
], {
|
||||
adapterConfig: {
|
||||
env: { API_TOKEN: { type: "plain", value: "old-token" } },
|
||||
},
|
||||
}, {
|
||||
configurationShell: true,
|
||||
visibleConfigurationSections: new Set(["environment"]),
|
||||
onDirtyChange,
|
||||
onDirtyDetailsChange,
|
||||
});
|
||||
roots.push(result.root);
|
||||
|
||||
const valueInput = result.container.querySelector<HTMLInputElement>('input[aria-label="Variable value"]');
|
||||
expect(valueInput).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
setInputValue(valueInput!, "draft-token");
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
|
||||
expect(onDirtyDetailsChange).toHaveBeenLastCalledWith({
|
||||
count: 1,
|
||||
sections: ["environment"],
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces request failures instead of converting them into model test checks", async () => {
|
||||
mockAgentsApi.testEnvironment.mockRejectedValueOnce(new Error("Network unavailable"));
|
||||
|
||||
|
||||
@@ -338,6 +338,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
|
||||
// ---- Edit mode: overlay for dirty tracking ----
|
||||
const [overlay, setOverlay] = useState<AgentConfigOverlay>(emptyOverlay);
|
||||
const [environmentDraftDirty, setEnvironmentDraftDirty] = useState(false);
|
||||
const agentRef = useRef<Agent | null>(null);
|
||||
|
||||
// Clear overlay when agent data refreshes (after save)
|
||||
@@ -350,8 +351,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
}
|
||||
}, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const isDirty = !isCreate && isOverlayDirty(overlay);
|
||||
const dirtyDetails = useMemo(() => getAgentConfigDirtyDetails(overlay), [overlay]);
|
||||
const overlayDirty = !isCreate && isOverlayDirty(overlay);
|
||||
const isDirty = overlayDirty || environmentDraftDirty;
|
||||
const dirtyDetails = useMemo(() => {
|
||||
const details = getAgentConfigDirtyDetails(overlay);
|
||||
if (!environmentDraftDirty || "env" in overlay.adapterConfig) return details;
|
||||
return {
|
||||
count: details.count + 1,
|
||||
sections: details.sections.includes("environment")
|
||||
? details.sections
|
||||
: [...details.sections, "environment"],
|
||||
};
|
||||
}, [environmentDraftDirty, overlay]);
|
||||
const changes = useMemo(
|
||||
() => isCreate ? [] : buildAgentConfigChanges(props.agent, overlay),
|
||||
[isCreate, overlay, !isCreate ? props.agent : undefined], // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -406,6 +417,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
|
||||
/** Build accumulated patch and send to parent */
|
||||
const handleCancel = useCallback(() => {
|
||||
environmentVariablesEditorRef.current?.discardPendingDraft();
|
||||
setEnvironmentDraftDirty(false);
|
||||
setOverlay({ ...emptyOverlay });
|
||||
}, []);
|
||||
|
||||
@@ -1446,6 +1459,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
? set!({ envBindings: env ?? {}, envVars: "" })
|
||||
: mark("adapterConfig", "env", env)
|
||||
}
|
||||
onDirtyChange={isCreate ? undefined : setEnvironmentDraftDirty}
|
||||
/>
|
||||
</Field>}
|
||||
|
||||
@@ -1631,6 +1645,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
? set!({ envBindings: env ?? {}, envVars: "" })
|
||||
: mark("adapterConfig", "env", env)
|
||||
}
|
||||
onDirtyChange={isCreate ? undefined : setEnvironmentDraftDirty}
|
||||
/>
|
||||
{!isCreate && (
|
||||
<Field label="Secret access" hint={help.secretAccess}>
|
||||
|
||||
@@ -143,6 +143,8 @@ export interface EnvironmentVariablesEditorHandle {
|
||||
* action reads parent state. Returns the promoted value when a draft existed.
|
||||
*/
|
||||
flushPendingDraft: () => Record<string, EnvBinding> | null;
|
||||
/** Discard the editor-local draft and restore the controlled value. */
|
||||
discardPendingDraft: () => void;
|
||||
}
|
||||
|
||||
export const EnvironmentVariablesEditor = forwardRef<EnvironmentVariablesEditorHandle, EnvironmentVariablesEditorProps>(function EnvironmentVariablesEditor({
|
||||
@@ -288,7 +290,20 @@ export const EnvironmentVariablesEditor = forwardRef<EnvironmentVariablesEditorH
|
||||
return draftValue ?? {};
|
||||
}, [disabled, draftValue, draftValueKey, hasUnsavedChanges, onChange]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ flushPendingDraft }), [flushPendingDraft]);
|
||||
const discardPendingDraft = useCallback(() => {
|
||||
if (disabled || !hasUnsavedChanges) return;
|
||||
pendingSaveValueKeyRef.current = null;
|
||||
const nextRows = rowsFromValue(value);
|
||||
setRows(nextRows);
|
||||
touchCommittedNames(nextRows);
|
||||
markCommitted(normalizedEnvKey(value), nextRows);
|
||||
}, [disabled, hasUnsavedChanges, value]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({ flushPendingDraft, discardPendingDraft }),
|
||||
[discardPendingDraft, flushPendingDraft],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const form = editorRootRef.current?.closest("form");
|
||||
|
||||
Reference in new issue
Block a user