diff --git a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPage.tsx index 1de76fd1e1..3846047c4d 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPage.tsx @@ -1,4 +1,6 @@ import { isAxiosError } from "axios"; +import { saveAs } from "file-saver"; +import JSZip from "jszip"; import type { FC } from "react"; import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; @@ -62,6 +64,35 @@ const personalSkillError = ( }; }; +const downloadPersonalSkillFile = async ( + name: string, + fetchContent: (name: string) => Promise, +): Promise => { + const content = await fetchContent(name); + saveAs( + new Blob([content], { type: "text/markdown;charset=utf-8" }), + `${name}.md`, + ); +}; + +const exportPersonalSkillsArchive = async ( + skills: readonly UserSkillMetadata[], + fetchContent: (name: string) => Promise, +): Promise => { + const contents = await Promise.all( + skills.map(async (skill) => ({ + name: skill.name, + content: await fetchContent(skill.name), + })), + ); + const zip = new JSZip(); + for (const { name, content } of contents) { + zip.file(`${name}/SKILL.md`, content); + } + const archive = await zip.generateAsync({ type: "blob" }); + saveAs(archive, "personal-skills.zip"); +}; + const AgentSettingsPersonalSkillsPage: FC = () => { const queryClient = useQueryClient(); const [dialogState, setDialogState] = useState(null); @@ -148,6 +179,35 @@ const AgentSettingsPersonalSkillsPage: FC = () => { }, }); + const fetchSkillContent = (name: string): Promise => + queryClient.fetchQuery(userSkill(name)).then((skill) => skill.content); + + const downloadMutation = useMutation({ + mutationFn: (name: string) => + downloadPersonalSkillFile(name, fetchSkillContent), + onError: (error) => { + toast.error( + getErrorMessage(error, "Failed to download personal skill."), + { + description: getErrorDetail(error), + }, + ); + }, + }); + + const exportAllMutation = useMutation({ + mutationFn: () => exportPersonalSkillsArchive(skills, fetchSkillContent), + onError: (error) => { + toast.error(getErrorMessage(error, "Failed to export personal skills."), { + description: getErrorDetail(error), + }); + }, + }); + + const downloadingSkillName = downloadMutation.isPending + ? downloadMutation.variables + : undefined; + let editInitialValues: PersonalSkillFormValues | undefined; let editLoadError: unknown = editSkillQuery.error; if (editSkillQuery.data) { @@ -274,6 +334,14 @@ const AgentSettingsPersonalSkillsPage: FC = () => { deleteMutation.reset(); setDialogState({ type: "delete", skill }); }} + onDownload={(skill) => { + downloadMutation.mutate(skill.name); + }} + onExportAll={() => { + exportAllMutation.mutate(); + }} + downloadingSkillName={downloadingSkillName} + isExportingAll={exportAllMutation.isPending} editorState={editorState} deleteState={deleteState} /> diff --git a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx index f1464a612a..de1eb4ad98 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx @@ -39,6 +39,8 @@ const baseArgs: AgentSettingsPersonalSkillsPageViewProps = { onCreate: fn(), onEdit: fn(), onDelete: fn(), + onDownload: fn(), + onExportAll: fn(), }; const meta = { @@ -52,6 +54,45 @@ type Story = StoryObj; export const Populated: Story = {}; +export const DownloadingSkill: Story = { + args: { + downloadingSkillName: "review-sql", + }, +}; + +export const ExportingAll: Story = { + args: { + isExportingAll: true, + }, +}; + +export const DownloadsSkill: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const row = canvas.getByRole("row", { name: /review-sql/ }); + await userEvent.click( + within(row).getByRole("button", { name: "Download" }), + ); + + await waitFor(() => { + expect(args.onDownload).toHaveBeenCalledWith( + expect.objectContaining({ name: "review-sql" }), + ); + }); + }, +}; + +export const ExportsAllSkills: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Export all" })); + + await waitFor(() => { + expect(args.onExportAll).toHaveBeenCalled(); + }); + }, +}; + export const Loading: Story = { args: { skills: [], diff --git a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.tsx index cb843191c8..62ebaf7158 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.tsx @@ -73,6 +73,10 @@ export interface AgentSettingsPersonalSkillsPageViewProps { onCreate: () => void; onEdit: (name: string) => void; onDelete: (skill: UserSkillMetadata) => void; + onDownload: (skill: UserSkillMetadata) => void; + onExportAll: () => void; + downloadingSkillName?: string; + isExportingAll?: boolean; editorState?: PersonalSkillEditorState; deleteState?: PersonalSkillDeleteState; } @@ -208,6 +212,10 @@ export const AgentSettingsPersonalSkillsPageView: FC< onCreate, onEdit, onDelete, + onDownload, + onExportAll, + downloadingSkillName, + isExportingAll = false, editorState, deleteState, }) => { @@ -217,13 +225,27 @@ export const AgentSettingsPersonalSkillsPageView: FC< Add skill ); + const headerActions = ( +
+ + {addSkillAction} +
+ ); return (
{isAtLimit && ( @@ -282,6 +304,17 @@ export const AgentSettingsPersonalSkillsPageView: FC< {formatUpdatedAt(skill.updated_at)}
+