mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
fix(site): Show folders in the template version editor (#6145)
This commit is contained in:
@@ -583,9 +583,6 @@ jobs:
|
||||
- run: yarn playwright:install
|
||||
working-directory: site
|
||||
|
||||
- run: yarn playwright:install-deps
|
||||
working-directory: site
|
||||
|
||||
- run: yarn playwright:test
|
||||
env:
|
||||
DEBUG: pw:api
|
||||
|
||||
+1
-2
@@ -15,8 +15,7 @@
|
||||
"format:write:only": "prettier --write",
|
||||
"lint": "jest --selectProjects lint",
|
||||
"lint:fix": "FIX=true yarn lint",
|
||||
"playwright:install": "playwright install",
|
||||
"playwright:install-deps": "playwright install-deps",
|
||||
"playwright:install": "playwright install --with-deps chromium",
|
||||
"playwright:test": "playwright test --config=e2e/playwright.config.ts",
|
||||
"storybook": "start-storybook -p 6006",
|
||||
"storybook:build": "build-storybook",
|
||||
|
||||
+60
-81
@@ -5,104 +5,73 @@ import TreeView from "@material-ui/lab/TreeView"
|
||||
import TreeItem from "@material-ui/lab/TreeItem"
|
||||
import Menu from "@material-ui/core/Menu"
|
||||
import MenuItem from "@material-ui/core/MenuItem"
|
||||
import { FC, useMemo, useState } from "react"
|
||||
import { TemplateVersionFiles } from "util/templateVersion"
|
||||
import { FC, useState } from "react"
|
||||
import { FileTree } from "util/filetree"
|
||||
import { DockerIcon } from "components/Icons/DockerIcon"
|
||||
|
||||
export interface File {
|
||||
path: string
|
||||
content?: string
|
||||
children: Record<string, File>
|
||||
const sortFileTree = (fileTree: FileTree) => (a: string, b: string) => {
|
||||
const contentA = fileTree[a]
|
||||
const contentB = fileTree[b]
|
||||
if (typeof contentA === "object") {
|
||||
return -1
|
||||
}
|
||||
if (typeof contentB === "object") {
|
||||
return 1
|
||||
}
|
||||
return a.localeCompare(b)
|
||||
}
|
||||
|
||||
export const FileTree: FC<{
|
||||
onSelect: (file: File) => void
|
||||
onDelete: (file: File) => void
|
||||
onRename: (file: File) => void
|
||||
files: TemplateVersionFiles
|
||||
activeFile?: File
|
||||
}> = ({ activeFile, files, onDelete, onRename, onSelect }) => {
|
||||
const styles = useStyles()
|
||||
const fileTree = useMemo<Record<string, File>>(() => {
|
||||
const paths = Object.keys(files)
|
||||
const roots: Record<string, File> = {}
|
||||
paths.forEach((path) => {
|
||||
const pathParts = path.split("/")
|
||||
const firstPart = pathParts.shift()
|
||||
if (!firstPart) {
|
||||
// Not possible!
|
||||
return
|
||||
}
|
||||
let activeFile = roots[firstPart]
|
||||
if (!activeFile) {
|
||||
activeFile = {
|
||||
path: firstPart,
|
||||
children: {},
|
||||
}
|
||||
roots[firstPart] = activeFile
|
||||
}
|
||||
while (pathParts.length > 0) {
|
||||
const pathPart = pathParts.shift()
|
||||
if (!pathPart) {
|
||||
continue
|
||||
}
|
||||
if (!activeFile.children[pathPart]) {
|
||||
activeFile.children[pathPart] = {
|
||||
path: activeFile.path + "/" + pathPart,
|
||||
children: {},
|
||||
}
|
||||
}
|
||||
activeFile = activeFile.children[pathPart]
|
||||
}
|
||||
activeFile.content = files[path]
|
||||
activeFile.path = path
|
||||
})
|
||||
return roots
|
||||
}, [files])
|
||||
const [contextMenu, setContextMenu] = useState<
|
||||
| {
|
||||
file: File
|
||||
clientX: number
|
||||
clientY: number
|
||||
}
|
||||
| undefined
|
||||
>()
|
||||
type ContextMenu = {
|
||||
path: string
|
||||
clientX: number
|
||||
clientY: number
|
||||
}
|
||||
|
||||
const buildTreeItems = (name: string, file: File): JSX.Element => {
|
||||
export const FileTreeView: FC<{
|
||||
onSelect: (path: string) => void
|
||||
onDelete: (path: string) => void
|
||||
onRename: (path: string) => void
|
||||
fileTree: FileTree
|
||||
activePath?: string
|
||||
}> = ({ fileTree, activePath, onDelete, onRename, onSelect }) => {
|
||||
const styles = useStyles()
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenu | undefined>()
|
||||
|
||||
const buildTreeItems = (
|
||||
filename: string,
|
||||
content?: FileTree | string,
|
||||
parentPath?: string,
|
||||
): JSX.Element => {
|
||||
const currentPath = parentPath ? `${parentPath}/${filename}` : filename
|
||||
let icon: JSX.Element | null = null
|
||||
if (file.path.endsWith(".tf")) {
|
||||
if (filename.endsWith(".tf")) {
|
||||
icon = <FileTypeTerraform />
|
||||
}
|
||||
if (file.path.endsWith(".md")) {
|
||||
if (filename.endsWith(".md")) {
|
||||
icon = <FileTypeMarkdown />
|
||||
}
|
||||
if (file.path.endsWith("Dockerfile")) {
|
||||
if (filename.endsWith("Dockerfile")) {
|
||||
icon = <FileTypeDockerfile />
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeItem
|
||||
nodeId={file.path}
|
||||
key={file.path}
|
||||
label={name}
|
||||
nodeId={currentPath}
|
||||
key={currentPath}
|
||||
label={filename}
|
||||
className={`${styles.fileTreeItem} ${
|
||||
file.path === activeFile?.path ? "active" : ""
|
||||
currentPath === activePath ? "active" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (file.content) {
|
||||
onSelect(file)
|
||||
}
|
||||
onSelect(currentPath)
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
if (!file.content) {
|
||||
return
|
||||
}
|
||||
setContextMenu(
|
||||
contextMenu
|
||||
? undefined
|
||||
: {
|
||||
file: file,
|
||||
path: currentPath,
|
||||
clientY: event.clientY,
|
||||
clientX: event.clientX,
|
||||
},
|
||||
@@ -110,9 +79,16 @@ export const FileTree: FC<{
|
||||
}}
|
||||
icon={icon}
|
||||
>
|
||||
{Object.entries(file.children || {}).map(([name, file]) => {
|
||||
return buildTreeItems(name, file)
|
||||
})}
|
||||
{typeof content === "object" ? (
|
||||
Object.keys(content)
|
||||
.sort(sortFileTree(content))
|
||||
.map((filename) => {
|
||||
const child = content[filename]
|
||||
return buildTreeItems(filename, child, currentPath)
|
||||
})
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</TreeItem>
|
||||
)
|
||||
}
|
||||
@@ -124,9 +100,12 @@ export const FileTree: FC<{
|
||||
aria-label="Files"
|
||||
className={styles.fileTree}
|
||||
>
|
||||
{Object.entries(fileTree).map(([name, file]) => {
|
||||
return buildTreeItems(name, file)
|
||||
})}
|
||||
{Object.keys(fileTree)
|
||||
.sort(sortFileTree(fileTree))
|
||||
.map((filename) => {
|
||||
const child = fileTree[filename]
|
||||
return buildTreeItems(filename, child)
|
||||
})}
|
||||
|
||||
<Menu
|
||||
onClose={() => setContextMenu(undefined)}
|
||||
@@ -154,7 +133,7 @@ export const FileTree: FC<{
|
||||
if (!contextMenu) {
|
||||
return
|
||||
}
|
||||
onRename(contextMenu.file)
|
||||
onRename(contextMenu.path)
|
||||
setContextMenu(undefined)
|
||||
}}
|
||||
>
|
||||
@@ -165,7 +144,7 @@ export const FileTree: FC<{
|
||||
if (!contextMenu) {
|
||||
return
|
||||
}
|
||||
onDelete(contextMenu.file)
|
||||
onDelete(contextMenu.path)
|
||||
setContextMenu(undefined)
|
||||
}}
|
||||
>
|
||||
@@ -2,7 +2,7 @@ import { Story } from "@storybook/react"
|
||||
import {
|
||||
MockTemplate,
|
||||
MockTemplateVersion,
|
||||
MockTemplateVersionFiles,
|
||||
MockTemplateVersionFileTree,
|
||||
MockWorkspaceBuildLogs,
|
||||
MockWorkspaceResource,
|
||||
MockWorkspaceResource2,
|
||||
@@ -29,7 +29,7 @@ export const Example = Template.bind({})
|
||||
Example.args = {
|
||||
template: MockTemplate,
|
||||
templateVersion: MockTemplateVersion,
|
||||
initialFiles: MockTemplateVersionFiles,
|
||||
defaultFileTree: MockTemplateVersionFileTree,
|
||||
}
|
||||
|
||||
export const Logs = Template.bind({})
|
||||
@@ -37,7 +37,7 @@ export const Logs = Template.bind({})
|
||||
Logs.args = {
|
||||
template: MockTemplate,
|
||||
templateVersion: MockTemplateVersion,
|
||||
initialFiles: MockTemplateVersionFiles,
|
||||
defaultFileTree: MockTemplateVersionFileTree,
|
||||
buildLogs: MockWorkspaceBuildLogs,
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export const Resources = Template.bind({})
|
||||
Resources.args = {
|
||||
template: MockTemplate,
|
||||
templateVersion: MockTemplateVersion,
|
||||
initialFiles: MockTemplateVersionFiles,
|
||||
defaultFileTree: MockTemplateVersionFileTree,
|
||||
buildLogs: MockWorkspaceBuildLogs,
|
||||
resources: [
|
||||
MockWorkspaceResource,
|
||||
|
||||
@@ -16,49 +16,60 @@ import { AvatarData } from "components/AvatarData/AvatarData"
|
||||
import { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable"
|
||||
import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs"
|
||||
import { FC, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { dashboardContentBottomPadding, navHeight } from "theme/constants"
|
||||
import { TemplateVersionFiles } from "util/templateVersion"
|
||||
import { navHeight, dashboardContentBottomPadding } from "theme/constants"
|
||||
import {
|
||||
existsFile,
|
||||
FileTree,
|
||||
getFileContent,
|
||||
isFolder,
|
||||
removeFile,
|
||||
setFile,
|
||||
traverse,
|
||||
} from "util/filetree"
|
||||
import {
|
||||
CreateFileDialog,
|
||||
DeleteFileDialog,
|
||||
RenameFileDialog,
|
||||
} from "./FileDialog"
|
||||
import { FileTree } from "./FileTree"
|
||||
import { FileTreeView } from "./FileTreeView"
|
||||
import { MonacoEditor } from "./MonacoEditor"
|
||||
import {
|
||||
getStatus,
|
||||
TemplateVersionStatusBadge,
|
||||
} from "./TemplateVersionStatusBadge"
|
||||
|
||||
interface File {
|
||||
path: string
|
||||
content?: string
|
||||
children: Record<string, File>
|
||||
}
|
||||
|
||||
export interface TemplateVersionEditorProps {
|
||||
template: Template
|
||||
templateVersion: TemplateVersion
|
||||
initialFiles: TemplateVersionFiles
|
||||
|
||||
defaultFileTree: FileTree
|
||||
buildLogs?: ProvisionerJobLog[]
|
||||
resources?: WorkspaceResource[]
|
||||
|
||||
disablePreview: boolean
|
||||
disableUpdate: boolean
|
||||
|
||||
onPreview: (files: TemplateVersionFiles) => void
|
||||
onPreview: (files: FileTree) => void
|
||||
onUpdate: () => void
|
||||
}
|
||||
|
||||
const topbarHeight = navHeight
|
||||
|
||||
const findInitialFile = (fileTree: FileTree): string | undefined => {
|
||||
let initialFile: string | undefined
|
||||
|
||||
traverse(fileTree, (content, filename, path) => {
|
||||
if (filename.endsWith(".tf")) {
|
||||
initialFile = path
|
||||
}
|
||||
})
|
||||
|
||||
return initialFile
|
||||
}
|
||||
|
||||
export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
disablePreview,
|
||||
disableUpdate,
|
||||
template,
|
||||
templateVersion,
|
||||
initialFiles,
|
||||
defaultFileTree,
|
||||
onPreview,
|
||||
onUpdate,
|
||||
buildLogs,
|
||||
@@ -69,29 +80,19 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
// This is for Storybook!
|
||||
return resources ? 1 : 0
|
||||
})
|
||||
const [files, setFiles] = useState(initialFiles)
|
||||
const [fileTree, setFileTree] = useState(defaultFileTree)
|
||||
const [createFileOpen, setCreateFileOpen] = useState(false)
|
||||
const [deleteFileOpen, setDeleteFileOpen] = useState<File>()
|
||||
const [renameFileOpen, setRenameFileOpen] = useState<File>()
|
||||
const [activeFile, setActiveFile] = useState<File | undefined>(() => {
|
||||
const fileKeys = Object.keys(initialFiles)
|
||||
for (let i = 0; i < fileKeys.length; i++) {
|
||||
// Open a Terraform file by default!
|
||||
if (fileKeys[i].endsWith(".tf")) {
|
||||
return {
|
||||
path: fileKeys[i],
|
||||
content: initialFiles[fileKeys[i]],
|
||||
children: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const [deleteFileOpen, setDeleteFileOpen] = useState<string>()
|
||||
const [renameFileOpen, setRenameFileOpen] = useState<string>()
|
||||
const [activePath, setActivePath] = useState<string | undefined>(() =>
|
||||
findInitialFile(fileTree),
|
||||
)
|
||||
|
||||
const triggerPreview = useCallback(() => {
|
||||
onPreview(files)
|
||||
onPreview(fileTree)
|
||||
// Switch to the build log!
|
||||
setSelectedTab(0)
|
||||
}, [files, onPreview])
|
||||
}, [fileTree, onPreview])
|
||||
|
||||
// Stop ctrl+s from saving files and make ctrl+enter trigger a preview.
|
||||
useEffect(() => {
|
||||
@@ -114,7 +115,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
return () => {
|
||||
document.removeEventListener("keydown", keyListener)
|
||||
}
|
||||
}, [files, triggerPreview])
|
||||
}, [triggerPreview])
|
||||
|
||||
// Automatically switch to the template preview tab when the build succeeds.
|
||||
const previousVersion = useRef<TemplateVersion>()
|
||||
@@ -137,6 +138,8 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
const hasIcon = template.icon && template.icon !== ""
|
||||
const templateVersionSucceeded = templateVersion.job.status === "succeeded"
|
||||
const showBuildLogs = Boolean(buildLogs)
|
||||
const editorValue = getFileContent(activePath ?? "", fileTree) as string
|
||||
|
||||
useEffect(() => {
|
||||
window.dispatchEvent(new Event("resize"))
|
||||
}, [showBuildLogs])
|
||||
@@ -228,17 +231,10 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
onClose={() => {
|
||||
setCreateFileOpen(false)
|
||||
}}
|
||||
checkExists={(path) => Boolean(files[path])}
|
||||
checkExists={(path) => existsFile(path, fileTree)}
|
||||
onConfirm={(path) => {
|
||||
setFiles({
|
||||
...files,
|
||||
[path]: "",
|
||||
})
|
||||
setActiveFile({
|
||||
path,
|
||||
content: "",
|
||||
children: {},
|
||||
})
|
||||
setFileTree((fileTree) => setFile(path, "", fileTree))
|
||||
setActivePath(path)
|
||||
setCreateFileOpen(false)
|
||||
setDirty(true)
|
||||
}}
|
||||
@@ -248,64 +244,69 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
if (!deleteFileOpen) {
|
||||
throw new Error("delete file must be set")
|
||||
}
|
||||
const deleted = { ...files }
|
||||
delete deleted[deleteFileOpen.path]
|
||||
setFiles(deleted)
|
||||
setFileTree((fileTree) => removeFile(deleteFileOpen, fileTree))
|
||||
setDeleteFileOpen(undefined)
|
||||
if (activeFile?.path === deleteFileOpen.path) {
|
||||
setActiveFile(undefined)
|
||||
if (activePath === deleteFileOpen) {
|
||||
setActivePath(undefined)
|
||||
}
|
||||
setDirty(true)
|
||||
}}
|
||||
open={Boolean(deleteFileOpen)}
|
||||
onClose={() => setDeleteFileOpen(undefined)}
|
||||
filename={deleteFileOpen?.path || ""}
|
||||
filename={deleteFileOpen || ""}
|
||||
/>
|
||||
<RenameFileDialog
|
||||
open={Boolean(renameFileOpen)}
|
||||
onClose={() => {
|
||||
setRenameFileOpen(undefined)
|
||||
}}
|
||||
filename={renameFileOpen?.path || ""}
|
||||
checkExists={(path) => Boolean(files[path])}
|
||||
filename={renameFileOpen || ""}
|
||||
checkExists={(path) => existsFile(path, fileTree)}
|
||||
onConfirm={(newPath) => {
|
||||
if (!renameFileOpen) {
|
||||
return
|
||||
}
|
||||
const renamed = { ...files }
|
||||
renamed[newPath] = renamed[renameFileOpen.path]
|
||||
delete renamed[renameFileOpen.path]
|
||||
setFiles(renamed)
|
||||
renameFileOpen.path = newPath
|
||||
setActiveFile(renameFileOpen)
|
||||
setFileTree((fileTree) => {
|
||||
fileTree = setFile(
|
||||
newPath,
|
||||
getFileContent(renameFileOpen, fileTree) as string,
|
||||
fileTree,
|
||||
)
|
||||
fileTree = removeFile(renameFileOpen, fileTree)
|
||||
return fileTree
|
||||
})
|
||||
setActivePath(newPath)
|
||||
setRenameFileOpen(undefined)
|
||||
setDirty(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FileTree
|
||||
files={files}
|
||||
<FileTreeView
|
||||
fileTree={fileTree}
|
||||
onDelete={(file) => setDeleteFileOpen(file)}
|
||||
onSelect={(file) => setActiveFile(file)}
|
||||
onSelect={(filePath) => {
|
||||
if (!isFolder(filePath, fileTree)) {
|
||||
setActivePath(filePath)
|
||||
}
|
||||
}}
|
||||
onRename={(file) => setRenameFileOpen(file)}
|
||||
activeFile={activeFile}
|
||||
activePath={activePath}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.editorPane}>
|
||||
<div className={styles.editor} data-chromatic="ignore">
|
||||
{activeFile ? (
|
||||
{activePath ? (
|
||||
<MonacoEditor
|
||||
value={activeFile?.content}
|
||||
path={activeFile?.path}
|
||||
value={editorValue}
|
||||
path={activePath}
|
||||
onChange={(value) => {
|
||||
if (!activeFile) {
|
||||
if (!activePath) {
|
||||
return
|
||||
}
|
||||
setFiles({
|
||||
...files,
|
||||
[activeFile.path]: value,
|
||||
})
|
||||
setFileTree((fileTree) =>
|
||||
setFile(activePath, value, fileTree),
|
||||
)
|
||||
setDirty(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
+32
-37
@@ -5,8 +5,8 @@ import { FC } from "react"
|
||||
import { Helmet } from "react-helmet-async"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { pageTitle } from "util/page"
|
||||
import { templateVersionMachine } from "xServices/templateVersion/templateVersionXService"
|
||||
import { templateVersionEditorMachine } from "xServices/templateVersionEditor/templateVersionEditorXService"
|
||||
import { useTemplateVersionData } from "./data"
|
||||
|
||||
type Params = {
|
||||
version: string
|
||||
@@ -16,9 +16,11 @@ type Params = {
|
||||
export const TemplateVersionEditorPage: FC = () => {
|
||||
const { version: versionName, template: templateName } = useParams() as Params
|
||||
const orgId = useOrganizationId()
|
||||
const [versionState] = useMachine(templateVersionMachine, {
|
||||
context: { templateName, versionName, orgId },
|
||||
})
|
||||
const { isSuccess, data } = useTemplateVersionData(
|
||||
orgId,
|
||||
templateName,
|
||||
versionName,
|
||||
)
|
||||
const [editorState, sendEvent] = useMachine(templateVersionEditorMachine, {
|
||||
context: { orgId },
|
||||
})
|
||||
@@ -29,39 +31,32 @@ export const TemplateVersionEditorPage: FC = () => {
|
||||
<title>{pageTitle(`${templateName} · Template Editor`)}</title>
|
||||
</Helmet>
|
||||
|
||||
{versionState.context.template &&
|
||||
versionState.context.currentFiles &&
|
||||
versionState.context.currentVersion && (
|
||||
<TemplateVersionEditor
|
||||
template={versionState.context.template}
|
||||
templateVersion={
|
||||
editorState.context.version || versionState.context.currentVersion
|
||||
}
|
||||
initialFiles={versionState.context.currentFiles}
|
||||
onPreview={(files) => {
|
||||
if (!versionState.context.template) {
|
||||
throw new Error("no template")
|
||||
}
|
||||
sendEvent({
|
||||
type: "CREATE_VERSION",
|
||||
files: files,
|
||||
templateId: versionState.context.template.id,
|
||||
})
|
||||
}}
|
||||
onUpdate={() => {
|
||||
sendEvent({
|
||||
type: "UPDATE_ACTIVE_VERSION",
|
||||
})
|
||||
}}
|
||||
disablePreview={editorState.hasTag("loading")}
|
||||
disableUpdate={
|
||||
editorState.hasTag("loading") ||
|
||||
editorState.context.version?.job.status !== "succeeded"
|
||||
}
|
||||
resources={editorState.context.resources}
|
||||
buildLogs={editorState.context.buildLogs}
|
||||
/>
|
||||
)}
|
||||
{isSuccess && (
|
||||
<TemplateVersionEditor
|
||||
template={data.template}
|
||||
templateVersion={editorState.context.version || data.currentVersion}
|
||||
defaultFileTree={data.fileTree}
|
||||
onPreview={(fileTree) => {
|
||||
sendEvent({
|
||||
type: "CREATE_VERSION",
|
||||
fileTree,
|
||||
templateId: data.template.id,
|
||||
})
|
||||
}}
|
||||
onUpdate={() => {
|
||||
sendEvent({
|
||||
type: "UPDATE_ACTIVE_VERSION",
|
||||
})
|
||||
}}
|
||||
disablePreview={editorState.hasTag("loading")}
|
||||
disableUpdate={
|
||||
editorState.hasTag("loading") ||
|
||||
editorState.context.version?.job.status !== "succeeded"
|
||||
}
|
||||
resources={editorState.context.resources}
|
||||
buildLogs={editorState.context.buildLogs}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getTemplateByName, getTemplateVersionByName } from "api/api"
|
||||
import { createTemplateVersionFileTree } from "util/templateVersion"
|
||||
|
||||
const getTemplateVersionData = async (
|
||||
orgId: string,
|
||||
templateName: string,
|
||||
versionName: string,
|
||||
) => {
|
||||
const [template, currentVersion] = await Promise.all([
|
||||
getTemplateByName(orgId, templateName),
|
||||
getTemplateVersionByName(orgId, templateName, versionName),
|
||||
])
|
||||
const fileTree = await createTemplateVersionFileTree(currentVersion)
|
||||
|
||||
return {
|
||||
template,
|
||||
currentVersion,
|
||||
fileTree,
|
||||
}
|
||||
}
|
||||
|
||||
export const useTemplateVersionData = (
|
||||
orgId: string,
|
||||
templateName: string,
|
||||
versionName: string,
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: ["templateVersion", templateName, versionName],
|
||||
queryFn: () => getTemplateVersionData(orgId, templateName, versionName),
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import * as TypesGen from "../api/typesGenerated"
|
||||
import range from "lodash/range"
|
||||
import { Permissions } from "xServices/auth/authXService"
|
||||
import { TemplateVersionFiles } from "util/templateVersion"
|
||||
import { FileTree } from "util/filetree"
|
||||
|
||||
export const MockTemplateDAUResponse: TypesGen.TemplateDAUsResponse = {
|
||||
entries: [
|
||||
@@ -303,6 +304,42 @@ spec {
|
||||
`,
|
||||
}
|
||||
|
||||
export const MockTemplateVersionFileTree: FileTree = {
|
||||
"README.md": "# Example\n\nThis is an example template.",
|
||||
"main.tf": `// Provides info about the workspace.
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
// Provides the startup script used to download
|
||||
// the agent and communicate with Coder.
|
||||
resource "coder_agent" "dev" {
|
||||
os = "linux"
|
||||
arch = "amd64"
|
||||
}
|
||||
|
||||
resource "kubernetes_pod" "main" {
|
||||
// Ensures that the Pod dies when the workspace shuts down!
|
||||
count = data.coder_workspace.me.start_count
|
||||
metadata {
|
||||
name = "dev-\${data.coder_workspace.me.id}"
|
||||
}
|
||||
spec {
|
||||
container {
|
||||
image = "ubuntu"
|
||||
command = ["sh", "-c", coder_agent.main.init_script]
|
||||
env {
|
||||
name = "CODER_AGENT_TOKEN"
|
||||
value = coder_agent.main.token
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
images: {
|
||||
"java.Dockerfile": "FROM eclipse-temurin:17-jdk-jammy",
|
||||
"python.Dockerfile": "FROM python:3.8-slim-buster",
|
||||
},
|
||||
}
|
||||
|
||||
export const MockWorkspaceApp: TypesGen.WorkspaceApp = {
|
||||
id: "test-app",
|
||||
slug: "test-app",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
existsFile,
|
||||
FileTree,
|
||||
getFileContent,
|
||||
isFolder,
|
||||
removeFile,
|
||||
setFile,
|
||||
traverse,
|
||||
} from "./filetree"
|
||||
|
||||
test("setFile() set file into the file tree", () => {
|
||||
let fileTree: FileTree = {
|
||||
"main.tf": "terraform",
|
||||
images: { "java.Dockerfile": "java dockerfile" },
|
||||
}
|
||||
fileTree = setFile("images/python.Dockerfile", "python dockerfile", fileTree)
|
||||
expect((fileTree.images as FileTree)["python.Dockerfile"]).toEqual(
|
||||
"python dockerfile",
|
||||
)
|
||||
})
|
||||
|
||||
test("getFileContent() return the file content from the file tree", () => {
|
||||
const fileTree: FileTree = {
|
||||
"main.tf": "terraform content",
|
||||
images: { "java.Dockerfile": "java dockerfile" },
|
||||
}
|
||||
expect(getFileContent("images/java.Dockerfile", fileTree)).toEqual(
|
||||
"java dockerfile",
|
||||
)
|
||||
})
|
||||
|
||||
test("removeFile() removes a file from the file tree", () => {
|
||||
let fileTree: FileTree = {
|
||||
"main.tf": "terraform content",
|
||||
images: { "java.Dockerfile": "java dockerfile" },
|
||||
}
|
||||
fileTree = removeFile("images", fileTree)
|
||||
expect(fileTree.images).toBeUndefined()
|
||||
})
|
||||
|
||||
test("existsFile() returns if there is or not a file", () => {
|
||||
const fileTree: FileTree = {
|
||||
"main.tf": "terraform content",
|
||||
images: { "java.Dockerfile": "java dockerfile" },
|
||||
}
|
||||
expect(existsFile("images/java.Dockerfile", fileTree)).toBeTruthy()
|
||||
expect(existsFile("no-existent-path", fileTree)).toBeFalsy()
|
||||
})
|
||||
|
||||
test("isFolder() returns when a path is a folder or not", () => {
|
||||
const fileTree: FileTree = {
|
||||
"main.tf": "terraform content",
|
||||
images: { "java.Dockerfile": "java dockerfile" },
|
||||
}
|
||||
expect(isFolder("images", fileTree)).toBeTruthy()
|
||||
expect(isFolder("images/java.Dockerfile", fileTree)).toBeFalsy()
|
||||
})
|
||||
|
||||
test("traverse() go trough all the file tree files", () => {
|
||||
const fileTree: FileTree = {
|
||||
"main.tf": "terraform content",
|
||||
images: { "java.Dockerfile": "java dockerfile" },
|
||||
}
|
||||
const filePaths: string[] = []
|
||||
traverse(fileTree, (_content, _filename, fullPath) => {
|
||||
filePaths.push(fullPath)
|
||||
})
|
||||
const expectedFilePaths = ["main.tf", "images", "images/java.Dockerfile"]
|
||||
expect(filePaths).toEqual(expectedFilePaths)
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import set from "lodash/set"
|
||||
import has from "lodash/has"
|
||||
import omit from "lodash/omit"
|
||||
import get from "lodash/get"
|
||||
|
||||
export type FileTree = {
|
||||
[key: string]: FileTree | string
|
||||
}
|
||||
|
||||
export const setFile = (
|
||||
path: string,
|
||||
content: string,
|
||||
fileTree: FileTree,
|
||||
): FileTree => {
|
||||
return set(fileTree, path.split("/"), content)
|
||||
}
|
||||
|
||||
export const existsFile = (path: string, fileTree: FileTree) => {
|
||||
return has(fileTree, path.split("/"))
|
||||
}
|
||||
|
||||
export const removeFile = (path: string, fileTree: FileTree) => {
|
||||
return omit(fileTree, path.split("/"))
|
||||
}
|
||||
|
||||
export const getFileContent = (path: string, fileTree: FileTree) => {
|
||||
return get(fileTree, path.split("/")) as string | FileTree
|
||||
}
|
||||
|
||||
export const isFolder = (path: string, fileTree: FileTree) => {
|
||||
const content = getFileContent(path, fileTree)
|
||||
return typeof content === "object"
|
||||
}
|
||||
|
||||
export const traverse = (
|
||||
fileTree: FileTree,
|
||||
callback: (
|
||||
content: FileTree | string,
|
||||
filename: string,
|
||||
fullPath: string,
|
||||
) => void,
|
||||
parent?: string,
|
||||
) => {
|
||||
Object.keys(fileTree).forEach((filename) => {
|
||||
const fullPath = parent ? `${parent}/${filename}` : filename
|
||||
const content = fileTree[filename]
|
||||
callback(content, filename, fullPath)
|
||||
if (typeof content === "object") {
|
||||
traverse(content, callback, fullPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getFile } from "api/api"
|
||||
import * as API from "api/api"
|
||||
import { TemplateVersion } from "api/typesGenerated"
|
||||
import untar from "js-untar"
|
||||
import { FileTree, setFile } from "./filetree"
|
||||
|
||||
/**
|
||||
* Content by filename
|
||||
@@ -13,7 +14,7 @@ export const getTemplateVersionFiles = async (
|
||||
allowedFiles: string[],
|
||||
): Promise<TemplateVersionFiles> => {
|
||||
const files: TemplateVersionFiles = {}
|
||||
const tarFile = await getFile(version.job.file_id)
|
||||
const tarFile = await API.getFile(version.job.file_id)
|
||||
const blobs: Record<string, Blob> = {}
|
||||
|
||||
await untar(tarFile).then(undefined, undefined, async (file) => {
|
||||
@@ -37,3 +38,30 @@ export const getTemplateVersionFiles = async (
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
const allowedExtensions = ["tf", "md", "Dockerfile"]
|
||||
|
||||
export const createTemplateVersionFileTree = async (
|
||||
version: TemplateVersion,
|
||||
): Promise<FileTree> => {
|
||||
let fileTree: FileTree = {}
|
||||
const tarFile = await API.getFile(version.job.file_id)
|
||||
const blobs: Record<string, Blob> = {}
|
||||
|
||||
await untar(tarFile).then(undefined, undefined, async (file) => {
|
||||
if (allowedExtensions.some((ext) => file.name.endsWith(ext))) {
|
||||
blobs[file.name] = file.blob
|
||||
}
|
||||
})
|
||||
|
||||
// We don't want to get the blob text during untar to not block the main thread.
|
||||
// Also, by doing it here, we can make all the loading in parallel.
|
||||
await Promise.all(
|
||||
Object.entries(blobs).map(async ([fullPath, blob]) => {
|
||||
const content = await blob.text()
|
||||
fileTree = setFile(fullPath, content, fileTree)
|
||||
}),
|
||||
)
|
||||
|
||||
return fileTree
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
} from "api/typesGenerated"
|
||||
import { assign, createMachine } from "xstate"
|
||||
import * as API from "api/api"
|
||||
import { TemplateVersionFiles } from "util/templateVersion"
|
||||
import Tar from "tar-js"
|
||||
import { FileTree, traverse } from "util/filetree"
|
||||
|
||||
export interface CreateVersionData {
|
||||
file: File
|
||||
@@ -16,9 +16,8 @@ export interface CreateVersionData {
|
||||
|
||||
export interface TemplateVersionEditorMachineContext {
|
||||
orgId: string
|
||||
|
||||
templateId?: string
|
||||
files?: TemplateVersionFiles
|
||||
fileTree?: FileTree
|
||||
uploadResponse?: UploadResponse
|
||||
version?: TemplateVersion
|
||||
resources?: WorkspaceResource[]
|
||||
@@ -34,7 +33,7 @@ export const templateVersionEditorMachine = createMachine(
|
||||
events: {} as
|
||||
| {
|
||||
type: "CREATE_VERSION"
|
||||
files: TemplateVersionFiles
|
||||
fileTree: FileTree
|
||||
templateId: string
|
||||
}
|
||||
| { type: "CANCEL_VERSION" }
|
||||
@@ -131,8 +130,7 @@ export const templateVersionEditorMachine = createMachine(
|
||||
actions: "addBuildLog",
|
||||
},
|
||||
CANCEL_VERSION: {
|
||||
actions: "cancelBuild",
|
||||
target: "idle",
|
||||
target: "cancelingBuild",
|
||||
},
|
||||
CREATE_VERSION: {
|
||||
actions: ["assignCreateBuild"],
|
||||
@@ -167,7 +165,7 @@ export const templateVersionEditorMachine = createMachine(
|
||||
{
|
||||
actions: {
|
||||
assignCreateBuild: assign({
|
||||
files: (_, event) => event.files,
|
||||
fileTree: (_, event) => event.fileTree,
|
||||
templateId: (_, event) => event.templateId,
|
||||
buildLogs: (_, _1) => [],
|
||||
resources: (_, _1) => [],
|
||||
@@ -206,13 +204,15 @@ export const templateVersionEditorMachine = createMachine(
|
||||
},
|
||||
services: {
|
||||
uploadTar: (ctx) => {
|
||||
if (!ctx.files) {
|
||||
if (!ctx.fileTree) {
|
||||
throw new Error("files must be set")
|
||||
}
|
||||
const tar = new Tar()
|
||||
let out: Uint8Array = new Uint8Array()
|
||||
Object.entries(ctx.files).forEach(([path, content]) => {
|
||||
out = tar.append(path, content)
|
||||
traverse(ctx.fileTree, (content, _filename, fullPath) => {
|
||||
if (typeof content === "string") {
|
||||
out = tar.append(fullPath, content)
|
||||
}
|
||||
})
|
||||
return API.uploadTemplateFile(new File([out], "template.tar"))
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user