feat: Add template version page (#5071)

This commit is contained in:
Bruno Quaresma
2022-11-15 16:24:13 +00:00
committed by GitHub
parent 773fc73280
commit f262fb4811
34 changed files with 1750 additions and 824 deletions
+4 -1
View File
@@ -340,7 +340,10 @@ func New(options *Options) *API {
httpmw.ExtractOrganizationParam(options.Database),
)
r.Get("/", api.organization)
r.Post("/templateversions", api.postTemplateVersionsByOrganization)
r.Route("/templateversions", func(r chi.Router) {
r.Post("/", api.postTemplateVersionsByOrganization)
r.Get("/{templateversionname}", api.templateVersionByOrganizationAndName)
})
r.Route("/templates", func(r chi.Router) {
r.Post("/", api.postTemplateByOrganization)
r.Get("/", api.templatesByOrganization)
+5 -4
View File
@@ -238,10 +238,11 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
"GET:/api/v2/applications/auth-redirect": {AssertAction: rbac.ActionCreate, AssertObject: rbac.ResourceAPIKey},
// These endpoints need payloads to get to the auth part. Payloads will be required
"PUT:/api/v2/users/{user}/roles": {StatusCode: http.StatusBadRequest, NoAuthorize: true},
"PUT:/api/v2/organizations/{organization}/members/{user}/roles": {NoAuthorize: true},
"POST:/api/v2/workspaces/{workspace}/builds": {StatusCode: http.StatusBadRequest, NoAuthorize: true},
"POST:/api/v2/organizations/{organization}/templateversions": {StatusCode: http.StatusBadRequest, NoAuthorize: true},
"PUT:/api/v2/users/{user}/roles": {StatusCode: http.StatusBadRequest, NoAuthorize: true},
"PUT:/api/v2/organizations/{organization}/members/{user}/roles": {NoAuthorize: true},
"POST:/api/v2/workspaces/{workspace}/builds": {StatusCode: http.StatusBadRequest, NoAuthorize: true},
"POST:/api/v2/organizations/{organization}/templateversions": {StatusCode: http.StatusBadRequest, NoAuthorize: true},
"GET:/api/v2/organizations/{organization}/templateversions/{templateversionname}": {StatusCode: http.StatusBadRequest, NoAuthorize: true},
// Endpoints that use the SQLQuery filter.
"GET:/api/v2/workspaces/": {StatusCode: http.StatusOK, NoAuthorize: true},
@@ -1471,6 +1471,22 @@ func (q *fakeQuerier) GetTemplateVersionByTemplateIDAndName(_ context.Context, a
return database.TemplateVersion{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetTemplateVersionByOrganizationAndName(_ context.Context, arg database.GetTemplateVersionByOrganizationAndNameParams) (database.TemplateVersion, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, templateVersion := range q.templateVersions {
if templateVersion.OrganizationID != arg.OrganizationID {
continue
}
if !strings.EqualFold(templateVersion.Name, arg.Name) {
continue
}
return templateVersion, nil
}
return database.TemplateVersion{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetTemplateVersionByID(_ context.Context, templateVersionID uuid.UUID) (database.TemplateVersion, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
+1
View File
@@ -81,6 +81,7 @@ type sqlcQuerier interface {
GetTemplateDAUs(ctx context.Context, templateID uuid.UUID) ([]GetTemplateDAUsRow, error)
GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (TemplateVersion, error)
GetTemplateVersionByJobID(ctx context.Context, jobID uuid.UUID) (TemplateVersion, error)
GetTemplateVersionByOrganizationAndName(ctx context.Context, arg GetTemplateVersionByOrganizationAndNameParams) (TemplateVersion, error)
GetTemplateVersionByTemplateIDAndName(ctx context.Context, arg GetTemplateVersionByTemplateIDAndNameParams) (TemplateVersion, error)
GetTemplateVersionsByTemplateID(ctx context.Context, arg GetTemplateVersionsByTemplateIDParams) ([]TemplateVersion, error)
GetTemplateVersionsCreatedAfter(ctx context.Context, createdAt time.Time) ([]TemplateVersion, error)
+32
View File
@@ -3550,6 +3550,38 @@ func (q *sqlQuerier) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.U
return i, err
}
const getTemplateVersionByOrganizationAndName = `-- name: GetTemplateVersionByOrganizationAndName :one
SELECT
id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by
FROM
template_versions
WHERE
organization_id = $1
AND "name" = $2
`
type GetTemplateVersionByOrganizationAndNameParams struct {
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
Name string `db:"name" json:"name"`
}
func (q *sqlQuerier) GetTemplateVersionByOrganizationAndName(ctx context.Context, arg GetTemplateVersionByOrganizationAndNameParams) (TemplateVersion, error) {
row := q.db.QueryRowContext(ctx, getTemplateVersionByOrganizationAndName, arg.OrganizationID, arg.Name)
var i TemplateVersion
err := row.Scan(
&i.ID,
&i.TemplateID,
&i.OrganizationID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Name,
&i.Readme,
&i.JobID,
&i.CreatedBy,
)
return i, err
}
const getTemplateVersionByTemplateIDAndName = `-- name: GetTemplateVersionByTemplateIDAndName :one
SELECT
id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by
@@ -52,6 +52,15 @@ WHERE
template_id = $1
AND "name" = $2;
-- name: GetTemplateVersionByOrganizationAndName :one
SELECT
*
FROM
template_versions
WHERE
organization_id = $1
AND "name" = $2;
-- name: GetTemplateVersionByID :one
SELECT
*
+42
View File
@@ -596,6 +596,48 @@ func (api *API) templateVersionByName(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(job), user))
}
func (api *API) templateVersionByOrganizationAndName(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
organization := httpmw.OrganizationParam(r)
templateVersionName := chi.URLParam(r, "templateversionname")
templateVersion, err := api.Database.GetTemplateVersionByOrganizationAndName(ctx, database.GetTemplateVersionByOrganizationAndNameParams{
OrganizationID: organization.ID,
Name: templateVersionName,
})
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: fmt.Sprintf("No template version found by name %q.", templateVersionName),
})
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching template version.",
Detail: err.Error(),
})
return
}
job, err := api.Database.GetProvisionerJobByID(ctx, templateVersion.JobID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching provisioner job.",
Detail: err.Error(),
})
return
}
user, err := api.Database.GetUserByID(ctx, templateVersion.CreatedBy)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error on fetching user.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(job), user))
}
func (api *API) patchActiveTemplateVersion(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
+33
View File
@@ -928,3 +928,36 @@ func TestPaginatedTemplateVersions(t *testing.T) {
})
}
}
func TestTemplateVersionByOrganizationAndName(t *testing.T) {
t.Parallel()
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.TemplateVersionByOrganizationAndName(ctx, user.OrganizationID, "nothing")
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
t.Run("Found", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.TemplateVersionByOrganizationAndName(ctx, user.OrganizationID, version.Name)
require.NoError(t, err)
})
}
+19
View File
@@ -138,6 +138,25 @@ func (c *Client) CreateTemplateVersion(ctx context.Context, organizationID uuid.
return templateVersion, json.NewDecoder(res.Body).Decode(&templateVersion)
}
func (c *Client) TemplateVersionByOrganizationAndName(ctx context.Context, organizationID uuid.UUID, name string) (TemplateVersion, error) {
res, err := c.Request(ctx, http.MethodGet,
fmt.Sprintf("/api/v2/organizations/%s/templateversions/%s", organizationID.String(), name),
nil,
)
if err != nil {
return TemplateVersion{}, xerrors.Errorf("execute request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return TemplateVersion{}, readBodyAsError(res)
}
var templateVersion TemplateVersion
return templateVersion, json.NewDecoder(res.Body).Decode(&templateVersion)
}
// CreateTemplate creates a new template inside an organization.
func (c *Client) CreateTemplate(ctx context.Context, organizationID uuid.UUID, request CreateTemplateRequest) (Template, error) {
res, err := c.Request(ctx, http.MethodPost,
+16
View File
@@ -0,0 +1,16 @@
declare module "js-untar" {
interface File {
name: string
readAsString: () => string
}
const Untar: (buffer: ArrayBuffer) => {
then: (
resolve?: () => Promise<void>,
reject?: () => Promise<void>,
progress: (file: File) => Promise<void>,
) => Promise<void>
}
export default Untar
}
+1
View File
@@ -50,6 +50,7 @@
"front-matter": "4.0.2",
"history": "5.3.0",
"i18next": "21.9.1",
"js-untar": "2.0.0",
"just-debounce-it": "3.1.1",
"react": "18.2.0",
"react-chartjs-2": "4.3.1",
+21 -10
View File
@@ -83,6 +83,9 @@ const NetworkSettingsPage = lazy(
() => import("./pages/DeploySettingsPage/NetworkSettingsPage"),
)
const GitAuthPage = lazy(() => import("./pages/GitAuthPage/GitAuthPage"))
const TemplateVersionPage = lazy(
() => import("./pages/TemplateVersionPage/TemplateVersionPage"),
)
export const AppRouter: FC = () => {
const xServices = useContext(XServiceContext)
@@ -123,16 +126,14 @@ export const AppRouter: FC = () => {
}
/>
<Route path="workspaces">
<Route
index
element={
<AuthAndFrame>
<WorkspacesPage />
</AuthAndFrame>
}
/>
</Route>
<Route
path="workspaces"
element={
<AuthAndFrame>
<WorkspacesPage />
</AuthAndFrame>
}
/>
<Route path="templates">
<Route
@@ -181,6 +182,16 @@ export const AppRouter: FC = () => {
</RequireAuth>
}
/>
<Route path="versions">
<Route
path=":version"
element={
<AuthAndFrame>
<TemplateVersionPage />
</AuthAndFrame>
}
/>
</Route>
</Route>
</Route>
+1
View File
@@ -0,0 +1 @@
export default jest.fn()
+17
View File
@@ -219,6 +219,16 @@ export const getTemplateVersions = async (
return response.data
}
export const getTemplateVersionByName = async (
organizationId: string,
versionName: string,
): Promise<TypesGen.TemplateVersion> => {
const response = await axios.get<TypesGen.TemplateVersion>(
`/api/v2/organizations/${organizationId}/templateversions/${versionName}`,
)
return response.data
}
export const updateTemplateMeta = async (
templateId: string,
data: TypesGen.UpdateTemplateMeta,
@@ -646,3 +656,10 @@ export const getReplicas = async (): Promise<TypesGen.Replica[]> => {
const response = await axios.get(`/api/v2/replicas`)
return response.data
}
export const getFile = async (fileId: string): Promise<ArrayBuffer> => {
const response = await axios.get<ArrayBuffer>(`/api/v2/files/${fileId}`, {
responseType: "arraybuffer",
})
return response.data
}
@@ -0,0 +1,21 @@
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon"
export const MarkdownIcon = (props: SvgIconProps): JSX.Element => (
<SvgIcon {...props} viewBox="0 0 32 32">
<rect
x="2.5"
y="7.955"
width="27"
height="16.091"
style={{ fill: "none", stroke: "#755838" }}
/>
<polygon
points="5.909 20.636 5.909 11.364 8.636 11.364 11.364 14.773 14.091 11.364 16.818 11.364 16.818 20.636 14.091 20.636 14.091 15.318 11.364 18.727 8.636 15.318 8.636 20.636 5.909 20.636"
style={{ stroke: "#755838" }}
/>
<polygon
points="22.955 20.636 18.864 16.136 21.591 16.136 21.591 11.364 24.318 11.364 24.318 16.136 27.045 16.136 22.955 20.636"
style={{ stroke: "#755838" }}
/>
</SvgIcon>
)
@@ -0,0 +1,22 @@
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon"
export const TerraformIcon = (props: SvgIconProps): JSX.Element => (
<SvgIcon {...props} viewBox="0 0 32 32">
<polygon
points="12.042 6.858 20.071 11.448 20.071 20.462 12.042 15.868 12.042 6.858 12.042 6.858"
style={{ fill: "#813cf3" }}
/>
<polygon
points="20.5 20.415 28.459 15.84 28.459 6.887 20.5 11.429 20.5 20.415 20.5 20.415"
style={{ fill: "#813cf3" }}
/>
<polygon
points="3.541 11.01 11.571 15.599 11.571 6.59 3.541 2 3.541 11.01 3.541 11.01"
style={{ fill: "#813cf3" }}
/>
<polygon
points="12.042 25.41 20.071 30 20.071 20.957 12.042 16.368 12.042 25.41 12.042 25.41"
style={{ fill: "#813cf3" }}
/>
</SvgIcon>
)
+18 -4
View File
@@ -1,4 +1,5 @@
import { makeStyles } from "@material-ui/core/styles"
import { PropsWithChildren, FC } from "react"
import { combineClasses } from "../../util/combineClasses"
import { Stack } from "../Stack/Stack"
@@ -7,7 +8,7 @@ export interface PageHeaderProps {
className?: string
}
export const PageHeader: React.FC<React.PropsWithChildren<PageHeaderProps>> = ({
export const PageHeader: FC<PropsWithChildren<PageHeaderProps>> = ({
children,
actions,
className,
@@ -29,7 +30,7 @@ export const PageHeader: React.FC<React.PropsWithChildren<PageHeaderProps>> = ({
)
}
export const PageHeaderTitle: React.FC<React.PropsWithChildren<unknown>> = ({
export const PageHeaderTitle: FC<PropsWithChildren<unknown>> = ({
children,
}) => {
const styles = useStyles({})
@@ -37,8 +38,8 @@ export const PageHeaderTitle: React.FC<React.PropsWithChildren<unknown>> = ({
return <h1 className={styles.title}>{children}</h1>
}
export const PageHeaderSubtitle: React.FC<
React.PropsWithChildren<{ condensed?: boolean }>
export const PageHeaderSubtitle: FC<
PropsWithChildren<{ condensed?: boolean }>
> = ({ children, condensed }) => {
const styles = useStyles({
condensed,
@@ -47,6 +48,11 @@ export const PageHeaderSubtitle: React.FC<
return <h2 className={styles.subtitle}>{children}</h2>
}
export const PageHeaderCaption: FC<PropsWithChildren> = ({ children }) => {
const styles = useStyles({})
return <span className={styles.caption}>{children}</span>
}
const useStyles = makeStyles((theme) => ({
root: {
display: "flex",
@@ -88,4 +94,12 @@ const useStyles = makeStyles((theme) => ({
width: "100%",
},
},
caption: {
fontSize: 12,
color: theme.palette.text.secondary,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.1em",
},
}))
@@ -0,0 +1,55 @@
import { makeStyles } from "@material-ui/core/styles"
import { ComponentProps, FC } from "react"
import { Prism } from "react-syntax-highlighter"
import { colors } from "theme/colors"
import darcula from "react-syntax-highlighter/dist/cjs/styles/prism/darcula"
import { combineClasses } from "util/combineClasses"
export const SyntaxHighlighter: FC<ComponentProps<typeof Prism>> = ({
className,
...props
}) => {
const styles = useStyles()
return (
<Prism
style={darcula}
useInlineStyles={false}
// Use inline styles does not work correctly
// https://github.com/react-syntax-highlighter/react-syntax-highlighter/issues/329
codeTagProps={{ style: {} }}
className={combineClasses([styles.prism, className])}
{...props}
/>
)
}
const useStyles = makeStyles((theme) => ({
prism: {
margin: 0,
background: theme.palette.background.paperLight,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(2, 3),
"& code": {
color: theme.palette.text.secondary,
},
"& .key, & .property, & .code-snippet, & .keyword": {
color: colors.turquoise[7],
},
"& .url": {
color: colors.blue[6],
},
"& .comment": {
color: theme.palette.text.disabled,
},
"& .title": {
color: theme.palette.text.primary,
fontWeight: 600,
},
},
}))
@@ -1,5 +1,6 @@
import { makeStyles } from "@material-ui/core/styles"
import { Stats, StatsItem } from "components/Stats/Stats"
import { FC } from "react"
import { Link } from "react-router-dom"
import { createDayString } from "util/createDayString"
import {
formatTemplateBuildTime,
@@ -26,77 +27,39 @@ export const TemplateStats: FC<TemplateStatsProps> = ({
template,
activeVersion,
}) => {
const styles = useStyles()
return (
<div className={styles.stats}>
<div className={styles.statItem}>
<span className={styles.statsLabel}>{Language.usedByLabel}:</span>
<span className={styles.statsValue}>
{formatTemplateActiveDevelopers(template.active_user_count)}{" "}
{template.active_user_count === 1
? Language.developerSingular
: Language.developerPlural}
</span>
</div>
<div className={styles.statItem}>
<span className={styles.statsLabel}>{Language.buildTimeLabel}:</span>
<span className={styles.statsValue}>
{formatTemplateBuildTime(template.build_time_stats.start_ms)}{" "}
</span>
</div>
<div className={styles.statItem}>
<span className={styles.statsLabel}>
{Language.activeVersionLabel}:
</span>
<span className={styles.statsValue}>{activeVersion.name}</span>
</div>
<div className={styles.statItem}>
<span className={styles.statsLabel}>{Language.lastUpdateLabel}:</span>
<span className={styles.statsValue} data-chromatic="ignore">
{createDayString(template.updated_at)}
</span>
</div>
<div className={styles.statItem}>
<span className={styles.statsLabel}>{Language.createdByLabel}:</span>
<span className={styles.statsValue}>{template.created_by_name}</span>
</div>
</div>
<Stats>
<StatsItem
label={Language.usedByLabel}
value={
<>
{formatTemplateActiveDevelopers(template.active_user_count)}{" "}
{template.active_user_count === 1
? Language.developerSingular
: Language.developerPlural}
</>
}
/>
<StatsItem
label={Language.buildTimeLabel}
value={formatTemplateBuildTime(template.build_time_stats.start_ms)}
/>
<StatsItem
label={Language.activeVersionLabel}
value={
<Link to={`versions/${activeVersion.name}`}>
{activeVersion.name}
</Link>
}
/>
<StatsItem
label={Language.lastUpdateLabel}
value={createDayString(template.updated_at)}
/>
<StatsItem
label={Language.createdByLabel}
value={template.created_by_name}
/>
</Stats>
)
}
const useStyles = makeStyles((theme) => ({
stats: {
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
borderRadius: theme.shape.borderRadius,
display: "flex",
alignItems: "center",
color: theme.palette.text.secondary,
border: `1px solid ${theme.palette.divider}`,
[theme.breakpoints.down("sm")]: {
display: "block",
},
},
statItem: {
padding: theme.spacing(2),
paddingTop: theme.spacing(1.75),
display: "flex",
alignItems: "baseline",
gap: theme.spacing(1),
},
statsLabel: {
display: "block",
wordWrap: "break-word",
},
statsValue: {
display: "block",
wordWrap: "break-word",
color: theme.palette.text.primary,
},
}))
@@ -4,7 +4,9 @@ import TableRow from "@material-ui/core/TableRow"
import { TemplateVersion } from "api/typesGenerated"
import { Stack } from "components/Stack/Stack"
import { UserAvatar } from "components/UserAvatar/UserAvatar"
import { useClickable } from "hooks/useClickable"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
export interface VersionRowProps {
version: TemplateVersion
@@ -13,11 +15,16 @@ export interface VersionRowProps {
export const VersionRow: React.FC<VersionRowProps> = ({ version }) => {
const styles = useStyles()
const { t } = useTranslation("templatePage")
const navigate = useNavigate()
const clickableProps = useClickable(() => {
navigate(`versions/${version.name}`)
})
return (
<TableRow
className={styles.versionRow}
data-testid={`version-${version.id}`}
{...clickableProps}
>
<TableCell className={styles.versionCell}>
<Stack
@@ -54,6 +61,12 @@ export const VersionRow: React.FC<VersionRowProps> = ({ version }) => {
const useStyles = makeStyles((theme) => ({
versionRow: {
cursor: "pointer",
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
"&:not(:last-child) td:before": {
position: "absolute",
top: 20,
@@ -132,6 +132,7 @@ const useStyles = makeStyles((theme) => ({
color: theme.palette.text.primary,
fontWeight: 600,
},
outdatedLabel: {
color: theme.palette.error.main,
display: "flex",
+19
View File
@@ -0,0 +1,19 @@
import { useSearchParams } from "react-router-dom"
export interface UseTabResult {
value: string | null
set: (value: string) => void
}
export const useTab = (tabKey: string): UseTabResult => {
const [searchParams, setSearchParams] = useSearchParams()
const value = searchParams.get(tabKey)
return {
value,
set: (value: string) => {
searchParams.set(tabKey, value)
setSearchParams(searchParams)
},
}
}
+2
View File
@@ -8,6 +8,7 @@ import agent from "./agent.json"
import buildPage from "./buildPage.json"
import workspacesPage from "./workspacesPage.json"
import usersPage from "./usersPage.json"
import templateVersionPage from "./templateVersionPage.json"
export const en = {
common,
@@ -20,4 +21,5 @@ export const en = {
buildPage,
workspacesPage,
usersPage,
templateVersionPage,
}
+11
View File
@@ -0,0 +1,11 @@
{
"title": "Version",
"header": {
"caption": "Version"
},
"stats": {
"template": "Template",
"createdBy": "Created by",
"created": "Created"
}
}
@@ -0,0 +1,53 @@
import {
renderWithAuth,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers"
import TemplateVersionPage from "./TemplateVersionPage"
import * as templateVersionUtils from "util/templateVersion"
import { screen } from "@testing-library/react"
import * as CreateDayString from "util/createDayString"
import userEvent from "@testing-library/user-event"
const TEMPLATE_NAME = "coder-ts"
const VERSION_NAME = "12345"
const TERRAFORM_FILENAME = "main.tf"
const README_FILENAME = "readme.md"
const GPG_FILENAME = "key.gpg"
const TEMPLATE_VERSION_FILES = {
[TERRAFORM_FILENAME]: "{}",
[README_FILENAME]: "Readme",
[GPG_FILENAME]: "Some sensitive info",
}
const setup = async () => {
jest
.spyOn(templateVersionUtils, "getTemplateVersionFiles")
.mockResolvedValueOnce(TEMPLATE_VERSION_FILES)
jest
.spyOn(CreateDayString, "createDayString")
.mockImplementation(() => "a minute ago")
renderWithAuth(<TemplateVersionPage />, {
route: `/templates/${TEMPLATE_NAME}/versions/${VERSION_NAME}`,
path: "/templates/:template/versions/:version",
})
await waitForLoaderToBeRemoved()
}
describe("TemplateVersionPage", () => {
beforeEach(setup)
it("shows the tf and md files only", () => {
expect(screen.queryByText(TERRAFORM_FILENAME)).toBeInTheDocument()
expect(screen.queryByText(README_FILENAME)).toBeInTheDocument()
expect(screen.queryByText(GPG_FILENAME)).not.toBeInTheDocument()
})
it("shows the right content when click on the file name", async () => {
await userEvent.click(screen.getByText(README_FILENAME))
expect(
screen.queryByText(TEMPLATE_VERSION_FILES[README_FILENAME]),
).toBeInTheDocument()
})
})
@@ -0,0 +1,44 @@
import { useMachine } from "@xstate/react"
import { useOrganizationId } from "hooks/useOrganizationId"
import { useTab } from "hooks/useTab"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { pageTitle } from "util/page"
import { templateVersionMachine } from "xServices/templateVersion/templateVersionXService"
import TemplateVersionPageView from "./TemplateVersionPageView"
type Params = {
version: string
template: string
}
export const TemplateVersionPage: FC = () => {
const { version: versionName, template: templateName } = useParams() as Params
const orgId = useOrganizationId()
const [state] = useMachine(templateVersionMachine, {
context: { versionName, orgId },
})
const tab = useTab("file")
const { t } = useTranslation("templateVersionPage")
return (
<>
<Helmet>
<title>
{pageTitle(`${t("title")} ${versionName} · ${templateName}`)}
</title>
</Helmet>
<TemplateVersionPageView
context={state.context}
versionName={versionName}
templateName={templateName}
tab={tab}
/>
</>
)
}
export default TemplateVersionPage
@@ -0,0 +1,69 @@
import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react"
import { UseTabResult } from "hooks/useTab"
import {
makeMockApiError,
MockOrganization,
MockTemplate,
MockTemplateVersion,
} from "testHelpers/entities"
import {
TemplateVersionPageView,
TemplateVersionPageViewProps,
} from "./TemplateVersionPageView"
export default {
title: "pages/TemplateVersionPageView",
component: TemplateVersionPageView,
}
const Template: Story<TemplateVersionPageViewProps> = (args) => (
<TemplateVersionPageView {...args} />
)
const tab: UseTabResult = {
value: "0",
set: action("changeTab"),
}
const readmeContent = `---
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.
\`\`\``
const defaultArgs = {
tab,
templateName: MockTemplate.name,
versionName: MockTemplateVersion.name,
context: {
orgId: MockOrganization.id,
versionName: MockTemplateVersion.name,
version: MockTemplateVersion,
files: {
"README.md": readmeContent,
"main.tf": `{}`,
},
},
}
export const Default = Template.bind({})
Default.args = defaultArgs
export const Error = Template.bind({})
Error.args = {
...defaultArgs,
context: {
...defaultArgs.context,
version: undefined,
files: undefined,
error: makeMockApiError({
message: "Error on loading the template version",
}),
},
}
@@ -0,0 +1,182 @@
import { makeStyles } from "@material-ui/core/styles"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
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 {
PageHeader,
PageHeaderCaption,
PageHeaderTitle,
} from "components/PageHeader/PageHeader"
import { Stack } from "components/Stack/Stack"
import { Stats, StatsItem } from "components/Stats/Stats"
import { SyntaxHighlighter } from "components/SyntaxHighlighter/SyntaxHighlighter"
import { UseTabResult } from "hooks/useTab"
import { FC } from "react"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { combineClasses } from "util/combineClasses"
import { createDayString } from "util/createDayString"
import { TemplateVersionMachineContext } from "xServices/templateVersion/templateVersionXService"
export interface TemplateVersionPageViewProps {
/**
* Used to display the version name before loading the version in the API
*/
versionName: string
templateName: string
tab: UseTabResult
context: TemplateVersionMachineContext
}
export const TemplateVersionPageView: FC<TemplateVersionPageViewProps> = ({
context,
tab,
versionName,
templateName,
}) => {
const styles = useStyles()
const { files, error, version } = context
const { t } = useTranslation("templateVersionPage")
return (
<Margins>
<PageHeader>
<PageHeaderCaption>{t("header.caption")}</PageHeaderCaption>
<PageHeaderTitle>{versionName}</PageHeaderTitle>
</PageHeader>
{!files && !error && <Loader />}
<Stack spacing={4}>
{Boolean(error) && <AlertBanner severity="error" error={error} />}
{version && files && (
<>
<Stats>
<StatsItem
label={t("stats.template")}
value={
<Link to={`/templates/${templateName}`}>{templateName}</Link>
}
/>
<StatsItem
label={t("stats.createdBy")}
value={version.created_by.username}
/>
<StatsItem
label={t("stats.created")}
value={createDayString(version.created_at)}
/>
</Stats>
<div className={styles.files}>
<div className={styles.tabs}>
{Object.keys(files).map((filename, index) => {
const tabValue = index.toString()
return (
<button
className={combineClasses({
[styles.tab]: true,
[styles.tabActive]: tabValue === tab.value,
})}
onClick={() => {
tab.set(tabValue)
}}
key={filename}
>
{filename.endsWith("tf") ? (
<TerraformIcon />
) : (
<MarkdownIcon />
)}
{filename}
</button>
)
})}
</div>
<SyntaxHighlighter
showLineNumbers
className={styles.prism}
language={
Object.keys(files)[Number(tab.value)].endsWith("tf")
? "hcl"
: "markdown"
}
>
{Object.values(files)[Number(tab.value)]}
</SyntaxHighlighter>
</div>
</>
)}
</Stack>
</Margins>
)
}
const useStyles = makeStyles((theme) => ({
tabsWrapper: {
borderBottom: `1px solid ${theme.palette.divider}`,
},
tabs: {
display: "flex",
alignItems: "baseline",
borderBottom: `1px solid ${theme.palette.divider}`,
},
tab: {
background: "transparent",
border: 0,
padding: theme.spacing(0, 3),
display: "flex",
alignItems: "center",
height: theme.spacing(6),
opacity: 0.75,
cursor: "pointer",
gap: theme.spacing(0.5),
position: "relative",
"& svg": {
width: 22,
maxHeight: 16,
},
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
},
tabActive: {
opacity: 1,
fontWeight: 600,
"&:after": {
content: '""',
display: "block",
height: 1,
width: "100%",
bottom: 0,
left: 0,
backgroundColor: theme.palette.secondary.dark,
position: "absolute",
},
},
codeWrapper: {
background: theme.palette.background.paperLight,
},
files: {
borderRadius: theme.shape.borderRadius,
border: `1px solid ${theme.palette.divider}`,
},
prism: {
borderRadius: 0,
},
}))
export default TemplateVersionPageView
+6
View File
@@ -63,6 +63,12 @@ export const handlers = [
)
},
),
rest.get(
"api/v2/organizations/:organizationId/templateversions/:templateVersionName",
async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockTemplateVersion))
},
),
rest.delete("/api/v2/templates/:templateId", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockTemplate))
}),
+1
View File
@@ -37,6 +37,7 @@ export const darkPalette: PaletteOptions = {
text: {
primary: colors.gray[1],
secondary: colors.gray[5],
disabled: colors.gray[7],
},
divider: colors.gray[13],
warning: {
+34
View File
@@ -0,0 +1,34 @@
import { getFile } from "api/api"
import { TemplateVersion } from "api/typesGenerated"
import untar from "js-untar"
/**
* Content by filename
*/
export type TemplateVersionFiles = Record<string, string>
export const getTemplateVersionFiles = async (
version: TemplateVersion,
): Promise<TemplateVersionFiles> => {
const files: TemplateVersionFiles = {}
const tarFile = await getFile(version.job.file_id)
await untar(tarFile).then(undefined, undefined, async (file) => {
const paths = file.name.split("/")
const filename = paths[paths.length - 1]
files[filename] = file.readAsString()
})
return files
}
export const filterTemplateFilesByExtension = (
files: TemplateVersionFiles,
extensions: string[],
): TemplateVersionFiles => {
return Object.keys(files).reduce((filteredFiles, filename) => {
const [_, extension] = filename.split(".")
return extensions.includes(extension)
? { ...filteredFiles, [filename]: files[filename] }
: filteredFiles
}, {} as TemplateVersionFiles)
}
@@ -0,0 +1,96 @@
import { getTemplateVersionByName } from "api/api"
import { TemplateVersion } from "api/typesGenerated"
import {
filterTemplateFilesByExtension,
getTemplateVersionFiles,
TemplateVersionFiles,
} from "util/templateVersion"
import { assign, createMachine } from "xstate"
export interface TemplateVersionMachineContext {
orgId: string
versionName: string
version?: TemplateVersion
files?: TemplateVersionFiles
error?: Error | unknown
}
export const templateVersionMachine = createMachine(
{
predictableActionArguments: true,
id: "templateVersion",
schema: {
context: {} as TemplateVersionMachineContext,
services: {} as {
loadVersion: {
data: TemplateVersion
}
loadFiles: {
data: TemplateVersionFiles
}
},
},
tsTypes: {} as import("./templateVersionXService.typegen").Typegen0,
initial: "loadingVersion",
states: {
loadingVersion: {
invoke: {
src: "loadVersion",
onDone: {
target: "loadingFiles",
actions: ["assignVersion"],
},
onError: {
target: "done.error",
actions: ["assignError"],
},
},
},
loadingFiles: {
invoke: {
src: "loadFiles",
onDone: {
target: "done.ok",
actions: ["assignFiles"],
},
onError: {
target: "done.error",
actions: ["assignError"],
},
},
},
done: {
states: {
ok: { type: "final" },
error: { type: "final" },
},
},
},
},
{
actions: {
assignError: assign({
error: (_, { data }) => data,
}),
assignVersion: assign({
version: (_, { data }) => data,
}),
assignFiles: assign({
files: (_, { data }) => data,
}),
},
services: {
loadVersion: ({ orgId, versionName }) =>
getTemplateVersionByName(orgId, versionName),
loadFiles: async ({ version }) => {
if (!version) {
throw new Error("Version is not defined")
}
return filterTemplateFilesByExtension(
await getTemplateVersionFiles(version),
["tf", "md"],
)
},
},
},
)
+843 -733
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
js-untar@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/js-untar/-/js-untar-2.0.0.tgz#b452d28dedd3b0be92c2ac9a7d70f612a93c7453"
integrity sha512-7CsDLrYQMbLxDt2zl9uKaPZSdmJMvGGQ7wo9hoB3J+z/VcO2w63bXFgHVnjF1+S9wD3zAu8FBVj7EYWjTQ3Z7g==