mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site): Fix template version editor rename (#6251)
This commit is contained in:
@@ -87,7 +87,11 @@ const useStyles = makeStyles((theme) => ({
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
|
||||
"& p": {
|
||||
"& p:not(.MuiFormHelperText-root)": {
|
||||
margin: 0,
|
||||
},
|
||||
|
||||
"& > p": {
|
||||
margin: theme.spacing(1, 0),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,15 +3,18 @@ import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { ChangeEvent, FC, useState } from "react"
|
||||
import Typography from "@material-ui/core/Typography"
|
||||
import { allowedExtensions, isAllowedFile } from "util/templateVersion"
|
||||
import { FileTree, validatePath } from "util/filetree"
|
||||
|
||||
export const CreateFileDialog: FC<{
|
||||
onClose: () => void
|
||||
checkExists: (path: string) => boolean
|
||||
onConfirm: (path: string) => void
|
||||
open: boolean
|
||||
}> = ({ checkExists, onClose, onConfirm, open }) => {
|
||||
fileTree: FileTree
|
||||
}> = ({ checkExists, onClose, onConfirm, open, fileTree }) => {
|
||||
const [pathValue, setPathValue] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [error, setError] = useState<string>()
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setPathValue(event.target.value)
|
||||
}
|
||||
@@ -24,7 +27,20 @@ export const CreateFileDialog: FC<{
|
||||
setError("File already exists")
|
||||
return
|
||||
}
|
||||
if (!isAllowedFile(pathValue)) {
|
||||
const extensions = allowedExtensions.join(", ")
|
||||
setError(
|
||||
`This extension is not allowed. You only can create files with the following extensions: ${extensions}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const pathError = validatePath(pathValue, fileTree)
|
||||
if (pathError) {
|
||||
setError(pathError)
|
||||
return
|
||||
}
|
||||
onConfirm(pathValue)
|
||||
setError(undefined)
|
||||
setPathValue("")
|
||||
}
|
||||
|
||||
@@ -33,6 +49,7 @@ export const CreateFileDialog: FC<{
|
||||
open={open}
|
||||
onClose={() => {
|
||||
onClose()
|
||||
setError(undefined)
|
||||
setPathValue("")
|
||||
}}
|
||||
onConfirm={handleConfirm}
|
||||
@@ -42,10 +59,10 @@ export const CreateFileDialog: FC<{
|
||||
confirmText="Create"
|
||||
title="Create File"
|
||||
description={
|
||||
<Stack spacing={1}>
|
||||
<Stack>
|
||||
<Typography>
|
||||
Specify the path to a file to be created. This path can contain
|
||||
slashes too!
|
||||
slashes too.
|
||||
</Typography>
|
||||
<TextField
|
||||
autoFocus
|
||||
@@ -54,14 +71,18 @@ export const CreateFileDialog: FC<{
|
||||
handleConfirm()
|
||||
}
|
||||
}}
|
||||
error={Boolean(error)}
|
||||
helperText={error}
|
||||
name="file-path"
|
||||
autoComplete="off"
|
||||
id="file-path"
|
||||
placeholder="main.tf"
|
||||
placeholder="example.tf"
|
||||
value={pathValue}
|
||||
onChange={handleChange}
|
||||
label="File Path"
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
@@ -82,7 +103,12 @@ export const DeleteFileDialog: FC<{
|
||||
open={open}
|
||||
onConfirm={onConfirm}
|
||||
title="Delete File"
|
||||
description={`Are you sure you want to delete "${filename}"?`}
|
||||
description={
|
||||
<>
|
||||
Are you sure you want to delete <strong>{filename}</strong>? It will
|
||||
be deleted permanently.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -93,9 +119,10 @@ export const RenameFileDialog: FC<{
|
||||
checkExists: (path: string) => boolean
|
||||
open: boolean
|
||||
filename: string
|
||||
}> = ({ checkExists, onClose, onConfirm, open, filename }) => {
|
||||
fileTree: FileTree
|
||||
}> = ({ checkExists, onClose, onConfirm, open, filename, fileTree }) => {
|
||||
const [pathValue, setPathValue] = useState(filename)
|
||||
const [error, setError] = useState("")
|
||||
const [error, setError] = useState<string>()
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setPathValue(event.target.value)
|
||||
}
|
||||
@@ -108,7 +135,20 @@ export const RenameFileDialog: FC<{
|
||||
setError("File already exists")
|
||||
return
|
||||
}
|
||||
if (!isAllowedFile(pathValue)) {
|
||||
const extensions = allowedExtensions.join(", ")
|
||||
setError(
|
||||
`This extension is not allowed. You only can rename files with the following extensions: ${extensions}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const pathError = validatePath(pathValue, fileTree)
|
||||
if (pathError) {
|
||||
setError(pathError)
|
||||
return
|
||||
}
|
||||
onConfirm(pathValue)
|
||||
setError(undefined)
|
||||
setPathValue("")
|
||||
}
|
||||
|
||||
@@ -117,20 +157,21 @@ export const RenameFileDialog: FC<{
|
||||
open={open}
|
||||
onClose={() => {
|
||||
onClose()
|
||||
setError(undefined)
|
||||
setPathValue("")
|
||||
}}
|
||||
onConfirm={handleConfirm}
|
||||
hideCancel={false}
|
||||
type="success"
|
||||
cancelText="Cancel"
|
||||
confirmText="Create"
|
||||
confirmText="Rename"
|
||||
title="Rename File"
|
||||
description={
|
||||
<Stack spacing={1}>
|
||||
<Typography>
|
||||
Rename {`"${filename}"`} to something else. This path can contain
|
||||
slashes too!
|
||||
</Typography>
|
||||
<Stack>
|
||||
<p>
|
||||
Rename <strong>{filename}</strong> to something else. This path can
|
||||
contain slashes too!
|
||||
</p>
|
||||
<TextField
|
||||
autoFocus
|
||||
onKeyDown={(event) => {
|
||||
@@ -138,12 +179,12 @@ export const RenameFileDialog: FC<{
|
||||
handleConfirm()
|
||||
}
|
||||
}}
|
||||
error={Boolean(error)}
|
||||
helperText={error}
|
||||
name="file-path"
|
||||
autoComplete="off"
|
||||
id="file-path"
|
||||
placeholder="main.tf"
|
||||
defaultValue={filename}
|
||||
placeholder={filename}
|
||||
value={pathValue}
|
||||
onChange={handleChange}
|
||||
label="File Path"
|
||||
|
||||
@@ -67,7 +67,8 @@ export const FileTreeView: FC<{
|
||||
onSelect(currentPath)
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
event.preventDefault() // Avoid default browser behavior
|
||||
event.stopPropagation() // Avoid trigger parent context menu
|
||||
setContextMenu(
|
||||
contextMenu
|
||||
? undefined
|
||||
@@ -137,7 +138,7 @@ export const FileTreeView: FC<{
|
||||
setContextMenu(undefined)
|
||||
}}
|
||||
>
|
||||
Rename...
|
||||
Rename
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
@@ -148,7 +149,7 @@ export const FileTreeView: FC<{
|
||||
setContextMenu(undefined)
|
||||
}}
|
||||
>
|
||||
Delete Permanently
|
||||
Delete
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</TreeView>
|
||||
|
||||
@@ -18,13 +18,15 @@ import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuild
|
||||
import { FC, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { navHeight, dashboardContentBottomPadding } from "theme/constants"
|
||||
import {
|
||||
createFile,
|
||||
existsFile,
|
||||
FileTree,
|
||||
getFileContent,
|
||||
isFolder,
|
||||
moveFile,
|
||||
removeFile,
|
||||
setFile,
|
||||
traverse,
|
||||
updateFile,
|
||||
} from "util/filetree"
|
||||
import {
|
||||
CreateFileDialog,
|
||||
@@ -216,13 +218,14 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
<CreateFileDialog
|
||||
fileTree={fileTree}
|
||||
open={createFileOpen}
|
||||
onClose={() => {
|
||||
setCreateFileOpen(false)
|
||||
}}
|
||||
checkExists={(path) => existsFile(path, fileTree)}
|
||||
onConfirm={(path) => {
|
||||
setFileTree((fileTree) => setFile(path, "", fileTree))
|
||||
setFileTree((fileTree) => createFile(path, fileTree, ""))
|
||||
setActivePath(path)
|
||||
setCreateFileOpen(false)
|
||||
setDirty(true)
|
||||
@@ -245,6 +248,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
filename={deleteFileOpen || ""}
|
||||
/>
|
||||
<RenameFileDialog
|
||||
fileTree={fileTree}
|
||||
open={Boolean(renameFileOpen)}
|
||||
onClose={() => {
|
||||
setRenameFileOpen(undefined)
|
||||
@@ -255,15 +259,9 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
if (!renameFileOpen) {
|
||||
return
|
||||
}
|
||||
setFileTree((fileTree) => {
|
||||
fileTree = setFile(
|
||||
newPath,
|
||||
getFileContent(renameFileOpen, fileTree) as string,
|
||||
fileTree,
|
||||
)
|
||||
fileTree = removeFile(renameFileOpen, fileTree)
|
||||
return fileTree
|
||||
})
|
||||
setFileTree((fileTree) =>
|
||||
moveFile(renameFileOpen, newPath, fileTree),
|
||||
)
|
||||
setActivePath(newPath)
|
||||
setRenameFileOpen(undefined)
|
||||
setDirty(true)
|
||||
@@ -294,7 +292,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
return
|
||||
}
|
||||
setFileTree((fileTree) =>
|
||||
setFile(activePath, value, fileTree),
|
||||
updateFile(activePath, value, fileTree),
|
||||
)
|
||||
setDirty(true)
|
||||
}}
|
||||
|
||||
@@ -46,7 +46,7 @@ export const WorkspaceSchedule: FC<
|
||||
|
||||
return (
|
||||
<div className={styles.schedule}>
|
||||
<Stack spacing={2}>
|
||||
<Stack>
|
||||
<div>
|
||||
<span className={styles.scheduleLabel}>{Language.timezoneLabel}</span>
|
||||
<span className={styles.scheduleValue}>{timezone}</span>
|
||||
|
||||
@@ -39,12 +39,7 @@ export const WorkspaceChangeVersionForm: FC<{
|
||||
return (
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<Stack direction="column" spacing={3}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
className={styles.workspace}
|
||||
alignItems="center"
|
||||
>
|
||||
<Stack direction="row" className={styles.workspace} alignItems="center">
|
||||
<div className={styles.workspaceIcon}>
|
||||
<img src={workspace.template_icon} alt="" />
|
||||
</div>
|
||||
|
||||
@@ -3,17 +3,22 @@ import {
|
||||
FileTree,
|
||||
getFileContent,
|
||||
isFolder,
|
||||
moveFile,
|
||||
removeFile,
|
||||
setFile,
|
||||
createFile,
|
||||
traverse,
|
||||
} from "./filetree"
|
||||
|
||||
test("setFile() set file into the file tree", () => {
|
||||
test("createFile() 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)
|
||||
fileTree = createFile(
|
||||
"images/python.Dockerfile",
|
||||
fileTree,
|
||||
"python dockerfile",
|
||||
)
|
||||
expect((fileTree.images as FileTree)["python.Dockerfile"]).toEqual(
|
||||
"python dockerfile",
|
||||
)
|
||||
@@ -29,13 +34,65 @@ test("getFileContent() return the file content from the file tree", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("removeFile() removes a file from the file tree", () => {
|
||||
test("removeFile() removes a file from a folder", () => {
|
||||
let fileTree: FileTree = {
|
||||
"main.tf": "terraform content",
|
||||
images: { "java.Dockerfile": "java dockerfile" },
|
||||
images: {
|
||||
"java.Dockerfile": "java dockerfile",
|
||||
"python.Dockerfile": "python Dockerfile",
|
||||
},
|
||||
}
|
||||
fileTree = removeFile("images", fileTree)
|
||||
expect(fileTree.images).toBeUndefined()
|
||||
fileTree = removeFile("images/python.Dockerfile", fileTree)
|
||||
const expectedFileTree = {
|
||||
"main.tf": "terraform content",
|
||||
images: {
|
||||
"java.Dockerfile": "java dockerfile",
|
||||
},
|
||||
}
|
||||
expect(expectedFileTree).toEqual(fileTree)
|
||||
})
|
||||
|
||||
test("removeFile() removes a file from root", () => {
|
||||
let fileTree: FileTree = {
|
||||
"main.tf": "terraform content",
|
||||
images: {
|
||||
"java.Dockerfile": "java dockerfile",
|
||||
"python.Dockerfile": "python Dockerfile",
|
||||
},
|
||||
}
|
||||
fileTree = removeFile("main.tf", fileTree)
|
||||
const expectedFileTree = {
|
||||
images: {
|
||||
"java.Dockerfile": "java dockerfile",
|
||||
"python.Dockerfile": "python Dockerfile",
|
||||
},
|
||||
}
|
||||
expect(expectedFileTree).toEqual(fileTree)
|
||||
})
|
||||
|
||||
test("moveFile() moves a file from in file tree", () => {
|
||||
let fileTree: FileTree = {
|
||||
"main.tf": "terraform content",
|
||||
images: {
|
||||
"java.Dockerfile": "java dockerfile",
|
||||
"python.Dockerfile": "python Dockerfile",
|
||||
},
|
||||
}
|
||||
fileTree = moveFile(
|
||||
"images/java.Dockerfile",
|
||||
"other/java.Dockerfile",
|
||||
fileTree,
|
||||
)
|
||||
const expectedFileTree = {
|
||||
"main.tf": "terraform content",
|
||||
images: {
|
||||
"python.Dockerfile": "python Dockerfile",
|
||||
},
|
||||
other: {
|
||||
"java.Dockerfile": "java dockerfile",
|
||||
},
|
||||
}
|
||||
expect(fileTree).toEqual(expectedFileTree)
|
||||
})
|
||||
|
||||
test("existsFile() returns if there is or not a file", () => {
|
||||
|
||||
@@ -1,15 +1,46 @@
|
||||
import set from "lodash/set"
|
||||
import has from "lodash/has"
|
||||
import omit from "lodash/omit"
|
||||
import unset from "lodash/unset"
|
||||
import get from "lodash/get"
|
||||
|
||||
export type FileTree = {
|
||||
[key: string]: FileTree | string
|
||||
}
|
||||
|
||||
export const setFile = (
|
||||
export const createFile = (
|
||||
path: string,
|
||||
content: string,
|
||||
fileTree: FileTree,
|
||||
value: string,
|
||||
): FileTree => {
|
||||
if (existsFile(path, fileTree)) {
|
||||
throw new Error(`File ${path} already exists`)
|
||||
}
|
||||
const pathError = validatePath(path, fileTree)
|
||||
if (pathError) {
|
||||
throw new Error(pathError)
|
||||
}
|
||||
|
||||
return set(fileTree, path.split("/"), value)
|
||||
}
|
||||
|
||||
export const validatePath = (
|
||||
path: string,
|
||||
fileTree: FileTree,
|
||||
): string | undefined => {
|
||||
const paths = path.split("/")
|
||||
paths.pop() // The last item is the filename
|
||||
for (let i = 0; i <= paths.length; i++) {
|
||||
const path = paths.slice(0, i + 1)
|
||||
const pathStr = path.join("/")
|
||||
if (existsFile(pathStr, fileTree) && !isFolder(pathStr, fileTree)) {
|
||||
return `Invalid path. The path ${path} is not a folder`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const updateFile = (
|
||||
path: string,
|
||||
content: FileTree | string,
|
||||
fileTree: FileTree,
|
||||
): FileTree => {
|
||||
return set(fileTree, path.split("/"), content)
|
||||
@@ -20,7 +51,23 @@ export const existsFile = (path: string, fileTree: FileTree) => {
|
||||
}
|
||||
|
||||
export const removeFile = (path: string, fileTree: FileTree) => {
|
||||
return omit(fileTree, path.split("/"))
|
||||
const updatedFileTree = { ...fileTree }
|
||||
unset(updatedFileTree, path.split("/"))
|
||||
return updatedFileTree
|
||||
}
|
||||
|
||||
export const moveFile = (
|
||||
currentPath: string,
|
||||
newPath: string,
|
||||
fileTree: FileTree,
|
||||
) => {
|
||||
const content = getFileContent(currentPath, fileTree)
|
||||
if (typeof content !== "string") {
|
||||
throw new Error("Move folders is not allowed")
|
||||
}
|
||||
fileTree = removeFile(currentPath, fileTree)
|
||||
fileTree = createFile(newPath, fileTree, content)
|
||||
return fileTree
|
||||
}
|
||||
|
||||
export const getFileContent = (path: string, fileTree: FileTree) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as API from "api/api"
|
||||
import { TemplateVersion } from "api/typesGenerated"
|
||||
import { FileTree, setFile } from "./filetree"
|
||||
import { FileTree, createFile } from "./filetree"
|
||||
import { TarReader } from "./tar"
|
||||
|
||||
/**
|
||||
@@ -23,7 +23,7 @@ export const getTemplateVersionFiles = async (
|
||||
return files
|
||||
}
|
||||
|
||||
const allowedExtensions = ["tf", "md", "Dockerfile"]
|
||||
export const allowedExtensions = ["tf", "md", "Dockerfile"]
|
||||
|
||||
export const isAllowedFile = (name: string) => {
|
||||
return allowedExtensions.some((ext) => name.endsWith(ext))
|
||||
@@ -35,10 +35,10 @@ export const createTemplateVersionFileTree = async (
|
||||
let fileTree: FileTree = {}
|
||||
for (const file of tarReader.fileInfo) {
|
||||
if (isAllowedFile(file.name)) {
|
||||
fileTree = setFile(
|
||||
fileTree = createFile(
|
||||
file.name,
|
||||
tarReader.getTextFile(file.name) as string,
|
||||
fileTree,
|
||||
tarReader.getTextFile(file.name) as string,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,11 +252,17 @@ export const templateVersionEditorMachine = createMachine(
|
||||
}
|
||||
// Add the editable files
|
||||
traverse(fileTree, (content, _filename, fullPath) => {
|
||||
// When a file is deleted. Don't add it to the tar.
|
||||
if (content === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof content === "string") {
|
||||
tar.addFile(fullPath, content)
|
||||
} else {
|
||||
tar.addFolder(fullPath)
|
||||
return
|
||||
}
|
||||
|
||||
tar.addFolder(fullPath)
|
||||
})
|
||||
const blob = await tar.write()
|
||||
return API.uploadTemplateFile(new File([blob], "template.tar"))
|
||||
|
||||
Reference in New Issue
Block a user