diff --git a/cli/testdata/coder_templates_init_--help.golden b/cli/testdata/coder_templates_init_--help.golden index 9f7289407c..5a1d4ffd94 100644 --- a/cli/testdata/coder_templates_init_--help.golden +++ b/cli/testdata/coder_templates_init_--help.golden @@ -6,7 +6,7 @@ USAGE: Get started with a templated template. OPTIONS: - --id aws-devcontainer|aws-linux|aws-windows|azure-linux|do-linux|docker|gcp-devcontainer|gcp-linux|gcp-vm-container|gcp-windows|kubernetes|nomad-docker + --id aws-devcontainer|aws-linux|aws-windows|azure-linux|do-linux|docker|gcp-devcontainer|gcp-linux|gcp-vm-container|gcp-windows|kubernetes|nomad-docker|scratch Specify a given example template by ID. ——— diff --git a/docs/cli/templates_init.md b/docs/cli/templates_init.md index 06b4c849f4..0e20a7acaa 100644 --- a/docs/cli/templates_init.md +++ b/docs/cli/templates_init.md @@ -14,8 +14,8 @@ coder templates init [flags] [directory] ### --id -| | | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Type | enum[aws-devcontainer\|aws-linux\|aws-windows\|azure-linux\|do-linux\|docker\|gcp-devcontainer\|gcp-linux\|gcp-vm-container\|gcp-windows\|kubernetes\|nomad-docker] | +| | | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Type | enum[aws-devcontainer\|aws-linux\|aws-windows\|azure-linux\|do-linux\|docker\|gcp-devcontainer\|gcp-linux\|gcp-vm-container\|gcp-windows\|kubernetes\|nomad-docker\|scratch] | Specify a given example template by ID. diff --git a/examples/examples.gen.json b/examples/examples.gen.json index 270357a310..0999521386 100644 --- a/examples/examples.gen.json +++ b/examples/examples.gen.json @@ -156,5 +156,14 @@ "container" ], "markdown": "\n# Remote Development on Nomad\n\nProvision Nomad Jobs as [Coder workspaces](https://coder.com/docs/v2/latest/workspaces) with this example template. This example shows how to use Nomad service tasks to be used as a development environment using docker and host csi volumes.\n\n\u003c!-- TODO: Add screenshot --\u003e\n\n\u003e **Note**\n\u003e This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case.\n\n## Prerequisites\n\n- [Nomad](https://www.nomadproject.io/downloads)\n- [Docker](https://docs.docker.com/get-docker/)\n\n## Setup\n\n### 1. Start the CSI Host Volume Plugin\n\nThe CSI Host Volume plugin is used to mount host volumes into Nomad tasks. This is useful for development environments where you want to mount persistent volumes into your container workspace.\n\n1. Login to the Nomad server using SSH.\n\n2. Append the following stanza to your Nomad server configuration file and restart the nomad service.\n\n ```hcl\n plugin \"docker\" {\n config {\n allow_privileged = true\n }\n }\n ```\n\n ```shell\n sudo systemctl restart nomad\n ```\n\n3. Create a file `hostpath.nomad` with following content:\n\n ```hcl\n job \"hostpath-csi-plugin\" {\n datacenters = [\"dc1\"]\n type = \"system\"\n\n group \"csi\" {\n task \"plugin\" {\n driver = \"docker\"\n\n config {\n image = \"registry.k8s.io/sig-storage/hostpathplugin:v1.10.0\"\n\n args = [\n \"--drivername=csi-hostpath\",\n \"--v=5\",\n \"--endpoint=${CSI_ENDPOINT}\",\n \"--nodeid=node-${NOMAD_ALLOC_INDEX}\",\n ]\n\n privileged = true\n }\n\n csi_plugin {\n id = \"hostpath\"\n type = \"monolith\"\n mount_dir = \"/csi\"\n }\n\n resources {\n cpu = 256\n memory = 128\n }\n }\n }\n }\n ```\n\n4. Run the job:\n\n ```shell\n nomad job run hostpath.nomad\n ```\n\n### 2. Setup the Nomad Template\n\n1. Create the template by running the following command:\n\n ```shell\n coder template init nomad-docker\n cd nomad-docker\n coder template push\n ```\n\n2. Set up Nomad server address and optional authentication:\n\n3. Create a new workspace and start developing.\n" + }, + { + "id": "scratch", + "url": "", + "name": "Scratch", + "description": "A minimal starter template for Coder", + "icon": "/emojis/1f4e6.png", + "tags": [], + "markdown": "\n# A minimal Scaffolding for a Coder Template\n\nUse this starter template as a basis to create your own unique template from scratch.\n" } ] diff --git a/examples/examples.go b/examples/examples.go index 9672f52781..af2dc80bc6 100644 --- a/examples/examples.go +++ b/examples/examples.go @@ -35,6 +35,7 @@ var ( //go:embed templates/gcp-windows //go:embed templates/kubernetes //go:embed templates/nomad-docker + //go:embed templates/scratch files embed.FS exampleBasePath = "https://github.com/coder/coder/tree/main/examples/templates/" diff --git a/examples/templates/scratch/README.md b/examples/templates/scratch/README.md new file mode 100644 index 0000000000..85b8eab2bb --- /dev/null +++ b/examples/templates/scratch/README.md @@ -0,0 +1,12 @@ +--- +display_name: Scratch +description: A minimal starter template for Coder +icon: ../../../site/static/emojis/1f4e6.png +maintainer_github: coder +verified: true +tags: [] +--- + +# A minimal Scaffolding for a Coder Template + +Use this starter template as a basis to create your own unique template from scratch. diff --git a/examples/templates/scratch/main.tf b/examples/templates/scratch/main.tf new file mode 100644 index 0000000000..35a7c69d6b --- /dev/null +++ b/examples/templates/scratch/main.tf @@ -0,0 +1,62 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} + +data "coder_provisioner" "me" {} + +data "coder_workspace" "me" {} + +resource "coder_agent" "main" { + arch = data.coder_provisioner.me.arch + os = data.coder_provisioner.me.os + + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } +} + +# Use this to set environment variables in your workspace +# details: https://registry.terraform.io/providers/coder/coder/latest/docs/resources/env +resource "coder_env" "welcome_message" { + agent_id = coder_agent.main.id + name = "WELCOME_MESSAGE" + value = "Welcome to your Coder workspace!" +} + +# Adds code-server +# See all available modules at https://registry.coder.com +module "code-server" { + source = "registry.coder.com/modules/code-server/coder" + version = "1.0.2" + agent_id = coder_agent.main.id +} + +# Runs a script at workspace start/stop or on a cron schedule +# details: https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script +resource "coder_script" "startup_script" { + agent_id = coder_agent.main.id + display_name = "Startup Script" + script = <<-EOF + #!/bin/sh + set -e + # Run programs at workspace startup + EOF + run_on_start = true + start_blocks_login = true +} diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index ff3e7206df..549367d87e 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -134,7 +134,7 @@ export const createTemplate = async ( const name = randomName(); await page.getByLabel("Name *").fill(name); await page.getByTestId("form-submit").click(); - await expect(page).toHaveURL("/templates/" + name, { + await expect(page).toHaveURL(`/templates/${name}/files`, { timeout: 30000, }); return name; diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx index 8891e376b2..9301701365 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx @@ -88,7 +88,7 @@ test("Create template from starter template", async () => { ); await waitFor(() => expect(API.createTemplate).toBeCalledTimes(1)); expect(router.state.location.pathname).toEqual( - `/templates/${MockTemplate.name}`, + `/templates/${MockTemplate.name}/files`, ); expect(API.createTemplateVersion).toHaveBeenCalledWith(MockOrganization.id, { example_id: "aws-windows", @@ -138,7 +138,7 @@ test("Create template from duplicating a template", async () => { ); await waitFor(() => { expect(router.state.location.pathname).toEqual( - `/templates/${MockTemplate.name}`, + `/templates/${MockTemplate.name}/files`, ); }); }); diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx index 7051a5ab95..c5440e0929 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -6,11 +6,16 @@ import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizont import { DuplicateTemplateView } from "./DuplicateTemplateView"; import { ImportStarterTemplateView } from "./ImportStarterTemplateView"; import { UploadTemplateView } from "./UploadTemplateView"; +import { Template } from "api/typesGenerated"; const CreateTemplatePage: FC = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); + const onSuccess = (template: Template) => { + navigate(`/templates/${template.name}/files`); + }; + const onCancel = () => { navigate(-1); }; @@ -23,11 +28,11 @@ const CreateTemplatePage: FC = () => { {searchParams.has("fromTemplate") ? ( - + ) : searchParams.has("exampleId") ? ( - + ) : ( - + )} diff --git a/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx b/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx index 3a96ce6b9b..62540343c9 100644 --- a/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx @@ -15,8 +15,15 @@ import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Loader } from "components/Loader/Loader"; import { CreateTemplateForm } from "./CreateTemplateForm"; import { firstVersionFromFile, getFormPermissions, newTemplate } from "./utils"; +import { Template } from "api/typesGenerated"; -export const DuplicateTemplateView: FC = () => { +type DuplicateTemplateViewProps = { + onSuccess: (template: Template) => void; +}; + +export const DuplicateTemplateView: FC = ({ + onSuccess, +}) => { const navigate = useNavigate(); const organizationId = useOrganizationId(); const [searchParams] = useSearchParams(); @@ -79,7 +86,7 @@ export const DuplicateTemplateView: FC = () => { ), template: newTemplate(formData), }); - navigate(`/templates/${template.name}`); + onSuccess(template); }} /> ); diff --git a/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx index 9ab04ab704..5075b0ecae 100644 --- a/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx @@ -18,8 +18,15 @@ import { getFormPermissions, newTemplate, } from "./utils"; +import { Template } from "api/typesGenerated"; -export const ImportStarterTemplateView: FC = () => { +type ImportStarterTemplateViewProps = { + onSuccess: (template: Template) => void; +}; + +export const ImportStarterTemplateView: FC = ({ + onSuccess, +}) => { const navigate = useNavigate(); const organizationId = useOrganizationId(); const [searchParams] = useSearchParams(); @@ -76,7 +83,7 @@ export const ImportStarterTemplateView: FC = () => { ), template: newTemplate(formData), }); - navigate(`/templates/${template.name}`); + onSuccess(template); }} /> ); diff --git a/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx index e6f4e918ec..d228e484d0 100644 --- a/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx @@ -11,8 +11,16 @@ import { useOrganizationId } from "contexts/auth/useOrganizationId"; import { useDashboard } from "modules/dashboard/useDashboard"; import { CreateTemplateForm } from "./CreateTemplateForm"; import { firstVersionFromFile, getFormPermissions, newTemplate } from "./utils"; +import { Template } from "api/typesGenerated"; +import { FC } from "react"; -export const UploadTemplateView = () => { +type UploadTemplateViewProps = { + onSuccess: (template: Template) => void; +}; + +export const UploadTemplateView: FC = ({ + onSuccess, +}) => { const navigate = useNavigate(); const organizationId = useOrganizationId(); @@ -61,7 +69,7 @@ export const UploadTemplateView = () => { ), template: newTemplate(formData), }); - navigate(`/templates/${template.name}`); + onSuccess(template); }} /> ); diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.test.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.test.tsx new file mode 100644 index 0000000000..a1f25f937a --- /dev/null +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; +import StarterTemplatesPage from "./StarterTemplatesPage"; +import { AppProviders } from "App"; +import { RouterProvider, createMemoryRouter } from "react-router-dom"; +import { RequireAuth } from "contexts/auth/RequireAuth"; +import { rest } from "msw"; +import { + MockTemplateExample, + MockTemplateExample2, +} from "testHelpers/entities"; +import { server } from "testHelpers/server"; + +test("does not display the scratch template", async () => { + server.use( + rest.get( + "api/v2/organizations/:organizationId/templates/examples", + (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json([ + MockTemplateExample, + MockTemplateExample2, + { + ...MockTemplateExample, + id: "scratch", + name: "Scratch", + description: "Create a template from scratch", + }, + ]), + ); + }, + ), + ); + + render( + + , + children: [ + { + path: "/starter-templates", + element: , + }, + ], + }, + ], + { initialEntries: ["/starter-templates"] }, + )} + /> + , + ); + + await screen.findByText(MockTemplateExample.name); + screen.getByText(MockTemplateExample2.name); + expect(screen.queryByText("Scratch")).not.toBeInTheDocument(); +}); diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx index 5c47bc1311..e24a370267 100644 --- a/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx @@ -6,12 +6,14 @@ import { useOrganizationId } from "contexts/auth/useOrganizationId"; import { pageTitle } from "utils/page"; import { getTemplatesByTag } from "utils/starterTemplates"; import { StarterTemplatesPageView } from "./StarterTemplatesPageView"; +import { TemplateExample } from "api/typesGenerated"; const StarterTemplatesPage: FC = () => { const organizationId = useOrganizationId(); const templateExamplesQuery = useQuery(templateExamples(organizationId)); const starterTemplatesByTag = templateExamplesQuery.data - ? getTemplatesByTag(templateExamplesQuery.data) + ? // Currently, the scratch template should not be displayed on the starter templates page. + getTemplatesByTag(removeScratchExample(templateExamplesQuery.data)) : undefined; return ( @@ -28,4 +30,8 @@ const StarterTemplatesPage: FC = () => { ); }; +const removeScratchExample = (data: TemplateExample[]) => { + return data.filter((example) => example.id !== "scratch"); +}; + export default StarterTemplatesPage; diff --git a/site/src/pages/TemplatesPage/CreateTemplateButton.stories.tsx b/site/src/pages/TemplatesPage/CreateTemplateButton.stories.tsx new file mode 100644 index 0000000000..c1c965662c --- /dev/null +++ b/site/src/pages/TemplatesPage/CreateTemplateButton.stories.tsx @@ -0,0 +1,22 @@ +import { CreateTemplateButton } from "./CreateTemplateButton"; +import type { Meta, StoryObj } from "@storybook/react"; +import { userEvent, screen } from "@storybook/test"; + +const meta: Meta = { + title: "pages/TemplatesPage/CreateTemplateButton", + component: CreateTemplateButton, +}; + +export default meta; +type Story = StoryObj; + +export const Close: Story = {}; + +export const Open: Story = { + play: async ({ step }) => { + const user = userEvent.setup(); + await step("click on trigger", async () => { + await user.click(screen.getByRole("button")); + }); + }, +}; diff --git a/site/src/pages/TemplatesPage/CreateTemplateButton.tsx b/site/src/pages/TemplatesPage/CreateTemplateButton.tsx new file mode 100644 index 0000000000..12057045d6 --- /dev/null +++ b/site/src/pages/TemplatesPage/CreateTemplateButton.tsx @@ -0,0 +1,56 @@ +import Button from "@mui/material/Button"; +import AddIcon from "@mui/icons-material/AddOutlined"; +import { + MoreMenu, + MoreMenuContent, + MoreMenuItem, + MoreMenuTrigger, +} from "components/MoreMenu/MoreMenu"; +import NoteAddOutlined from "@mui/icons-material/NoteAddOutlined"; +import UploadOutlined from "@mui/icons-material/UploadOutlined"; +import Inventory2 from "@mui/icons-material/Inventory2"; +import { FC } from "react"; + +type CreateTemplateButtonProps = { + onNavigate: (path: string) => void; +}; + +export const CreateTemplateButton: FC = ({ + onNavigate, +}) => { + return ( + + + + + + { + onNavigate(`/templates/new?exampleId=scratch`); + }} + > + + From scratch + + { + onNavigate("/templates/new"); + }} + > + + Upload template + + { + onNavigate("/starter-templates"); + }} + > + + Choose a starter template + + + + ); +}; diff --git a/site/src/pages/TemplatesPage/TemplatesPage.test.tsx b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx new file mode 100644 index 0000000000..b91c4aa96e --- /dev/null +++ b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import { AppProviders } from "App"; +import { RequireAuth } from "contexts/auth/RequireAuth"; +import { RouterProvider, createMemoryRouter } from "react-router-dom"; +import TemplatesPage from "./TemplatesPage"; +import userEvent from "@testing-library/user-event"; + +test("create template from scratch", async () => { + const user = userEvent.setup(); + const router = createMemoryRouter( + [ + { + element: , + children: [ + { + path: "/templates", + element: , + }, + { + path: "/templates/new", + element:
, + }, + ], + }, + ], + { initialEntries: ["/templates"] }, + ); + render( + + + , + ); + const createTemplateButton = await screen.findByRole("button", { + name: "Create Template", + }); + await user.click(createTemplateButton); + const fromScratchMenuItem = await screen.findByText("From scratch"); + await user.click(fromScratchMenuItem); + await screen.findByTestId("new-template-page"); + expect(router.state.location.pathname).toBe("/templates/new"); + expect(router.state.location.search).toBe("?exampleId=scratch"); +}); diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 276a77ea5f..9b71a3ca3e 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -6,9 +6,8 @@ import TableCell from "@mui/material/TableCell"; import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; -import AddIcon from "@mui/icons-material/AddOutlined"; import { type FC } from "react"; -import { useNavigate, Link as RouterLink } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { createDayString } from "utils/createDayString"; import { formatTemplateBuildTime, @@ -45,6 +44,7 @@ import { docs } from "utils/docs"; import Skeleton from "@mui/material/Skeleton"; import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton"; import { DeprecatedBadge } from "components/Badges/Badges"; +import { CreateTemplateButton } from "./CreateTemplateButton"; export const Language = { developerCount: (activeCount: number): string => { @@ -165,26 +165,13 @@ export const TemplatesPageView: FC = ({ }) => { const isLoading = !templates; const isEmpty = templates && templates.length === 0; + const navigate = useNavigate(); return ( - - - - ) + canCreateTemplates && } >