Skip to content
Draft
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
2 changes: 1 addition & 1 deletion site/src/api/queries/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ export const previousTemplateVersion = (
};
};

const templateVersionPresetsKey = (versionId: string) => [
export const templateVersionPresetsKey = (versionId: string) => [
templateVersionRoot,
versionId,
"presets",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,9 @@ export const MultiSelectCombobox: React.FC<MultiSelectComboboxProps> = ({
const dropdownRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);

const [selected, setSelected] = useState<Option[]>(arrayDefaultOptions ?? []);
const [selected, setSelected] = useState<Option[]>(
value ?? arrayDefaultOptions ?? [],
);
const [options, setOptions] = useState<GroupOption>(
transitionToGroupOption(arrayDefaultOptions, groupBy),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
type ChatProjectTemplatesValues,
chatProjectTemplatesRequest,
chatProjectTemplatesValues,
templatesValidationDetail,
} from "./ChatProjectTemplatesFields";

/** @public */
Expand Down Expand Up @@ -249,13 +250,6 @@ const ChatProjectForm: React.FC<ChatProjectFormProps> = ({
const selectedOrganization = organizations.find(
(organization) => organization.id === form.values.organizationId,
);
// The form has no field named after these validations, so the templates
// section shows them itself.
const templatesSaveError = isApiValidationError(error)
? error.response.data.validations?.find((validation) =>
validation.field.startsWith("templates."),
)?.detail
: undefined;
const templatesOrganizationId = project
? undefined
: selectedOrganization?.id;
Expand Down Expand Up @@ -357,7 +351,7 @@ const ChatProjectForm: React.FC<ChatProjectFormProps> = ({
<ChatProjectTemplatesFields
templates={eligibleTemplatesQuery.data}
templatesError={eligibleTemplatesQuery.error}
saveError={templatesSaveError}
saveError={templatesValidationDetail(error)}
value={form.values.templates}
onChange={(templates) => {
void form.setFieldValue("templates", templates);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useId } from "react";
import { useQuery } from "react-query";
import { getErrorMessage } from "#/api/errors";
import { getErrorMessage, isApiValidationError } from "#/api/errors";
import {
richParameters,
templateVersionPresets,
Expand Down Expand Up @@ -74,6 +74,18 @@ export const chatProjectTemplatesRequest = (
};
};

/**
* Returns the server's rejection of the templates in a save error. Forms
* have no field named after these validations, so the templates fields show
* it through `saveError`.
*/
export const templatesValidationDetail = (error: unknown) =>
isApiValidationError(error)
? error.response.data.validations?.find((validation) =>
validation.field.startsWith("templates."),
)?.detail
: undefined;

const templateLabel = (template: Template) =>
template.display_name || template.name;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import { chatProjectInstructionsKey } from "#/api/queries/chatProjects";
import type {
ChatProject,
ChatProjectInstructions,
Template,
} from "#/api/typesGenerated";
import { TooltipProvider } from "#/components/Tooltip/Tooltip";
import { ThemeOverride } from "#/contexts/ThemeProvider";
import {
MockChatProject,
MockChatProjectInstructions,
MockTemplate,
MockUnsetChatProjectInstructions,
mockApiError,
} from "#/testHelpers/entities";
Expand Down Expand Up @@ -471,3 +473,125 @@ describe("ProjectDetailsPanel", () => {
await screen.findByRole("button", { name: "Edit instructions" });
});
});

const dockerTemplate: Template = {
...MockTemplate,
id: "template-docker",
name: "docker",
display_name: "Docker",
};
const kubernetesTemplate: Template = {
...MockTemplate,
id: "template-kubernetes",
name: "kubernetes",
display_name: "Kubernetes",
};

describe("ProjectDetailsPanel templates", () => {
afterEach(() => {
vi.restoreAllMocks();
});

const setup = () => {
mockInstructions(MockUnsetChatProjectInstructions);
vi.spyOn(API, "getTemplates").mockResolvedValue([
dockerTemplate,
kubernetesTemplate,
]);
const updateProject = vi
.spyOn(API.experimental, "updateChatProject")
.mockResolvedValue(MockChatProject);
return { updateProject, ...renderPanel() };
};

it("saves the templates picked in the editor", async () => {
const { user, updateProject } = setup();

await user.click(
screen.getByRole("button", { name: "Edit templates: 0 templates" }),
);
await user.click(await screen.findByRole("button", { name: "Select all" }));
await user.click(screen.getByRole("button", { name: "Save" }));

await waitFor(() =>
expect(updateProject).toHaveBeenCalledWith(
MockChatProject.organization_id,
MockChatProject.id,
{
templates: {
template_ids: [dockerTemplate.id, kubernetesTemplate.id],
},
},
),
);
});

it("sends one templates update when Save is double clicked", async () => {
const { user, updateProject } = setup();
updateProject.mockReturnValue(new Promise(() => {}));

await user.click(
screen.getByRole("button", { name: "Edit templates: 0 templates" }),
);
await user.click(await screen.findByRole("button", { name: "Select all" }));
await userEvent
.setup({ delay: null })
.dblClick(screen.getByRole("button", { name: "Save" }));

await waitFor(() => expect(updateProject).toHaveBeenCalled());
expect(updateProject).toHaveBeenCalledTimes(1);
});

it("starts the editor from the saved templates", async () => {
const { user, updateProject, rerenderWithProject } = setup();
rerenderWithProject({
...MockChatProject,
templates: {
template_ids: [dockerTemplate.id, kubernetesTemplate.id],
},
});

await user.click(
screen.getByRole("button", { name: "Edit templates: 2 templates" }),
);
await user.click(
await screen.findByRole("combobox", { name: "Templates" }),
);
await user.keyboard("{Backspace}");
await user.click(screen.getByRole("button", { name: "Save" }));

await waitFor(() =>
expect(updateProject).toHaveBeenCalledWith(
MockChatProject.organization_id,
MockChatProject.id,
{ templates: { template_ids: [dockerTemplate.id] } },
),
);
});

it("keeps the draft when the project refetches while editing", async () => {
const { user, updateProject, rerenderWithProject } = setup();

await user.click(
screen.getByRole("button", { name: "Edit templates: 0 templates" }),
);
await user.click(await screen.findByRole("button", { name: "Select all" }));
rerenderWithProject({
...MockChatProject,
templates: { template_ids: [kubernetesTemplate.id] },
});
await user.click(screen.getByRole("button", { name: "Save" }));

await waitFor(() =>
expect(updateProject).toHaveBeenCalledWith(
MockChatProject.organization_id,
MockChatProject.id,
{
templates: {
template_ids: [dockerTemplate.id, kubernetesTemplate.id],
},
},
),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatProjectInstructions,
deleteChatProjectInstructions,
updateChatProject,
updateChatProjectInstructions,
} from "#/api/queries/chatProjects";
import { user } from "#/api/queries/users";
Expand All @@ -11,12 +12,13 @@ import { useAuthenticated } from "#/hooks/useAuthenticated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { ProjectDetailsPanelView } from "./ProjectDetailsPanelView";
import { ProjectInstructionsDialog } from "./ProjectInstructionsDialog";
import { ProjectTemplatesDialog } from "./ProjectTemplatesDialog";

type ProjectDetailsPanelProps = {
readonly project: ChatProject;
};

/** Loads a project's details and edits its instructions. */
/** Loads a project's details and edits its templates and instructions. */
export const ProjectDetailsPanel: React.FC<ProjectDetailsPanelProps> = ({
project,
}) => (
Expand Down Expand Up @@ -56,14 +58,17 @@ const ProjectDetailsPanelContent: React.FC<ProjectDetailsPanelProps> = ({
const deleteMutation = useMutation(
deleteChatProjectInstructions(queryClient, project),
);
const updateTemplatesMutation = useMutation(updateChatProject(queryClient));
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [isTemplatesEditorOpen, setIsTemplatesEditorOpen] = useState(false);
// isPending reaches the dialog only after a render, so a fast double
// click could otherwise submit the same change twice.
const isMutationInFlight = useRef(false);

const resetErrors = () => {
updateMutation.reset();
deleteMutation.reset();
updateTemplatesMutation.reset();
};

const openEditor = () => {
Expand All @@ -89,11 +94,31 @@ const ProjectDetailsPanelContent: React.FC<ProjectDetailsPanelProps> = ({
ownerLabel={ownerLabel}
createdAt={project.created_at}
organizationLabel={organizationLabel}
templateCount={project.templates.template_ids.length}
onEditTemplates={() => {
updateTemplatesMutation.reset();
setIsTemplatesEditorOpen(true);
}}
instructions={instructionsQuery.data}
error={instructionsQuery.error}
onRetry={() => void instructionsQuery.refetch()}
onEditInstructions={openEditor}
/>
<ProjectTemplatesDialog
project={project}
open={isTemplatesEditorOpen}
onOpenChange={setIsTemplatesEditorOpen}
isSaving={updateTemplatesMutation.isPending}
error={updateTemplatesMutation.error}
onSave={(templates) =>
runMutation((onSettled) =>
updateTemplatesMutation.mutate(
{ project, request: { templates } },
{ onSuccess: () => setIsTemplatesEditorOpen(false), onSettled },
),
)
}
/>
<ProjectInstructionsDialog
open={isEditorOpen}
onOpenChange={setIsEditorOpen}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const meta: Meta<typeof ProjectDetailsPanelView> = {
args: {
ownerLabel: "You",
createdAt: MockChatProject.created_at,
templateCount: 2,
onEditTemplates: fn(),
instructions: MockChatProjectInstructions,
error: undefined,
onRetry: fn(),
Expand All @@ -38,6 +40,14 @@ export const WithOrganization: Story = {
args: { organizationLabel: "Coder" },
};

export const NoTemplates: Story = {
args: { templateCount: 0 },
};

export const OneTemplate: Story = {
args: { templateCount: 1 },
};

export const OwnerLoading: Story = {
args: { ownerLabel: undefined },
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useId } from "react";
import type { ChatProjectInstructions } from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import { Link } from "#/components/Link/Link";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import {
Tooltip,
Expand All @@ -17,6 +18,8 @@ type ProjectDetailsPanelViewProps = {
readonly createdAt: string;
/** Omit to hide the organization row. */
readonly organizationLabel?: string;
readonly templateCount: number;
readonly onEditTemplates: () => void;
/** `undefined` until the instructions load. */
readonly instructions: ChatProjectInstructions | undefined;
readonly error: unknown;
Expand All @@ -32,6 +35,8 @@ export const ProjectDetailsPanelView: React.FC<
ownerLabel,
createdAt,
organizationLabel,
templateCount,
onEditTemplates,
instructions,
error,
onRetry,
Expand Down Expand Up @@ -87,6 +92,21 @@ export const ProjectDetailsPanelView: React.FC<
{organizationLabel !== undefined && (
<DetailRow label="Organization">{organizationLabel}</DetailRow>
)}
<DetailRow label="Templates">
<Link asChild showExternalIcon={false}>
<button
type="button"
className="cursor-pointer border-0 bg-transparent p-0 font-sans"
onClick={onEditTemplates}
>
<span className="sr-only">Edit templates:</span>{" "}
{templateCount === 1
? "1 template"
: `${templateCount} templates`}
<PenIcon />
</button>
</Link>
</DetailRow>
</dl>
<section
aria-labelledby={instructionsHeadingId}
Expand Down
Loading
Loading