Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions 4 site/.knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
// AI settings stack; they are consumed by the provider pages in PR 4.
// Remove this exclusion once those pages land.
"src/api/queries/aiProviders.ts",
// TODO(ai-settings): addableProviderTypes.ts is staged in PR 3 of the
// AI settings stack; its exports are consumed by the provider pages
// in PR 4. Remove this exclusion once those pages land.
"src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts",
// TODO(devtools): debugPanelUtils.ts is staged in PR 7; its exports are
// consumed by the Debug panel components in PRs 8 and 9. Remove this
// exclusion once the panel components land.
Expand Down
43 changes: 43 additions & 0 deletions 43 site/src/hooks/useUnsavedChangesPrompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useEffect } from "react";
import { useBlocker } from "react-router";

type UnsavedChangesPromptState = {
isOpen: boolean;
onCancel: () => void;
onConfirm: () => void;
};

/**
* Warns the user before leaving while there are unsaved changes. Pairs a
* `beforeunload` listener for hard navigations (tab close, refresh, address
* bar) with `useBlocker` for in-app navigations. The browser owns the dialog
* for hard navigations; the caller renders one for in-app navigations using
* the returned state.
*/
export const useUnsavedChangesPrompt = (
enabled: boolean,
): UnsavedChangesPromptState => {
useEffect(() => {
if (!enabled) return;
const onBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
// Older browsers also require a return value to trigger the prompt.
return "";
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => {
window.removeEventListener("beforeunload", onBeforeUnload);
};
}, [enabled]);

const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
enabled && currentLocation.pathname !== nextLocation.pathname,
);

return {
isOpen: blocker.state === "blocked",
onCancel: () => blocker.reset?.(),
onConfirm: () => blocker.proceed?.(),
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useId } from "react";
import { Input } from "#/components/Input/Input";
import { Label } from "#/components/Label/Label";
import type { FormHelpers } from "#/utils/formUtils";

type CredentialFieldProps = {
label: string;
helpers: FormHelpers;
autoComplete?: string;
placeholder?: string;
description?: React.ReactNode;
required?: boolean;
onFocus?: () => void;
};

export const CredentialField: React.FC<CredentialFieldProps> = ({
label,
helpers,
autoComplete,
placeholder,
description,
required = false,
onFocus,
}) => {
const inputId = useId();
const errorId = `${inputId}-error`;
const helperId = `${inputId}-helper`;
const descriptionId = `${inputId}-description`;
const describedBy = [
description ? descriptionId : null,
helpers.error ? errorId : helpers.helperText ? helperId : null,
]
.filter(Boolean)
.join(" ");

const labelNode = (
<Label htmlFor={inputId}>
{label}{" "}
{required && (
<span className="text-xs font-bold text-content-destructive">*</span>
)}
</Label>
);

const descriptionNode = description && (
<div id={descriptionId} className="text-xs text-content-secondary">
{description}
</div>
);

const helperNode = helpers.error ? (
<span id={errorId} className="text-xs text-content-destructive">
{helpers.helperText}
</span>
) : helpers.helperText ? (
<span id={helperId} className="text-xs text-content-secondary">
{helpers.helperText}
</span>
) : null;

const inputNode = (
<Input
id={inputId}
name={helpers.name}
value={helpers.value}
onChange={helpers.onChange}
onBlur={helpers.onBlur}
onFocus={onFocus}
autoComplete={autoComplete}
placeholder={placeholder}
aria-invalid={helpers.error}
aria-describedby={describedBy || undefined}
/>
);

return (
<div className="flex flex-col gap-2">
{labelNode}
{descriptionNode}
{inputNode}
{helperNode}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test";
import { ProviderForm } from "./ProviderForm";

const meta: Meta<typeof ProviderForm> = {
title: "pages/AISettingsPage/ProviderForm",
component: ProviderForm,
args: {
editing: false,
isLoading: false,
onSubmit: fn(),
},
};

export default meta;
type Story = StoryObj<typeof ProviderForm>;

export const AddAnthropicDefault: Story = {};

export const AddOpenAI: Story = {
args: {
initialValues: {
type: "openai",
name: "corporate-openai",
displayName: "Corporate OpenAI",
baseUrl: "https://api.openai.com/v1",
apiKey: "sk-example",
enabled: true,
},
},
};

export const AddBedrock: Story = {
args: {
initialValues: {
type: "bedrock",
name: "bedrock-prod",
displayName: "Bedrock Prod",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
model: "anthropic.claude-3-5-sonnet-20241022-v2:0",
smallFastModel: "anthropic.claude-3-5-haiku-20241022-v1:0",
accessKey: "AKIAIOSFODNN7EXAMPLE",
accessKeySecret: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
enabled: true,
},
},
};

export const EditBedrockKeepCredentials: Story = {
args: {
editing: true,
bedrockSavedAccessCredentials: true,
initialValues: {
type: "bedrock",
name: "bedrock",
displayName: "Bedrock",
baseUrl: "https://bedrock-runtime.us-east-2.amazonaws.com",
model: "anthropic.claude-opus-4-7",
smallFastModel: "anthropic.claude-haiku-4-5",
accessKey: "",
accessKeySecret: "",
enabled: true,
},
},
};

export const EditProvider: Story = {
args: {
editing: true,
openAiAnthropicSavedApiKey: true,
openAiAnthropicMaskedApiKey: "sk-ant-***\u2026***ABCD",
initialValues: {
type: "anthropic",
name: "production-anthropic",
displayName: "Production Anthropic",
baseUrl: "https://api.anthropic.com",
apiKey: "",
enabled: true,
},
},
};

export const EditOpenAiAnthropicNoSavedKey: Story = {
args: {
editing: true,
openAiAnthropicSavedApiKey: false,
initialValues: {
type: "anthropic",
name: "production-anthropic",
displayName: "Production Anthropic",
baseUrl: "https://api.anthropic.com",
apiKey: "",
enabled: true,
},
},
};

export const Submitting: Story = {
args: {
isLoading: true,
initialValues: {
type: "openai",
name: "openai",
displayName: "OpenAI",
baseUrl: "https://api.openai.com/v1",
apiKey: "sk-example",
},
},
};

export const CredentialFocusClear: Story = {
args: {
editing: true,
openAiAnthropicSavedApiKey: true,
openAiAnthropicMaskedApiKey: "sk-ant-***\u2026***ABCD",
initialValues: {
type: "anthropic",
name: "production-anthropic",
displayName: "Production Anthropic",
baseUrl: "https://api.anthropic.com",
apiKey: "",
enabled: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const apiKeyInput = await canvas.findByLabelText(/api key/i);
expect(apiKeyInput).toHaveValue("sk-ant-***\u2026***ABCD");
await userEvent.click(apiKeyInput);
await waitFor(() => expect(apiKeyInput).toHaveValue(""));
},
};
export const UnsavedChangesPrompt: Story = {
args: {
editing: true,
initialValues: {
type: "openai",
name: "corporate-openai",
displayName: "Corporate OpenAI",
baseUrl: "https://api.openai.com/v1",
apiKey: "",
enabled: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Dirty the form by editing the display name.
const displayName = await canvas.findByLabelText(/display name/i);
await userEvent.type(displayName, " Edited");
// Attempt to leave via the in-form Cancel link.
const cancelLink = canvas.getByRole("link", { name: /cancel/i });
await userEvent.click(cancelLink);
// The dialog renders in a portal, so search the document.
const dialog = await screen.findByRole("dialog");
await expect(
within(dialog).getByText("Unsaved changes"),
).toBeInTheDocument();
await expect(
within(dialog).getByText(/your updates haven't been saved/i),
).toBeInTheDocument();
},
};
Loading
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.