feat: add template export functionality to UI (#18214)

## 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 <kyle@coder.com>
This commit is contained in:
blink-so[bot]
2025-06-03 14:26:50 -04:00
committed by GitHub
co-authored by Kyle Carberry
parent 7b273b0b8c
commit cc89820d7c
2 changed files with 58 additions and 1 deletions
+25
View File
@@ -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<Blob> => {
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,
@@ -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<TemplateMenuProps> = ({
templateName,
templateVersion,
templateId,
fileId,
onDelete,
}) => {
const dialogState = useDeletionDialogState(templateId, onDelete);
@@ -68,6 +71,24 @@ const TemplateMenu: FC<TemplateMenuProps> = ({
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 (
<>
<DropdownMenu>
@@ -102,6 +123,16 @@ const TemplateMenu: FC<TemplateMenuProps> = ({
<CopyIcon className="size-icon-sm" />
Duplicate&hellip;
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport()}>
<DownloadIcon className="size-icon-sm" />
Export as TAR
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("zip")}>
<DownloadIcon className="size-icon-sm" />
Export as ZIP
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
@@ -206,6 +237,7 @@ export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
templateId={template.id}
templateName={template.name}
templateVersion={activeVersion.name}
fileId={activeVersion.job.file_id}
onDelete={onDeleteTemplate}
/>
)}