DEV: Add validation message to string fields in theme object editor (#26257)

Why this change?

In our schema, we support the `min_length` and `max_length` validation
rules like so:

```
some_objects_setting
  type: objects
  schema:
    name: some_object
    properties:
      title:
        type: string
        validations:
          min_length: 1
          max_length: 10
```

While the validations used to validate the objects on the server side,
we should also add client side validation for better UX.
This commit is contained in:
Alan Guo Xiang Tan 2024-03-21 12:39:25 +08:00 committed by GitHub
parent 70f7c0ee6f
commit 8de869630f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 185 additions and 19 deletions

View File

@ -0,0 +1,5 @@
<template>
<div class="schema-field__input-description">
{{@description}}
</div>
</template>

View File

@ -36,30 +36,28 @@ export default class SchemaThemeSettingField extends Component {
@cached
get description() {
return this.args.description.trim().replace(/\n/g, "<br>");
}
if (!this.args.description) {
return;
}
get hasDescription() {
return this.args.description?.length > 0;
return htmlSafe(this.args.description.trim().replace(/\n/g, "<br>"));
}
<template>
<div class="schema-field" data-name={{@name}}>
<label class="schema-field__label">{{@name}}</label>
<label class="schema-field__label">{{@name}}{{if
@spec.required
"*"
}}</label>
<div class="schema-field__input">
<this.component
@value={{@value}}
@spec={{@spec}}
@onChange={{@onValueChange}}
@description={{this.description}}
/>
</div>
{{#if this.hasDescription}}
<div class="schema-field__description">
{{htmlSafe this.description}}
</div>
{{/if}}
</div>
</template>
}

View File

@ -2,6 +2,7 @@ import Component from "@glimmer/component";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
export default class SchemaThemeSettingTypeBoolean extends Component {
@action
@ -11,5 +12,6 @@ export default class SchemaThemeSettingTypeBoolean extends Component {
<template>
<Input @checked={{@value}} {{on "input" this.onInput}} @type="checkbox" />
<FieldInputDescription @description={{@description}} />
</template>
}

View File

@ -2,6 +2,7 @@ import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
import CategoryChooser from "select-kit/components/category-chooser";
export default class SchemaThemeSettingTypeCategory extends Component {
@ -19,5 +20,6 @@ export default class SchemaThemeSettingTypeCategory extends Component {
@onChange={{this.onInput}}
@options={{hash allowUncategorized=false}}
/>
<FieldInputDescription @description={{@description}} />
</template>
}

View File

@ -1,6 +1,7 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
import ComboBox from "select-kit/components/combo-box";
export default class SchemaThemeSettingTypeEnum extends Component {
@ -27,5 +28,6 @@ export default class SchemaThemeSettingTypeEnum extends Component {
@value={{this.value}}
@onChange={{this.onInput}}
/>
<FieldInputDescription @description={{@description}} />
</template>
}

View File

@ -2,6 +2,7 @@ import Component from "@glimmer/component";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
export default class SchemaThemeSettingTypeFloat extends Component {
@action
@ -16,5 +17,7 @@ export default class SchemaThemeSettingTypeFloat extends Component {
@type="number"
step="0.1"
/>
<FieldInputDescription @description={{@description}} />
</template>
}

View File

@ -3,6 +3,7 @@ import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import Group from "discourse/models/group";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
import GroupChooser from "select-kit/components/group-chooser";
export default class SchemaThemeSettingTypeGroup extends Component {
@ -24,5 +25,7 @@ export default class SchemaThemeSettingTypeGroup extends Component {
@onChange={{this.onInput}}
@options={{hash maximum=1}}
/>
<FieldInputDescription @description={{@description}} />
</template>
}

View File

@ -2,6 +2,7 @@ import Component from "@glimmer/component";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
export default class SchemaThemeSettingTypeInteger extends Component {
@action
@ -11,5 +12,7 @@ export default class SchemaThemeSettingTypeInteger extends Component {
<template>
<Input @value={{@value}} {{on "input" this.onInput}} @type="number" />
<FieldInputDescription @description={{@description}} />
</template>
}

View File

@ -1,15 +1,81 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { and, not } from "truth-helpers";
import concatClass from "discourse/helpers/concat-class";
import I18n from "discourse-i18n";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
export default class SchemaThemeSettingTypeString extends Component {
@tracked touched = false;
@tracked value = this.args.value || "";
minLength = this.args.spec.validations?.min_length;
maxLength = this.args.spec.validations?.max_length;
required = this.args.spec.required;
@action
onInput(event) {
this.args.onChange(event.currentTarget.value);
this.touched = true;
const newValue = event.currentTarget.value;
this.args.onChange(newValue);
this.value = newValue;
}
get validationErrorMessage() {
if (!this.touched) {
return;
}
const valueLength = this.value.length;
if (valueLength === 0) {
if (this.required) {
return I18n.t("admin.customize.theme.schema.fields.required");
} else {
return;
}
}
if (this.minLength && valueLength < this.minLength) {
return I18n.t("admin.customize.theme.schema.fields.string.too_short", {
count: this.minLength,
});
}
}
<template>
<Input @value={{@value}} {{on "input" this.onInput}} />
<Input
class="--string"
@value={{this.value}}
{{on "input" this.onInput}}
required={{this.required}}
minLength={{this.minLength}}
maxLength={{this.maxLength}}
/>
<div class="schema-field__input-supporting-text">
{{#if (and @description (not this.validationErrorMessage))}}
<FieldInputDescription @description={{@description}} />
{{/if}}
{{#if this.validationErrorMessage}}
<div class="schema-field__input-error">
{{this.validationErrorMessage}}
</div>
{{/if}}
{{#if this.maxLength}}
<div
class={{concatClass
"schema-field__input-count"
(if this.validationErrorMessage " --error")
}}
>
{{this.value.length}}/{{this.maxLength}}
</div>
{{/if}}
</div>
</template>
}

View File

@ -2,6 +2,7 @@ import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
import TagChooser from "select-kit/components/tag-chooser";
export default class SchemaThemeSettingTypeTag extends Component {
@ -19,5 +20,7 @@ export default class SchemaThemeSettingTypeTag extends Component {
@onChange={{this.onInput}}
@options={{hash allowAny=false}}
/>
<FieldInputDescription @description={{@description}} />
</template>
}

View File

@ -421,6 +421,56 @@ module(
assert.dom(inputFields.fields.icon.inputElement).hasValue("phone");
});
test("input fields of type string", async function (assert) {
const setting = ThemeSettings.create({
setting: "objects_setting",
objects_schema: {
name: "something",
identifier: "id",
properties: {
id: {
type: "string",
required: true,
validations: {
max_length: 5,
min_length: 2,
},
},
},
},
value: [
{
id: "bu1",
},
],
});
await render(<template>
<AdminSchemaThemeSettingEditor @themeId="1" @setting={{setting}} />
</template>);
const fieldSelector =
".schema-field[data-name='id'] .schema-field__input";
assert.dom(`${fieldSelector} .schema-field__input-count`).hasText("3/5");
await fillIn(`${fieldSelector} input`, "1");
assert.dom(`${fieldSelector} .schema-field__input-error`).hasText(
I18n.t("admin.customize.theme.schema.fields.string.too_short", {
count: 2,
})
);
await fillIn(`${fieldSelector} input`, "");
assert.dom(`${fieldSelector} .schema-field__input-count`).hasText("0/5");
assert
.dom(`${fieldSelector} .schema-field__input-error`)
.hasText(I18n.t("admin.customize.theme.schema.fields.required"));
});
test("input fields of type integer", async function (assert) {
const setting = schemaAndData(3);

View File

@ -1,17 +1,41 @@
.schema-field {
margin-bottom: 1em;
width: 50%;
min-width: 200px;
.schema-field__input {
input {
width: 100%;
margin-bottom: 0;
width: 100%;
}
margin-bottom: 0.3em;
}
.select-kit {
width: 100%;
}
.schema-field__description {
font-size: var(--font-down-1);
color: var(--primary-medium);
.schema-field__input-description {
font-size: var(--font-down-1);
color: var(--primary-medium);
}
}
.schema-field__input-supporting-text {
display: flex;
flex-direction: row;
margin-top: 0.2em;
.schema-field__input-count {
margin-left: auto;
font-size: var(--font-down-1);
&.--error {
color: var(--danger);
}
}
.schema-field__input-error {
font-size: var(--font-down-1);
color: var(--danger);
}
}
}

View File

@ -5647,6 +5647,11 @@ en:
schema:
title: "Edit %{name} setting"
back_button: "Back to %{name}"
fields:
required: "*required"
string:
too_short: "must be at least %{count} characters"
colors:
select_base:
title: "Select base color palette"

View File

@ -42,7 +42,7 @@ module PageObjects
end
def input_field_description(field_name)
page.find(".schema-field[data-name=\"#{field_name}\"] .schema-field__description")
page.find(".schema-field[data-name=\"#{field_name}\"] .schema-field__input-description")
end
end
end