2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2025-10-03 17:21:20 +08:00

FIX: Allow saving category when color attributes are 3-digits hex (#35119)

This commitreworks the validation and handling of color pickers in
the category edit page so they're simpler and more predictable. In
particular:

* It no longer attempts to "autocomplete" 3-digits hex values to the
equivalent 6-digits format -- we now accept both 3 and 6 digits formats
* It no longer disables the save button if the input color value is
invalid. The save button is always clickable, and if the admin attempts
to save with an invalid color value, the form will display errors
explaining which input is invalid and what's wrong with the color value

Internal topic: t/163062.
This commit is contained in:
Osama Sayegh 2025-10-02 14:33:30 +03:00 committed by GitHub
parent 28a58a764b
commit 2b7835d02a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 171 additions and 25 deletions

View file

@ -32,16 +32,16 @@ export default class ColorInput extends Component {
} }


@computed("hexValueWithFallback") @computed("hexValueWithFallback")
get normalizedValue() { get valueForPicker() {
return this.normalize(this.hexValueWithFallback); return this.normalize(this.hexValueWithFallback, { forPicker: true });
} }


normalize(color) { normalize(color, { forPicker = false } = {}) {
if (this._valid(color)) { if (this._valid(color)) {
if (!color.startsWith("#")) { if (!color.startsWith("#")) {
color = "#" + color; color = "#" + color;
} }
if (color.length === 4) { if (color.length === 4 && (!this.skipNormalize || forPicker)) {
color = color =
"#" + "#" +
color color
@ -109,8 +109,8 @@ export default class ColorInput extends Component {
<input <input
class="picker" class="picker"
type="color" type="color"
value={{this.normalizedValue}} value={{this.valueForPicker}}
title={{this.normalizedValue}} title={{this.valueForPicker}}
{{on "input" this.onPickerInput}} {{on "input" this.onPickerInput}}
aria-labelledby={{this.ariaLabelledby}} aria-labelledby={{this.ariaLabelledby}}
/> />

View file

@ -129,6 +129,10 @@ export default class EditCategoryGeneral extends Component {
updateColor(field, newColor) { updateColor(field, newColor) {
const color = newColor.replace("#", ""); const color = newColor.replace("#", "");


if (color === field.value) {
return;
}

if (field.name === "color") { if (field.name === "color") {
const whiteDiff = this.colorDifference(color, CATEGORY_TEXT_COLORS[0]); const whiteDiff = this.colorDifference(color, CATEGORY_TEXT_COLORS[0]);
const blackDiff = this.colorDifference(color, CATEGORY_TEXT_COLORS[1]); const blackDiff = this.colorDifference(color, CATEGORY_TEXT_COLORS[1]);
@ -160,6 +164,41 @@ export default class EditCategoryGeneral extends Component {
return rDiff + gDiff + bDiff; return rDiff + gDiff + bDiff;
} }


@action
validateColor(name, color, { addError }) {
color = color.trim();

let title;
if (name === "color") {
title = i18n("category.background_color");
} else if (name === "text_color") {
title = i18n("category.foreground_color");
} else {
throw new Error(`unknown title for category attribute ${name}`);
}

if (!color) {
addError(name, {
title,
message: i18n("category.color_validations.cant_be_empty"),
});
}

if (color.length !== 3 && color.length !== 6) {
addError(name, {
title,
message: i18n("category.color_validations.incorrect_length"),
});
}

if (!/^[0-9A-Fa-f]+$/.test(color)) {
addError(name, {
title,
message: i18n("category.color_validations.non_hexdecimal"),
});
}
}

get categoryDescription() { get categoryDescription() {
if (this.args.category.description) { if (this.args.category.description) {
return htmlSafe(this.args.category.description); return htmlSafe(this.args.category.description);
@ -318,6 +357,8 @@ export default class EditCategoryGeneral extends Component {
@name="color" @name="color"
@title={{i18n "category.background_color"}} @title={{i18n "category.background_color"}}
@format="full" @format="full"
@validate={{this.validateColor}}
@validation="required"
as |field| as |field|
> >
<field.Custom> <field.Custom>
@ -328,6 +369,7 @@ export default class EditCategoryGeneral extends Component {
@valid={{@category.colorValid}} @valid={{@category.colorValid}}
@ariaLabelledby="background-color-label" @ariaLabelledby="background-color-label"
@onChangeColor={{fn this.updateColor field}} @onChangeColor={{fn this.updateColor field}}
@skipNormalize={{true}}
/> />
<ColorPicker <ColorPicker
@colors={{this.backgroundColors}} @colors={{this.backgroundColors}}
@ -345,6 +387,8 @@ export default class EditCategoryGeneral extends Component {
@name="text_color" @name="text_color"
@title={{i18n "category.foreground_color"}} @title={{i18n "category.foreground_color"}}
@format="full" @format="full"
@validate={{this.validateColor}}
@validation="required"
as |field| as |field|
> >
<field.Custom> <field.Custom>
@ -353,7 +397,8 @@ export default class EditCategoryGeneral extends Component {
<ColorInput <ColorInput
@hexValue={{readonly field.value}} @hexValue={{readonly field.value}}
@ariaLabelledby="foreground-color-label" @ariaLabelledby="foreground-color-label"
@onBlur={{fn this.updateColor field}} @onChangeColor={{fn this.updateColor field}}
@skipNormalize={{true}}
/> />
<ColorPicker <ColorPicker
@colors={{CATEGORY_TEXT_COLORS}} @colors={{CATEGORY_TEXT_COLORS}}

View file

@ -61,14 +61,6 @@ export default class EditCategoryTabsController extends Controller {
return false; return false;
} }


if (!transientData.color) {
return false;
}

if (transientData.text_color.length < 6) {
return false;
}

if (this.saving || this.deleting) { if (this.saving || this.deleting) {
return true; return true;
} }
@ -120,13 +112,12 @@ export default class EditCategoryTabsController extends Controller {
} }


@action @action
saveCategory(transientData) { saveCategory(data) {
if (this.validators.some((validator) => validator())) { if (this.validators.some((validator) => validator())) {
return; return;
} }


this.model.setProperties(transientData); this.model.setProperties(data);

this.set("saving", true); this.set("saving", true);


this.model this.model

View file

@ -225,7 +225,7 @@ class FKForm extends Component {
try { try {
this.isSubmitting = true; this.isSubmitting = true;


await this.validate(this.fields.values()); await this.validate([...this.fields.values()]);


if (this.formData.isValid) { if (this.formData.isValid) {
this.formData.save(); this.formData.save();

View file

@ -1,4 +1,3 @@
import { fn } from "@ember/helper";
import { getOwner } from "@ember/owner"; import { getOwner } from "@ember/owner";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import RouteTemplate from "ember-route-template"; import RouteTemplate from "ember-route-template";
@ -93,6 +92,7 @@ export default RouteTemplate(
<Form <Form
@data={{@controller.formData}} @data={{@controller.formData}}
@onDirtyCheck={{@controller.isLeavingForm}} @onDirtyCheck={{@controller.isLeavingForm}}
@onSubmit={{@controller.saveCategory}}
as |form transientData| as |form transientData|
> >
<form.Section <form.Section
@ -104,7 +104,7 @@ export default RouteTemplate(
<Tab <Tab
@selectedTab={{@controller.selectedTab}} @selectedTab={{@controller.selectedTab}}
@category={{@controller.model}} @category={{@controller.model}}
@action={{@controller.registerValidator}} @registerValidator={{@controller.registerValidator}}
@transientData={{transientData}} @transientData={{transientData}}
@form={{form}} @form={{form}}
/> />
@ -119,12 +119,10 @@ export default RouteTemplate(
{{/if}} {{/if}}


<form.Actions class="edit-category-footer"> <form.Actions class="edit-category-footer">
<form.Button <form.Submit
@disabled={{not (@controller.canSaveForm transientData)}} @disabled={{not (@controller.canSaveForm transientData)}}
@action={{fn @controller.saveCategory transientData}}
@label={{@controller.saveLabel}} @label={{@controller.saveLabel}}
id="save-category" id="save-category"
class="btn-primary"
/> />


{{#if @controller.model.can_delete}} {{#if @controller.model.can_delete}}

View file

@ -104,6 +104,8 @@ class Category < ActiveRecord::Base
}, },
allow_nil: true allow_nil: true
validates :slug, exclusion: { in: RESERVED_SLUGS } validates :slug, exclusion: { in: RESERVED_SLUGS }
validates :color, format: { with: /\A(\h{6}|\h{3})\z/ }
validates :text_color, format: { with: /\A(\h{6}|\h{3})\z/ }


after_create :create_category_definition after_create :create_category_definition
after_destroy :trash_category_definition after_destroy :trash_category_definition

View file

@ -4461,6 +4461,10 @@ en:
style: "Styles" style: "Styles"
background_color: "Color" background_color: "Color"
foreground_color: "Text color" foreground_color: "Text color"
color_validations:
cant_be_empty: "can't be empty"
incorrect_length: "must match the format #RRGGBB or #RGB"
non_hexdecimal: "must only contain hexadecimal characters (0-9, A-F)"
styles: styles:
type: "Style" type: "Style"
icon: "Icon" icon: "Icon"

View file

@ -38,6 +38,34 @@ RSpec.describe Category do
expect(cats.errors[:name]).to be_present expect(cats.errors[:name]).to be_present
end end


it "validates format of color" do
expect(Fabricate.build(:category, color: "fff", user:)).to be_valid
expect(Fabricate.build(:category, color: "1ac", user:)).to be_valid
expect(Fabricate.build(:category, color: "fffeee", user:)).to be_valid
expect(Fabricate.build(:category, color: "FF11CC", user:)).to be_valid
expect(Fabricate.build(:category, color: "ABC", user:)).to be_valid

expect(Fabricate.build(:category, color: "ffq1e1", user:)).to_not be_valid
expect(Fabricate.build(:category, color: "", user:)).to_not be_valid
expect(Fabricate.build(:category, color: "f", user:)).to_not be_valid
expect(Fabricate.build(:category, color: "21", user:)).to_not be_valid
expect(Fabricate.build(:category, color: "XCA", user:)).to_not be_valid
end

it "validates format of text_color" do
expect(Fabricate.build(:category, text_color: "fff", user:)).to be_valid
expect(Fabricate.build(:category, text_color: "1ac", user:)).to be_valid
expect(Fabricate.build(:category, text_color: "fffeee", user:)).to be_valid
expect(Fabricate.build(:category, text_color: "FF11CC", user:)).to be_valid
expect(Fabricate.build(:category, text_color: "ABC", user:)).to be_valid

expect(Fabricate.build(:category, text_color: "ffq1e1", user:)).to_not be_valid
expect(Fabricate.build(:category, text_color: "", user:)).to_not be_valid
expect(Fabricate.build(:category, text_color: "f", user:)).to_not be_valid
expect(Fabricate.build(:category, text_color: "21", user:)).to_not be_valid
expect(Fabricate.build(:category, text_color: "XCA", user:)).to_not be_valid
end

describe "Associations" do describe "Associations" do
it { is_expected.to have_one(:category_setting).dependent(:destroy) } it { is_expected.to have_one(:category_setting).dependent(:destroy) }



View file

@ -0,0 +1,73 @@
# frozen_string_literal: true

describe "Edit Category General", type: :system do
fab!(:admin)
fab!(:category)
let(:category_page) { PageObjects::Pages::Category.new }
let(:form) { PageObjects::Components::FormKit.new(".form-kit") }
before { sign_in(admin) }

context "when changing background color" do
it "displays an error when the hex code is invalid" do
category_page.visit_general(category)

form.field("color").component.find("input.hex-input").fill_in(with: "ABZ")
category_page.save_settings
expect(form.field("color")).to have_errors(
I18n.t("js.category.color_validations.non_hexdecimal"),
)

form.field("color").component.find("input.hex-input").fill_in(with: "")
category_page.save_settings
expect(form.field("color")).to have_errors(
I18n.t("js.category.color_validations.cant_be_empty"),
)

form.field("color").component.find("input.hex-input").fill_in(with: "A")
category_page.save_settings
expect(form.field("color")).to have_errors(
I18n.t("js.category.color_validations.incorrect_length"),
)
end

it "saves successfully when the hex code is valid" do
category_page.visit_general(category)

form.field("color").component.find("input.hex-input").fill_in(with: "AB1")
category_page.save_settings
expect(form.field("color")).to have_no_errors
end
end

context "when changing text color" do
it "displays an error when the hex code is invalid" do
category_page.visit_general(category)

form.field("text_color").component.find("input.hex-input").fill_in(with: "ABZ")
category_page.save_settings
expect(form.field("text_color")).to have_errors(
I18n.t("js.category.color_validations.non_hexdecimal"),
)

form.field("text_color").component.find("input.hex-input").fill_in(with: "")
category_page.save_settings
expect(form.field("text_color")).to have_errors(
I18n.t("js.category.color_validations.cant_be_empty"),
)

form.field("text_color").component.find("input.hex-input").fill_in(with: "A")
category_page.save_settings
expect(form.field("text_color")).to have_errors(
I18n.t("js.category.color_validations.incorrect_length"),
)
end

it "saves successfully when the hex code is valid" do
category_page.visit_general(category)

form.field("text_color").component.find("input.hex-input").fill_in(with: "AB1")
category_page.save_settings
expect(form.field("text_color")).to have_no_errors
end
end
end

View file

@ -80,7 +80,7 @@ module PageObjects
end end


def has_no_errors? def has_no_errors?
!has_css?(".form-kit__errors") has_no_css?(".form-kit__errors")
end end


def control_type def control_type

View file

@ -15,6 +15,11 @@ module PageObjects
self self
end end


def visit_general(category)
page.visit("/c/#{category.slug}/edit/general")
self
end

def visit_edit_template(category) def visit_edit_template(category)
page.visit("/c/#{category.slug}/edit/topic-template") page.visit("/c/#{category.slug}/edit/topic-template")
self self