feat(site): Add source code tab on template page, group buttons and add edit file option (#6681)

This commit is contained in:
Bruno Quaresma
2023-03-21 16:52:42 +00:00
committed by GitHub
parent f97c22540a
commit 29d71bb3dd
14 changed files with 483 additions and 583 deletions
+4 -1
View File
@@ -125,10 +125,12 @@ const TemplateVariablesPage = lazy(
const WorkspaceSettingsPage = lazy(
() => import("./pages/WorkspaceSettingsPage/WorkspaceSettingsPage"),
)
const CreateTokenPage = lazy(
() => import("./pages/CreateTokenPage/CreateTokenPage"),
)
const TemplateFilesPage = lazy(
() => import("./pages/TemplateFilesPage/TemplateFilesPage"),
)
export const AppRouter: FC = () => {
return (
@@ -162,6 +164,7 @@ export const AppRouter: FC = () => {
path="permissions"
element={<TemplatePermissionsPage />}
/>
<Route path="files" element={<TemplateFilesPage />} />
</Route>
<Route path="workspace" element={<CreateWorkspacePage />} />
@@ -0,0 +1,150 @@
import { makeStyles } from "@material-ui/core/styles"
import { DockerIcon } from "components/Icons/DockerIcon"
import { MarkdownIcon } from "components/Icons/MarkdownIcon"
import { TerraformIcon } from "components/Icons/TerraformIcon"
import { SyntaxHighlighter } from "components/SyntaxHighlighter/SyntaxHighlighter"
import { UseTabResult } from "hooks/useTab"
import { FC } from "react"
import { combineClasses } from "util/combineClasses"
import { TemplateVersionFiles } from "util/templateVersion"
const iconByExtension: Record<string, JSX.Element> = {
tf: <TerraformIcon />,
md: <MarkdownIcon />,
mkd: <MarkdownIcon />,
Dockerfile: <DockerIcon />,
}
const getExtension = (filename: string) => {
if (filename.includes(".")) {
const [_, extension] = filename.split(".")
return extension
}
return filename
}
const languageByExtension: Record<string, string> = {
tf: "hcl",
md: "markdown",
mkd: "markdown",
Dockerfile: "dockerfile",
}
export const TemplateFiles: FC<{
currentFiles: TemplateVersionFiles
previousFiles?: TemplateVersionFiles
tab: UseTabResult
}> = ({ currentFiles, previousFiles, tab }) => {
const styles = useStyles()
const filenames = Object.keys(currentFiles)
const selectedFilename = filenames[Number(tab.value)]
const currentFile = currentFiles[selectedFilename]
const previousFile = previousFiles && previousFiles[selectedFilename]
return (
<div className={styles.files}>
<div className={styles.tabs}>
{filenames.map((filename, index) => {
const tabValue = index.toString()
const extension = getExtension(filename)
const icon = iconByExtension[extension]
const hasDiff =
previousFiles &&
previousFiles[filename] &&
currentFiles[filename] !== previousFiles[filename]
return (
<button
className={combineClasses({
[styles.tab]: true,
[styles.tabActive]: tabValue === tab.value,
})}
onClick={() => {
tab.set(tabValue)
}}
key={filename}
>
{icon}
{filename}
{hasDiff && <div className={styles.tabDiff} />}
</button>
)
})}
</div>
<SyntaxHighlighter
value={currentFile}
compareWith={previousFile}
language={languageByExtension[getExtension(selectedFilename)]}
/>
</div>
)
}
const useStyles = makeStyles((theme) => ({
tabs: {
display: "flex",
alignItems: "baseline",
borderBottom: `1px solid ${theme.palette.divider}`,
gap: 1,
},
tab: {
background: "transparent",
border: 0,
padding: theme.spacing(0, 3),
display: "flex",
alignItems: "center",
height: theme.spacing(6),
opacity: 0.85,
cursor: "pointer",
gap: theme.spacing(0.5),
position: "relative",
"& svg": {
width: 22,
maxHeight: 16,
},
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
},
tabActive: {
opacity: 1,
background: theme.palette.action.hover,
"&:after": {
content: '""',
display: "block",
height: 1,
width: "100%",
bottom: 0,
left: 0,
backgroundColor: theme.palette.secondary.dark,
position: "absolute",
},
},
tabDiff: {
height: 6,
width: 6,
backgroundColor: theme.palette.warning.light,
borderRadius: "100%",
marginLeft: theme.spacing(0.5),
},
codeWrapper: {
background: theme.palette.background.paperLight,
},
files: {
borderRadius: theme.shape.borderRadius,
border: `1px solid ${theme.palette.divider}`,
},
prism: {
borderRadius: 0,
},
}))
@@ -1,36 +1,56 @@
import { makeStyles } from "@material-ui/core/styles"
import { useMachine } from "@xstate/react"
import { useOrganizationId } from "hooks/useOrganizationId"
import { createContext, FC, Suspense, useContext } from "react"
import { NavLink, Outlet, useNavigate, useParams } from "react-router-dom"
import { combineClasses } from "util/combineClasses"
import {
TemplateContext,
templateMachine,
} from "xServices/template/templateXService"
import { Margins } from "components/Margins/Margins"
import { Stack } from "components/Stack/Stack"
import { Permissions } from "xServices/auth/authXService"
import { Loader } from "components/Loader/Loader"
import { usePermissions } from "hooks/usePermissions"
import { TemplatePageHeader } from "./TemplatePageHeader"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import {
checkAuthorization,
getTemplateByName,
getTemplateVersion,
} from "api/api"
import { useQuery } from "@tanstack/react-query"
import { useDashboard } from "components/Dashboard/DashboardProvider"
const useTemplateName = () => {
const { template } = useParams()
const templatePermissions = (templateId: string) => ({
canUpdateTemplate: {
object: {
resource_type: "template",
resource_id: templateId,
},
action: "update",
},
})
if (!template) {
throw new Error("No template found in the URL")
const fetchTemplate = async (orgId: string, templateName: string) => {
const template = await getTemplateByName(orgId, templateName)
const [activeVersion, permissions] = await Promise.all([
getTemplateVersion(template.active_version_id),
checkAuthorization({
checks: templatePermissions(template.id),
}),
])
return {
template,
activeVersion,
permissions,
}
return template
}
type TemplateLayoutContextValue = {
context: TemplateContext
permissions?: Permissions
const useTemplateData = (orgId: string, templateName: string) => {
return useQuery({
queryKey: ["template", templateName],
queryFn: () => fetchTemplate(orgId, templateName),
})
}
type TemplateLayoutContextValue = Awaited<ReturnType<typeof fetchTemplate>>
const TemplateLayoutContext = createContext<
TemplateLayoutContextValue | undefined
>(undefined)
@@ -50,38 +70,30 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
}) => {
const navigate = useNavigate()
const styles = useStyles()
const organizationId = useOrganizationId()
const templateName = useTemplateName()
const [templateState, _] = useMachine(templateMachine, {
context: {
templateName,
organizationId,
},
})
const {
template,
permissions: templatePermissions,
getTemplateError,
} = templateState.context
const permissions = usePermissions()
const orgId = useOrganizationId()
const { template } = useParams() as { template: string }
const templateData = useTemplateData(orgId, template)
const dashboard = useDashboard()
if (getTemplateError) {
if (templateData.error) {
return (
<div className={styles.error}>
<AlertBanner severity="error" error={getTemplateError} />
<AlertBanner severity="error" error={templateData.error} />
</div>
)
}
if (!template || !templatePermissions) {
if (templateData.isLoading || !templateData.data) {
return <Loader />
}
return (
<>
<TemplatePageHeader
template={template}
permissions={templatePermissions}
template={templateData.data.template}
activeVersion={templateData.data.activeVersion}
permissions={templateData.data.permissions}
canEditFiles={dashboard.experiments.includes("template_editor")}
onDeleteTemplate={() => {
navigate("/templates")
}}
@@ -92,7 +104,7 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
<Stack direction="row" spacing={0.25}>
<NavLink
end
to={`/templates/${template.name}`}
to={`/templates/${template}`}
className={({ isActive }) =>
combineClasses([
styles.tabItem,
@@ -103,7 +115,7 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
Summary
</NavLink>
<NavLink
to={`/templates/${template.name}/permissions`}
to={`/templates/${template}/permissions`}
className={({ isActive }) =>
combineClasses([
styles.tabItem,
@@ -113,14 +125,23 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
>
Permissions
</NavLink>
<NavLink
to={`/templates/${template}/files`}
className={({ isActive }) =>
combineClasses([
styles.tabItem,
isActive ? styles.tabItemActive : undefined,
])
}
>
Source Code
</NavLink>
</Stack>
</Margins>
</div>
<Margins>
<TemplateLayoutContext.Provider
value={{ permissions, context: templateState.context }}
>
<TemplateLayoutContext.Provider value={templateData.data}>
<Suspense fallback={<Loader />}>{children}</Suspense>
</TemplateLayoutContext.Provider>
</Margins>
@@ -1,5 +1,5 @@
import { ComponentMeta, Story } from "@storybook/react"
import { MockTemplate } from "testHelpers/entities"
import { MockTemplate, MockTemplateVersion } from "testHelpers/entities"
import {
TemplatePageHeader,
TemplatePageHeaderProps,
@@ -12,6 +12,9 @@ export default {
template: {
defaultValue: MockTemplate,
},
activeVersion: {
defaultValue: MockTemplateVersion,
},
permissions: {
defaultValue: {
canUpdateTemplate: true,
@@ -1,9 +1,10 @@
import Button from "@material-ui/core/Button"
import DeleteOutlined from "@material-ui/icons/DeleteOutlined"
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
import SettingsOutlined from "@material-ui/icons/SettingsOutlined"
import CodeOutlined from "@material-ui/icons/CodeOutlined"
import { AuthorizationResponse, Template } from "api/typesGenerated"
import {
AuthorizationResponse,
Template,
TemplateVersion,
} from "api/typesGenerated"
import { Avatar } from "components/Avatar/Avatar"
import { Maybe } from "components/Conditionals/Maybe"
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"
@@ -13,44 +14,87 @@ import {
PageHeaderSubtitle,
} from "components/PageHeader/PageHeader"
import { Stack } from "components/Stack/Stack"
import { FC } from "react"
import { FC, useState } from "react"
import { Link as RouterLink } from "react-router-dom"
import { useDeleteTemplate } from "./deleteTemplate"
import { Margins } from "components/Margins/Margins"
import MoreVertOutlined from "@material-ui/icons/MoreVertOutlined"
import Menu from "@material-ui/core/Menu"
import MenuItem from "@material-ui/core/MenuItem"
const Language = {
editButton: "Edit",
variablesButton: "Variables",
settingsButton: "Settings",
createButton: "Create workspace",
deleteButton: "Delete",
editFilesButton: "Edit files",
}
const TemplateSettingsButton: FC<{ templateName: string }> = ({
templateName,
}) => (
<Button
variant="outlined"
component={RouterLink}
to={`/templates/${templateName}/settings`}
startIcon={<SettingsOutlined />}
>
{Language.settingsButton}
</Button>
)
const TemplateMenu: FC<{
templateName: string
templateVersion: string
canEditFiles: boolean
onDelete: () => void
}> = ({ templateName, templateVersion, canEditFiles, onDelete }) => {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null)
const TemplateVariablesButton: FC<{ templateName: string }> = ({
templateName,
}) => (
<Button
variant="outlined"
component={RouterLink}
to={`/templates/${templateName}/variables`}
startIcon={<CodeOutlined />}
>
{Language.variablesButton}
</Button>
)
const handleClose = () => {
setAnchorEl(null)
}
return (
<div>
<Button
variant="outlined"
aria-controls="template-options"
aria-haspopup="true"
onClick={(e) => setAnchorEl(e.currentTarget)}
>
<MoreVertOutlined />
</Button>
<Menu
id="template-options"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleClose}
>
<MenuItem
onClick={handleClose}
component={RouterLink}
to={`/templates/${templateName}/settings`}
>
{Language.settingsButton}
</MenuItem>
<MenuItem
component={RouterLink}
to={`/templates/${templateName}/variables`}
onClick={handleClose}
>
{Language.variablesButton}
</MenuItem>
{canEditFiles && (
<MenuItem
component={RouterLink}
to={`/templates/${templateName}/versions/${templateVersion}/edit`}
onClick={handleClose}
>
{Language.editFilesButton}
</MenuItem>
)}
<MenuItem
onClick={() => {
onDelete()
handleClose()
}}
>
{Language.deleteButton}
</MenuItem>
</Menu>
</div>
)
}
const CreateWorkspaceButton: FC<{
templateName: string
@@ -65,21 +109,19 @@ const CreateWorkspaceButton: FC<{
</Button>
)
const DeleteTemplateButton: FC<{ onClick: () => void }> = ({ onClick }) => (
<Button variant="outlined" startIcon={<DeleteOutlined />} onClick={onClick}>
{Language.deleteButton}
</Button>
)
export type TemplatePageHeaderProps = {
template: Template
activeVersion: TemplateVersion
permissions: AuthorizationResponse
canEditFiles: boolean
onDeleteTemplate: () => void
}
export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
template,
activeVersion,
permissions,
canEditFiles,
onDeleteTemplate,
}) => {
const hasIcon = template.icon && template.icon !== ""
@@ -90,14 +132,15 @@ export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
<PageHeader
actions={
<>
<Maybe condition={permissions.canUpdateTemplate}>
<DeleteTemplateButton
onClick={deleteTemplate.openDeleteConfirmation}
/>
<TemplateSettingsButton templateName={template.name} />
<TemplateVariablesButton templateName={template.name} />
</Maybe>
<CreateWorkspaceButton templateName={template.name} />
<Maybe condition={permissions.canUpdateTemplate}>
<TemplateMenu
templateVersion={activeVersion.name}
templateName={template.name}
onDelete={deleteTemplate.openDeleteConfirmation}
canEditFiles={canEditFiles}
/>
</Maybe>
</>
}
>
@@ -0,0 +1,76 @@
import { useQuery } from "@tanstack/react-query"
import { getPreviousTemplateVersionByName } from "api/api"
import { TemplateVersion } from "api/typesGenerated"
import { Loader } from "components/Loader/Loader"
import { TemplateFiles } from "components/TemplateFiles/TemplateFiles"
import { useTemplateLayoutContext } from "components/TemplateLayout/TemplateLayout"
import { useOrganizationId } from "hooks/useOrganizationId"
import { useTab } from "hooks/useTab"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "util/page"
import { getTemplateVersionFiles } from "util/templateVersion"
const fetchTemplateFiles = async (
organizationId: string,
templateName: string,
activeVersion: TemplateVersion,
) => {
const previousVersion = await getPreviousTemplateVersionByName(
organizationId,
templateName,
activeVersion.name,
)
const loadFilesPromises: ReturnType<typeof getTemplateVersionFiles>[] = []
loadFilesPromises.push(getTemplateVersionFiles(activeVersion))
if (previousVersion) {
loadFilesPromises.push(getTemplateVersionFiles(previousVersion))
}
const [currentFiles, previousFiles] = await Promise.all(loadFilesPromises)
return {
currentFiles,
previousFiles,
}
}
const useTemplateFiles = (
organizationId: string,
templateName: string,
activeVersion: TemplateVersion,
) =>
useQuery({
queryKey: ["templateFiles", templateName],
queryFn: () =>
fetchTemplateFiles(organizationId, templateName, activeVersion),
})
const TemplateFilesPage: FC = () => {
const { template, activeVersion } = useTemplateLayoutContext()
const orgId = useOrganizationId()
const tab = useTab("file", "0")
const { data: templateFiles } = useTemplateFiles(
orgId,
template.name,
activeVersion,
)
return (
<>
<Helmet>
<title>{pageTitle(`${template?.name} · Source Code`)}</title>
</Helmet>
{templateFiles ? (
<TemplateFiles
currentFiles={templateFiles.currentFiles}
previousFiles={templateFiles.previousFiles}
tab={tab}
/>
) : (
<Loader />
)}
</>
)
}
export default TemplateFilesPage
@@ -18,11 +18,10 @@ export const TemplatePermissionsPage: FC<
React.PropsWithChildren<unknown>
> = () => {
const organizationId = useOrganizationId()
const { context } = useTemplateLayoutContext()
const { template, permissions } = context
const { template, permissions } = useTemplateLayoutContext()
const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility()
const [state, send] = useMachine(templateACLMachine, {
context: { templateId: template?.id },
context: { templateId: template.id },
})
const { templateACL, userToBeUpdated, groupToBeUpdated } = state.context
@@ -2,22 +2,15 @@ import { useTemplateLayoutContext } from "components/TemplateLayout/TemplateLayo
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "util/page"
import { useTemplateSummaryData } from "./data"
import { TemplateSummaryPageView } from "./TemplateSummaryPageView"
import { Loader } from "components/Loader/Loader"
export const TemplateSummaryPage: FC = () => {
const { context } = useTemplateLayoutContext()
const {
template,
activeTemplateVersion,
templateResources,
templateVersions,
templateDAUs,
} = context
if (!template || !activeTemplateVersion || !templateResources) {
return <Loader />
}
const { template, activeVersion } = useTemplateLayoutContext()
const { data } = useTemplateSummaryData(
template.id,
template.active_version_id,
)
return (
<>
@@ -33,11 +26,9 @@ export const TemplateSummaryPage: FC = () => {
</title>
</Helmet>
<TemplateSummaryPageView
data={data}
template={template}
activeTemplateVersion={activeTemplateVersion}
templateResources={templateResources}
templateVersions={templateVersions}
templateDAUs={templateDAUs}
activeVersion={activeVersion}
/>
</>
)
@@ -17,47 +17,47 @@ const Template: Story<TemplateSummaryPageViewProps> = (args) => (
export const Example = Template.bind({})
Example.args = {
template: Mocks.MockTemplate,
activeTemplateVersion: Mocks.MockTemplateVersion,
templateResources: [
Mocks.MockWorkspaceResource,
Mocks.MockWorkspaceResource2,
],
templateVersions: [Mocks.MockTemplateVersion],
activeVersion: Mocks.MockTemplateVersion,
data: {
resources: [Mocks.MockWorkspaceResource, Mocks.MockWorkspaceResource2],
versions: [Mocks.MockTemplateVersion],
daus: Mocks.MockTemplateDAUResponse,
},
}
export const NoIcon = Template.bind({})
NoIcon.args = {
template: { ...Mocks.MockTemplate, icon: "" },
activeTemplateVersion: Mocks.MockTemplateVersion,
templateResources: [
Mocks.MockWorkspaceResource,
Mocks.MockWorkspaceResource2,
],
templateVersions: [Mocks.MockTemplateVersion],
activeVersion: Mocks.MockTemplateVersion,
data: {
resources: [Mocks.MockWorkspaceResource, Mocks.MockWorkspaceResource2],
versions: [Mocks.MockTemplateVersion],
daus: Mocks.MockTemplateDAUResponse,
},
}
export const SmallViewport = Template.bind({})
SmallViewport.args = {
template: Mocks.MockTemplate,
activeTemplateVersion: {
activeVersion: {
...Mocks.MockTemplateVersion,
readme: `---
name:Template test
---
## Instructions
You can add instructions here
name:Template test
---
## Instructions
You can add instructions here
[Some link info](https://coder.com)
\`\`\`
# This is a really long sentence to test that the code block wraps into a new line properly.
\`\`\`
`,
[Some link info](https://coder.com)
\`\`\`
# This is a really long sentence to test that the code block wraps into a new line properly.
\`\`\`
`,
},
data: {
resources: [Mocks.MockWorkspaceResource, Mocks.MockWorkspaceResource2],
versions: [Mocks.MockTemplateVersion],
daus: Mocks.MockTemplateDAUResponse,
},
templateResources: [
Mocks.MockWorkspaceResource,
Mocks.MockWorkspaceResource2,
],
templateVersions: [Mocks.MockTemplateVersion],
}
SmallViewport.parameters = {
chromatic: { viewports: [600] },
@@ -1,10 +1,10 @@
import { makeStyles } from "@material-ui/core/styles"
import {
Template,
TemplateDAUsResponse,
TemplateVersion,
WorkspaceResource,
} from "api/typesGenerated"
import { Loader } from "components/Loader/Loader"
import { MemoizedMarkdown } from "components/Markdown/Markdown"
import { Stack } from "components/Stack/Stack"
import { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable"
@@ -13,26 +13,27 @@ import { VersionsTable } from "components/VersionsTable/VersionsTable"
import frontMatter from "front-matter"
import { FC } from "react"
import { DAUChart } from "../../../components/DAUChart/DAUChart"
import { TemplateSummaryData } from "./data"
export interface TemplateSummaryPageViewProps {
data?: TemplateSummaryData
template: Template
activeTemplateVersion: TemplateVersion
templateResources: WorkspaceResource[]
templateVersions?: TemplateVersion[]
templateDAUs?: TemplateDAUsResponse
activeVersion: TemplateVersion
}
export const TemplateSummaryPageView: FC<
React.PropsWithChildren<TemplateSummaryPageViewProps>
> = ({
export const TemplateSummaryPageView: FC<TemplateSummaryPageViewProps> = ({
data,
template,
activeTemplateVersion,
templateResources,
templateVersions,
templateDAUs,
activeVersion,
}) => {
const styles = useStyles()
const readme = frontMatter(activeTemplateVersion.readme)
if (!data) {
return <Loader />
}
const { daus, resources, versions } = data
const readme = frontMatter(activeVersion.readme)
const getStartedResources = (resources: WorkspaceResource[]) => {
return resources.filter(
@@ -42,14 +43,9 @@ export const TemplateSummaryPageView: FC<
return (
<Stack spacing={4}>
<TemplateStats
template={template}
activeVersion={activeTemplateVersion}
/>
{templateDAUs && <DAUChart daus={templateDAUs} />}
<TemplateResourcesTable
resources={getStartedResources(templateResources)}
/>
<TemplateStats template={template} activeVersion={activeVersion} />
{daus && <DAUChart daus={daus} />}
<TemplateResourcesTable resources={getStartedResources(resources)} />
<div className={styles.markdownSection} id="readme">
<div className={styles.readmeLabel}>README.md</div>
@@ -58,7 +54,7 @@ export const TemplateSummaryPageView: FC<
</div>
</div>
<VersionsTable versions={templateVersions} />
<VersionsTable versions={versions} />
</Stack>
)
}
@@ -0,0 +1,37 @@
import { useQuery } from "@tanstack/react-query"
import {
getTemplateVersionResources,
getTemplateVersions,
getTemplateDAUs,
} from "api/api"
const fetchTemplateSummary = async (
templateId: string,
activeVersionId: string,
) => {
const [resources, versions, daus] = await Promise.all([
getTemplateVersionResources(activeVersionId),
getTemplateVersions(templateId),
getTemplateDAUs(templateId),
])
return {
resources,
versions,
daus,
}
}
export const useTemplateSummaryData = (
templateId: string,
activeVersionId: string,
) => {
return useQuery({
queryKey: ["template", templateId, "summary"],
queryFn: () => fetchTemplateSummary(templateId, activeVersionId),
})
}
export type TemplateSummaryData = Awaited<
ReturnType<typeof fetchTemplateSummary>
>
@@ -1,11 +1,7 @@
import Button from "@material-ui/core/Button"
import Link from "@material-ui/core/Link"
import { makeStyles } from "@material-ui/core/styles"
import EditIcon from "@material-ui/icons/Edit"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { DockerIcon } from "components/Icons/DockerIcon"
import { MarkdownIcon } from "components/Icons/MarkdownIcon"
import { TerraformIcon } from "components/Icons/TerraformIcon"
import { Loader } from "components/Loader/Loader"
import { Margins } from "components/Margins/Margins"
import {
@@ -15,89 +11,14 @@ import {
} from "components/PageHeader/PageHeader"
import { Stack } from "components/Stack/Stack"
import { Stats, StatsItem } from "components/Stats/Stats"
import { SyntaxHighlighter } from "components/SyntaxHighlighter/SyntaxHighlighter"
import { TemplateFiles } from "components/TemplateFiles/TemplateFiles"
import { UseTabResult } from "hooks/useTab"
import { FC } from "react"
import { useTranslation } from "react-i18next"
import { Link as RouterLink } from "react-router-dom"
import { combineClasses } from "util/combineClasses"
import { createDayString } from "util/createDayString"
import { TemplateVersionFiles } from "util/templateVersion"
import { TemplateVersionMachineContext } from "xServices/templateVersion/templateVersionXService"
const iconByExtension: Record<string, JSX.Element> = {
tf: <TerraformIcon />,
md: <MarkdownIcon />,
mkd: <MarkdownIcon />,
Dockerfile: <DockerIcon />,
}
const getExtension = (filename: string) => {
if (filename.includes(".")) {
const [_, extension] = filename.split(".")
return extension
}
return filename
}
const languageByExtension: Record<string, string> = {
tf: "hcl",
md: "markdown",
mkd: "markdown",
Dockerfile: "dockerfile",
}
const Files: FC<{
currentFiles: TemplateVersionFiles
previousFiles?: TemplateVersionFiles
tab: UseTabResult
}> = ({ currentFiles, previousFiles, tab }) => {
const styles = useStyles()
const filenames = Object.keys(currentFiles)
const selectedFilename = filenames[Number(tab.value)]
const currentFile = currentFiles[selectedFilename]
const previousFile = previousFiles && previousFiles[selectedFilename]
return (
<div className={styles.files}>
<div className={styles.tabs}>
{filenames.map((filename, index) => {
const tabValue = index.toString()
const extension = getExtension(filename)
const icon = iconByExtension[extension]
const hasDiff =
previousFiles &&
previousFiles[filename] &&
currentFiles[filename] !== previousFiles[filename]
return (
<button
className={combineClasses({
[styles.tab]: true,
[styles.tabActive]: tabValue === tab.value,
})}
onClick={() => {
tab.set(tabValue)
}}
key={filename}
>
{icon}
{filename}
{hasDiff && <div className={styles.tabDiff} />}
</button>
)
})}
</div>
<SyntaxHighlighter
value={currentFile}
compareWith={previousFile}
language={languageByExtension[getExtension(selectedFilename)]}
/>
</div>
)
}
export interface TemplateVersionPageViewProps {
/**
* Used to display the version name before loading the version in the API
@@ -165,7 +86,7 @@ export const TemplateVersionPageView: FC<TemplateVersionPageViewProps> = ({
/>
</Stats>
<Files
<TemplateFiles
tab={tab}
currentFiles={currentFiles}
previousFiles={previousFiles}
@@ -177,76 +98,4 @@ export const TemplateVersionPageView: FC<TemplateVersionPageViewProps> = ({
)
}
const useStyles = makeStyles((theme) => ({
tabsWrapper: {
borderBottom: `1px solid ${theme.palette.divider}`,
},
tabs: {
display: "flex",
alignItems: "baseline",
borderBottom: `1px solid ${theme.palette.divider}`,
gap: 1,
},
tab: {
background: "transparent",
border: 0,
padding: theme.spacing(0, 3),
display: "flex",
alignItems: "center",
height: theme.spacing(6),
opacity: 0.85,
cursor: "pointer",
gap: theme.spacing(0.5),
position: "relative",
"& svg": {
width: 22,
maxHeight: 16,
},
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
},
tabActive: {
opacity: 1,
background: theme.palette.action.hover,
"&:after": {
content: '""',
display: "block",
height: 1,
width: "100%",
bottom: 0,
left: 0,
backgroundColor: theme.palette.secondary.dark,
position: "absolute",
},
},
tabDiff: {
height: 6,
width: 6,
backgroundColor: theme.palette.warning.light,
borderRadius: "100%",
marginLeft: theme.spacing(0.5),
},
codeWrapper: {
background: theme.palette.background.paperLight,
},
files: {
borderRadius: theme.shape.borderRadius,
border: `1px solid ${theme.palette.divider}`,
},
prism: {
borderRadius: 0,
},
}))
export default TemplateVersionPageView
+8
View File
@@ -234,5 +234,13 @@ export const getOverrides = ({
backgroundColor: colors.gray[12],
},
},
MuiMenu: {
paper: {
marginTop: 8,
borderRadius: 4,
padding: "4px 0",
minWidth: 120,
},
},
}
}
@@ -1,276 +0,0 @@
import { assign, createMachine } from "xstate"
import {
checkAuthorization,
getTemplateByName,
getTemplateDAUs,
getTemplateVersion,
getTemplateVersionResources,
getTemplateVersions,
} from "api/api"
import {
AuthorizationResponse,
Template,
TemplateDAUsResponse,
TemplateVersion,
WorkspaceResource,
} from "api/typesGenerated"
export interface TemplateContext {
organizationId: string
templateName: string
template?: Template
activeTemplateVersion?: TemplateVersion
templateResources?: WorkspaceResource[]
templateVersions?: TemplateVersion[]
templateDAUs?: TemplateDAUsResponse
permissions?: AuthorizationResponse
getTemplateError?: Error | unknown
}
const getPermissionsToCheck = (templateId: string) => ({
canUpdateTemplate: {
object: {
resource_type: "template",
resource_id: templateId,
},
action: "update",
},
})
export const templateMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOhgBdyCoAVMVABwBt1ywBiCAe0JIIDcuAazAk0WPIVIUq+WvWaswCAV0ytcPANoAGALqJQDLrFxUehkAA9EAJgDsOkgGZbO2wE5bANg8BGABZnHR0AgBoQAE9EPx0-Eg9EpIAOe28-D28ggF9siPEcAmI+fDNcdCYASXwAMy4SLCp+MDpGFjYANTAAJ1MeMjBKagBBTCaWhXawLt7NfE4eUVURMQxCqRKyiuq6hrHcZtbFTp6+-AGhuVHxo6mZs5V8QXVzfF0DJBBjU1fLGwQAgF4t4AKzeWzOTIhEEhZIRaIIZI6EEkEIhZyg2xuEF+XL5NaSYoELZVWr1NhtJQAJTgXAArt1MHALrJ5JS2DTYPTGXAFrxlqICoTSMSqNsySQKccwJzuUzYCzqLdqbSGfLHs8NNp9JZvmULJ9-kDkiRks4QR50SDkh5nH5nPCYjpXKi0SDnObbeaAniQEKiiLSmLSbspXdTnMFTIlZMlPdI3ylk9hIKCQHNsGduTYydZjwo4NWcrc2dYBq1Fq3jrPnrfobEAFQqbQt5kiCcf4go6ECD7Ca0XFMskAmksr7-RtReUQ1xEyRYOQlKsJOmp+K6rqTPr8H9ELaTclkg5wQEzRidB5u-Y0q6QgFbAEsuCMuO0xsmFx0BBIOwACIAUQAGX-Gh-03H45ksBFrycGE7zcW1bD8e0In+Pw3G8EggT8M0-Fbc8PGSV8Vw2TAeBqXBulQahfzAJhBg4ABhAB5AA5AAxSoqQAWQAfQA4DQPA7ddwQZCMVRUF7Bw3svXsEFuz8MFMNtC8bRtHQzWHYj1mKMjako6i5Fo+i2HYRjhlYxigP4oCQLAmstzrUA0OcaSSHsZwbSUjwAl83zwiiGJGw8LCwTtU8vGQnI8j9N9im-UzqDnAUSEShjizAYTnOsRB7BHEglLwoqskCWxFJ8ewPJ0bx8tsC1IQhZwdOFNK6MGZKem6LhuhIY46iotrTImdkssciCDRcvcbVRJrwr8fLXHsRTYRIOCau8WqYRxIjfXwLhv3gT4J2KaM5Ey7LIPrAFyqChBLVvEJPGk2w21PFrVyDacsz2G4c2mCN+jOqBrgOEbpXjSavicq6prEhar1tR7IXSaSfVik7AxJH7GjBzLIfOWA6UweUjqMGGof+TzbEKsEMiRdwYUhbtkhw5HnHvXx0g8D7Jy+9d6lxw5-oJy7Kb3B07vsJDkekjwcSyWxeaJfmZ0lf7ZTVZlgcyzWeTJ6GJp3a7kOWu7YjcR6wQCEFAQfbxlaxzMJTDFUuS1hUiZJuADdrWHcoQVtMJxdtWfvaL0hWm2rfcRXHxBR2M2+l2NdVfWxeNuHbW7Xz+xCWJkm8ZE3LbRO1zV12S0jRVzpFwH8F9inM4D03u2Ux6sWvQj2wTjH4qd5PQzrvMG-nYnSYz0TQRNG3gnUyE+zNNv-EevDrUya9mr7kiVexlPRoJxujdE7O7r8gIO+dXtUkyMvVazSfrt8bt7xpgcFttC1nXsROPy-SBH5w1iO6MKwRghAkbH2BSUtHBrTRPeC8rhxKJ30hRKiNF2psEAS3ZCNMkR4R8MOWqfloEIntCvDmnoRzyUIvLRO6VWTYP+F4TCNt0g22REhV6pCYgEJIPVDENsPBpA9GkehmCAHjREtdVmmFPCKy2u6RwcJzaQicMOb0wQ3ALVxNvXSRAmExBUWQvOA5baqXlrbXIuQgA */
createMachine(
{
id: "templateMachine",
predictableActionArguments: true,
tsTypes: {} as import("./templateXService.typegen").Typegen0,
schema: {
context: {} as TemplateContext,
services: {} as {
getTemplate: {
data: Template
}
getActiveTemplateVersion: {
data: TemplateVersion
}
getTemplateResources: {
data: WorkspaceResource[]
}
getTemplateVersions: {
data: TemplateVersion[]
}
getTemplateDAUs: {
data: TemplateDAUsResponse
}
getTemplatePermissions: {
data: AuthorizationResponse
}
},
},
initial: "gettingTemplate",
states: {
gettingTemplate: {
invoke: {
src: "getTemplate",
onDone: [
{
actions: "assignTemplate",
target: "initialInfo",
},
],
onError: [
{
actions: "assignGetTemplateError",
target: "error",
},
],
},
},
initialInfo: {
type: "parallel",
states: {
activeTemplateVersion: {
initial: "gettingActiveTemplateVersion",
states: {
gettingActiveTemplateVersion: {
invoke: {
src: "getActiveTemplateVersion",
onDone: [
{
actions: "assignActiveTemplateVersion",
target: "success",
},
],
},
},
success: {
type: "final",
},
},
},
templateResources: {
initial: "gettingTemplateResources",
states: {
gettingTemplateResources: {
invoke: {
src: "getTemplateResources",
onDone: [
{
actions: "assignTemplateResources",
target: "success",
},
],
},
},
success: {
type: "final",
},
},
},
templateVersions: {
initial: "gettingTemplateVersions",
states: {
gettingTemplateVersions: {
invoke: {
src: "getTemplateVersions",
onDone: [
{
actions: "assignTemplateVersions",
target: "success",
},
],
},
},
success: {
type: "final",
},
},
},
templateDAUs: {
initial: "gettingTemplateDAUs",
states: {
gettingTemplateDAUs: {
invoke: {
src: "getTemplateDAUs",
onDone: [
{
actions: "assignTemplateDAUs",
target: "success",
},
],
},
},
success: {
type: "final",
},
},
},
templatePermissions: {
initial: "gettingTemplatePermissions",
states: {
gettingTemplatePermissions: {
invoke: {
src: "getTemplatePermissions",
onDone: {
target: "success",
actions: "assignPermissions",
},
},
},
success: {
type: "final",
},
},
},
},
onDone: {
target: "loaded",
},
},
loaded: {
initial: "waiting",
states: {
refreshingTemplate: {
invoke: {
id: "refreshTemplate",
src: "getTemplate",
onDone: { target: "waiting", actions: "assignTemplate" },
},
},
waiting: {
after: {
5000: "refreshingTemplate",
},
},
},
},
error: {
type: "final",
},
},
},
{
services: {
getTemplate: (ctx) =>
getTemplateByName(ctx.organizationId, ctx.templateName),
getActiveTemplateVersion: (ctx) => {
if (!ctx.template) {
throw new Error("Template not loaded")
}
return getTemplateVersion(ctx.template.active_version_id)
},
getTemplateResources: (ctx) => {
if (!ctx.template) {
throw new Error("Template not loaded")
}
return getTemplateVersionResources(ctx.template.active_version_id)
},
getTemplateVersions: (ctx) => {
if (!ctx.template) {
throw new Error("Template not loaded")
}
return getTemplateVersions(ctx.template.id)
},
getTemplateDAUs: (ctx) => {
if (!ctx.template) {
throw new Error("Template not loaded")
}
return getTemplateDAUs(ctx.template.id)
},
getTemplatePermissions: (ctx) => {
if (!ctx.template) {
throw new Error("Template not loaded")
}
return checkAuthorization({
checks: getPermissionsToCheck(ctx.template.id),
})
},
},
actions: {
assignTemplate: assign({
template: (_, event) => event.data,
}),
assignActiveTemplateVersion: assign({
activeTemplateVersion: (_, event) => event.data,
}),
assignGetTemplateError: assign({
getTemplateError: (_, event) => event.data,
}),
assignTemplateResources: assign({
templateResources: (_, event) => event.data,
}),
assignTemplateVersions: assign({
templateVersions: (_, event) => event.data,
}),
assignTemplateDAUs: assign({
templateDAUs: (_, event) => event.data,
}),
assignPermissions: assign({
permissions: (_, event) => event.data,
}),
},
},
)