refactor(site): redesign <AppearanceSettingsPage /> (#27722)

> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell.

- Redesign Deployment Appearance settings to the standard two-column
settings layout (`HorizontalForm` / `FormSection`) for branding, with
announcement banners as a secondary `SettingsHeader` + table section
(aligned with OAuth2 apps / AI Governance).
- Demui `IconField` and reuse it for Logo URL (URL input, preview, emoji
picker); thin `IconPickerField` to wrap the shared component.
- Clean up announcement banner create/edit (draft-only until save,
page-top preview via portal, shadcn `Switch` / menu icons, empty-state
CTA) and clarify Premium paywall copy to cover branding and announcement
banners.

| Old | New |
| --- | --- |
| <img width="2936" height="2666" alt="appearancesettings_old"
src="https://github.com/user-attachments/assets/db11950d-2440-412b-a74d-fcf57f2467e9"/>
| <img width="2936" height="2714" alt="appearancesettings_new"
src="https://github.com/user-attachments/assets/3f418fb1-7f94-4fd8-bf54-0d63014c6533"/>
|
This commit is contained in:
Jake Howell
2026-08-08 18:03:31 +10:00
committed by GitHub
parent e751b285ca
commit ad225269aa
4 changed files with 247 additions and 196 deletions
@@ -1,5 +1,4 @@
import { useTheme } from "@emotion/react";
import TextField from "@mui/material/TextField";
import { useFormik } from "formik";
import { type FC, useState } from "react";
import { SliderPicker, TwitterPicker } from "react-color";
@@ -13,7 +12,10 @@ import {
DialogHeader,
DialogTitle,
} from "#/components/Dialog/Dialog";
import { Label } from "#/components/Label/Label";
import { Textarea } from "#/components/Textarea/Textarea";
import { AnnouncementBannerView } from "#/modules/dashboard/AnnouncementBanners/AnnouncementBannerView";
import { cn } from "#/utils/cn";
import { getFormHelpers } from "#/utils/formUtils";
interface AnnouncementBannerDialogProps {
@@ -28,6 +30,7 @@ export const AnnouncementBannerDialog: FC<AnnouncementBannerDialogProps> = ({
onUpdate,
}) => {
const theme = useTheme();
const isCreating = banner.message === "";
const bannerForm = useFormik<{
message: string;
@@ -39,9 +42,15 @@ export const AnnouncementBannerDialog: FC<AnnouncementBannerDialogProps> = ({
},
onSubmit: (banner) => onUpdate(banner),
});
const bannerFieldHelpers = getFormHelpers(bannerForm);
const getFieldHelpers = getFormHelpers(bannerForm);
const messageField = getFieldHelpers("message", {
helperText: "Markdown bold, italics, and links are supported.",
});
const messageHelperId = `${messageField.id}-helper`;
const messageErrorId = `${messageField.id}-error`;
const [showHuePicker, setShowHuePicker] = useState(false);
const previewMessage = bannerForm.values.message.trim();
return (
<Dialog
@@ -52,8 +61,10 @@ export const AnnouncementBannerDialog: FC<AnnouncementBannerDialogProps> = ({
}
}}
>
{/* Banner preview */}
<div className="fixed top-0 left-0 right-0 z-[60]">
{/* Banner preview. Rendered outside DialogContent so its fixed
positioning is relative to the viewport, not the dialog's
transformed containing block. */}
<div className="pointer-events-none fixed top-0 right-0 left-0 z-[60]">
<AnnouncementBannerView
message={bannerForm.values.message}
backgroundColor={bannerForm.values.background_color}
@@ -70,21 +81,43 @@ export const AnnouncementBannerDialog: FC<AnnouncementBannerDialogProps> = ({
</DialogHeader>
<div className="flex flex-col gap-4">
<div>
<h4 className="m-0 mb-2 text-base font-semibold text-content-primary">
Message
</h4>
<TextField
{...bannerFieldHelpers("message", {
helperText: "Markdown bold, italics, and links are supported.",
})}
fullWidth
multiline
inputProps={{
"aria-label": "Message",
placeholder: "Enter a message for the banner",
}}
<div className="flex flex-col gap-2">
<Label htmlFor={messageField.id}>Message</Label>
<Textarea
id={messageField.id}
name={messageField.name}
value={messageField.value}
onChange={messageField.onChange}
onBlur={messageField.onBlur}
rows={3}
placeholder="Enter a message for the banner"
aria-invalid={messageField.error}
aria-describedby={
messageField.error
? messageErrorId
: messageField.helperText
? messageHelperId
: undefined
}
className={cn(messageField.error && "border-border-destructive")}
/>
{messageField.error ? (
<span
id={messageErrorId}
className="text-xs text-content-destructive"
>
{messageField.helperText}
</span>
) : (
messageField.helperText && (
<span
id={messageHelperId}
className="text-xs text-content-secondary"
>
{messageField.helperText}
</span>
)
)}
</div>
<div>
<h4 className="m-0 mb-2 text-base font-semibold text-content-primary">
@@ -158,8 +191,8 @@ export const AnnouncementBannerDialog: FC<AnnouncementBannerDialogProps> = ({
<DialogActions
cancelText="Cancel"
confirmLoading={bannerForm.isSubmitting}
confirmText="Update"
confirmDisabled={bannerForm.isSubmitting}
confirmText={isCreating ? "Create" : "Update"}
confirmDisabled={bannerForm.isSubmitting || previewMessage === ""}
onCancel={onCancel}
onConfirm={bannerForm.handleSubmit}
/>
@@ -1,5 +1,4 @@
import Checkbox from "@mui/material/Checkbox";
import { EllipsisVerticalIcon } from "lucide-react";
import { EllipsisVerticalIcon, PencilIcon, TrashIcon } from "lucide-react";
import type { FC } from "react";
import type { BannerConfig } from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
@@ -9,6 +8,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { Switch } from "#/components/Switch/Switch";
import { TableCell, TableRow } from "#/components/Table/Table";
interface AnnouncementBannerItemProps {
@@ -30,11 +30,13 @@ export const AnnouncementBannerItem: FC<AnnouncementBannerItemProps> = ({
}) => {
return (
<TableRow>
<TableCell>
<Checkbox
size="small"
<TableCell className="align-middle pl-5">
<Switch
checked={enabled}
onClick={() => void onUpdate({ enabled: !enabled })}
aria-label="Enabled"
onCheckedChange={(checked) => {
void onUpdate({ enabled: checked });
}}
/>
</TableCell>
@@ -56,12 +58,14 @@ export const AnnouncementBannerItem: FC<AnnouncementBannerItemProps> = ({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit()}>
<PencilIcon className="size-icon-xs" />
Edit&hellip;
</DropdownMenuItem>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={() => onDelete()}
>
<TrashIcon className="size-icon-xs" />
Delete&hellip;
</DropdownMenuItem>
</DropdownMenuContent>
@@ -4,6 +4,11 @@ import type { BannerConfig } from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog";
import { Link } from "#/components/Link/Link";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import {
Table,
TableBody,
@@ -15,26 +20,46 @@ import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
import { AnnouncementBannerDialog } from "./AnnouncementBannerDialog";
import { AnnouncementBannerItem } from "./AnnouncementBannerItem";
const DEFAULT_BANNER: BannerConfig = {
enabled: true,
message: "",
background_color: "#ABB8C3",
};
type NewBannerButtonProps = {
onClick: () => void;
};
const NewBannerButton: FC<NewBannerButtonProps> = ({ onClick }) => (
<Button onClick={onClick} variant="outline">
<PlusIcon />
New announcement
</Button>
);
interface AnnouncementBannersettingsProps {
isEntitled: boolean;
announcementBanners: readonly BannerConfig[];
onSubmit: (banners: readonly BannerConfig[]) => Promise<void>;
}
type EditingBanner = {
/** `null` means creating a new banner. */
index: number | null;
banner: BannerConfig;
};
export const AnnouncementBannerSettings: FC<
AnnouncementBannersettingsProps
> = ({ isEntitled, announcementBanners, onSubmit }) => {
const [banners, setBanners] = useState(announcementBanners);
const [editingBannerId, setEditingBannerId] = useState<number | null>(null);
const [editingBanner, setEditingBanner] = useState<EditingBanner | null>(
null,
);
const [deletingBannerId, setDeletingBannerId] = useState<number | null>(null);
const addBanner = () => {
setBanners([
...banners,
{ enabled: true, message: "", background_color: "#ABB8C3" },
]);
setEditingBannerId(banners.length);
};
const openCreateDialog = () =>
setEditingBanner({ index: null, banner: DEFAULT_BANNER });
const updateBanner = (i: number, banner: Partial<BannerConfig>) => {
const newBanners = [...banners];
@@ -50,93 +75,90 @@ export const AnnouncementBannerSettings: FC<
return newBanners;
};
const editingBanner = editingBannerId !== null && banners[editingBannerId];
const deletingBanner = deletingBannerId !== null && banners[deletingBannerId];
// If we're not editing a new banner, remove all empty banners. This makes canceling the
// "new" dialog more intuitive, by not persisting an empty banner.
if (editingBannerId === null && banners.some((banner) => !banner.message)) {
setBanners(banners.filter((banner) => banner.message));
}
return (
<>
<div className="mt-8 overflow-hidden rounded-lg border border-solid border-border">
<div className="p-6">
<div className="flex items-center justify-between gap-4">
<h3 className="m-0 text-xl font-semibold">Announcement Banners</h3>
<Button
disabled={!isEntitled}
onClick={() => addBanner()}
variant="outline"
>
<PlusIcon />
New
</Button>
</div>
<div className="mt-2 text-sm text-content-secondary">
<div>
<SettingsHeader
actions={
isEntitled ? (
<NewBannerButton onClick={openCreateDialog} />
) : undefined
}
>
<SettingsHeaderTitle hierarchy="secondary" level="h2">
Announcement Banners
</SettingsHeaderTitle>
<SettingsHeaderDescription>
Display message banners to all users.
</div>
<div className="pt-4 text-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[1%]">Enabled</TableHead>
<TableHead>Message</TableHead>
<TableHead className="w-[2%]">Color</TableHead>
<TableHead className="w-[1%]" />
</TableRow>
</TableHeader>
<TableBody>
{!isEntitled || banners.length < 1 ? (
<TableEmpty
className="min-h-[160px]"
message="No announcement banners"
/>
) : (
banners.map((banner, i) => (
<AnnouncementBannerItem
key={banner.message}
enabled={banner.enabled && Boolean(banner.message)}
backgroundColor={banner.background_color}
message={banner.message}
onEdit={() => setEditingBannerId(i)}
onUpdate={async (banner) => {
const newBanners = updateBanner(i, banner);
await onSubmit(newBanners);
}}
onDelete={() => setDeletingBannerId(i)}
/>
))
)}
</TableBody>
</Table>
</div>
</div>
{!isEntitled && (
<footer className="bg-surface-secondary px-6 py-4 text-sm">
<div className="text-content-secondary">
<p>
{!isEntitled && (
<>
{" "}
Your license does not include Service Banners.{" "}
<Link href="mailto:sales@coder.com" showExternalIcon={false}>
Contact sales
</Link>{" "}
to learn more.
</p>
</div>
</footer>
)}
</>
)}
</SettingsHeaderDescription>
</SettingsHeader>
<Table aria-label="Announcement banners">
<TableHeader>
<TableRow>
<TableHead className="w-[1%] pl-5">Enabled</TableHead>
<TableHead>Message</TableHead>
<TableHead className="w-[2%]">Color</TableHead>
<TableHead className="w-[1%]" />
</TableRow>
</TableHeader>
<TableBody>
{!isEntitled || banners.length < 1 ? (
<TableEmpty
message="No announcement banners"
description="Create a banner to display a message to all users."
cta={
isEntitled ? (
<NewBannerButton onClick={openCreateDialog} />
) : undefined
}
/>
) : (
banners.map((banner, i) => (
<AnnouncementBannerItem
key={banner.message}
enabled={banner.enabled && Boolean(banner.message)}
backgroundColor={banner.background_color}
message={banner.message}
onEdit={() => setEditingBanner({ index: i, banner })}
onUpdate={async (banner) => {
const newBanners = updateBanner(i, banner);
await onSubmit(newBanners);
}}
onDelete={() => setDeletingBannerId(i)}
/>
))
)}
</TableBody>
</Table>
</div>
{editingBanner && (
<AnnouncementBannerDialog
banner={editingBanner}
onCancel={() => setEditingBannerId(null)}
banner={editingBanner.banner}
onCancel={() => setEditingBanner(null)}
onUpdate={async (banner) => {
const newBanners = updateBanner(editingBannerId, banner);
setEditingBannerId(null);
const nextBanner = { ...editingBanner.banner, ...banner };
const newBanners =
editingBanner.index === null
? [...banners, nextBanner]
: banners.map((existing, i) =>
i === editingBanner.index ? nextBanner : existing,
);
setBanners(newBanners);
setEditingBanner(null);
await onSubmit(newBanners);
}}
/>
@@ -7,22 +7,24 @@ import {
PremiumBadge,
} from "#/components/Badges/Badges";
import { Button } from "#/components/Button/Button";
import { Input } from "#/components/Input/Input";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "#/components/InputGroup/InputGroup";
FormFields,
FormFooter,
FormSection,
VerticalForm,
} from "#/components/Form/Form";
import { FormField } from "#/components/FormField/FormField";
import { IconField } from "#/components/IconField/IconField";
import { PaywallPremium } from "#/components/Paywall/PaywallPremium";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { Spinner } from "#/components/Spinner/Spinner";
import type { Permissions } from "#/modules/permissions";
import { docs } from "#/utils/docs";
import { getFormHelpers } from "#/utils/formUtils";
import { Fieldset } from "../Fieldset";
import { AnnouncementBannerSettings } from "./AnnouncementBannerSettings";
type AppearanceSettingsPageViewProps = {
@@ -38,102 +40,92 @@ type AppearanceSettingsPageViewProps = {
export const AppearanceSettingsPageView: FC<
AppearanceSettingsPageViewProps
> = ({ appearance, isEntitled, isPremium, permissions, onSaveAppearance }) => {
const applicationNameForm = useFormik<{
const form = useFormik<{
application_name: string;
}>({
initialValues: {
application_name: appearance.application_name,
},
onSubmit: (values) => onSaveAppearance(values),
});
const applicationNameFieldHelpers = getFormHelpers(applicationNameForm);
const logoForm = useFormik<{
logo_url: string;
}>({
initialValues: {
application_name: appearance.application_name,
logo_url: appearance.logo_url,
},
onSubmit: (values) => onSaveAppearance(values),
enableReinitialize: true,
});
const logoFieldHelpers = getFormHelpers(logoForm);
const getFieldHelpers = getFormHelpers(form);
const fieldsDisabled = !isEntitled || form.isSubmitting;
return (
<>
<SettingsHeader>
<SettingsHeaderTitle>Appearance</SettingsHeaderTitle>
<SettingsHeaderDescription>
Customize the look and feel of your Coder deployment.
</SettingsHeaderDescription>
</SettingsHeader>
<div className="flex flex-col gap-12">
<div>
<SettingsHeader>
<SettingsHeaderTitle>Appearance</SettingsHeaderTitle>
<SettingsHeaderDescription>
Customize the look and feel of your Coder deployment.
</SettingsHeaderDescription>
</SettingsHeader>
<Badges>
{isEntitled ? (
isPremium ? (
<PremiumBadge />
<Badges>
{isEntitled ? (
isPremium ? (
<PremiumBadge />
) : (
<EnterpriseBadge />
)
) : (
<EnterpriseBadge />
)
) : (
<PaywallPremium
message="Appearance"
description="With a Premium license, you can customize the appearance and branding of your deployment."
documentationLink={docs("/admin/setup/appearance")}
canViewPremium={permissions.viewAllLicenses}
/>
)}
</Badges>
<Fieldset
title="Application name"
subtitle="Specify a custom application name to be displayed on the login page."
validation={!isEntitled ? "This is an Enterprise only feature." : ""}
onSubmit={applicationNameForm.handleSubmit}
button={!isEntitled && <Button disabled>Submit</Button>}
>
<Input
{...applicationNameFieldHelpers("application_name")}
placeholder='Leave empty to display "Coder".'
disabled={!isEntitled}
aria-label="Application name"
/>
</Fieldset>
<Fieldset
title="Logo URL"
subtitle="Specify a custom URL for your logo to be displayed on the sign in page and in the top left
corner of the dashboard."
validation={
isEntitled
? "An image with transparency and an aspect ratio of 3:1 or less will look best."
: "This is an Enterprise only feature."
}
onSubmit={logoForm.handleSubmit}
button={!isEntitled && <Button disabled>Submit</Button>}
>
<InputGroup>
<InputGroupInput
{...logoFieldHelpers("logo_url")}
placeholder="Leave empty to display the Coder logo."
disabled={!isEntitled}
aria-label="Logo URL"
/>
<InputGroupAddon align="inline-end">
<img
alt=""
src={logoForm.values.logo_url}
className="size-6 max-w-full object-contain"
// Hide broken image icon while users type incomplete URLs.
onError={(e) => {
e.currentTarget.style.display = "none";
}}
onLoad={(e) => {
e.currentTarget.style.display = "inline";
}}
<PaywallPremium
message="Appearance"
description="With a Premium license, you can customize branding and announcement banners for your deployment."
documentationLink={docs("/admin/setup/appearance")}
canViewPremium={permissions.viewAllLicenses}
/>
</InputGroupAddon>
</InputGroup>
</Fieldset>
)}
</Badges>
<VerticalForm
onSubmit={form.handleSubmit}
aria-label="Appearance settings"
className="mt-8"
>
<FormSection
title="Branding"
description="Customize the application name and logo shown on the login page and in the dashboard."
>
<FormFields>
<FormField
field={getFieldHelpers("application_name", {
helperText: isEntitled
? 'Leave empty to use "Coder".'
: "This is an Enterprise only feature.",
})}
label="Application name"
placeholder="Coder"
disabled={fieldsDisabled}
/>
<IconField
{...getFieldHelpers("logo_url", {
helperText: isEntitled
? "Leave empty to use the Coder logo. An image with transparency and an aspect ratio of 3:1 or less will look best."
: "This is an Enterprise only feature.",
})}
label="Logo URL"
placeholder="/icon/coder.svg"
disabled={fieldsDisabled}
onPickEmoji={(value) => {
void form.setFieldValue("logo_url", value);
}}
/>
</FormFields>
</FormSection>
<FormFooter>
<Button type="submit" disabled={fieldsDisabled}>
<Spinner loading={form.isSubmitting} />
Save
</Button>
</FormFooter>
</VerticalForm>
</div>
<AnnouncementBannerSettings
isEntitled={isEntitled}
@@ -142,6 +134,6 @@ export const AppearanceSettingsPageView: FC<
onSaveAppearance({ announcement_banners: announcementBanners })
}
/>
</>
</div>
);
};