mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
Show template.display_name in the site UI (#5069)
* Show display_name field in the template settings * Show template.display_name on pages: Templates, CreateWorkspace * Fix: template.display_name pattern * make fmt/prettier * Fix tests * Fix: make fmt/prettier * Fix: merge * Fix: autoFocus * i18n: display_name
This commit is contained in:
@ -218,8 +218,9 @@ func TestTemplateEdit(t *testing.T) {
|
|||||||
// Properties don't change
|
// Properties don't change
|
||||||
assert.Equal(t, template.Name, updated.Name)
|
assert.Equal(t, template.Name, updated.Name)
|
||||||
assert.Equal(t, template.Description, updated.Description)
|
assert.Equal(t, template.Description, updated.Description)
|
||||||
assert.Equal(t, template.DisplayName, updated.DisplayName)
|
// These properties are removed, as the API considers it as "delete" request
|
||||||
// Icon is removed, as the API considers it as "delete" request
|
// See: https://github.com/coder/coder/issues/5066
|
||||||
assert.Equal(t, "", updated.Icon)
|
assert.Equal(t, "", updated.Icon)
|
||||||
|
assert.Equal(t, "", updated.DisplayName)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -480,7 +480,9 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update template metadata -- empty fields are not overwritten.
|
// Update template metadata -- empty fields are not overwritten,
|
||||||
|
// except for display_name, icon, and default_ttl.
|
||||||
|
// The exception is required to clear content of these fields with UI.
|
||||||
name := req.Name
|
name := req.Name
|
||||||
displayName := req.DisplayName
|
displayName := req.DisplayName
|
||||||
desc := req.Description
|
desc := req.Description
|
||||||
@ -490,9 +492,6 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
name = template.Name
|
name = template.Name
|
||||||
}
|
}
|
||||||
if displayName == "" {
|
|
||||||
displayName = template.DisplayName
|
|
||||||
}
|
|
||||||
if desc == "" {
|
if desc == "" {
|
||||||
desc = template.Description
|
desc = template.Description
|
||||||
}
|
}
|
||||||
|
@ -151,9 +151,12 @@ export const TemplateLayout: FC<PropsWithChildren> = ({ children }) => {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<PageHeaderTitle>{template.name}</PageHeaderTitle>
|
<PageHeaderTitle>
|
||||||
|
{template.display_name.length > 0
|
||||||
|
? template.display_name
|
||||||
|
: template.name}
|
||||||
|
</PageHeaderTitle>
|
||||||
<PageHeaderSubtitle condensed>
|
<PageHeaderSubtitle condensed>
|
||||||
{template.description === ""
|
{template.description === ""
|
||||||
? Language.noDescription
|
? Language.noDescription
|
||||||
|
@ -9,5 +9,6 @@
|
|||||||
"deleteTemplateCaption": "Once you delete a template, there is no going back. Please be certain.",
|
"deleteTemplateCaption": "Once you delete a template, there is no going back. Please be certain.",
|
||||||
"deleteCta": "Delete Template"
|
"deleteCta": "Delete Template"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"displayNameLabel": "Display name"
|
||||||
}
|
}
|
||||||
|
@ -182,7 +182,9 @@ export const CreateWorkspacePageView: FC<
|
|||||||
</div>
|
</div>
|
||||||
<Stack direction="column" spacing={0.5}>
|
<Stack direction="column" spacing={0.5}>
|
||||||
<span className={styles.templateName}>
|
<span className={styles.templateName}>
|
||||||
{props.selectedTemplate.name}
|
{props.selectedTemplate.display_name.length > 0
|
||||||
|
? props.selectedTemplate.display_name
|
||||||
|
: props.selectedTemplate.name}
|
||||||
</span>
|
</span>
|
||||||
{props.selectedTemplate.description && (
|
{props.selectedTemplate.description && (
|
||||||
<span className={styles.templateDescription}>
|
<span className={styles.templateDescription}>
|
||||||
|
@ -37,7 +37,7 @@ describe("TemplateSummaryPage", () => {
|
|||||||
mock.mockImplementation(() => "a minute ago")
|
mock.mockImplementation(() => "a minute ago")
|
||||||
|
|
||||||
renderPage()
|
renderPage()
|
||||||
await screen.findByText(MockTemplate.name)
|
await screen.findByText(MockTemplate.display_name)
|
||||||
await screen.findByTestId("markdown")
|
await screen.findByTestId("markdown")
|
||||||
screen.getByText(MockWorkspaceResource.name)
|
screen.getByText(MockWorkspaceResource.name)
|
||||||
screen.queryAllByText(`${MockTemplateVersion.name}`).length
|
screen.queryAllByText(`${MockTemplateVersion.name}`).length
|
||||||
|
@ -22,7 +22,15 @@ export const TemplateSummaryPage: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{pageTitle(`${template.name} · Template`)}</title>
|
<title>
|
||||||
|
{pageTitle(
|
||||||
|
`${
|
||||||
|
template.display_name.length > 0
|
||||||
|
? template.display_name
|
||||||
|
: template.name
|
||||||
|
} · Template`,
|
||||||
|
)}
|
||||||
|
</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<TemplateSummaryPageView
|
<TemplateSummaryPageView
|
||||||
template={template}
|
template={template}
|
||||||
|
@ -12,8 +12,15 @@ import { Stack } from "components/Stack/Stack"
|
|||||||
import { FormikContextType, FormikTouched, useFormik } from "formik"
|
import { FormikContextType, FormikTouched, useFormik } from "formik"
|
||||||
import { FC, useRef, useState } from "react"
|
import { FC, useRef, useState } from "react"
|
||||||
import { colors } from "theme/colors"
|
import { colors } from "theme/colors"
|
||||||
import { getFormHelpers, nameValidator, onChangeTrimmed } from "util/formUtils"
|
import {
|
||||||
|
getFormHelpers,
|
||||||
|
nameValidator,
|
||||||
|
templateDisplayNameValidator,
|
||||||
|
onChangeTrimmed,
|
||||||
|
} from "util/formUtils"
|
||||||
import * as Yup from "yup"
|
import * as Yup from "yup"
|
||||||
|
import i18next from "i18next"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
export const Language = {
|
export const Language = {
|
||||||
nameLabel: "Name",
|
nameLabel: "Name",
|
||||||
@ -36,6 +43,11 @@ const MS_HOUR_CONVERSION = 3600000
|
|||||||
|
|
||||||
export const validationSchema = Yup.object({
|
export const validationSchema = Yup.object({
|
||||||
name: nameValidator(Language.nameLabel),
|
name: nameValidator(Language.nameLabel),
|
||||||
|
display_name: templateDisplayNameValidator(
|
||||||
|
i18next.t("displayNameLabel", {
|
||||||
|
ns: "templatePage",
|
||||||
|
}),
|
||||||
|
),
|
||||||
description: Yup.string().max(
|
description: Yup.string().max(
|
||||||
MAX_DESCRIPTION_CHAR_LIMIT,
|
MAX_DESCRIPTION_CHAR_LIMIT,
|
||||||
Language.descriptionMaxError,
|
Language.descriptionMaxError,
|
||||||
@ -92,6 +104,8 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||||||
const hasIcon = form.values.icon && form.values.icon !== ""
|
const hasIcon = form.values.icon && form.values.icon !== ""
|
||||||
const emojiButtonRef = useRef<HTMLButtonElement>(null)
|
const emojiButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const { t } = useTranslation("templatePage")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.handleSubmit} aria-label={Language.formAriaLabel}>
|
<form onSubmit={form.handleSubmit} aria-label={Language.formAriaLabel}>
|
||||||
<Stack>
|
<Stack>
|
||||||
@ -105,6 +119,14 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
{...getFieldHelpers("display_name")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
fullWidth
|
||||||
|
label={t("displayNameLabel")}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...getFieldHelpers("description")}
|
{...getFieldHelpers("description")}
|
||||||
multiline
|
multiline
|
||||||
|
@ -24,7 +24,7 @@ const renderTemplateSettingsPage = async () => {
|
|||||||
|
|
||||||
const validFormValues = {
|
const validFormValues = {
|
||||||
name: "Name",
|
name: "Name",
|
||||||
display_name: "Test Template",
|
display_name: "A display name",
|
||||||
description: "A description",
|
description: "A description",
|
||||||
icon: "A string",
|
icon: "A string",
|
||||||
default_ttl_ms: 1,
|
default_ttl_ms: 1,
|
||||||
@ -32,6 +32,7 @@ const validFormValues = {
|
|||||||
|
|
||||||
const fillAndSubmitForm = async ({
|
const fillAndSubmitForm = async ({
|
||||||
name,
|
name,
|
||||||
|
display_name,
|
||||||
description,
|
description,
|
||||||
default_ttl_ms,
|
default_ttl_ms,
|
||||||
icon,
|
icon,
|
||||||
@ -40,6 +41,15 @@ const fillAndSubmitForm = async ({
|
|||||||
await userEvent.clear(nameField)
|
await userEvent.clear(nameField)
|
||||||
await userEvent.type(nameField, name)
|
await userEvent.type(nameField, name)
|
||||||
|
|
||||||
|
const { t } = i18next
|
||||||
|
const displayNameLabel = t("displayNameLabel", {
|
||||||
|
ns: "templatePage",
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayNameField = await screen.findByLabelText(displayNameLabel)
|
||||||
|
await userEvent.clear(displayNameField)
|
||||||
|
await userEvent.type(displayNameField, display_name)
|
||||||
|
|
||||||
const descriptionField = await screen.findByLabelText(
|
const descriptionField = await screen.findByLabelText(
|
||||||
FormLanguage.descriptionLabel,
|
FormLanguage.descriptionLabel,
|
||||||
)
|
)
|
||||||
|
@ -46,7 +46,7 @@ describe("TemplatesPage", () => {
|
|||||||
render(<TemplatesPage />)
|
render(<TemplatesPage />)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
await screen.findByText(MockTemplate.name)
|
await screen.findByText(MockTemplate.display_name)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("shows empty view without permissions to create", async () => {
|
it("shows empty view without permissions to create", async () => {
|
||||||
|
@ -209,7 +209,11 @@ export const TemplatesPageView: FC<
|
|||||||
>
|
>
|
||||||
<TableCellLink to={templatePageLink}>
|
<TableCellLink to={templatePageLink}>
|
||||||
<AvatarData
|
<AvatarData
|
||||||
title={template.name}
|
title={
|
||||||
|
template.display_name.length > 0
|
||||||
|
? template.display_name
|
||||||
|
: template.name
|
||||||
|
}
|
||||||
subtitle={template.description}
|
subtitle={template.description}
|
||||||
highlightTitle
|
highlightTitle
|
||||||
avatar={
|
avatar={
|
||||||
|
@ -19,8 +19,11 @@ export const Language = {
|
|||||||
nameInvalidChars: (name: string): string => {
|
nameInvalidChars: (name: string): string => {
|
||||||
return `${name} must start with a-Z or 0-9 and can contain a-Z, 0-9 or -`
|
return `${name} must start with a-Z or 0-9 and can contain a-Z, 0-9 or -`
|
||||||
},
|
},
|
||||||
nameTooLong: (name: string): string => {
|
nameTooLong: (name: string, len: number): string => {
|
||||||
return `${name} cannot be longer than 32 characters`
|
return `${name} cannot be longer than ${len} characters`
|
||||||
|
},
|
||||||
|
templateDisplayNameInvalidChars: (name: string): string => {
|
||||||
|
return `${name} must start and end with non-whitespace character`
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,15 +77,28 @@ export const onChangeTrimmed =
|
|||||||
form.handleChange(event)
|
form.handleChange(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
// REMARK: Keep in sync with coderd/httpapi/httpapi.go#L40
|
// REMARK: Keep these consts in sync with coderd/httpapi/httpapi.go
|
||||||
const maxLenName = 32
|
const maxLenName = 32
|
||||||
|
const templateDisplayNameMaxLength = 64
|
||||||
// REMARK: Keep in sync with coderd/httpapi/httpapi.go#L18
|
|
||||||
const usernameRE = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/
|
const usernameRE = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/
|
||||||
|
const templateDisplayNameRE = /^[^\s](.*[^\s])?$/
|
||||||
|
|
||||||
// REMARK: see #1756 for name/username semantics
|
// REMARK: see #1756 for name/username semantics
|
||||||
export const nameValidator = (name: string): Yup.StringSchema =>
|
export const nameValidator = (name: string): Yup.StringSchema =>
|
||||||
Yup.string()
|
Yup.string()
|
||||||
.required(Language.nameRequired(name))
|
.required(Language.nameRequired(name))
|
||||||
.matches(usernameRE, Language.nameInvalidChars(name))
|
.matches(usernameRE, Language.nameInvalidChars(name))
|
||||||
.max(maxLenName, Language.nameTooLong(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),
|
||||||
|
)
|
||||||
|
Reference in New Issue
Block a user