mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore(site): Upgrade storybook to 7.0.4 (#7127)
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { firstOrItem } from "./array"
|
||||
|
||||
describe("array", () => {
|
||||
describe("firstOrItem", () => {
|
||||
it("returns null if empty array", () => {
|
||||
expect(firstOrItem([], null)).toBeNull()
|
||||
})
|
||||
|
||||
it("returns first item if array with more one item", () => {
|
||||
expect(firstOrItem(["a", "b"], "c")).toEqual("a")
|
||||
})
|
||||
|
||||
it("returns item if single item", () => {
|
||||
expect(firstOrItem("c", "d")).toEqual("c")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Helper function that, given an array or a single item:
|
||||
* - If an array with no elements, returns null
|
||||
* - If an array with 1 or more elements, returns the first element
|
||||
* - If a single item, returns that item
|
||||
*/
|
||||
export const firstOrItem = <T>(
|
||||
itemOrItems: undefined | T | T[],
|
||||
defaults: T,
|
||||
): T => {
|
||||
if (Array.isArray(itemOrItems)) {
|
||||
return itemOrItems.length > 0 ? itemOrItems[0] : defaults
|
||||
}
|
||||
|
||||
if (typeof itemOrItems === "undefined") {
|
||||
return defaults
|
||||
}
|
||||
|
||||
return itemOrItems
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Used to convert our theme colors to Hex since monaco theme only support hex colors
|
||||
// From https://www.jameslmilner.com/posts/converting-rgb-hex-hsl-colors/
|
||||
export function hslToHex(hsl: string): string {
|
||||
const [h, s, l] = hsl
|
||||
.replace("hsl(", "")
|
||||
.replace(")", "")
|
||||
.replaceAll("%", "")
|
||||
.split(",")
|
||||
.map(Number)
|
||||
|
||||
const hDecimal = l / 100
|
||||
const a = (s * Math.min(hDecimal, 1 - hDecimal)) / 100
|
||||
const f = (n: number) => {
|
||||
const k = (n + h / 30) % 12
|
||||
const color = hDecimal - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
|
||||
|
||||
// Convert to Hex and prefix with "0" if required
|
||||
return Math.round(255 * color)
|
||||
.toString(16)
|
||||
.padStart(2, "0")
|
||||
}
|
||||
return `#${f(0)}${f(8)}${f(4)}`
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { combineClasses } from "./combineClasses"
|
||||
|
||||
const staticStyles = {
|
||||
text: "MuiText",
|
||||
success: "MuiText-Green",
|
||||
warning: "MuiText-Red",
|
||||
}
|
||||
|
||||
describe("combineClasses", () => {
|
||||
it.each([
|
||||
// Falsy
|
||||
[undefined, undefined],
|
||||
[{ [staticStyles.text]: false }, undefined],
|
||||
[{ [staticStyles.text]: undefined }, undefined],
|
||||
[[], undefined],
|
||||
|
||||
// Truthy
|
||||
[{ [staticStyles.text]: true }, "MuiText"],
|
||||
[
|
||||
{ [staticStyles.text]: true, [staticStyles.warning]: true },
|
||||
"MuiText MuiText-Red",
|
||||
],
|
||||
[[staticStyles.text], "MuiText"],
|
||||
|
||||
// Mixed
|
||||
[{ [staticStyles.text]: true, [staticStyles.success]: false }, "MuiText"],
|
||||
[[staticStyles.text, staticStyles.success], "MuiText MuiText-Green"],
|
||||
])(`classNames(%p) returns %p`, (staticClasses, result) => {
|
||||
expect(combineClasses(staticClasses)).toBe(result)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
export const appendCSSString = (
|
||||
cssString: string,
|
||||
cssClass: string,
|
||||
): string => {
|
||||
if (cssString === "") {
|
||||
return cssClass
|
||||
}
|
||||
return `${cssString} ${cssClass}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param classes May be an object or an array. When using an object, each key
|
||||
* is a css class name and its associated value indicates whether it should be
|
||||
* applied. When using an array, only truthy values are applied.
|
||||
* @returns A string with each class that should be included. Classes are
|
||||
* separated by two spaces. If no classes are included, returns `undefined`.
|
||||
* @example
|
||||
* combineClasses() // -> undefined
|
||||
* combineClasses({ text: true }) // -> "text"
|
||||
* combineClasses({ text: true, success: true }) // -> "text success"
|
||||
* combineClasses({ text: true, success: false }) // -> "text"
|
||||
* combineClasses([ "text", false, "" ]) // -> "text"
|
||||
*/
|
||||
export const combineClasses = (
|
||||
classes:
|
||||
| Array<string | false | undefined>
|
||||
| Record<string, boolean | undefined> = {},
|
||||
): string | undefined => {
|
||||
let result = ""
|
||||
|
||||
if (Array.isArray(classes)) {
|
||||
for (const cssClass of classes) {
|
||||
if (cssClass) {
|
||||
result = appendCSSString(result, cssClass)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const cssClass in classes) {
|
||||
const useClass = classes[cssClass]
|
||||
if (useClass) {
|
||||
result = appendCSSString(result, cssClass)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : undefined
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import dayjs from "dayjs"
|
||||
import relativeTime from "dayjs/plugin/relativeTime"
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
/**
|
||||
* Returns a human-readable string describing the passing of time
|
||||
* Broken into its own module for testing purposes
|
||||
*/
|
||||
export function createDayString(time: string): string {
|
||||
return dayjs().to(dayjs(time))
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const delay = (ms: number): Promise<void> =>
|
||||
new Promise((res) => {
|
||||
setTimeout(res, ms)
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useMemo } from "react"
|
||||
import { DeploymentGroup, DeploymentOption } from "../api/types"
|
||||
|
||||
const deploymentOptions = (
|
||||
options: DeploymentOption[],
|
||||
...names: string[]
|
||||
): DeploymentOption[] => {
|
||||
const found: DeploymentOption[] = []
|
||||
for (const name of names) {
|
||||
const option = options.find((o) => o.name === name)
|
||||
if (option) {
|
||||
found.push(option)
|
||||
} else {
|
||||
throw new Error(`Deployment option ${name} not found`)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
export const useDeploymentOptions = (
|
||||
options: DeploymentOption[],
|
||||
...names: string[]
|
||||
): DeploymentOption[] => {
|
||||
return useMemo(() => deploymentOptions(options, ...names), [options, names])
|
||||
}
|
||||
|
||||
export const deploymentGroupHasParent = (
|
||||
group: DeploymentGroup | undefined,
|
||||
parent: string,
|
||||
): boolean => {
|
||||
if (!group) {
|
||||
return false
|
||||
}
|
||||
if (group.parent) {
|
||||
return deploymentGroupHasParent(group.parent, parent)
|
||||
}
|
||||
if (group.name === parent) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import dayjs from "dayjs"
|
||||
import duration from "dayjs/plugin/duration"
|
||||
import relativeTime from "dayjs/plugin/relativeTime"
|
||||
|
||||
dayjs.extend(duration)
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
export const humanDuration = (
|
||||
time: number,
|
||||
durationUnitType?: duration.DurationUnitType | undefined,
|
||||
) => {
|
||||
return dayjs.duration(time, durationUnitType).humanize()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ellipsizeText } from "./ellipsizeText"
|
||||
import { Nullable } from "./nullable"
|
||||
|
||||
describe("ellipsizeText", () => {
|
||||
it.each([
|
||||
[undefined, 10, undefined],
|
||||
[null, 10, undefined],
|
||||
["", 10, ""],
|
||||
["Hello World", "Hello World".length, "Hello World"],
|
||||
["Hello World", "Hello...".length, "Hello..."],
|
||||
])(
|
||||
`ellipsizeText(%p, %p) returns %p`,
|
||||
(
|
||||
str: Nullable<string>,
|
||||
maxLength: number | undefined,
|
||||
output: Nullable<string>,
|
||||
) => {
|
||||
expect(ellipsizeText(str, maxLength)).toBe(output)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Nullable } from "./nullable"
|
||||
|
||||
/** Truncates and ellipsizes text if it's longer than maxLength */
|
||||
export const ellipsizeText = (
|
||||
text: Nullable<string>,
|
||||
maxLength = 80,
|
||||
): string | undefined => {
|
||||
if (typeof text !== "string") {
|
||||
return
|
||||
}
|
||||
return text.length <= maxLength ? text : `${text.substr(0, maxLength - 3)}...`
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { dispatchCustomEvent, isCustomEvent } from "./events"
|
||||
|
||||
describe("events", () => {
|
||||
describe("dispatchCustomEvent", () => {
|
||||
it("dispatch a custom event", (done) => {
|
||||
const eventDetail = { title: "Event title" }
|
||||
|
||||
window.addEventListener("eventType", (event) => {
|
||||
if (isCustomEvent(event)) {
|
||||
expect(event.detail).toEqual(eventDetail)
|
||||
done()
|
||||
}
|
||||
})
|
||||
|
||||
dispatchCustomEvent("eventType", eventDetail)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Dispatches a custom event with descriptive type information.
|
||||
*
|
||||
* @param eventType a unique name defining the type of the event. e.g. `"coder:workspace:ready"`
|
||||
* @param detail an optional payload accessible to an event listener.
|
||||
* @param target an optional event target. Defaults to current `window`.
|
||||
*/
|
||||
export const dispatchCustomEvent = <D = unknown>(
|
||||
eventType: string,
|
||||
detail?: D,
|
||||
target: EventTarget = window,
|
||||
): CustomEvent<D> => {
|
||||
const event = new CustomEvent<D>(eventType, { detail })
|
||||
|
||||
target.dispatchEvent(event)
|
||||
|
||||
return event
|
||||
}
|
||||
/** Annotates a custom event listener with descriptive type information. */
|
||||
export type CustomEventListener<D = unknown> = (event: CustomEvent<D>) => void
|
||||
|
||||
/**
|
||||
* Determines if an Event object is a CustomEvent.
|
||||
*
|
||||
* @remark this is especially necessary when an event originates from an iframe
|
||||
* as `instanceof` will not match against another origin's prototype chain.
|
||||
*/
|
||||
export const isCustomEvent = <D = unknown>(
|
||||
event: CustomEvent<D> | Event,
|
||||
): event is CustomEvent<D> => {
|
||||
return Boolean((event as CustomEvent).detail)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
existsFile,
|
||||
FileTree,
|
||||
getFileContent,
|
||||
isFolder,
|
||||
moveFile,
|
||||
removeFile,
|
||||
createFile,
|
||||
traverse,
|
||||
} from "./filetree"
|
||||
|
||||
test("createFile() set file into the file tree", () => {
|
||||
let fileTree: FileTree = {
|
||||
"main.tf": "terraform",
|
||||
images: { "java.Dockerfile": "java dockerfile" },
|
||||
}
|
||||
fileTree = createFile(
|
||||
"images/python.Dockerfile",
|
||||
fileTree,
|
||||
"python dockerfile",
|
||||
)
|
||||
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 a folder", () => {
|
||||
let fileTree: FileTree = {
|
||||
"main.tf": "terraform content",
|
||||
images: {
|
||||
"java.Dockerfile": "java dockerfile",
|
||||
"python.Dockerfile": "python Dockerfile",
|
||||
},
|
||||
}
|
||||
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", () => {
|
||||
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,99 @@
|
||||
import set from "lodash/set"
|
||||
import has from "lodash/has"
|
||||
import unset from "lodash/unset"
|
||||
import get from "lodash/get"
|
||||
|
||||
export type FileTree = {
|
||||
[key: string]: FileTree | string
|
||||
}
|
||||
|
||||
export const createFile = (
|
||||
path: 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)
|
||||
}
|
||||
|
||||
export const existsFile = (path: string, fileTree: FileTree) => {
|
||||
return has(fileTree, path.split("/"))
|
||||
}
|
||||
|
||||
export const removeFile = (path: string, fileTree: FileTree) => {
|
||||
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) => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as TypesGen from "../api/typesGenerated"
|
||||
import { queryToFilter } from "./filters"
|
||||
|
||||
describe("queryToFilter", () => {
|
||||
it.each<
|
||||
[string | undefined, TypesGen.WorkspaceFilter | TypesGen.UsersRequest]
|
||||
>([
|
||||
[undefined, {}],
|
||||
["", { q: "" }],
|
||||
["asdkfvjn", { q: "asdkfvjn" }],
|
||||
["owner:me", { q: "owner:me" }],
|
||||
["owner:me owner:me2", { q: "owner:me owner:me2" }],
|
||||
["me/dev", { q: "me/dev" }],
|
||||
["me/", { q: "me/" }],
|
||||
[" key:val owner:me ", { q: "key:val owner:me" }],
|
||||
])(`query=%p, filter=%p`, (query, filter) => {
|
||||
expect(queryToFilter(query)).toEqual(filter)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as TypesGen from "../api/typesGenerated"
|
||||
|
||||
export const queryToFilter = (
|
||||
query?: string,
|
||||
): TypesGen.WorkspaceFilter | TypesGen.UsersRequest => {
|
||||
const preparedQuery = query?.trim().replace(/ +/g, " ")
|
||||
return {
|
||||
q: preparedQuery,
|
||||
}
|
||||
}
|
||||
|
||||
export const workspaceFilterQuery = {
|
||||
me: "owner:me",
|
||||
all: "",
|
||||
running: "status:running",
|
||||
}
|
||||
|
||||
export const userFilterQuery = {
|
||||
active: "status:active",
|
||||
all: "",
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { FormikContextType } from "formik/dist/types"
|
||||
import { getFormHelpers, nameValidator, onChangeTrimmed } from "./formUtils"
|
||||
|
||||
interface TestType {
|
||||
untouchedGoodField: string
|
||||
untouchedBadField: string
|
||||
touchedGoodField: string
|
||||
touchedBadField: string
|
||||
}
|
||||
|
||||
const mockHandleChange = jest.fn()
|
||||
|
||||
const form = {
|
||||
errors: {
|
||||
untouchedGoodField: undefined,
|
||||
untouchedBadField: "oops!",
|
||||
touchedGoodField: undefined,
|
||||
touchedBadField: "oops!",
|
||||
},
|
||||
touched: {
|
||||
untouchedGoodField: false,
|
||||
untouchedBadField: false,
|
||||
touchedGoodField: true,
|
||||
touchedBadField: true,
|
||||
},
|
||||
handleChange: mockHandleChange,
|
||||
handleBlur: jest.fn(),
|
||||
getFieldProps: (name: string) => {
|
||||
return {
|
||||
name,
|
||||
onBlur: jest.fn(),
|
||||
onChange: jest.fn(),
|
||||
value: "",
|
||||
}
|
||||
},
|
||||
} as unknown as FormikContextType<TestType>
|
||||
|
||||
const nameSchema = nameValidator("name")
|
||||
|
||||
describe("form util functions", () => {
|
||||
describe("getFormHelpers", () => {
|
||||
describe("without API errors", () => {
|
||||
const getFieldHelpers = getFormHelpers<TestType>(form)
|
||||
const untouchedGoodResult = getFieldHelpers("untouchedGoodField")
|
||||
const untouchedBadResult = getFieldHelpers("untouchedBadField")
|
||||
const touchedGoodResult = getFieldHelpers("touchedGoodField")
|
||||
const touchedBadResult = getFieldHelpers("touchedBadField")
|
||||
it("populates the 'field props'", () => {
|
||||
expect(untouchedGoodResult.name).toEqual("untouchedGoodField")
|
||||
expect(untouchedGoodResult.onBlur).toBeDefined()
|
||||
expect(untouchedGoodResult.onChange).toBeDefined()
|
||||
expect(untouchedGoodResult.value).toBeDefined()
|
||||
})
|
||||
it("sets the id to the name", () => {
|
||||
expect(untouchedGoodResult.id).toEqual("untouchedGoodField")
|
||||
})
|
||||
it("sets error to true if touched and invalid", () => {
|
||||
expect(untouchedGoodResult.error).toBeFalsy
|
||||
expect(untouchedBadResult.error).toBeFalsy
|
||||
expect(touchedGoodResult.error).toBeFalsy
|
||||
expect(touchedBadResult.error).toBeTruthy
|
||||
})
|
||||
it("sets helperText to the error message if touched and invalid", () => {
|
||||
expect(untouchedGoodResult.helperText).toBeUndefined
|
||||
expect(untouchedBadResult.helperText).toBeUndefined
|
||||
expect(touchedGoodResult.helperText).toBeUndefined
|
||||
expect(touchedBadResult.helperText).toEqual("oops!")
|
||||
})
|
||||
})
|
||||
describe("with API errors", () => {
|
||||
it("shows an error if there is only an API error", () => {
|
||||
const getFieldHelpers = getFormHelpers<TestType>(form, {
|
||||
touchedGoodField: "API error!",
|
||||
})
|
||||
const result = getFieldHelpers("touchedGoodField")
|
||||
expect(result.error).toBeTruthy()
|
||||
expect(result.helperText).toEqual("API error!")
|
||||
})
|
||||
it("shows an error if there is only a validation error", () => {
|
||||
const getFieldHelpers = getFormHelpers<TestType>(form, {})
|
||||
const result = getFieldHelpers("touchedBadField")
|
||||
expect(result.error).toBeTruthy()
|
||||
expect(result.helperText).toEqual("oops!")
|
||||
})
|
||||
it("shows the API error if both are present", () => {
|
||||
const getFieldHelpers = getFormHelpers<TestType>(form, {
|
||||
touchedBadField: "API error!",
|
||||
})
|
||||
const result = getFieldHelpers("touchedBadField")
|
||||
expect(result.error).toBeTruthy()
|
||||
expect(result.helperText).toEqual("API error!")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("onChangeTrimmed", () => {
|
||||
it("calls handleChange with trimmed value", () => {
|
||||
const event = {
|
||||
target: { value: " hello " },
|
||||
} as React.ChangeEvent<HTMLInputElement>
|
||||
onChangeTrimmed<TestType>(form)(event)
|
||||
expect(mockHandleChange).toHaveBeenCalledWith({
|
||||
target: { value: "hello" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("nameValidator", () => {
|
||||
it("allows a 1-letter name", () => {
|
||||
const validate = () => nameSchema.validateSync("a")
|
||||
expect(validate).not.toThrow()
|
||||
})
|
||||
|
||||
it("allows a 32-letter name", () => {
|
||||
const input = Array(32).fill("a").join("")
|
||||
const validate = () => nameSchema.validateSync(input)
|
||||
expect(validate).not.toThrow()
|
||||
})
|
||||
|
||||
it("allows 'test-3' to be used as name", () => {
|
||||
const validate = () => nameSchema.validateSync("test-3")
|
||||
expect(validate).not.toThrow()
|
||||
})
|
||||
|
||||
it("allows '3-test' to be used as a name", () => {
|
||||
const validate = () => nameSchema.validateSync("3-test")
|
||||
expect(validate).not.toThrow()
|
||||
})
|
||||
|
||||
it("disallows a 33-letter name", () => {
|
||||
const input = Array(33).fill("a").join("")
|
||||
const validate = () => nameSchema.validateSync(input)
|
||||
expect(validate).toThrow()
|
||||
})
|
||||
|
||||
it("disallows a space", () => {
|
||||
const validate = () => nameSchema.validateSync("test 3")
|
||||
expect(validate).toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import { isApiValidationError, mapApiErrorToFieldErrors } from "api/errors"
|
||||
import { FormikContextType, FormikErrors, getIn } from "formik"
|
||||
import {
|
||||
ChangeEvent,
|
||||
ChangeEventHandler,
|
||||
FocusEventHandler,
|
||||
ReactNode,
|
||||
} from "react"
|
||||
import * as Yup from "yup"
|
||||
|
||||
const Language = {
|
||||
nameRequired: (name: string): string => {
|
||||
return name ? `Please enter a ${name.toLowerCase()}.` : "Required"
|
||||
},
|
||||
nameInvalidChars: (name: string): string => {
|
||||
return `${name} must start with a-Z or 0-9 and can contain a-Z, 0-9 or -`
|
||||
},
|
||||
nameTooLong: (name: string, len: number): string => {
|
||||
return `${name} cannot be longer than ${len} characters`
|
||||
},
|
||||
templateDisplayNameInvalidChars: (name: string): string => {
|
||||
return `${name} must start and end with non-whitespace character`
|
||||
},
|
||||
}
|
||||
|
||||
interface FormHelpers {
|
||||
name: string
|
||||
onBlur: FocusEventHandler
|
||||
onChange: ChangeEventHandler
|
||||
id: string
|
||||
value?: string | number
|
||||
error: boolean
|
||||
helperText?: ReactNode
|
||||
}
|
||||
|
||||
export const getFormHelpers =
|
||||
<T>(form: FormikContextType<T>, error?: Error | unknown) =>
|
||||
(
|
||||
name: string,
|
||||
HelperText: ReactNode = "",
|
||||
backendErrorName?: string,
|
||||
): FormHelpers => {
|
||||
const apiValidationErrors = isApiValidationError(error)
|
||||
? (mapApiErrorToFieldErrors(error.response.data) as FormikErrors<T>)
|
||||
: // This should not return the error since it is not and api validation error but I didn't have time to fix this and tests
|
||||
error
|
||||
|
||||
if (typeof name !== "string") {
|
||||
throw new Error(
|
||||
`name must be type of string, instead received '${typeof name}'`,
|
||||
)
|
||||
}
|
||||
|
||||
const apiErrorName = backendErrorName ?? name
|
||||
|
||||
// getIn is a util function from Formik that gets at any depth of nesting
|
||||
// and is necessary for the types to work
|
||||
const touched = getIn(form.touched, name)
|
||||
const apiError = getIn(apiValidationErrors, apiErrorName)
|
||||
const frontendError = getIn(form.errors, name)
|
||||
const returnError = apiError ?? frontendError
|
||||
|
||||
return {
|
||||
...form.getFieldProps(name),
|
||||
id: name,
|
||||
error: touched && Boolean(returnError),
|
||||
helperText: touched ? returnError || HelperText : HelperText,
|
||||
}
|
||||
}
|
||||
|
||||
export const onChangeTrimmed =
|
||||
<T>(form: FormikContextType<T>, callback?: () => void) =>
|
||||
(event: ChangeEvent<HTMLInputElement>): void => {
|
||||
event.target.value = event.target.value.trim()
|
||||
form.handleChange(event)
|
||||
callback && callback()
|
||||
}
|
||||
|
||||
// REMARK: Keep these consts in sync with coderd/httpapi/httpapi.go
|
||||
const maxLenName = 32
|
||||
const templateDisplayNameMaxLength = 64
|
||||
const usernameRE = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/
|
||||
const templateDisplayNameRE = /^[^\s](.*[^\s])?$/
|
||||
|
||||
// REMARK: see #1756 for name/username semantics
|
||||
export const nameValidator = (name: string): Yup.StringSchema =>
|
||||
Yup.string()
|
||||
.required(Language.nameRequired(name))
|
||||
.matches(usernameRE, Language.nameInvalidChars(name))
|
||||
.max(maxLenName, Language.nameTooLong(name, maxLenName))
|
||||
|
||||
export const templateDisplayNameValidator = (
|
||||
displayName: string,
|
||||
): Yup.StringSchema =>
|
||||
Yup.string()
|
||||
.matches(
|
||||
templateDisplayNameRE,
|
||||
Language.templateDisplayNameInvalidChars(displayName),
|
||||
)
|
||||
.max(
|
||||
templateDisplayNameMaxLength,
|
||||
Language.nameTooLong(displayName, templateDisplayNameMaxLength),
|
||||
)
|
||||
.optional()
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Group } from "api/typesGenerated"
|
||||
|
||||
export const everyOneGroup = (organizationId: string): Group => ({
|
||||
id: organizationId,
|
||||
name: "Everyone",
|
||||
organization_id: organizationId,
|
||||
members: [],
|
||||
avatar_url: "",
|
||||
quota_allowance: 0,
|
||||
})
|
||||
|
||||
export const getGroupSubtitle = (group: Group): string => {
|
||||
// It is the everyone group when a group id is the same of the org id
|
||||
if (group.id === group.organization_id) {
|
||||
return `All users`
|
||||
}
|
||||
|
||||
if (group.members.length === 1) {
|
||||
return `1 member`
|
||||
}
|
||||
|
||||
return `${group.members.length} members`
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* A Nullable may be its concrete type, `null` or `undefined`
|
||||
* @remark Exact opposite of the native TS type NonNullable<T>
|
||||
*/
|
||||
export type Nullable<T> = null | undefined | T
|
||||
@@ -0,0 +1,4 @@
|
||||
export const pageTitle = (prefix: string | string[]): string => {
|
||||
const title = Array.isArray(prefix) ? prefix.join(" · ") : prefix
|
||||
return `${title} - Coder`
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Generate a cryptographically secure random string using the specified number
|
||||
* of bytes then encode with base64.
|
||||
*
|
||||
* Base64 encodes 6 bits per character and pads with = so the length will not
|
||||
* equal the number of randomly generated bytes.
|
||||
* @see <https://developer.mozilla.org/en-US/docs/Glossary/Base64#encoded_size_increase>
|
||||
*/
|
||||
export const generateRandomString = (bytes: number): string => {
|
||||
const byteArr = window.crypto.getRandomValues(new Uint8Array(bytes))
|
||||
// The types for `map` don't seem to support mapping from one array type to
|
||||
// another and `String.fromCharCode.apply` wants `number[]` so loop like this
|
||||
// instead.
|
||||
const strArr: string[] = []
|
||||
for (const byte of byteArr) {
|
||||
strArr.push(String.fromCharCode(byte))
|
||||
}
|
||||
return btoa(strArr.join(""))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { embedRedirect, retrieveRedirect } from "./redirect"
|
||||
|
||||
describe("redirect helper functions", () => {
|
||||
describe("embedRedirect", () => {
|
||||
it("embeds the page to return to in the URL", () => {
|
||||
const result = embedRedirect("/workspaces", "/page")
|
||||
expect(result).toEqual("/page?redirect=%2Fworkspaces")
|
||||
})
|
||||
it("defaults to navigating to the login page", () => {
|
||||
const result = embedRedirect("/workspaces")
|
||||
expect(result).toEqual("/login?redirect=%2Fworkspaces")
|
||||
})
|
||||
})
|
||||
describe("retrieveRedirect", () => {
|
||||
it("retrieves the page to return to from the URL", () => {
|
||||
const result = retrieveRedirect("?redirect=%2Fworkspaces")
|
||||
expect(result).toEqual("/workspaces")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Creates a url containing a page to navigate to now, and embedding another
|
||||
* URL in the query string so you can return to it later.
|
||||
* @param navigateTo page to navigate to now (by default, /login)
|
||||
* @param returnTo page to redirect to later (for instance, after logging in)
|
||||
* @returns URL containing a redirect query parameter
|
||||
*/
|
||||
export const embedRedirect = (
|
||||
returnTo: string,
|
||||
navigateTo = "/login",
|
||||
): string => `${navigateTo}?redirect=${encodeURIComponent(returnTo)}`
|
||||
|
||||
/**
|
||||
* Retrieves a url from the query string of the current URL
|
||||
* @param search the query string in the current URL
|
||||
* @returns the URL to redirect to
|
||||
*/
|
||||
export const retrieveRedirect = (search: string): string => {
|
||||
const defaultRedirect = "/"
|
||||
const searchParams = new URLSearchParams(search)
|
||||
const redirect = searchParams.get("redirect")
|
||||
return redirect ? redirect : defaultRedirect
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
TemplateVersionParameter,
|
||||
WorkspaceBuildParameter,
|
||||
} from "api/typesGenerated"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as Yup from "yup"
|
||||
|
||||
export const selectInitialRichParametersValues = (
|
||||
templateParameters?: TemplateVersionParameter[],
|
||||
defaultValuesFromQuery?: Record<string, string>,
|
||||
): WorkspaceBuildParameter[] => {
|
||||
const defaults: WorkspaceBuildParameter[] = []
|
||||
if (!templateParameters) {
|
||||
return defaults
|
||||
}
|
||||
|
||||
templateParameters.forEach((parameter) => {
|
||||
if (parameter.options.length > 0) {
|
||||
let parameterValue = parameter.options[0].value
|
||||
if (defaultValuesFromQuery && defaultValuesFromQuery[parameter.name]) {
|
||||
parameterValue = defaultValuesFromQuery[parameter.name]
|
||||
}
|
||||
|
||||
const buildParameter: WorkspaceBuildParameter = {
|
||||
name: parameter.name,
|
||||
value: parameterValue,
|
||||
}
|
||||
defaults.push(buildParameter)
|
||||
return
|
||||
}
|
||||
|
||||
let parameterValue = parameter.default_value
|
||||
if (defaultValuesFromQuery && defaultValuesFromQuery[parameter.name]) {
|
||||
parameterValue = defaultValuesFromQuery[parameter.name]
|
||||
}
|
||||
|
||||
const buildParameter: WorkspaceBuildParameter = {
|
||||
name: parameter.name,
|
||||
value: parameterValue || "",
|
||||
}
|
||||
defaults.push(buildParameter)
|
||||
})
|
||||
return defaults
|
||||
}
|
||||
|
||||
export const useValidationSchemaForRichParameters = (
|
||||
ns: string,
|
||||
templateParameters?: TemplateVersionParameter[],
|
||||
lastBuildParameters?: WorkspaceBuildParameter[],
|
||||
): Yup.AnySchema => {
|
||||
const { t } = useTranslation(ns)
|
||||
|
||||
if (!templateParameters) {
|
||||
return Yup.object()
|
||||
}
|
||||
|
||||
return Yup.array()
|
||||
.of(
|
||||
Yup.object().shape({
|
||||
name: Yup.string().required(),
|
||||
value: Yup.string().test("verify with template", (val, ctx) => {
|
||||
const name = ctx.parent.name
|
||||
const templateParameter = templateParameters.find(
|
||||
(parameter) => parameter.name === name,
|
||||
)
|
||||
if (templateParameter) {
|
||||
switch (templateParameter.type) {
|
||||
case "number":
|
||||
if (
|
||||
templateParameter.validation_min &&
|
||||
templateParameter.validation_max
|
||||
) {
|
||||
if (
|
||||
Number(val) < templateParameter.validation_min ||
|
||||
templateParameter.validation_max < Number(val)
|
||||
) {
|
||||
return ctx.createError({
|
||||
path: ctx.path,
|
||||
message: t("validationNumberNotInRange", {
|
||||
min: templateParameter.validation_min,
|
||||
max: templateParameter.validation_max,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
templateParameter.validation_monotonic &&
|
||||
lastBuildParameters
|
||||
) {
|
||||
const lastBuildParameter = lastBuildParameters.find(
|
||||
(last) => last.name === name,
|
||||
)
|
||||
if (lastBuildParameter) {
|
||||
switch (templateParameter.validation_monotonic) {
|
||||
case "increasing":
|
||||
if (Number(lastBuildParameter.value) > Number(val)) {
|
||||
return ctx.createError({
|
||||
path: ctx.path,
|
||||
message: t("validationNumberNotIncreasing", {
|
||||
last: lastBuildParameter.value,
|
||||
}),
|
||||
})
|
||||
}
|
||||
break
|
||||
case "decreasing":
|
||||
if (Number(lastBuildParameter.value) < Number(val)) {
|
||||
return ctx.createError({
|
||||
path: ctx.path,
|
||||
message: t("validationNumberNotDecreasing", {
|
||||
last: lastBuildParameter.value,
|
||||
}),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
case "string":
|
||||
{
|
||||
if (
|
||||
!templateParameter.validation_regex ||
|
||||
templateParameter.validation_regex.length === 0
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
const regex = new RegExp(templateParameter.validation_regex)
|
||||
if (val && !regex.test(val)) {
|
||||
return ctx.createError({
|
||||
path: ctx.path,
|
||||
message: t("validationPatternNotMatched", {
|
||||
error: templateParameter.validation_error,
|
||||
pattern: templateParameter.validation_regex,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return true
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.required()
|
||||
}
|
||||
|
||||
export const workspaceBuildParameterValue = (
|
||||
workspaceBuildParameters: WorkspaceBuildParameter[],
|
||||
parameter: TemplateVersionParameter,
|
||||
): string => {
|
||||
const buildParameter = workspaceBuildParameters.find((buildParameter) => {
|
||||
return buildParameter.name === parameter.name
|
||||
})
|
||||
return (buildParameter && buildParameter.value) || ""
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import dayjs from "dayjs"
|
||||
import duration from "dayjs/plugin/duration"
|
||||
import { emptySchedule } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule"
|
||||
import { emptyTTL } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl"
|
||||
import { Workspace } from "../api/typesGenerated"
|
||||
import * as Mocks from "../testHelpers/entities"
|
||||
import {
|
||||
deadlineExtensionMax,
|
||||
deadlineExtensionMin,
|
||||
extractTimezone,
|
||||
getMaxDeadline,
|
||||
getMaxDeadlineChange,
|
||||
getMinDeadline,
|
||||
stripTimezone,
|
||||
scheduleChanged,
|
||||
} from "./schedule"
|
||||
|
||||
dayjs.extend(duration)
|
||||
const now = dayjs()
|
||||
const startTime = dayjs(Mocks.MockWorkspaceBuild.updated_at)
|
||||
|
||||
describe("util/schedule", () => {
|
||||
describe("stripTimezone", () => {
|
||||
it.each<[string, string]>([
|
||||
["CRON_TZ=Canada/Eastern 30 9 1-5", "30 9 1-5"],
|
||||
["CRON_TZ=America/Central 0 8 1,2,4,5", "0 8 1,2,4,5"],
|
||||
["30 9 1-5", "30 9 1-5"],
|
||||
])(`stripTimezone(%p) returns %p`, (input, expected) => {
|
||||
expect(stripTimezone(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractTimezone", () => {
|
||||
it.each<[string, string]>([
|
||||
["CRON_TZ=Canada/Eastern 30 9 1-5", "Canada/Eastern"],
|
||||
["CRON_TZ=America/Central 0 8 1,2,4,5", "America/Central"],
|
||||
["30 9 1-5", "UTC"],
|
||||
])(`extractTimezone(%p) returns %p`, (input, expected) => {
|
||||
expect(extractTimezone(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("maxDeadline", () => {
|
||||
const workspace: Workspace = {
|
||||
...Mocks.MockWorkspace,
|
||||
latest_build: {
|
||||
...Mocks.MockWorkspaceBuild,
|
||||
deadline: startTime.add(8, "hours").utc().format(),
|
||||
},
|
||||
}
|
||||
it("should be 24 hours from the workspace start time", () => {
|
||||
const delta = getMaxDeadline(workspace).diff(startTime)
|
||||
expect(delta).toEqual(deadlineExtensionMax.asMilliseconds())
|
||||
})
|
||||
})
|
||||
|
||||
describe("minDeadline", () => {
|
||||
it("should never be less than 30 minutes", () => {
|
||||
const delta = getMinDeadline().diff(now)
|
||||
expect(delta).toBeGreaterThanOrEqual(deadlineExtensionMin.asMilliseconds())
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMaxDeadlineChange", () => {
|
||||
it("should return the number of hours you can add before hitting the max deadline", () => {
|
||||
const deadline = dayjs()
|
||||
const maxDeadline = dayjs().add(1, "hour").add(40, "minutes")
|
||||
// you can only add one hour even though the max is 1:40 away
|
||||
expect(getMaxDeadlineChange(deadline, maxDeadline)).toEqual(1)
|
||||
})
|
||||
|
||||
it("should return the number of hours you can subtract before hitting the min deadline", () => {
|
||||
const deadline = dayjs().add(2, "hours").add(40, "minutes")
|
||||
const minDeadline = dayjs()
|
||||
// you can only subtract 2 hours even though the min is 2:40 less
|
||||
expect(getMaxDeadlineChange(deadline, minDeadline)).toEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("scheduleChanged", () => {
|
||||
describe("autostart", () => {
|
||||
it("should be true if toggle values are different", () => {
|
||||
const autostart = { autostartEnabled: true, ...emptySchedule }
|
||||
const formValues = {
|
||||
autostartEnabled: false,
|
||||
...emptySchedule,
|
||||
autostopEnabled: false,
|
||||
ttl: emptyTTL,
|
||||
}
|
||||
expect(scheduleChanged(autostart, formValues)).toBe(true)
|
||||
})
|
||||
it("should be true if schedule values are different", () => {
|
||||
const autostart = { autostartEnabled: true, ...emptySchedule }
|
||||
const formValues = {
|
||||
autostartEnabled: true,
|
||||
...{ ...emptySchedule, monday: true, startTime: "09:00" },
|
||||
autostopEnabled: false,
|
||||
ttl: emptyTTL,
|
||||
}
|
||||
expect(scheduleChanged(autostart, formValues)).toBe(true)
|
||||
})
|
||||
it("should be false if all autostart values are the same", () => {
|
||||
const autostart = { autostartEnabled: true, ...emptySchedule }
|
||||
const formValues = {
|
||||
autostartEnabled: true,
|
||||
...emptySchedule,
|
||||
autostopEnabled: false,
|
||||
ttl: emptyTTL,
|
||||
}
|
||||
expect(scheduleChanged(autostart, formValues)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("autostop", () => {
|
||||
it("should be true if toggle values are different", () => {
|
||||
const autostop = { autostopEnabled: true, ttl: 1000 }
|
||||
const formValues = {
|
||||
autostartEnabled: false,
|
||||
...emptySchedule,
|
||||
autostopEnabled: false,
|
||||
ttl: 1000,
|
||||
}
|
||||
expect(scheduleChanged(autostop, formValues)).toBe(true)
|
||||
})
|
||||
it("should be true if ttl values are different", () => {
|
||||
const autostop = { autostopEnabled: true, ttl: 1000 }
|
||||
const formValues = {
|
||||
autostartEnabled: false,
|
||||
...emptySchedule,
|
||||
autostopEnabled: true,
|
||||
ttl: 2000,
|
||||
}
|
||||
expect(scheduleChanged(autostop, formValues)).toBe(true)
|
||||
})
|
||||
it("should be false if all autostop values are the same", () => {
|
||||
const autostop = { autostopEnabled: true, ttl: 1000 }
|
||||
const formValues = {
|
||||
autostartEnabled: false,
|
||||
...emptySchedule,
|
||||
autostopEnabled: true,
|
||||
ttl: 1000,
|
||||
}
|
||||
expect(scheduleChanged(autostop, formValues)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import map from "lodash/map"
|
||||
import some from "lodash/some"
|
||||
import cronstrue from "cronstrue"
|
||||
import dayjs, { Dayjs } from "dayjs"
|
||||
import advancedFormat from "dayjs/plugin/advancedFormat"
|
||||
import duration from "dayjs/plugin/duration"
|
||||
import relativeTime from "dayjs/plugin/relativeTime"
|
||||
import timezone from "dayjs/plugin/timezone"
|
||||
import utc from "dayjs/plugin/utc"
|
||||
import { Workspace } from "../api/typesGenerated"
|
||||
import { isWorkspaceOn } from "./workspace"
|
||||
import { WorkspaceScheduleFormValues } from "components/WorkspaceScheduleForm/WorkspaceScheduleForm"
|
||||
import { Autostop } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl"
|
||||
import { Autostart } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule"
|
||||
|
||||
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
|
||||
// sorted alphabetically.
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(advancedFormat)
|
||||
dayjs.extend(duration)
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.extend(timezone)
|
||||
|
||||
/**
|
||||
* @fileoverview Client-side counterpart of the coderd/autostart/schedule Go
|
||||
* package. This package is a variation on crontab that uses minute, hour and
|
||||
* day of week.
|
||||
*/
|
||||
|
||||
/**
|
||||
* DEFAULT_TIMEZONE is the default timezone that crontab assumes unless one is
|
||||
* specified.
|
||||
*/
|
||||
const DEFAULT_TIMEZONE = "UTC"
|
||||
|
||||
/**
|
||||
* stripTimezone strips a leading timezone from a schedule string
|
||||
*/
|
||||
export const stripTimezone = (raw: string): string => {
|
||||
return raw.replace(/CRON_TZ=\S*\s/, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* extractTimezone returns a leading timezone from a schedule string if one is
|
||||
* specified; otherwise the specified defaultTZ
|
||||
*/
|
||||
export const extractTimezone = (
|
||||
raw: string,
|
||||
defaultTZ = DEFAULT_TIMEZONE,
|
||||
): string => {
|
||||
const matches = raw.match(/CRON_TZ=\S*\s/g)
|
||||
|
||||
if (matches && matches.length > 0) {
|
||||
return matches[0].replace(/CRON_TZ=/, "").trim()
|
||||
} else {
|
||||
return defaultTZ
|
||||
}
|
||||
}
|
||||
|
||||
/** Language used in the schedule components */
|
||||
export const Language = {
|
||||
manual: "Manual",
|
||||
workspaceShuttingDownLabel: "Workspace is shutting down",
|
||||
afterStart: "after start",
|
||||
autostartLabel: "Starts at",
|
||||
autostopLabel: "Stops at",
|
||||
}
|
||||
|
||||
export const autostartDisplay = (schedule: string | undefined): string => {
|
||||
if (schedule) {
|
||||
return (
|
||||
cronstrue
|
||||
.toString(stripTimezone(schedule), {
|
||||
throwExceptionOnParseError: false,
|
||||
})
|
||||
// We don't want to keep the At because it is on the label
|
||||
.replace("At", "")
|
||||
)
|
||||
} else {
|
||||
return Language.manual
|
||||
}
|
||||
}
|
||||
|
||||
export const isShuttingDown = (
|
||||
workspace: Workspace,
|
||||
deadline?: Dayjs,
|
||||
): boolean => {
|
||||
if (!deadline) {
|
||||
if (!workspace.latest_build.deadline) {
|
||||
return false
|
||||
}
|
||||
deadline = dayjs(workspace.latest_build.deadline).utc()
|
||||
}
|
||||
const now = dayjs().utc()
|
||||
return isWorkspaceOn(workspace) && now.isAfter(deadline)
|
||||
}
|
||||
|
||||
export const autostopDisplay = (workspace: Workspace): string => {
|
||||
const ttl = workspace.ttl_ms
|
||||
|
||||
if (isWorkspaceOn(workspace) && workspace.latest_build.deadline) {
|
||||
// Workspace is on --> derive from latest_build.deadline. Note that the
|
||||
// user may modify their workspace object (ttl) while the workspace is
|
||||
// running and depending on system semantics, the deadline may still
|
||||
// represent the previously defined ttl. Thus, we always derive from the
|
||||
// deadline as the source of truth.
|
||||
|
||||
const deadline = dayjs(workspace.latest_build.deadline).utc()
|
||||
if (isShuttingDown(workspace, deadline)) {
|
||||
return Language.workspaceShuttingDownLabel
|
||||
} else {
|
||||
return deadline.tz(dayjs.tz.guess()).format("MMM D, YYYY h:mm A")
|
||||
}
|
||||
} else if (!ttl || ttl < 1) {
|
||||
// If the workspace is not on, and the ttl is 0 or undefined, then the
|
||||
// workspace is set to manually shutdown.
|
||||
return Language.manual
|
||||
} else {
|
||||
// The workspace has a ttl set, but is either in an unknown state or is
|
||||
// not running. Therefore, we derive from workspace.ttl.
|
||||
const duration = dayjs.duration(ttl, "milliseconds")
|
||||
return `${duration.humanize()} ${Language.afterStart}`
|
||||
}
|
||||
}
|
||||
|
||||
export const deadlineExtensionMin = dayjs.duration(30, "minutes")
|
||||
export const deadlineExtensionMax = dayjs.duration(24, "hours")
|
||||
|
||||
/**
|
||||
* Depends on the time the workspace was last updated and a global constant.
|
||||
* @param ws workspace
|
||||
* @returns the latest datetime at which the workspace can be automatically shut down.
|
||||
*/
|
||||
export function getMaxDeadline(ws: Workspace | undefined): dayjs.Dayjs {
|
||||
// note: we count runtime from updated_at as started_at counts from the start of
|
||||
// the workspace build process, which can take a while.
|
||||
if (ws === undefined) {
|
||||
throw Error("Cannot calculate max deadline because workspace is undefined")
|
||||
}
|
||||
const startedAt = dayjs(ws.latest_build.updated_at)
|
||||
return startedAt.add(deadlineExtensionMax)
|
||||
}
|
||||
|
||||
/**
|
||||
* Depends on the current time and a global constant.
|
||||
* @returns the earliest datetime at which the workspace can be automatically shut down.
|
||||
*/
|
||||
export function getMinDeadline(): dayjs.Dayjs {
|
||||
return dayjs().add(deadlineExtensionMin)
|
||||
}
|
||||
|
||||
export const getDeadline = (workspace: Workspace): dayjs.Dayjs =>
|
||||
dayjs(workspace.latest_build.deadline).utc()
|
||||
|
||||
/**
|
||||
* Get number of hours you can add or subtract to the current deadline before hitting the max or min deadline.
|
||||
* @param deadline
|
||||
* @param workspace
|
||||
* @returns number, in hours
|
||||
*/
|
||||
export const getMaxDeadlineChange = (
|
||||
deadline: dayjs.Dayjs,
|
||||
extremeDeadline: dayjs.Dayjs,
|
||||
): number => Math.abs(deadline.diff(extremeDeadline, "hours"))
|
||||
|
||||
export const scheduleChanged = (
|
||||
initialValues: Autostart | Autostop,
|
||||
formValues: WorkspaceScheduleFormValues,
|
||||
): boolean =>
|
||||
some(
|
||||
map(
|
||||
{ ...initialValues },
|
||||
(v: boolean | string, k: keyof typeof initialValues) =>
|
||||
formValues[k] !== v,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
import { TemplateExample } from "api/typesGenerated"
|
||||
|
||||
export type StarterTemplatesByTag = Record<string, TemplateExample[]>
|
||||
|
||||
export const getTemplatesByTag = (
|
||||
templates: TemplateExample[],
|
||||
): StarterTemplatesByTag => {
|
||||
const tags: StarterTemplatesByTag = {
|
||||
all: templates,
|
||||
}
|
||||
|
||||
templates.forEach((template) => {
|
||||
template.tags.forEach((tag) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- this can be undefined
|
||||
if (tags[tag]) {
|
||||
tags[tag].push(template)
|
||||
} else {
|
||||
tags[tag] = [template]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return tags
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { TarReader, TarWriter, ITarFileInfo, TarFileType } from "./tar"
|
||||
|
||||
const mtime = 1666666666666
|
||||
|
||||
test("tar", async () => {
|
||||
// Write
|
||||
const writer = new TarWriter()
|
||||
writer.addFile("a.txt", "hello", { mtime })
|
||||
writer.addFile("b.txt", new Blob(["world"]), { mtime })
|
||||
writer.addFile("c.txt", "", { mtime })
|
||||
writer.addFolder("etc", { mtime })
|
||||
writer.addFile("etc/d.txt", "Some text content", {
|
||||
mtime,
|
||||
user: "coder",
|
||||
group: "codergroup",
|
||||
mode: parseInt("777", 8),
|
||||
})
|
||||
const blob = await writer.write()
|
||||
|
||||
// Read
|
||||
const reader = new TarReader()
|
||||
const fileInfos = await reader.readFile(blob)
|
||||
verifyFile(fileInfos[0], reader.getTextFile(fileInfos[0].name) as string, {
|
||||
name: "a.txt",
|
||||
content: "hello",
|
||||
})
|
||||
verifyFile(fileInfos[1], reader.getTextFile(fileInfos[1].name) as string, {
|
||||
name: "b.txt",
|
||||
content: "world",
|
||||
})
|
||||
verifyFile(fileInfos[2], reader.getTextFile(fileInfos[2].name) as string, {
|
||||
name: "c.txt",
|
||||
content: "",
|
||||
})
|
||||
verifyFolder(fileInfos[3], {
|
||||
name: "etc",
|
||||
})
|
||||
verifyFile(fileInfos[4], reader.getTextFile(fileInfos[4].name) as string, {
|
||||
name: "etc/d.txt",
|
||||
content: "Some text content",
|
||||
})
|
||||
expect(fileInfos[4].group).toEqual("codergroup")
|
||||
expect(fileInfos[4].user).toEqual("coder")
|
||||
expect(fileInfos[4].mode).toEqual(parseInt("777", 8))
|
||||
})
|
||||
|
||||
function verifyFile(
|
||||
info: ITarFileInfo,
|
||||
content: string,
|
||||
expected: { name: string; content: string },
|
||||
) {
|
||||
expect(info.name).toEqual(expected.name)
|
||||
expect(info.size).toEqual(expected.content.length)
|
||||
expect(content).toEqual(expected.content)
|
||||
}
|
||||
|
||||
function verifyFolder(info: ITarFileInfo, expected: { name: string }) {
|
||||
expect(info.name).toEqual(expected.name)
|
||||
expect(info.type).toEqual(TarFileType.Dir)
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// Based on https://github.com/gera2ld/tarjs
|
||||
// and https://github.com/ankitrohatgi/tarballjs/blob/master/tarball.js
|
||||
export enum TarFileType {
|
||||
File = "0",
|
||||
Dir = "5",
|
||||
}
|
||||
const encoder = new TextEncoder()
|
||||
const utf8Encode = (input: string) => encoder.encode(input)
|
||||
const decoder = new TextDecoder()
|
||||
const utf8Decode = (input: Uint8Array) => decoder.decode(input)
|
||||
|
||||
export interface ITarFileInfo {
|
||||
name: string
|
||||
type: TarFileType
|
||||
size: number
|
||||
mode: number
|
||||
mtime: number
|
||||
user: string
|
||||
group: string
|
||||
headerOffset: number
|
||||
}
|
||||
|
||||
export interface ITarWriteItem {
|
||||
name: string
|
||||
type: TarFileType
|
||||
data: ArrayBuffer | Promise<ArrayBuffer> | null
|
||||
size: number
|
||||
opts?: Partial<ITarWriteOptions>
|
||||
}
|
||||
|
||||
export interface ITarWriteOptions {
|
||||
uid: number
|
||||
gid: number
|
||||
mode: number
|
||||
mtime: number
|
||||
user: string
|
||||
group: string
|
||||
}
|
||||
|
||||
export class TarReader {
|
||||
public fileInfo: ITarFileInfo[] = []
|
||||
private _buffer: ArrayBuffer | null = null
|
||||
|
||||
constructor() {
|
||||
this.reset()
|
||||
}
|
||||
|
||||
get buffer() {
|
||||
if (!this._buffer) {
|
||||
throw new Error("Buffer is not set")
|
||||
}
|
||||
|
||||
return this._buffer
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.fileInfo = []
|
||||
this._buffer = null
|
||||
}
|
||||
|
||||
async readFile(file: ArrayBuffer | Uint8Array | Blob) {
|
||||
this.reset()
|
||||
this._buffer = await getArrayBuffer(file)
|
||||
this.readFileInfo()
|
||||
return this.fileInfo
|
||||
}
|
||||
|
||||
private readFileInfo() {
|
||||
this.fileInfo = []
|
||||
let offset = 0
|
||||
|
||||
while (offset < this.buffer.byteLength - 512) {
|
||||
const fileName = this.readFileName(offset)
|
||||
if (!fileName) {
|
||||
break
|
||||
}
|
||||
const fileType = this.readFileType(offset)
|
||||
const fileSize = this.readFileSize(offset)
|
||||
const fileMode = this.readFileMode(offset)
|
||||
const fileMtime = this.readFileMtime(offset)
|
||||
const fileUser = this.readFileUser(offset)
|
||||
const fileGroup = this.readFileGroup(offset)
|
||||
|
||||
this.fileInfo.push({
|
||||
name: fileName,
|
||||
type: fileType,
|
||||
size: fileSize,
|
||||
headerOffset: offset,
|
||||
mode: fileMode,
|
||||
mtime: fileMtime,
|
||||
user: fileUser,
|
||||
group: fileGroup,
|
||||
})
|
||||
|
||||
offset += 512 + 512 * Math.floor((fileSize + 511) / 512)
|
||||
}
|
||||
}
|
||||
|
||||
private readString(offset: number, maxSize: number) {
|
||||
let size = 0
|
||||
let view = new Uint8Array(this.buffer, offset, maxSize)
|
||||
while (size < maxSize && view[size]) {
|
||||
size += 1
|
||||
}
|
||||
view = new Uint8Array(this.buffer, offset, size)
|
||||
return utf8Decode(view)
|
||||
}
|
||||
|
||||
private readFileName(offset: number) {
|
||||
return this.readString(offset, 100)
|
||||
}
|
||||
|
||||
private readFileMode(offset: number) {
|
||||
const mode = this.readString(offset + 100, 8)
|
||||
return parseInt(mode, 8)
|
||||
}
|
||||
|
||||
private readFileMtime(offset: number) {
|
||||
const mtime = this.readString(offset + 136, 12)
|
||||
return parseInt(mtime, 8)
|
||||
}
|
||||
|
||||
private readFileUser(offset: number) {
|
||||
return this.readString(offset + 265, 32)
|
||||
}
|
||||
|
||||
private readFileGroup(offset: number) {
|
||||
return this.readString(offset + 297, 32)
|
||||
}
|
||||
|
||||
private readFileType(offset: number) {
|
||||
const typeView = new Uint8Array(this.buffer, offset + 156, 1)
|
||||
const typeStr = String.fromCharCode(typeView[0])
|
||||
if (typeStr === "0") {
|
||||
return TarFileType.File
|
||||
} else if (typeStr === "5") {
|
||||
return TarFileType.Dir
|
||||
} else {
|
||||
throw new Error("No supported file type")
|
||||
}
|
||||
}
|
||||
|
||||
private readFileSize(offset: number) {
|
||||
// offset = 124, length = 12
|
||||
const view = new Uint8Array(this.buffer, offset + 124, 12)
|
||||
const sizeStr = utf8Decode(view)
|
||||
return parseInt(sizeStr, 8)
|
||||
}
|
||||
|
||||
private readFileBlob(offset: number, size: number, mimetype: string) {
|
||||
const view = new Uint8Array(this.buffer, offset, size)
|
||||
return new Blob([view], { type: mimetype })
|
||||
}
|
||||
|
||||
private readTextFile(offset: number, size: number) {
|
||||
const view = new Uint8Array(this.buffer, offset, size)
|
||||
return utf8Decode(view)
|
||||
}
|
||||
|
||||
getTextFile(filename: string) {
|
||||
const item = this.fileInfo.find((info) => info.name === filename)
|
||||
if (item) {
|
||||
return this.readTextFile(item.headerOffset + 512, item.size)
|
||||
}
|
||||
}
|
||||
|
||||
getFileBlob(filename: string, mimetype = "") {
|
||||
const item = this.fileInfo.find((info) => info.name === filename)
|
||||
if (item) {
|
||||
return this.readFileBlob(item.headerOffset + 512, item.size, mimetype)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TarWriter {
|
||||
private fileData: ITarWriteItem[] = []
|
||||
private _buffer: ArrayBuffer | null = null
|
||||
|
||||
get buffer() {
|
||||
if (!this._buffer) {
|
||||
throw new Error("Buffer is not set")
|
||||
}
|
||||
return this._buffer
|
||||
}
|
||||
|
||||
addFile(
|
||||
name: string,
|
||||
file: string | ArrayBuffer | Uint8Array | Blob,
|
||||
opts?: Partial<ITarWriteOptions>,
|
||||
) {
|
||||
const data = getArrayBuffer(file)
|
||||
const size = (data as ArrayBuffer).byteLength ?? (file as Blob).size
|
||||
const item: ITarWriteItem = {
|
||||
name,
|
||||
type: TarFileType.File,
|
||||
data,
|
||||
size,
|
||||
opts,
|
||||
}
|
||||
this.fileData.push(item)
|
||||
}
|
||||
|
||||
addFolder(name: string, opts?: Partial<ITarWriteOptions>) {
|
||||
this.fileData.push({
|
||||
name,
|
||||
type: TarFileType.Dir,
|
||||
data: null,
|
||||
size: 0,
|
||||
opts,
|
||||
})
|
||||
}
|
||||
|
||||
private createBuffer() {
|
||||
const dataSize = this.fileData.reduce(
|
||||
(prev, item) => prev + 512 + 512 * Math.floor((item.size + 511) / 512),
|
||||
0,
|
||||
)
|
||||
const bufSize = 10240 * Math.floor((dataSize + 10240 - 1) / 10240)
|
||||
this._buffer = new ArrayBuffer(bufSize)
|
||||
}
|
||||
|
||||
async write() {
|
||||
this.createBuffer()
|
||||
const view = new Uint8Array(this.buffer)
|
||||
let offset = 0
|
||||
for (const item of this.fileData) {
|
||||
// write header
|
||||
this.writeFileName(item.name, offset)
|
||||
this.writeFileType(item.type, offset)
|
||||
this.writeFileSize(item.size, offset)
|
||||
this.fillHeader(offset, item.opts as Partial<ITarWriteOptions>, item.type)
|
||||
this.writeChecksum(offset)
|
||||
|
||||
// write data
|
||||
const data = new Uint8Array((await item.data) as ArrayBuffer)
|
||||
view.set(data, offset + 512)
|
||||
offset += 512 + 512 * Math.floor((item.size + 511) / 512)
|
||||
}
|
||||
return new Blob([this.buffer], { type: "application/x-tar" })
|
||||
}
|
||||
|
||||
private writeString(str: string, offset: number, size: number) {
|
||||
const strView = utf8Encode(str)
|
||||
const view = new Uint8Array(this.buffer, offset, size)
|
||||
for (let i = 0; i < size; i += 1) {
|
||||
view[i] = i < strView.length ? strView[i] : 0
|
||||
}
|
||||
}
|
||||
|
||||
private writeFileName(name: string, offset: number) {
|
||||
// offset: 0
|
||||
this.writeString(name, offset, 100)
|
||||
}
|
||||
|
||||
private writeFileType(type: TarFileType, offset: number) {
|
||||
// offset: 156
|
||||
const typeView = new Uint8Array(this.buffer, offset + 156, 1)
|
||||
typeView[0] = type.charCodeAt(0)
|
||||
}
|
||||
|
||||
private writeFileSize(size: number, offset: number) {
|
||||
// offset: 124
|
||||
const sizeStr = size.toString(8).padStart(11, "0")
|
||||
this.writeString(sizeStr, offset + 124, 12)
|
||||
}
|
||||
|
||||
private writeFileMode(mode: number, offset: number) {
|
||||
// offset: 100
|
||||
this.writeString(mode.toString(8).padStart(7, "0"), offset + 100, 8)
|
||||
}
|
||||
|
||||
private writeFileUid(uid: number, offset: number) {
|
||||
// offset: 108
|
||||
this.writeString(uid.toString(8).padStart(7, "0"), offset + 108, 8)
|
||||
}
|
||||
|
||||
private writeFileGid(gid: number, offset: number) {
|
||||
// offset: 116
|
||||
this.writeString(gid.toString(8).padStart(7, "0"), offset + 116, 8)
|
||||
}
|
||||
|
||||
private writeFileMtime(mtime: number, offset: number) {
|
||||
// offset: 136
|
||||
this.writeString(mtime.toString(8).padStart(11, "0"), offset + 136, 12)
|
||||
}
|
||||
|
||||
private writeFileUser(user: string, offset: number) {
|
||||
// offset: 265
|
||||
this.writeString(user, offset + 265, 32)
|
||||
}
|
||||
|
||||
private writeFileGroup(group: string, offset: number) {
|
||||
// offset: 297
|
||||
this.writeString(group, offset + 297, 32)
|
||||
}
|
||||
|
||||
private writeChecksum(offset: number) {
|
||||
// offset: 148
|
||||
this.writeString(" ", offset + 148, 8) // first fill with spaces
|
||||
|
||||
// add up header bytes
|
||||
const header = new Uint8Array(this.buffer, offset, 512)
|
||||
let chksum = 0
|
||||
for (let i = 0; i < 512; i += 1) {
|
||||
chksum += header[i]
|
||||
}
|
||||
this.writeString(chksum.toString(8), offset + 148, 8)
|
||||
}
|
||||
|
||||
private fillHeader(
|
||||
offset: number,
|
||||
opts: Partial<ITarWriteOptions>,
|
||||
fileType: TarFileType,
|
||||
) {
|
||||
const { uid, gid, mode, mtime, user, group } = {
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
mode: fileType === TarFileType.File ? 0o664 : 0o775,
|
||||
mtime: ~~(Date.now() / 1000),
|
||||
user: "tarballjs",
|
||||
group: "tarballjs",
|
||||
...opts,
|
||||
}
|
||||
|
||||
this.writeFileMode(mode, offset)
|
||||
this.writeFileUid(uid, offset)
|
||||
this.writeFileGid(gid, offset)
|
||||
this.writeFileMtime(mtime, offset)
|
||||
|
||||
this.writeString("ustar", offset + 257, 6) // magic string
|
||||
this.writeString("00", offset + 263, 2) // magic version
|
||||
|
||||
this.writeFileUser(user, offset)
|
||||
this.writeFileGroup(group, offset)
|
||||
}
|
||||
}
|
||||
|
||||
function getArrayBuffer(file: string | ArrayBuffer | Uint8Array | Blob) {
|
||||
if (typeof file === "string") {
|
||||
return utf8Encode(file).buffer
|
||||
}
|
||||
if (file instanceof ArrayBuffer) {
|
||||
return file
|
||||
}
|
||||
if (ArrayBuffer.isView(file)) {
|
||||
return new Uint8Array(file).buffer
|
||||
}
|
||||
return file.arrayBuffer()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as API from "api/api"
|
||||
import { TemplateVersion } from "api/typesGenerated"
|
||||
import { FileTree, createFile } from "./filetree"
|
||||
import { TarReader } from "./tar"
|
||||
|
||||
/**
|
||||
* Content by filename
|
||||
*/
|
||||
export type TemplateVersionFiles = Record<string, string>
|
||||
|
||||
export const getTemplateVersionFiles = async (
|
||||
version: TemplateVersion,
|
||||
): Promise<TemplateVersionFiles> => {
|
||||
const files: TemplateVersionFiles = {}
|
||||
const tarFile = await API.getFile(version.job.file_id)
|
||||
const tarReader = new TarReader()
|
||||
await tarReader.readFile(tarFile)
|
||||
for (const file of tarReader.fileInfo) {
|
||||
if (isAllowedFile(file.name)) {
|
||||
files[file.name] = tarReader.getTextFile(file.name) as string
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
export const allowedExtensions = ["tf", "md", "Dockerfile"]
|
||||
|
||||
export const isAllowedFile = (name: string) => {
|
||||
return allowedExtensions.some((ext) => name.endsWith(ext))
|
||||
}
|
||||
|
||||
export const createTemplateVersionFileTree = async (
|
||||
tarReader: TarReader,
|
||||
): Promise<FileTree> => {
|
||||
let fileTree: FileTree = {}
|
||||
for (const file of tarReader.fileInfo) {
|
||||
if (isAllowedFile(file.name)) {
|
||||
fileTree = createFile(
|
||||
file.name,
|
||||
fileTree,
|
||||
tarReader.getTextFile(file.name) as string,
|
||||
)
|
||||
}
|
||||
}
|
||||
return fileTree
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import dayjs from "dayjs"
|
||||
import duration from "dayjs/plugin/duration"
|
||||
import relativeTime from "dayjs/plugin/relativeTime"
|
||||
|
||||
dayjs.extend(duration)
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
export const formatTemplateActiveDevelopers = (num?: number): string => {
|
||||
if (num === undefined || num < 0) {
|
||||
// Loading
|
||||
return "-"
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
export const formatTemplateBuildTime = (buildTimeMs?: number): string => {
|
||||
return buildTimeMs === undefined
|
||||
? "Unknown"
|
||||
: `${Math.round(dayjs.duration(buildTimeMs, "milliseconds").asSeconds())}s`
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import dayjs from "dayjs"
|
||||
import * as TypesGen from "../api/typesGenerated"
|
||||
import * as Mocks from "../testHelpers/entities"
|
||||
import {
|
||||
defaultWorkspaceExtension,
|
||||
getDisplayVersionStatus,
|
||||
getDisplayWorkspaceBuildInitiatedBy,
|
||||
getDisplayWorkspaceTemplateName,
|
||||
isWorkspaceOn,
|
||||
} from "./workspace"
|
||||
|
||||
describe("util > workspace", () => {
|
||||
describe("isWorkspaceOn", () => {
|
||||
it.each<
|
||||
[TypesGen.WorkspaceTransition, TypesGen.ProvisionerJobStatus, boolean]
|
||||
>([
|
||||
["delete", "canceled", false],
|
||||
["delete", "canceling", false],
|
||||
["delete", "failed", false],
|
||||
["delete", "pending", false],
|
||||
["delete", "running", false],
|
||||
["delete", "succeeded", false],
|
||||
|
||||
["stop", "canceled", false],
|
||||
["stop", "canceling", false],
|
||||
["stop", "failed", false],
|
||||
["stop", "pending", false],
|
||||
["stop", "running", false],
|
||||
["stop", "succeeded", false],
|
||||
|
||||
["start", "canceled", false],
|
||||
["start", "canceling", false],
|
||||
["start", "failed", false],
|
||||
["start", "pending", false],
|
||||
["start", "running", false],
|
||||
["start", "succeeded", true],
|
||||
])(
|
||||
`transition=%p, status=%p, isWorkspaceOn=%p`,
|
||||
(transition, status, isOn) => {
|
||||
const workspace: TypesGen.Workspace = {
|
||||
...Mocks.MockWorkspace,
|
||||
latest_build: {
|
||||
...Mocks.MockWorkspaceBuild,
|
||||
job: {
|
||||
...Mocks.MockProvisionerJob,
|
||||
status,
|
||||
},
|
||||
transition,
|
||||
},
|
||||
}
|
||||
expect(isWorkspaceOn(workspace)).toBe(isOn)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe("defaultWorkspaceExtension", () => {
|
||||
it.each<[string, TypesGen.PutExtendWorkspaceRequest]>([
|
||||
[
|
||||
"2022-06-02T14:56:34Z",
|
||||
{
|
||||
deadline: "2022-06-02T18:56:34Z",
|
||||
},
|
||||
],
|
||||
|
||||
// This case is the same as above, but in a different timezone to prove
|
||||
// that UTC conversion for deadline works as expected
|
||||
[
|
||||
"2022-06-02T10:56:20-04:00",
|
||||
{
|
||||
deadline: "2022-06-02T18:56:20Z",
|
||||
},
|
||||
],
|
||||
])(`defaultWorkspaceExtension(%p) returns %p`, (startTime, request) => {
|
||||
expect(defaultWorkspaceExtension(dayjs(startTime))).toEqual(request)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getDisplayWorkspaceBuildInitiatedBy", () => {
|
||||
it.each<[TypesGen.WorkspaceBuild, string]>([
|
||||
[Mocks.MockWorkspaceBuild, "TestUser"],
|
||||
[
|
||||
{
|
||||
...Mocks.MockWorkspaceBuild,
|
||||
reason: "autostart",
|
||||
},
|
||||
"Coder",
|
||||
],
|
||||
[
|
||||
{
|
||||
...Mocks.MockWorkspaceBuild,
|
||||
reason: "autostop",
|
||||
},
|
||||
"Coder",
|
||||
],
|
||||
])(
|
||||
`getDisplayWorkspaceBuildInitiatedBy(%p) returns %p`,
|
||||
(build, initiatedBy) => {
|
||||
expect(getDisplayWorkspaceBuildInitiatedBy(build)).toEqual(initiatedBy)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe("getDisplayVersionStatus", () => {
|
||||
it.each<[string, string, string, boolean]>([
|
||||
["", "", "Unknown", false],
|
||||
["", "v1.2.3", "Unknown", false],
|
||||
["v1.2.3", "", "v1.2.3", false],
|
||||
["v1.2.3", "v1.2.3", "v1.2.3", false],
|
||||
["v1.2.3", "v1.2.4", "v1.2.3", true],
|
||||
["v1.2.4", "v1.2.3", "v1.2.4", false],
|
||||
["foo", "bar", "foo", false],
|
||||
])(
|
||||
`getDisplayVersionStatus(theme, %p, %p) returns (%p, %p)`,
|
||||
(agentVersion, serverVersion, expectedVersion, expectedOutdated) => {
|
||||
const { displayVersion, outdated } = getDisplayVersionStatus(
|
||||
agentVersion,
|
||||
serverVersion,
|
||||
)
|
||||
expect(displayVersion).toEqual(expectedVersion)
|
||||
expect(expectedOutdated).toEqual(outdated)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe("getDisplayWorkspaceTemplateName", () => {
|
||||
it("display name is not set", async () => {
|
||||
const workspace: TypesGen.Workspace = {
|
||||
...Mocks.MockWorkspace,
|
||||
template_display_name: "",
|
||||
}
|
||||
const displayed = getDisplayWorkspaceTemplateName(workspace)
|
||||
expect(displayed).toEqual(workspace.template_name)
|
||||
})
|
||||
it("display name is set", async () => {
|
||||
const workspace: TypesGen.Workspace = {
|
||||
...Mocks.MockWorkspace,
|
||||
}
|
||||
const displayed = getDisplayWorkspaceTemplateName(workspace)
|
||||
expect(displayed).toEqual(workspace.template_display_name)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Theme } from "@material-ui/core/styles"
|
||||
import dayjs from "dayjs"
|
||||
import duration from "dayjs/plugin/duration"
|
||||
import minMax from "dayjs/plugin/minMax"
|
||||
import utc from "dayjs/plugin/utc"
|
||||
import semver from "semver"
|
||||
import { PaletteIndex } from "theme/palettes"
|
||||
import * as TypesGen from "../api/typesGenerated"
|
||||
|
||||
dayjs.extend(duration)
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(minMax)
|
||||
|
||||
const DisplayWorkspaceBuildStatusLanguage = {
|
||||
succeeded: "Succeeded",
|
||||
pending: "Pending",
|
||||
running: "Running",
|
||||
canceling: "Canceling",
|
||||
canceled: "Canceled",
|
||||
failed: "Failed",
|
||||
}
|
||||
|
||||
const DisplayAgentVersionLanguage = {
|
||||
unknown: "Unknown",
|
||||
}
|
||||
|
||||
export const getDisplayWorkspaceBuildStatus = (
|
||||
theme: Theme,
|
||||
build: TypesGen.WorkspaceBuild,
|
||||
): {
|
||||
color: string
|
||||
status: string
|
||||
type: PaletteIndex
|
||||
} => {
|
||||
switch (build.job.status) {
|
||||
case "succeeded":
|
||||
return {
|
||||
type: "success",
|
||||
color: theme.palette.success.main,
|
||||
status: DisplayWorkspaceBuildStatusLanguage.succeeded,
|
||||
}
|
||||
case "pending":
|
||||
return {
|
||||
type: "secondary",
|
||||
color: theme.palette.text.secondary,
|
||||
status: DisplayWorkspaceBuildStatusLanguage.pending,
|
||||
}
|
||||
case "running":
|
||||
return {
|
||||
type: "info",
|
||||
color: theme.palette.primary.main,
|
||||
status: DisplayWorkspaceBuildStatusLanguage.running,
|
||||
}
|
||||
case "failed":
|
||||
return {
|
||||
type: "error",
|
||||
color: theme.palette.text.secondary,
|
||||
status: DisplayWorkspaceBuildStatusLanguage.failed,
|
||||
}
|
||||
case "canceling":
|
||||
return {
|
||||
type: "warning",
|
||||
color: theme.palette.warning.light,
|
||||
status: DisplayWorkspaceBuildStatusLanguage.canceling,
|
||||
}
|
||||
case "canceled":
|
||||
return {
|
||||
type: "secondary",
|
||||
color: theme.palette.text.secondary,
|
||||
status: DisplayWorkspaceBuildStatusLanguage.canceled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getDisplayWorkspaceBuildInitiatedBy = (
|
||||
build: TypesGen.WorkspaceBuild,
|
||||
): string => {
|
||||
switch (build.reason) {
|
||||
case "initiator":
|
||||
return build.initiator_name
|
||||
case "autostart":
|
||||
case "autostop":
|
||||
return "Coder"
|
||||
}
|
||||
}
|
||||
|
||||
const getWorkspaceBuildDurationInSeconds = (
|
||||
build: TypesGen.WorkspaceBuild,
|
||||
): number | undefined => {
|
||||
const isCompleted = build.job.started_at && build.job.completed_at
|
||||
|
||||
if (!isCompleted) {
|
||||
return
|
||||
}
|
||||
|
||||
const startedAt = dayjs(build.job.started_at)
|
||||
const completedAt = dayjs(build.job.completed_at)
|
||||
return completedAt.diff(startedAt, "seconds")
|
||||
}
|
||||
|
||||
export const displayWorkspaceBuildDuration = (
|
||||
build: TypesGen.WorkspaceBuild,
|
||||
inProgressLabel = "In progress",
|
||||
): string => {
|
||||
const duration = getWorkspaceBuildDurationInSeconds(build)
|
||||
return duration ? `${duration} seconds` : inProgressLabel
|
||||
}
|
||||
|
||||
export const getDisplayVersionStatus = (
|
||||
agentVersion: string,
|
||||
serverVersion: string,
|
||||
): { displayVersion: string; outdated: boolean } => {
|
||||
if (!semver.valid(serverVersion) || !semver.valid(agentVersion)) {
|
||||
return {
|
||||
displayVersion: agentVersion || DisplayAgentVersionLanguage.unknown,
|
||||
outdated: false,
|
||||
}
|
||||
} else if (semver.lt(agentVersion, serverVersion)) {
|
||||
return {
|
||||
displayVersion: agentVersion,
|
||||
outdated: true,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
displayVersion: agentVersion,
|
||||
outdated: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const isWorkspaceOn = (workspace: TypesGen.Workspace): boolean => {
|
||||
const transition = workspace.latest_build.transition
|
||||
const status = workspace.latest_build.job.status
|
||||
return transition === "start" && status === "succeeded"
|
||||
}
|
||||
|
||||
export const defaultWorkspaceExtension = (
|
||||
__startDate?: dayjs.Dayjs,
|
||||
): TypesGen.PutExtendWorkspaceRequest => {
|
||||
const now = __startDate ? dayjs(__startDate) : dayjs()
|
||||
const fourHoursFromNow = now.add(4, "hours").utc()
|
||||
|
||||
return {
|
||||
deadline: fourHoursFromNow.format(),
|
||||
}
|
||||
}
|
||||
|
||||
// You can see the favicon designs here: https://www.figma.com/file/YIGBkXUcnRGz2ZKNmLaJQf/Coder-v2-Design?node-id=560%3A620
|
||||
|
||||
type FaviconType =
|
||||
| "favicon"
|
||||
| "favicon-success"
|
||||
| "favicon-error"
|
||||
| "favicon-warning"
|
||||
| "favicon-running"
|
||||
|
||||
export const getFaviconByStatus = (
|
||||
build: TypesGen.WorkspaceBuild,
|
||||
): FaviconType => {
|
||||
switch (build.status) {
|
||||
case undefined:
|
||||
return "favicon"
|
||||
case "running":
|
||||
return "favicon-success"
|
||||
case "starting":
|
||||
return "favicon-running"
|
||||
case "stopping":
|
||||
return "favicon-running"
|
||||
case "stopped":
|
||||
return "favicon"
|
||||
case "deleting":
|
||||
return "favicon"
|
||||
case "deleted":
|
||||
return "favicon"
|
||||
case "canceling":
|
||||
return "favicon-warning"
|
||||
case "canceled":
|
||||
return "favicon"
|
||||
case "failed":
|
||||
return "favicon-error"
|
||||
case "pending":
|
||||
return "favicon"
|
||||
}
|
||||
}
|
||||
|
||||
export const getDisplayWorkspaceTemplateName = (
|
||||
workspace: TypesGen.Workspace,
|
||||
): string => {
|
||||
return workspace.template_display_name.length > 0
|
||||
? workspace.template_display_name
|
||||
: workspace.template_name
|
||||
}
|
||||
Reference in New Issue
Block a user