FEATURE: Introduce new color palettes config area (#31742)

As part of the theme/color palette overhaul project, we're introducing a
new admin page for editing color palettes. The new page is located at
`/admin/config/colors/:id`. It's linked from anywhere, but it will be
linked in the sidebar as we progress more in the overhaul project.

Related PRs: https://github.com/discourse/discourse/pull/30893
https://github.com/discourse/discourse/pull/30915
https://github.com/discourse/discourse/pull/31328.

Internal topic: t/148628.
This commit is contained in:
Osama Sayegh
2025-03-12 16:57:31 +03:00
committed by GitHub
parent 69fa96df21
commit 25e8b5af9f
21 changed files with 616 additions and 48 deletions

View File

@ -0,0 +1,228 @@
import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import Form from "discourse/components/form";
import { extractError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
import AdminConfigAreaCard from "admin/components/admin-config-area-card";
import ColorPaletteEditor, {
LIGHT,
} from "admin/components/color-palette-editor";
export default class AdminConfigAreasColorPalette extends Component {
@service toasts;
@service router;
@tracked editingName = false;
@tracked editorMode = LIGHT;
@tracked hasUnsavedChanges = false;
@cached
get data() {
return {
name: this.args.colorPalette.name,
user_selectable: this.args.colorPalette.user_selectable,
colors: this.args.colorPalette.colors,
editingName: this.editingName,
};
}
@action
toggleEditingName() {
this.editingName = !this.editingName;
}
@action
onLightColorChange(name, value) {
const color = this.data.colors.find((c) => c.name === name);
color.hex = value;
this.hasUnsavedChanges = true;
}
@action
onDarkColorChange(name, value) {
const color = this.data.colors.find((c) => c.name === name);
color.dark_hex = value;
this.hasUnsavedChanges = true;
}
@action
async handleSubmit(data) {
this.args.colorPalette.name = data.name;
this.args.colorPalette.user_selectable = data.user_selectable;
try {
await this.args.colorPalette.save();
this.editingName = false;
this.hasUnsavedChanges = false;
this.toasts.success({
data: {
message: i18n("saved"),
},
});
} catch (error) {
this.toasts.error({
duration: 3000,
data: {
message: extractError(error),
},
});
}
}
@action
onEditorTabSwitch(newMode) {
this.editorMode = newMode;
}
@action
async duplicate() {
const copy = this.args.colorPalette.copy();
copy.name = i18n("admin.config_areas.color_palettes.copy_of", {
name: this.args.colorPalette.name,
});
await copy.save();
this.router.replaceWith("adminConfig.color-palettes-show", copy);
this.toasts.success({
data: {
message: i18n("admin.config_areas.color_palettes.copy_created", {
name: this.args.colorPalette.name,
}),
},
});
}
@action
handleNameChange(value, { set }) {
set("name", value);
this.hasUnsavedChanges = true;
}
@action
handleUserSelectableChange(value, { set }) {
set("user_selectable", value);
this.hasUnsavedChanges = true;
}
<template>
<Form
@data={{this.data}}
@onSubmit={{this.handleSubmit}}
as |form transientData|
>
<div class="admin-config-area">
<div class="admin-config-area__primary-content">
<div class="admin-config-color-palettes__top-controls">
<form.Field
@name="name"
@showTitle={{false}}
@title={{i18n "admin.config_areas.color_palettes.palette_name"}}
@validation="required"
@format="full"
@onSet={{this.handleNameChange}}
as |field|
>
{{#if transientData.editingName}}
<div class="admin-config-color-palettes__name-control">
<field.Input />
<DButton
class="btn-flat"
@icon="xmark"
@action={{this.toggleEditingName}}
/>
</div>
{{else}}
<field.Custom>
<div class="admin-config-color-palettes__name-control">
<h2>{{@colorPalette.name}}</h2>
<DButton
class="btn-flat"
@icon="pencil"
@action={{this.toggleEditingName}}
/>
</div>
</field.Custom>
{{/if}}
</form.Field>
<DButton
class="duplicate-palette"
@label="admin.customize.copy"
@action={{this.duplicate}}
/>
</div>
<form.Alert class="fonts-and-logos-hint">
<div class="admin-config-color-palettes__fonts-and-logos-hint">
<span>{{i18n
"admin.config_areas.color_palettes.fonts_and_logos_hint"
}}</span>
<LinkTo @route="adminConfig.branding">{{i18n
"admin.config_areas.color_palettes.go_to_branding"
}}</LinkTo>
</div>
</form.Alert>
<AdminConfigAreaCard
@heading="admin.config_areas.color_palettes.color_options.title"
>
<:content>
<form.Field
@name="user_selectable"
@title={{i18n
"admin.config_areas.color_palettes.color_options.toggle"
}}
@showTitle={{false}}
@description={{i18n
"admin.config_areas.color_palettes.color_options.toggle_description"
}}
@format="full"
@onSet={{this.handleUserSelectableChange}}
as |field|
>
<field.Toggle />
</form.Field>
</:content>
</AdminConfigAreaCard>
<AdminConfigAreaCard
@heading="admin.config_areas.color_palettes.colors.title"
>
<:content>
<form.Field
@name="colors"
@title={{i18n "admin.config_areas.color_palettes.colors.title"}}
@showTitle={{false}}
@format="full"
as |field|
>
<field.Custom>
<ColorPaletteEditor
@initialMode={{this.editorMode}}
@colors={{transientData.colors}}
@onLightColorChange={{this.onLightColorChange}}
@onDarkColorChange={{this.onDarkColorChange}}
@onTabSwitch={{this.onEditorTabSwitch}}
/>
</field.Custom>
</form.Field>
</:content>
</AdminConfigAreaCard>
<AdminConfigAreaCard>
<:content>
<div class="admin-config-color-palettes__save-card">
{{#if this.hasUnsavedChanges}}
<span class="admin-config-color-palettes__unsaved-changes">
{{i18n "admin.config_areas.color_palettes.unsaved_changes"}}
</span>
{{/if}}
<form.Submit
@label="admin.config_areas.color_palettes.save_changes"
/>
</div>
</:content>
</AdminConfigAreaCard>
</div>
</div>
</Form>
</template>
}

View File

@ -7,25 +7,19 @@ import concatClass from "discourse/helpers/concat-class";
import dIcon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
const LIGHT = "light";
const DARK = "dark";
export const LIGHT = "light";
export const DARK = "dark";
class Color {
@tracked lightValue;
@tracked darkValue;
constructor({ name, lightValue, darkValue }) {
constructor({ name, lightValue, darkValue, description, translatedName }) {
this.name = name;
this.lightValue = lightValue;
this.darkValue = darkValue;
}
get displayName() {
return this.name.replaceAll("_", " ");
}
get description() {
return i18n(`admin.customize.colors.${this.name}.description`);
this.displayName = translatedName;
this.description = description;
}
}
@ -68,26 +62,38 @@ const Picker = class extends Component {
}
get displayedColor() {
let color;
if (this.args.showDark) {
return this.args.color.darkValue;
color = this.args.color.darkValue ?? this.args.color.lightValue;
} else {
return this.args.color.lightValue;
color = this.args.color.lightValue ?? this.args.color.darkValue;
}
return this.ensureSixDigitsHex(color);
}
get activeValue() {
let color;
if (this.args.showDark) {
color = this.args.color.darkValue;
color = this.args.color.darkValue ?? this.args.color.lightValue;
} else {
color = this.args.color.lightValue;
color = this.args.color.lightValue ?? this.args.color.darkValue;
}
if (color) {
return `#${color}`;
return `#${this.ensureSixDigitsHex(color)}`;
}
}
ensureSixDigitsHex(hex) {
if (hex.length === 3) {
return hex
.split("")
.map((digit) => `${digit}${digit}`)
.join("");
}
return hex;
}
<template>
<input
class="color-palette-editor__input"
@ -124,6 +130,8 @@ export default class ColorPaletteEditor extends Component {
name: color.name,
lightValue: color.hex,
darkValue: color.dark_hex,
description: color.description,
translatedName: color.translatedName,
});
});
}
@ -134,7 +142,11 @@ export default class ColorPaletteEditor extends Component {
event.type === "click" ||
(event.type === "keydown" && event.keyCode === 13)
) {
this.selectedMode = newMode;
if (this.args.onTabSwitch) {
this.args.onTabSwitch(newMode);
} else {
this.selectedMode = newMode;
}
}
}

View File

@ -1,3 +1,4 @@
import { tracked } from "@glimmer/tracking";
import EmberObject from "@ember/object";
import { observes, on } from "@ember-decorators/object";
import { propertyNotEqual } from "discourse/lib/computed";
@ -5,26 +6,35 @@ import discourseComputed from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";
export default class ColorSchemeColor extends EmberObject {
@tracked hex;
@tracked dark_hex;
// Whether the current value is different than Discourse's default color scheme.
@propertyNotEqual("hex", "default_hex") overridden;
@on("init")
startTrackingChanges() {
this.set("originals", { hex: this.hex || "FFFFFF" });
this.set("originals", {
hex: this.hex || "FFFFFF",
darkHex: this.dark_hex,
});
// force changed property to be recalculated
this.notifyPropertyChange("hex");
}
// Whether value has changed since it was last saved.
@discourseComputed("hex")
changed(hex) {
@discourseComputed("hex", "dark_hex")
changed(hex, darkHex) {
if (!this.originals) {
return false;
}
if (hex !== this.originals.hex) {
return true;
}
if (darkHex !== this.originals.darkHex) {
return true;
}
return false;
}

View File

@ -1,3 +1,4 @@
import { tracked } from "@glimmer/tracking";
import { A } from "@ember/array";
import ArrayProxy from "@ember/array/proxy";
import EmberObject from "@ember/object";
@ -27,6 +28,7 @@ export default class ColorScheme extends EmberObject {
return ColorSchemeColor.create({
name: c.name,
hex: c.hex,
dark_hex: c.dark_hex,
default_hex: c.default_hex,
is_advanced: c.is_advanced,
});
@ -38,6 +40,31 @@ export default class ColorScheme extends EmberObject {
});
}
static async find(id) {
const json = await ajax(`/admin/config/colors/${id}`);
return ColorScheme.create({
id: json.id,
name: json.name,
is_base: json.is_base,
theme_id: json.theme_id,
theme_name: json.theme_name,
base_scheme_id: json.base_scheme_id,
user_selectable: json.user_selectable,
colors: json.colors.map((c) => {
return ColorSchemeColor.create({
name: c.name,
hex: c.hex,
dark_hex: c.dark_hex,
default_hex: c.default_hex,
is_advanced: c.is_advanced,
});
}),
});
}
@tracked name;
@tracked user_selectable;
@not("id") newRecord;
init() {
@ -75,7 +102,9 @@ export default class ColorScheme extends EmberObject {
});
this.colors.forEach((c) => {
newScheme.colors.pushObject(
ColorSchemeColor.create(c.getProperties("name", "hex", "default_hex"))
ColorSchemeColor.create(
c.getProperties("name", "hex", "default_hex", "dark_hex")
)
);
});
return newScheme;
@ -128,20 +157,17 @@ export default class ColorScheme extends EmberObject {
data.colors = [];
this.colors.forEach((c) => {
if (!this.id || c.get("changed")) {
data.colors.pushObject(c.getProperties("name", "hex"));
data.colors.pushObject(c.getProperties("name", "hex", "dark_hex"));
}
});
}
return ajax(
"/admin/color_schemes" + (this.id ? "/" + this.id : "") + ".json",
{
data: JSON.stringify({ color_scheme: data }),
type: this.id ? "PUT" : "POST",
dataType: "json",
contentType: "application/json",
}
).then((result) => {
return ajax(`/admin/color_schemes${this.id ? `/${this.id}` : ""}.json`, {
data: JSON.stringify({ color_scheme: data }),
type: this.id ? "PUT" : "POST",
dataType: "json",
contentType: "application/json",
}).then((result) => {
if (result.id) {
this.set("id", result.id);
}

View File

@ -0,0 +1,8 @@
import DiscourseRoute from "discourse/routes/discourse";
import ColorScheme from "admin/models/color-scheme";
export default class AdminConfigColorPalettesShowRoute extends DiscourseRoute {
model(params) {
return ColorScheme.find(params.palette_id);
}
}

View File

@ -335,6 +335,10 @@ export default function () {
this.route("user-api", function () {
this.route("settings", { path: "/" });
});
this.route("color-palettes-show", {
path: "/colors/:palette_id",
});
}
);

View File

@ -0,0 +1 @@
<AdminConfigAreas::ColorPalette @colorPalette={{this.model}} />

View File

@ -2,6 +2,7 @@ import { click, find, render, triggerEvent } from "@ember/test-helpers";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import ColorPaletteEditor from "admin/components/color-palette-editor";
import ColorSchemeColor from "admin/models/color-scheme-color";
function editor() {
return {
@ -98,7 +99,7 @@ module("Integration | Component | ColorPaletteEditor", function (hooks) {
hex: "473921",
dark_hex: "f2cca9",
},
];
].map((data) => ColorSchemeColor.create(data));
await render(
<template><ColorPaletteEditor @colors={{colors}} /></template>
@ -166,29 +167,19 @@ module("Integration | Component | ColorPaletteEditor", function (hooks) {
);
});
test("replacing underscores in color name with spaces for display", async function (assert) {
test("uses the i18n string for the color name", async function (assert) {
const colors = [
{
name: "my_awesome_color",
hex: "aaaaaa",
dark_hex: "1e3c8a",
},
{
name: "header_background",
hex: "473921",
dark_hex: "f2cca9",
},
];
].map((data) => ColorSchemeColor.create(data));
await render(
<template><ColorPaletteEditor @colors={{colors}} /></template>
);
assert.strictEqual(
this.subject.color("my_awesome_color").displayName(),
"my awesome color"
);
assert.strictEqual(
this.subject.color("header_background").displayName(),
"header background"
@ -207,7 +198,7 @@ module("Integration | Component | ColorPaletteEditor", function (hooks) {
hex: "473921",
dark_hex: "f2cca9",
},
];
].map((data) => ColorSchemeColor.create(data));
const lightChanges = [];
const darkChanges = [];
@ -387,4 +378,42 @@ module("Integration | Component | ColorPaletteEditor", function (hooks) {
"the dark color for the header_background color is remembered after switching tabs"
);
});
test("converting 3 digits hex values to 6 digits", async function (assert) {
const colors = [
{
name: "primary",
hex: "a8c",
dark_hex: "971",
},
].map((data) => ColorSchemeColor.create(data));
await render(
<template><ColorPaletteEditor @colors={{colors}} /></template>
);
assert.strictEqual(
this.subject.color("primary").input().value,
"#aa88cc",
"the input field has the equivalent 6 digits value"
);
assert.strictEqual(
this.subject.color("primary").displayedValue(),
"aa88cc",
"the displayed value shows the 6 digits format"
);
await this.subject.switchToDarkTab();
assert.strictEqual(
this.subject.color("primary").input().value,
"#997711",
"the input field has the equivalent 6 digits value"
);
assert.strictEqual(
this.subject.color("primary").displayedValue(),
"997711",
"the displayed value shows the 6 digits format"
);
});
});

View File

@ -1277,3 +1277,4 @@ a.inline-editable-field {
@import "admin/customize_themes_show_schema";
@import "admin/admin_bulk_users_delete_modal";
@import "admin/color-palette-editor";
@import "admin/admin_config_color_palettes";

View File

@ -0,0 +1,38 @@
.admin-config.color-palettes-show {
.admin-config-color-palettes {
&__name-control {
display: flex;
h2 {
margin-bottom: 0;
}
}
&__top-controls {
display: flex;
justify-content: space-between;
margin-bottom: 1em;
}
&__fonts-and-logos-hint {
display: flex;
justify-content: space-between;
}
&__unsaved-changes {
font-size: var(--font-down-1);
color: var(--primary-medium);
align-self: center;
}
&__save-card {
display: flex;
justify-content: flex-end;
gap: 1em;
}
}
.alert {
margin-bottom: 1em;
}
}

View File

@ -40,7 +40,9 @@ class Admin::ColorSchemesController < Admin::AdminController
end
def color_scheme_params
params.permit(color_scheme: [:base_scheme_id, :name, :user_selectable, colors: %i[name hex]])[
params.permit(
color_scheme: [:base_scheme_id, :name, :user_selectable, colors: %i[name hex dark_hex]],
)[
:color_scheme
]
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
class Admin::Config::ColorPalettesController < Admin::AdminController
def show
render_serialized(ColorScheme.find(params[:id]), ColorSchemeSerializer, root: false)
end
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
class ColorSchemeColorSerializer < ApplicationSerializer
attributes :name, :hex, :default_hex, :is_advanced
attributes :name, :hex, :default_hex, :is_advanced, :dark_hex
def hex
object.hex # otherwise something crazy is returned

View File

@ -24,7 +24,11 @@ class ColorSchemeRevisor
if existing = @color_scheme.colors_by_name[c[:name]]
existing.update(c)
else
@color_scheme.color_scheme_colors << ColorSchemeColor.new(name: c[:name], hex: c[:hex])
@color_scheme.color_scheme_colors << ColorSchemeColor.new(
name: c[:name],
hex: c[:hex],
dark_hex: c[:dark_hex],
)
end
end
@color_scheme.clear_colors_cache

View File

@ -6072,6 +6072,20 @@ en:
delete: "Delete"
delete_successful: "User field deleted."
save_successful: "User field saved."
color_palettes:
palette_name: "Name"
color_options:
title: "Color options"
toggle: "User selectable"
toggle_description: "Color palette can be selected by users"
colors:
title: "Colors"
fonts_and_logos_hint: "Looking to update your fonts and logos?"
go_to_branding: "Go to Branding"
save_changes: "Save changes"
unsaved_changes: "You have unsaved changes"
copy_of: "Copy of %{name}"
copy_created: 'A new copy of "%{name}" has been created'
plugins:
title: "Plugins"
name: "Name"

View File

@ -414,6 +414,7 @@ Discourse::Application.routes.draw do
get "group-permissions" => "site_settings#index"
get "branding" => "branding#index"
put "branding/logo" => "branding#logo"
get "colors/:id" => "color_palettes#show"
resources :flags, only: %i[index new create update destroy] do
put "toggle"

View File

@ -3577,6 +3577,7 @@ experimental:
use_overhauled_theme_color_palette:
default: false
hidden: true
client: true
rich_editor:
client: true
default: false

View File

@ -0,0 +1,95 @@
# frozen_string_literal: true
describe "Admin Color Palettes Config Area Page", type: :system do
fab!(:admin)
fab!(:color_scheme) { Fabricate(:color_scheme, user_selectable: false, name: "A Test Palette") }
let(:config_area) { PageObjects::Pages::AdminColorPalettesConfigArea.new }
let(:toasts) { PageObjects::Components::Toasts.new }
before { sign_in(admin) }
it "allows editing the palette name" do
config_area.visit(color_scheme.id)
config_area.edit_name_button.click
config_area.name_field.fill_in("Changed name 2.0")
expect(config_area).to have_unsaved_changes_indicator
config_area.form.submit
expect(toasts).to have_success(I18n.t("js.saved"))
expect(config_area).to have_no_unsaved_changes_indicator
expect(color_scheme.reload.name).to eq("Changed name 2.0")
expect(config_area.name_heading.text).to eq("Changed name 2.0")
end
it "allows changing the user selectable field" do
config_area.visit(color_scheme.id)
config_area.user_selectable_field.toggle
expect(config_area).to have_unsaved_changes_indicator
config_area.form.submit
expect(toasts).to have_success(I18n.t("js.saved"))
expect(config_area).to have_no_unsaved_changes_indicator
expect(config_area.user_selectable_field.value).to eq(true)
expect(color_scheme.reload.user_selectable).to eq(true)
end
it "allows changing colors" do
config_area.visit(color_scheme.id)
expect(config_area.color_palette_editor).to have_light_tab_active
config_area.color_palette_editor.input_for_color("primary").fill_in(with: "#abcdef")
expect(config_area).to have_unsaved_changes_indicator
config_area.color_palette_editor.switch_to_dark_tab
expect(config_area.color_palette_editor).to have_dark_tab_active
config_area.color_palette_editor.input_for_color("primary").fill_in(with: "#fedcba")
config_area.color_palette_editor.input_for_color("secondary").fill_in(with: "#111222")
config_area.form.submit
expect(toasts).to have_success(I18n.t("js.saved"))
expect(config_area).to have_no_unsaved_changes_indicator
expect(config_area.color_palette_editor).to have_dark_tab_active
expect(config_area.color_palette_editor.input_for_color("primary").value).to eq("#fedcba")
expect(config_area.color_palette_editor.input_for_color("secondary").value).to eq("#111222")
config_area.color_palette_editor.switch_to_light_tab
expect(config_area.color_palette_editor).to have_light_tab_active
expect(config_area.color_palette_editor.input_for_color("primary").value).to eq("#abcdef")
expect(color_scheme.colors.find_by(name: "primary").hex).to eq("abcdef")
expect(color_scheme.colors.find_by(name: "primary").dark_hex).to eq("fedcba")
expect(color_scheme.colors.find_by(name: "secondary").dark_hex).to eq("111222")
end
it "allows duplicating the color palette" do
max_id = ColorScheme.maximum(:id)
color_scheme.update!(user_selectable: true)
config_area.visit(color_scheme.id)
expect(config_area.user_selectable_field.value).to eq(true)
config_area.duplicate_button.click
expect(page).to have_current_path("/admin/config/colors/#{max_id + 1}")
expect(config_area).to have_no_unsaved_changes_indicator
expect(config_area.name_heading.text).to eq(
I18n.t("admin_js.admin.config_areas.color_palettes.copy_of", name: color_scheme.name),
)
expect(toasts).to have_success(
I18n.t("admin_js.admin.config_areas.color_palettes.copy_created", name: color_scheme.name),
)
expect(config_area.user_selectable_field.value).to eq(false)
end
end

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
module PageObjects
module Components
class ColorPaletteEditor < PageObjects::Components::Base
attr_reader :component
def initialize(component)
@component = component
end
def has_light_tab_active?
component.has_css?(".light-tab.active")
end
def has_dark_tab_active?
component.has_css?(".dark-tab.active")
end
def switch_to_light_tab
component.find(".light-tab").click
end
def switch_to_dark_tab
component.find(".dark-tab").click
end
def input_for_color(name)
component.find(
".color-palette-editor__colors-item[data-color-name=\"#{name}\"] input[type=\"color\"]",
)
end
end
end
end

View File

@ -48,6 +48,8 @@ module PageObjects
url = component.find(".uploaded-image-preview a.lightbox", wait: 10)[:href]
sha1 = url.match(/(\h{40})/).captures.first
Upload.find_by(sha1:)
when "toggle"
component.find("button[role=\"switch\"]", visible: :all)["aria-checked"] == "true"
end
end
@ -91,6 +93,8 @@ module PageObjects
component.find("input[type='checkbox']").click
when "password"
component.find(".form-kit__control-password-toggle").click
when "toggle"
component.find("button[role=\"switch\"]", visible: :all).ancestor("label").click
else
raise "'toggle' is not supported for control type: #{control_type}"
end

View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
module PageObjects
module Pages
class AdminColorPalettesConfigArea < PageObjects::Pages::Base
def visit(palette_id)
page.visit("/admin/config/colors/#{palette_id}")
end
def form
PageObjects::Components::FormKit.new(".admin-config.color-palettes-show .form-kit")
end
def edit_name_button
name_field.component.find(".btn-flat")
end
def name_field
form.field("name")
end
def name_heading
name_field.find("h2")
end
def user_selectable_field
form.field("user_selectable")
end
def color_palette_editor
PageObjects::Components::ColorPaletteEditor.new(
form.field("colors").component.find(".color-palette-editor"),
)
end
def duplicate_button
page.find(".duplicate-palette")
end
def has_unsaved_changes_indicator?
page.has_text?(I18n.t("admin_js.admin.config_areas.color_palettes.unsaved_changes"))
end
def has_no_unsaved_changes_indicator?
page.has_no_text?(I18n.t("admin_js.admin.config_areas.color_palettes.unsaved_changes"))
end
end
end
end