From cc89820d7ceec9ee2847b0526a22861e9adc4430 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:26:50 -0400 Subject: [PATCH] feat: add template export functionality to UI (#18214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR adds template export functionality to the Coder UI, addressing issue #17859. Users can now export templates directly from the web interface without requiring CLI access. ## Changes ### Frontend API - Added `downloadTemplateVersion` function to `site/src/api/api.ts` - Supports both TAR (default) and ZIP formats - Uses existing `/api/v2/files/{fileId}` endpoint with format parameter ### UI Enhancement - Added "Export as TAR" and "Export as ZIP" options to template dropdown menu - Positioned logically between "Duplicate" and "Delete" actions - Uses download icon from Lucide React for consistency ### User Experience - Files automatically named as `{templateName}-{templateVersion}.{extension}` - Immediate download trigger on click - Proper error handling with console logging - Clean blob URL management to prevent memory leaks ## Testing The implementation has been tested for: - ✅ TypeScript compilation - ✅ Proper function signatures and types - ✅ UI component integration - ✅ Error handling structure ## Screenshots The export options appear in the template dropdown menu: - Export as TAR (default format, compatible with `coder template pull`) - Export as ZIP (compressed format for easier handling) ## Fixes Closes #17859 ## Notes This enhancement makes template management more accessible for users who: - Don't have CLI access - Manage deployments on devices without Coder CLI - Prefer web-based workflows - Need to transfer templates between environments The implementation follows existing patterns in the codebase and maintains consistency with the current UI design. --------- Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> Co-authored-by: Kyle Carberry --- site/src/api/api.ts | 25 ++++++++++++++ .../pages/TemplatePage/TemplatePageHeader.tsx | 34 ++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 81931c003c..5463ad7a44 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1084,6 +1084,31 @@ class ApiMethods { return response.data; }; + /** + * Downloads a template version as a tar or zip archive + * @param fileId The file ID from the template version's job + * @param format Optional format: "zip" for zip archive, empty/undefined for tar + * @returns Promise that resolves to a Blob containing the archive + */ + downloadTemplateVersion = async ( + fileId: string, + format?: "zip", + ): Promise => { + const params = new URLSearchParams(); + if (format) { + params.set("format", format); + } + + const response = await this.axios.get( + `/api/v2/files/${fileId}?${params.toString()}`, + { + responseType: "blob", + }, + ); + + return response.data; + }; + updateTemplateMeta = async ( templateId: string, data: TypesGen.UpdateTemplateMeta, diff --git a/site/src/pages/TemplatePage/TemplatePageHeader.tsx b/site/src/pages/TemplatePage/TemplatePageHeader.tsx index 54c3c04de8..a7ebbf0ad0 100644 --- a/site/src/pages/TemplatePage/TemplatePageHeader.tsx +++ b/site/src/pages/TemplatePage/TemplatePageHeader.tsx @@ -1,5 +1,6 @@ import EditIcon from "@mui/icons-material/EditOutlined"; import Button from "@mui/material/Button"; +import { API } from "api/api"; import { workspaces } from "api/queries/workspaces"; import type { AuthorizationResponse, @@ -26,7 +27,7 @@ import { } from "components/PageHeader/PageHeader"; import { Pill } from "components/Pill/Pill"; import { Stack } from "components/Stack/Stack"; -import { CopyIcon } from "lucide-react"; +import { CopyIcon, DownloadIcon } from "lucide-react"; import { EllipsisVertical, PlusIcon, @@ -46,6 +47,7 @@ type TemplateMenuProps = { templateName: string; templateVersion: string; templateId: string; + fileId: string; onDelete: () => void; }; @@ -54,6 +56,7 @@ const TemplateMenu: FC = ({ templateName, templateVersion, templateId, + fileId, onDelete, }) => { const dialogState = useDeletionDialogState(templateId, onDelete); @@ -68,6 +71,24 @@ const TemplateMenu: FC = ({ const templateLink = getLink(linkToTemplate(organizationName, templateName)); + const handleExport = async (format?: "zip") => { + try { + const blob = await API.downloadTemplateVersion(fileId, format); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + const extension = format === "zip" ? "zip" : "tar"; + link.download = `${templateName}-${templateVersion}.${extension}`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (error) { + console.error("Failed to export template:", error); + // TODO: Show user-friendly error message + } + }; + return ( <> @@ -102,6 +123,16 @@ const TemplateMenu: FC = ({ Duplicate… + + handleExport()}> + + Export as TAR + + + handleExport("zip")}> + + Export as ZIP + = ({ templateId={template.id} templateName={template.name} templateVersion={activeVersion.name} + fileId={activeVersion.job.file_id} onDelete={onDeleteTemplate} /> )}