FIX: display validation under custom sidebar fields (#20772)

Before, incorrectly filled fields were marked with red border. Now, additional information under the field is displayed to notify the user what is incorrect.

/t/93696
This commit is contained in:
Krzysztof Kotlarek 2023-03-27 13:03:16 +11:00 committed by GitHub
parent db3d30af3a
commit 4047073292
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 213 additions and 31 deletions

View File

@ -8,6 +8,7 @@ import I18n from "I18n";
import { sanitize } from "discourse/lib/text";
import { tracked } from "@glimmer/tracking";
import { A } from "@ember/array";
import { SIDEBAR_SECTION, SIDEBAR_URL } from "discourse/lib/constants";
const FULL_RELOAD_LINKS_REGEX = [/^\/my\/[a-z_\-\/]+$/, /^\/safe-mode$/];
@ -29,12 +30,34 @@ class Section {
}
get validTitle() {
return !isEmpty(this.title) && this.title.length <= 30;
return !this.#blankTitle && !this.#tooLongTitle;
}
get invalidTitleMessage() {
if (this.title === undefined) {
return;
}
if (this.#blankTitle) {
return I18n.t("sidebar.sections.custom.title.validation.blank");
}
if (this.#tooLongTitle) {
return I18n.t("sidebar.sections.custom.title.validation.maximum", {
count: SIDEBAR_SECTION.max_title_length,
});
}
}
get titleCssClass() {
return this.title === undefined || this.validTitle ? "" : "warning";
}
get #blankTitle() {
return isEmpty(this.title);
}
get #tooLongTitle() {
return this.title.length > SIDEBAR_SECTION.max_title_length;
}
}
class SectionLink {
@ -62,21 +85,71 @@ class SectionLink {
}
get validIcon() {
return !isEmpty(this.icon) && this.icon.length <= 40;
return !this.#blankIcon && !this.#tooLongIcon;
}
get validName() {
return !this.#blankName && !this.#tooLongName;
}
get validValue() {
return !this.#blankValue && !this.#tooLongValue && !this.#invalidValue;
}
get invalidIconMessage() {
if (this.#blankIcon) {
return I18n.t("sidebar.sections.custom.links.icon.validation.blank");
}
if (this.#tooLongIcon) {
return I18n.t("sidebar.sections.custom.links.icon.validation.maximum", {
count: SIDEBAR_URL.max_icon_length,
});
}
}
get invalidNameMessage() {
if (this.name === undefined) {
return;
}
if (this.#blankName) {
return I18n.t("sidebar.sections.custom.links.name.validation.blank");
}
if (this.#tooLongName) {
return I18n.t("sidebar.sections.custom.links.name.validation.maximum", {
count: SIDEBAR_URL.max_name_length,
});
}
}
get invalidValueMessage() {
if (this.value === undefined) {
return;
}
if (this.#blankValue) {
return I18n.t("sidebar.sections.custom.links.value.validation.blank");
}
if (this.#tooLongValue) {
return I18n.t("sidebar.sections.custom.links.value.validation.maximum", {
count: SIDEBAR_URL.max_value_length,
});
}
if (this.#invalidValue) {
return I18n.t("sidebar.sections.custom.links.value.validation.invalid");
}
}
get iconCssClass() {
return this.icon === undefined || this.validIcon ? "" : "warning";
}
get validName() {
return !isEmpty(this.name) && this.name.length <= 80;
}
get nameCssClass() {
return this.name === undefined || this.validName ? "" : "warning";
}
get valueCssClass() {
return this.value === undefined || this.validValue ? "" : "warning";
}
get external() {
return (
this.value &&
@ -88,6 +161,37 @@ class SectionLink {
);
}
get #blankIcon() {
return isEmpty(this.icon);
}
get #tooLongIcon() {
return this.icon.length > SIDEBAR_URL.max_icon_length;
}
get #blankName() {
return isEmpty(this.name);
}
get #tooLongName() {
return this.name.length > SIDEBAR_URL.max_name_length;
}
get #blankValue() {
return isEmpty(this.value);
}
get #tooLongValue() {
return this.value.length > SIDEBAR_URL.max_value_length;
}
get #invalidValue() {
return (
this.path &&
(this.external ? !this.#validExternal() : !this.#validInternal())
);
}
#validExternal() {
try {
return new URL(this.value);
@ -102,19 +206,6 @@ class SectionLink {
FULL_RELOAD_LINKS_REGEX.some((regex) => this.path.match(regex))
);
}
get validValue() {
return (
!isEmpty(this.value) &&
this.value.length <= 200 &&
this.path &&
(this.external ? this.#validExternal() : this.#validInternal())
);
}
get valueCssClass() {
return this.value === undefined || this.validValue ? "" : "warning";
}
}
export default Controller.extend(ModalFunctionality, {

View File

@ -11,3 +11,13 @@ export const SEARCH_PRIORITIES = {
};
export const SEARCH_PHRASE_REGEXP = '"([^"]+)"';
export const SIDEBAR_URL = {
max_icon_length: 40,
max_name_length: 80,
max_value_length: 200,
};
export const SIDEBAR_SECTION = {
max_title_length: 30,
};

View File

@ -6,7 +6,9 @@
<DModalBody @title={{this.header}}>
<form class="form-horizontal">
<div class="input-group">
<label for="section-name">{{i18n "sidebar.sections.custom.name"}}</label>
<label for="section-name">{{i18n
"sidebar.sections.custom.title.label"
}}</label>
<Input
name="section-name"
@type="text"
@ -14,12 +16,17 @@
class={{this.model.titleCssClass}}
{{on "input" (action (mut this.model.title) value="target.value")}}
/>
{{#if this.model.invalidTitleMessage}}
<div class="title warning">
{{this.model.invalidTitleMessage}}
</div>
{{/if}}
</div>
{{#each this.activeLinks as |link|}}
<div class="row-wrapper">
<div class="input-group">
<label for="link-name">{{i18n
"sidebar.sections.custom.links.icon"
"sidebar.sections.custom.links.icon.label"
}}</label>
<IconPicker
@name="icon"
@ -29,10 +36,15 @@
@onlyAvailable={{true}}
@onChange={{action (mut link.icon)}}
/>
{{#if link.invalidIconMessage}}
<div class="icon warning">
{{link.invalidIconMessage}}
</div>
{{/if}}
</div>
<div class="input-group">
<label for="link-name">{{i18n
"sidebar.sections.custom.links.name"
"sidebar.sections.custom.links.name.label"
}}</label>
<Input
name="link-name"
@ -41,10 +53,15 @@
class={{link.nameCssClass}}
{{on "input" (action (mut link.name) value="target.value")}}
/>
{{#if link.invalidNameMessage}}
<div class="name warning">
{{link.invalidNameMessage}}
</div>
{{/if}}
</div>
<div class="input-group">
<label for="link-url">{{i18n
"sidebar.sections.custom.links.value"
"sidebar.sections.custom.links.value.label"
}}</label>
<Input
name="link-url"
@ -53,6 +70,11 @@
class={{link.valueCssClass}}
{{on "input" (action (mut link.value) value="target.value")}}
/>
{{#if link.invalidValueMessage}}
<div class="value warning">
{{link.invalidValueMessage}}
</div>
{{/if}}
</div>
<DButton
@icon="trash-alt"

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
class SidebarSection < ActiveRecord::Base
MAX_TITLE_LENGTH = 30
belongs_to :user
has_many :sidebar_section_links, -> { order("position") }, dependent: :destroy
has_many :sidebar_urls,
@ -10,7 +12,14 @@ class SidebarSection < ActiveRecord::Base
accepts_nested_attributes_for :sidebar_urls, allow_destroy: true
validates :title, presence: true, uniqueness: { scope: %i[user_id] }, length: { maximum: 30 }
validates :title,
presence: true,
uniqueness: {
scope: %i[user_id],
},
length: {
maximum: MAX_TITLE_LENGTH,
}
end
# == Schema Information

View File

@ -2,10 +2,13 @@
class SidebarUrl < ActiveRecord::Base
FULL_RELOAD_LINKS_REGEX = [%r{\A/my/[a-z_\-/]+\z}, %r{\A/safe-mode\z}]
MAX_ICON_LENGTH = 40
MAX_NAME_LENGTH = 80
MAX_VALUE_LENGTH = 200
validates :icon, presence: true, length: { maximum: 40 }
validates :name, presence: true, length: { maximum: 80 }
validates :value, presence: true, length: { maximum: 200 }
validates :icon, presence: true, length: { maximum: MAX_ICON_LENGTH }
validates :name, presence: true, length: { maximum: MAX_NAME_LENGTH }
validates :value, presence: true, length: { maximum: MAX_VALUE_LENGTH }
validate :path_validator

View File

@ -4389,17 +4389,34 @@ en:
custom:
add: "Add custom section"
edit: "Edit custom section"
name: "Section title"
save: "Save"
delete: "Delete"
delete_confirm: "Are you sure you want to delete this section?"
public: "Make this section public and visible to everyone"
links:
icon: "Icon"
name: "Name"
value: "Link"
add: "Add another link"
delete: "Delete link"
icon:
label: "Icon"
validation:
blank: "Icon cannot be blank"
maximum: "Icon must be shorter than %{count} characters"
name:
label: "Name"
validation:
blank: "Name cannot be blank"
maximum: "Name must be shorter than %{count} characters"
value:
label: "Link"
validation:
blank: "Link cannot be blank"
maximum: "Link must be shorter than %{count} characters"
invalid: "Format is invalid"
title:
label: "Section title"
validation:
blank: "Title cannot be blank"
maximum: "Title must be shorter than %{count} characters"
about:
header_link_text: "About"
messages:

View File

@ -163,6 +163,16 @@ task "javascript:update_constants" => :environment do
export const SEARCH_PRIORITIES = #{Searchable::PRIORITIES.to_json};
export const SEARCH_PHRASE_REGEXP = '#{Search::PHRASE_MATCH_REGEXP_PATTERN}';
export const SIDEBAR_URL = {
max_icon_length: #{SidebarUrl::MAX_ICON_LENGTH},
max_name_length: #{SidebarUrl::MAX_NAME_LENGTH},
max_value_length: #{SidebarUrl::MAX_VALUE_LENGTH}
}
export const SIDEBAR_SECTION = {
max_title_length: #{SidebarSection::MAX_TITLE_LENGTH},
}
JS
pretty_notifications = Notification.types.map { |n| " #{n[0]}: #{n[1]}," }.join("\n")

View File

@ -189,4 +189,24 @@ describe "Custom sidebar sections", type: :system, js: true do
expect(page).not_to have_button("Edited public section")
end
it "validates custom section fields" do
visit("/latest")
sidebar.open_new_custom_section
section_modal.fill_name("A" * (SidebarSection::MAX_TITLE_LENGTH + 1))
section_modal.fill_link("B" * (SidebarUrl::MAX_NAME_LENGTH + 1), "/wrong-url")
expect(page.find(".title.warning")).to have_content("Title must be shorter than 30 characters")
expect(page.find(".name.warning")).to have_content("Name must be shorter than 80 characters")
expect(page.find(".value.warning")).to have_content("Format is invalid")
section_modal.fill_name("")
section_modal.fill_link("", "")
expect(page.find(".title.warning")).to have_content("Title cannot be blank")
expect(page.find(".name.warning")).to have_content("Name cannot be blank")
expect(page.find(".value.warning")).to have_content("Link cannot be blank")
expect(section_modal).to have_disabled_save
end
end