mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
fix(site): CredentialField: mask api key after submit (#25848)
Fixes CODAGT-525 * Re-masks the field after submit * Sets font to monospaced for legibility * Extracts `createDeferred` to `testHelpers`
This commit is contained in:
@@ -8,7 +8,7 @@ import type {
|
||||
|
||||
const aiProvidersListKey = ["ai", "providers"] as const;
|
||||
|
||||
const aiProviderKeyFor = (idOrName: string) =>
|
||||
export const aiProviderKeyFor = (idOrName: string) =>
|
||||
[...aiProvidersListKey, idOrName] as const;
|
||||
|
||||
export const aiProvidersList = () => ({
|
||||
|
||||
+6
@@ -7,6 +7,7 @@ import { toast } from "sonner";
|
||||
import { getErrorMessage } from "#/api/errors";
|
||||
import {
|
||||
aiProvider,
|
||||
aiProviderKeyFor,
|
||||
deleteAIProviderMutation,
|
||||
updateAIProviderMutation,
|
||||
} from "#/api/queries/aiProviders";
|
||||
@@ -171,6 +172,10 @@ const UpdateProviderPageView: React.FC = () => {
|
||||
{ enabled: checked },
|
||||
{
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(
|
||||
aiProviderKeyFor(providerId),
|
||||
updated,
|
||||
);
|
||||
toast.success(
|
||||
`Provider "${updated.display_name || updated.name}" ${checked ? "enabled" : "disabled"}.`,
|
||||
);
|
||||
@@ -200,6 +205,7 @@ const UpdateProviderPageView: React.FC = () => {
|
||||
const request = providerFormValuesToUpdate(values, provider);
|
||||
try {
|
||||
const updated = await updateMutation.mutateAsync(request);
|
||||
queryClient.setQueryData(aiProviderKeyFor(providerId), updated);
|
||||
toast.success(
|
||||
`Provider "${updated.display_name || updated.name}" updated.`,
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ type CredentialFieldProps = {
|
||||
placeholder?: string;
|
||||
description?: React.ReactNode;
|
||||
required?: boolean;
|
||||
onBlur?: () => void;
|
||||
onFocus?: () => void;
|
||||
};
|
||||
|
||||
@@ -20,6 +21,7 @@ export const CredentialField: React.FC<CredentialFieldProps> = ({
|
||||
placeholder,
|
||||
description,
|
||||
required = false,
|
||||
onBlur,
|
||||
onFocus,
|
||||
}) => {
|
||||
const inputId = useId();
|
||||
@@ -62,9 +64,13 @@ export const CredentialField: React.FC<CredentialFieldProps> = ({
|
||||
<Input
|
||||
id={inputId}
|
||||
name={helpers.name}
|
||||
className="font-mono text-[13px]"
|
||||
value={helpers.value}
|
||||
onChange={helpers.onChange}
|
||||
onBlur={helpers.onBlur}
|
||||
onBlur={(event) => {
|
||||
helpers.onBlur(event);
|
||||
onBlur?.();
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
autoComplete={autoComplete}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test";
|
||||
import { ProviderForm } from "./ProviderForm";
|
||||
import { createDeferred, type Deferred } from "#/testHelpers/deferred";
|
||||
import { ProviderForm, SAVED_CREDENTIAL_MASK } from "./ProviderForm";
|
||||
|
||||
const meta: Meta<typeof ProviderForm> = {
|
||||
title: "pages/AISettingsPage/ProviderForm",
|
||||
@@ -15,6 +17,88 @@ const meta: Meta<typeof ProviderForm> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ProviderForm>;
|
||||
|
||||
const SuccessfulSubmitProviderForm = ({
|
||||
args,
|
||||
deferred,
|
||||
}: {
|
||||
args: ComponentProps<typeof ProviderForm>;
|
||||
deferred: Deferred<void>;
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<ProviderForm
|
||||
{...args}
|
||||
isLoading={isLoading}
|
||||
onSubmit={async (values) => {
|
||||
args.onSubmit?.(values);
|
||||
setIsLoading(true);
|
||||
await deferred.promise;
|
||||
setIsLoading(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const FailedSubmitProviderForm = ({
|
||||
args,
|
||||
deferred,
|
||||
}: {
|
||||
args: ComponentProps<typeof ProviderForm>;
|
||||
deferred: Deferred<void>;
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<unknown>();
|
||||
|
||||
return (
|
||||
<ProviderForm
|
||||
{...args}
|
||||
isLoading={isLoading}
|
||||
submitError={submitError}
|
||||
onSubmit={async (values) => {
|
||||
args.onSubmit?.(values);
|
||||
setIsLoading(true);
|
||||
await deferred.promise;
|
||||
setSubmitError(new Error(errorSubmitMessage));
|
||||
setIsLoading(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ExternalLoadingProviderForm = ({
|
||||
args,
|
||||
deferred,
|
||||
}: {
|
||||
args: ComponentProps<typeof ProviderForm>;
|
||||
deferred: Deferred<void>;
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProviderForm {...args} isLoading={isLoading} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setIsLoading(true);
|
||||
await deferred.promise;
|
||||
setIsLoading(false);
|
||||
}}
|
||||
>
|
||||
Simulate external save
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const errorSubmitMessage = "Failed to update provider.";
|
||||
|
||||
let bedrockSubmitDeferred = createDeferred<void>();
|
||||
let apiKeySubmitDeferred = createDeferred<void>();
|
||||
let failedSubmitDeferred = createDeferred<void>();
|
||||
let externalSaveDeferred = createDeferred<void>();
|
||||
|
||||
export const AddAnthropicDefault: Story = {};
|
||||
|
||||
export const AddOpenAI: Story = {
|
||||
@@ -47,6 +131,15 @@ export const AddBedrock: Story = {
|
||||
};
|
||||
|
||||
export const EditBedrockKeepCredentials: Story = {
|
||||
render: (args) => {
|
||||
bedrockSubmitDeferred = createDeferred<void>();
|
||||
return (
|
||||
<SuccessfulSubmitProviderForm
|
||||
args={args}
|
||||
deferred={bedrockSubmitDeferred}
|
||||
/>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
editing: true,
|
||||
bedrockSavedAccessCredentials: true,
|
||||
@@ -62,6 +155,59 @@ export const EditBedrockKeepCredentials: Story = {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const accessKeyInput = await canvas.findByLabelText(/^access key\s*\*?$/i);
|
||||
const accessKeySecretInput =
|
||||
await canvas.findByLabelText(/access key secret/i);
|
||||
|
||||
expect(accessKeyInput).toHaveProperty("type", "text");
|
||||
expect(accessKeySecretInput).toHaveProperty("type", "text");
|
||||
expect(accessKeyInput).toHaveValue(SAVED_CREDENTIAL_MASK);
|
||||
expect(accessKeySecretInput).toHaveValue(SAVED_CREDENTIAL_MASK);
|
||||
|
||||
await userEvent.click(accessKeyInput);
|
||||
await waitFor(() => expect(accessKeyInput).toHaveValue(""));
|
||||
await userEvent.click(accessKeySecretInput);
|
||||
await waitFor(() =>
|
||||
expect(accessKeyInput).toHaveValue(SAVED_CREDENTIAL_MASK),
|
||||
);
|
||||
|
||||
await userEvent.click(accessKeyInput);
|
||||
await waitFor(() => expect(accessKeyInput).toHaveValue(""));
|
||||
await userEvent.type(accessKeyInput, "AKIAI1lO0EXAMPLE");
|
||||
expect(accessKeyInput).toHaveValue("AKIAI1lO0EXAMPLE");
|
||||
|
||||
await userEvent.click(accessKeySecretInput);
|
||||
await waitFor(() => expect(accessKeySecretInput).toHaveValue(""));
|
||||
await userEvent.type(accessKeySecretInput, "wJalrI1lO0Secret");
|
||||
expect(accessKeySecretInput).toHaveValue("wJalrI1lO0Secret");
|
||||
|
||||
const displayName = canvas.getByLabelText(/display name/i);
|
||||
await userEvent.clear(displayName);
|
||||
await userEvent.type(displayName, "Updated Bedrock");
|
||||
|
||||
const submitButton = canvas.getByRole("button", {
|
||||
name: /update provider/i,
|
||||
});
|
||||
await waitFor(() => expect(submitButton).toBeEnabled());
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(args.onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accessKey: "AKIAI1lO0EXAMPLE",
|
||||
accessKeySecret: "wJalrI1lO0Secret",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(submitButton).toBeDisabled());
|
||||
bedrockSubmitDeferred.resolve();
|
||||
await waitFor(() => {
|
||||
expect(accessKeyInput).toHaveValue(SAVED_CREDENTIAL_MASK);
|
||||
expect(accessKeySecretInput).toHaveValue(SAVED_CREDENTIAL_MASK);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const AddCopilot: Story = {
|
||||
@@ -141,6 +287,134 @@ export const Submitting: Story = {
|
||||
};
|
||||
|
||||
export const CredentialFocusClear: Story = {
|
||||
render: (args) => {
|
||||
apiKeySubmitDeferred = createDeferred<void>();
|
||||
return (
|
||||
<SuccessfulSubmitProviderForm
|
||||
args={args}
|
||||
deferred={apiKeySubmitDeferred}
|
||||
/>
|
||||
);
|
||||
},
|
||||
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, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const apiKeyInput = await canvas.findByLabelText(/api key/i);
|
||||
|
||||
expect(apiKeyInput).toHaveProperty("type", "text");
|
||||
expect(apiKeyInput).toHaveValue("sk-ant-***\u2026***ABCD");
|
||||
|
||||
await userEvent.click(apiKeyInput);
|
||||
await waitFor(() => expect(apiKeyInput).toHaveValue(""));
|
||||
|
||||
const displayName = canvas.getByLabelText(/display name/i);
|
||||
await userEvent.click(displayName);
|
||||
await waitFor(() =>
|
||||
expect(apiKeyInput).toHaveValue("sk-ant-***\u2026***ABCD"),
|
||||
);
|
||||
|
||||
await userEvent.click(apiKeyInput);
|
||||
await waitFor(() => expect(apiKeyInput).toHaveValue(""));
|
||||
await userEvent.type(apiKeyInput, "sk-ant-I1lO0-new-secret");
|
||||
expect(apiKeyInput).toHaveValue("sk-ant-I1lO0-new-secret");
|
||||
|
||||
await userEvent.clear(displayName);
|
||||
await userEvent.type(displayName, "Updated Anthropic");
|
||||
|
||||
const submitButton = canvas.getByRole("button", {
|
||||
name: /update provider/i,
|
||||
});
|
||||
await waitFor(() => expect(submitButton).toBeEnabled());
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(args.onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: "sk-ant-I1lO0-new-secret",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(submitButton).toBeDisabled());
|
||||
apiKeySubmitDeferred.resolve();
|
||||
await waitFor(() =>
|
||||
expect(apiKeyInput).toHaveValue("sk-ant-***\u2026***ABCD"),
|
||||
);
|
||||
},
|
||||
};
|
||||
export const FailedSubmitKeepsCredential: Story = {
|
||||
render: (args) => {
|
||||
failedSubmitDeferred = createDeferred<void>();
|
||||
return (
|
||||
<FailedSubmitProviderForm args={args} deferred={failedSubmitDeferred} />
|
||||
);
|
||||
},
|
||||
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, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const apiKeyInput = await canvas.findByLabelText(/api key/i);
|
||||
|
||||
await userEvent.click(apiKeyInput);
|
||||
await waitFor(() => expect(apiKeyInput).toHaveValue(""));
|
||||
await userEvent.type(apiKeyInput, "sk-ant-I1lO0-new-secret");
|
||||
|
||||
const displayName = canvas.getByLabelText(/display name/i);
|
||||
await userEvent.clear(displayName);
|
||||
await userEvent.type(displayName, "Failed Anthropic");
|
||||
|
||||
const submitButton = canvas.getByRole("button", {
|
||||
name: /update provider/i,
|
||||
});
|
||||
await waitFor(() => expect(submitButton).toBeEnabled());
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(args.onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: "sk-ant-I1lO0-new-secret",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(submitButton).toBeDisabled());
|
||||
failedSubmitDeferred.resolve();
|
||||
await expect(await canvas.findByText(errorSubmitMessage)).toBeVisible();
|
||||
expect(apiKeyInput).toHaveValue("sk-ant-I1lO0-new-secret");
|
||||
},
|
||||
};
|
||||
|
||||
export const ExternalLoadingKeepsCredential: Story = {
|
||||
render: (args) => {
|
||||
externalSaveDeferred = createDeferred<void>();
|
||||
return (
|
||||
<ExternalLoadingProviderForm
|
||||
args={args}
|
||||
deferred={externalSaveDeferred}
|
||||
/>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
editing: true,
|
||||
openAiAnthropicSavedApiKey: true,
|
||||
@@ -157,11 +431,25 @@ export const CredentialFocusClear: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const apiKeyInput = await canvas.findByLabelText(/api key/i);
|
||||
expect(apiKeyInput).toHaveValue("sk-ant-***\u2026***ABCD");
|
||||
const submitButton = canvas.getByRole("button", {
|
||||
name: /update provider/i,
|
||||
});
|
||||
|
||||
await userEvent.click(apiKeyInput);
|
||||
await waitFor(() => expect(apiKeyInput).toHaveValue(""));
|
||||
await userEvent.type(apiKeyInput, "sk-ant-I1lO0-new-secret");
|
||||
await waitFor(() => expect(submitButton).toBeEnabled());
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: /simulate external save/i }),
|
||||
);
|
||||
await waitFor(() => expect(submitButton).toBeDisabled());
|
||||
externalSaveDeferred.resolve();
|
||||
await waitFor(() => expect(submitButton).toBeEnabled());
|
||||
expect(apiKeyInput).toHaveValue("sk-ant-I1lO0-new-secret");
|
||||
},
|
||||
};
|
||||
|
||||
export const UnsavedChangesPrompt: Story = {
|
||||
args: {
|
||||
editing: true,
|
||||
|
||||
@@ -259,6 +259,21 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
const typeDefaults =
|
||||
providerDefaults[resolvedType as keyof typeof providerDefaults];
|
||||
|
||||
// Seed Bedrock credentials with the mask when on file; focus clears it,
|
||||
// and a re-submitted "" tells the API mapping to keep the value.
|
||||
const maskedAccessKey = bedrockSavedAccessCredentials
|
||||
? SAVED_CREDENTIAL_MASK
|
||||
: "";
|
||||
const maskedAccessKeySecret = bedrockSavedAccessCredentials
|
||||
? SAVED_CREDENTIAL_MASK
|
||||
: "";
|
||||
// Same pattern for openai/anthropic. Prefer the API-supplied masked
|
||||
// rendering so the user sees the key's identifying suffix.
|
||||
const maskedApiKey = openAiAnthropicSavedApiKey
|
||||
? (openAiAnthropicMaskedApiKey ?? SAVED_CREDENTIAL_MASK)
|
||||
: "";
|
||||
|
||||
const didSubmit = useRef(false);
|
||||
const form = useFormik<ProviderFormValues>({
|
||||
initialValues: {
|
||||
...defaultInitialValues,
|
||||
@@ -266,21 +281,16 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
// Edit overrides prefills with server values; create gets them as-is.
|
||||
...(typeDefaults ?? {}),
|
||||
...initialValues,
|
||||
// Seed Bedrock credentials with the mask when on file; focus clears it,
|
||||
// and a re-submitted "" tells the API mapping to keep the value.
|
||||
accessKey: bedrockSavedAccessCredentials ? SAVED_CREDENTIAL_MASK : "",
|
||||
accessKeySecret: bedrockSavedAccessCredentials
|
||||
? SAVED_CREDENTIAL_MASK
|
||||
: "",
|
||||
// Same pattern for openai/anthropic. Prefer the API-supplied masked
|
||||
// rendering so the user sees the key's identifying suffix.
|
||||
apiKey: openAiAnthropicSavedApiKey
|
||||
? (openAiAnthropicMaskedApiKey ?? SAVED_CREDENTIAL_MASK)
|
||||
: "",
|
||||
accessKey: maskedAccessKey,
|
||||
accessKeySecret: maskedAccessKeySecret,
|
||||
apiKey: maskedApiKey,
|
||||
},
|
||||
validationSchema: getProviderFormSchema(editing),
|
||||
validateOnMount: true,
|
||||
onSubmit: onSubmit ?? (() => {}),
|
||||
onSubmit: (values) => {
|
||||
didSubmit.current = true;
|
||||
return onSubmit?.(values);
|
||||
},
|
||||
});
|
||||
const getFieldHelpers = getFormHelpers(form, submitError);
|
||||
|
||||
@@ -297,17 +307,46 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Restores the mask when the user leaves the field without entering
|
||||
// a new value, keeping the saved-credential appearance.
|
||||
const handleCredentialBlur = (
|
||||
field: "apiKey" | "accessKey" | "accessKeySecret",
|
||||
) => {
|
||||
const initial = form.initialValues[field];
|
||||
if (form.values[field] === "" && initial !== "") {
|
||||
void form.setFieldValue(field, initial);
|
||||
}
|
||||
};
|
||||
|
||||
// When the parent's mutation finishes without an error, treat the just-
|
||||
// submitted values as the new baseline so the unsaved-changes prompt does
|
||||
// not fire on subsequent navigations. React Query reports a missing error
|
||||
// as `null`, so a truthy check covers both null and undefined.
|
||||
const previousIsLoading = useRef(isLoading);
|
||||
useEffect(() => {
|
||||
if (previousIsLoading.current && !isLoading && !submitError) {
|
||||
form.resetForm({ values: form.values });
|
||||
if (previousIsLoading.current && !isLoading) {
|
||||
if (didSubmit.current && !submitError) {
|
||||
// Restore credential fields to their initial masked sentinels so
|
||||
// the raw key is never left visible after a successful save.
|
||||
const remaskedValues = {
|
||||
...form.values,
|
||||
apiKey: maskedApiKey,
|
||||
accessKey: maskedAccessKey,
|
||||
accessKeySecret: maskedAccessKeySecret,
|
||||
};
|
||||
form.resetForm({ values: remaskedValues });
|
||||
}
|
||||
didSubmit.current = false;
|
||||
}
|
||||
previousIsLoading.current = isLoading;
|
||||
}, [isLoading, submitError, form]);
|
||||
}, [
|
||||
isLoading,
|
||||
submitError,
|
||||
form,
|
||||
maskedApiKey,
|
||||
maskedAccessKey,
|
||||
maskedAccessKeySecret,
|
||||
]);
|
||||
|
||||
const unsavedChanges = useUnsavedChangesPrompt(
|
||||
form.dirty && !form.isSubmitting,
|
||||
@@ -367,6 +406,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
required
|
||||
label="API key"
|
||||
helpers={getFieldHelpers("apiKey")}
|
||||
onBlur={() => handleCredentialBlur("apiKey")}
|
||||
onFocus={() => handleCredentialFocus("apiKey")}
|
||||
autoComplete="new-password"
|
||||
placeholder={apiKeyPlaceholder(form.values.type)}
|
||||
@@ -430,12 +470,15 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
required
|
||||
label="Access key"
|
||||
helpers={getFieldHelpers("accessKey")}
|
||||
onBlur={() => handleCredentialBlur("accessKey")}
|
||||
onFocus={() => handleCredentialFocus("accessKey")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<CredentialField
|
||||
required
|
||||
label="Access key secret"
|
||||
helpers={getFieldHelpers("accessKeySecret")}
|
||||
onBlur={() => handleCredentialBlur("accessKeySecret")}
|
||||
onFocus={() => handleCredentialFocus("accessKeySecret")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { act, renderHook } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatQueuedMessage } from "#/api/typesGenerated";
|
||||
import { createDeferred } from "#/testHelpers/deferred";
|
||||
import { MockUserOwner, MockWorkspace } from "#/testHelpers/entities";
|
||||
import {
|
||||
draftInputStorageKeyPrefix,
|
||||
@@ -79,22 +80,6 @@ const setMobileViewport = (isMobile: boolean) => {
|
||||
});
|
||||
};
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
const createDeferred = <T>(): Deferred<T> => {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
describe("getWorkspaceOptionsWithLinkedWorkspace", () => {
|
||||
it("includes a missing linked workspace only when the current user owns it", () => {
|
||||
const existingWorkspace = {
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { API } from "#/api/api";
|
||||
import { createDeferred } from "#/testHelpers/deferred";
|
||||
import { chatDraftAttachmentStorageKey } from "../utils/chatDraftAttachmentStorage";
|
||||
import {
|
||||
resetChatDraftAttachmentRegistryForTest,
|
||||
useChatDraftAttachments,
|
||||
} from "./useChatDraftAttachments";
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
const createDeferred = <T>(): Deferred<T> => {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const orgID = "org-1";
|
||||
const chatID = "chat-a";
|
||||
const storageKey = chatDraftAttachmentStorageKey(orgID, chatID);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
export const createDeferred = <T>(): Deferred<T> => {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
Reference in New Issue
Block a user