DEV: Convert admin user fields to FormKit (#29070)

This change replaces the admin form for adding and editing custom user fields with a new FormKit implementation.
This commit is contained in:
Ted Johansson 2024-10-14 13:19:53 +08:00 committed by GitHub
parent ede06ffd43
commit 408de686bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 313 additions and 395 deletions

View File

@ -1,134 +1,163 @@
<div class="user-field">
{{#if (or this.isEditing (not this.userField.id))}}
<AdminFormRow @label="admin.user_fields.type">
<ComboBox
@content={{this.fieldTypes}}
@value={{this.buffered.field_type}}
@onChange={{fn (mut this.buffered.field_type)}}
/>
</AdminFormRow>
{{#if (or this.isEditing (not @userField.id))}}
<div class="admin-config-area user-field">
<div class="admin-config-area__primary-content">
<div class="admin-config-area-card">
<Form
@data={{this.formData}}
@onSubmit={{this.save}}
{{did-insert this._focusName}}
as |form transientData|
>
<form.Field
@name="field_type"
@title={{i18n "admin.user_fields.type"}}
@format="large"
@validation="required"
as |field|
>
<field.Select as |select|>
{{#each @fieldTypes as |fieldType|}}
<select.Option
@value={{fieldType.id}}
>{{fieldType.name}}</select.Option>
{{/each}}
</field.Select>
</form.Field>
<AdminFormRow @label="admin.user_fields.name">
<Input
@value={{this.buffered.name}}
class="user-field-name"
maxlength="255"
/>
</AdminFormRow>
<form.Field
@name="name"
@title={{i18n "admin.user_fields.name"}}
@format="large"
@validation="required"
as |field|
>
<field.Input class="user-field-name" maxlength="255" />
</form.Field>
<AdminFormRow @label="admin.user_fields.description">
<Input
@value={{this.buffered.description}}
class="user-field-desc"
maxlength="1000"
/>
</AdminFormRow>
<form.Field
@name="description"
@title={{i18n "admin.user_fields.description"}}
@format="large"
@validation="required"
as |field|
>
<field.Input class="user-field-desc" maxlength="1000" />
</form.Field>
{{#if this.bufferedFieldType.hasOptions}}
<AdminFormRow @label="admin.user_fields.options">
<ValueList @values={{this.buffered.options}} @inputType="array" />
</AdminFormRow>
{{#if
(or
(eq transientData.field_type "dropdown")
(eq transientData.field_type "multiselect")
)
}}
<form.Field
@name="options"
@title={{i18n "admin.user_fields.options"}}
@format="large"
@validation="required"
as |field|
>
<field.Custom>
<ValueList
@values={{transientData.options}}
@inputType="array"
@onChange={{field.set}}
/>
</field.Custom>
</form.Field>
{{/if}}
<AdminFormRow @label="admin.user_fields.requirement.title">
<label class="optional">
<RadioButton
@value="optional"
<form.Field
@name="requirement"
@selection={{this.buffered.requirement}}
@onChange={{action "changeRequirementType"}}
/>
<span>{{i18n "admin.user_fields.requirement.optional.title"}}</span>
</label>
<label class="for_all_users">
<RadioButton
@value="for_all_users"
@name="requirement"
@selection={{this.buffered.requirement}}
@onChange={{action "changeRequirementType"}}
/>
<div class="label-text">
<span>{{i18n
"admin.user_fields.requirement.for_all_users.title"
}}</span>
<div class="description">{{i18n
@title={{i18n "admin.user_fields.requirement.title"}}
@validation="required"
@onSet={{this.setRequirement}}
@format="full"
as |field|
>
<field.RadioGroup as |radioGroup|>
<radioGroup.Radio @value="optional">
{{i18n "admin.user_fields.requirement.optional.title"}}
</radioGroup.Radio>
<radioGroup.Radio @value="for_all_users" as |radio|>
{{i18n "admin.user_fields.requirement.for_all_users.title"}}
<radio.Description>{{i18n
"admin.user_fields.requirement.for_all_users.description"
}}</div>
</div>
</label>
<label class="on_signup">
<RadioButton
@value="on_signup"
@name="requirement"
@selection={{this.buffered.requirement}}
@onChange={{action "changeRequirementType"}}
/>
<div class="label-text">
<span>{{i18n "admin.user_fields.requirement.on_signup.title"}}</span>
<div class="description">{{i18n
}}</radio.Description>
</radioGroup.Radio>
<radioGroup.Radio @value="on_signup" as |radio|>
{{i18n "admin.user_fields.requirement.on_signup.title"}}
<radio.Description>{{i18n
"admin.user_fields.requirement.on_signup.description"
}}</div>
</div>
</label>
</AdminFormRow>
}}</radio.Description>
</radioGroup.Radio>
</field.RadioGroup>
</form.Field>
<AdminFormRow @label="admin.user_fields.preferences">
<label>
<Input
@type="checkbox"
@checked={{this.buffered.editable}}
disabled={{this.editableDisabled}}
/>
<span>{{i18n "admin.user_fields.editable.title"}}</span>
</label>
<form.CheckboxGroup
class="user-field-preferences"
@title={{i18n "admin.user_fields.preferences"}}
as |group|
>
<group.Field
@name="editable"
@showTitle={{false}}
@title={{i18n "admin.user_fields.editable.title"}}
as |field|
>
<field.Checkbox disabled={{this.editableDisabled}} />
</group.Field>
<group.Field
@name="show_on_profile"
@showTitle={{false}}
@title={{i18n "admin.user_fields.show_on_profile.title"}}
as |field|
>
<field.Checkbox />
</group.Field>
<group.Field
@name="show_on_user_card"
@showTitle={{false}}
@title={{i18n "admin.user_fields.show_on_user_card.title"}}
as |field|
>
<field.Checkbox />
</group.Field>
<group.Field
@name="searchable"
@showTitle={{false}}
@title={{i18n "admin.user_fields.searchable.title"}}
as |field|
>
<field.Checkbox />
</group.Field>
</form.CheckboxGroup>
<label>
<Input @type="checkbox" @checked={{this.buffered.show_on_profile}} />
<span>{{i18n "admin.user_fields.show_on_profile.title"}}</span>
</label>
<label>
<Input @type="checkbox" @checked={{this.buffered.show_on_user_card}} />
<span>{{i18n "admin.user_fields.show_on_user_card.title"}}</span>
</label>
<label>
<Input @type="checkbox" @checked={{this.buffered.searchable}} />
<span>{{i18n "admin.user_fields.searchable.title"}}</span>
</label>
</AdminFormRow>
<PluginOutlet
@name="after-admin-user-fields"
@outletArgs={{hash buffered=this.buffered}}
/>
<AdminFormRow>
<DButton
@action={{this.save}}
<form.Actions>
<form.Submit
class="save"
@icon="check"
@label="admin.user_fields.save"
class="btn-primary save"
/>
<DButton
<form.Button
@action={{this.cancel}}
@icon="xmark"
@label="admin.user_fields.cancel"
class="btn-danger cancel"
class="btn-default"
/>
</AdminFormRow>
{{else}}
</form.Actions>
</Form>
</div>
</div>
</div>
{{else}}
<div class="user-field">
<div class="row">
<div class="form-display">
<b class="name">{{this.userField.name}}</b>
<b class="name">{{@userField.name}}</b>
<br />
<span class="description">{{html-safe
this.userField.description
}}</span>
<span class="description">{{html-safe @userField.description}}</span>
</div>
<div class="form-display field-type">{{this.fieldName}}</div>
<div class="form-display field-type">{{@userField.fieldTypeName}}</div>
<div class="form-element controls">
<DButton
@action={{this.edit}}
@ -137,19 +166,19 @@
class="btn-default edit"
/>
<DButton
@action={{fn this.destroyAction this.userField}}
@action={{fn @destroyAction @userField}}
@icon="trash-can"
@label="admin.user_fields.delete"
class="btn-danger cancel"
/>
<DButton
@action={{fn this.moveUpAction this.userField}}
@action={{fn @moveUpAction @userField}}
@icon="arrow-up"
@disabled={{this.cantMoveUp}}
class="btn-default"
/>
<DButton
@action={{fn this.moveDownAction this.userField}}
@action={{fn @moveDownAction @userField}}
@icon="arrow-down"
@disabled={{this.cantMoveDown}}
class="btn-default"
@ -157,5 +186,5 @@
</div>
</div>
<div class="row user-field-flags">{{this.flags}}</div>
{{/if}}
</div>
</div>
{{/if}}

View File

@ -1,4 +1,5 @@
import Component from "@ember/component";
import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { schedule } from "@ember/runloop";
import { service } from "@ember/service";
@ -6,78 +7,102 @@ import { isEmpty } from "@ember/utils";
import { tagName } from "@ember-decorators/component";
import { Promise } from "rsvp";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n, propertyEqual } from "discourse/lib/computed";
import { bufferedProperty } from "discourse/mixins/buffered-content";
import discourseComputed from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import UserField from "admin/models/user-field";
@tagName("")
export default class AdminUserFieldItem extends Component.extend(
bufferedProperty("userField")
) {
export default class AdminUserFieldItem extends Component {
@service adminCustomUserFields;
@service dialog;
isEditing = false;
@tracked isEditing = false;
@tracked
editableDisabled = this.args.userField.requirement === "for_all_users";
@propertyEqual("userField", "firstField") cantMoveUp;
@propertyEqual("userField", "lastField") cantMoveDown;
@i18n("admin.user_fields.description") userFieldsDescription;
@discourseComputed("buffered.field_type")
bufferedFieldType(fieldType) {
return UserField.fieldTypeById(fieldType);
get fieldName() {
return UserField.fieldTypeById(this.fieldType)?.name;
}
didInsertElement() {
super.didInsertElement(...arguments);
this._focusName();
get cantMoveUp() {
return this.args.userField.id === this.args.firstField?.id;
}
_focusName() {
schedule("afterRender", () => {
document.querySelector(".user-field-name")?.focus();
});
get cantMoveDown() {
return this.args.userField.id === this.args.lastField?.id;
}
@discourseComputed("userField.field_type")
fieldName(fieldType) {
return UserField.fieldTypeById(fieldType)?.name;
get isNewRecord() {
return isEmpty(this.args.userField?.id);
}
@discourseComputed(
"userField.{editable,show_on_profile,show_on_user_card,searchable}"
)
flags(userField) {
const ret = [];
if (userField.editable) {
ret.push(I18n.t("admin.user_fields.editable.enabled"));
get flags() {
const flags = [
"editable",
"show_on_profile",
"show_on_user_card",
"searchable",
];
return flags
.map((flag) => {
if (this.args.userField[flag]) {
return I18n.t(`admin.user_fields.${flag}.enabled`);
}
if (userField.show_on_profile) {
ret.push(I18n.t("admin.user_fields.show_on_profile.enabled"));
}
if (userField.show_on_user_card) {
ret.push(I18n.t("admin.user_fields.show_on_user_card.enabled"));
}
if (userField.searchable) {
ret.push(I18n.t("admin.user_fields.searchable.enabled"));
})
.filter(Boolean)
.join(", ");
}
return ret.join(", ");
}
@discourseComputed("buffered.requirement")
editableDisabled(requirement) {
return requirement === "for_all_users";
@cached
get formData() {
return this.args.userField.getProperties(
"field_type",
"name",
"description",
"requirement",
"editable",
"show_on_profile",
"show_on_user_card",
"searchable",
"options",
...this.adminCustomUserFields.additionalProperties
);
}
@action
changeRequirementType(requirement) {
this.buffered.set("requirement", requirement);
this.buffered.set("editable", requirement === "for_all_users");
setRequirement(value, { set }) {
set("requirement", value);
if (value === "for_all_users") {
this.editableDisabled = true;
set("editable", true);
} else {
this.editableDisabled = false;
}
}
@action
async save(data) {
let confirm = true;
if (data.requirement === "for_all_users") {
confirm = await this._confirmChanges();
}
if (!confirm) {
return;
}
return this.args.userField
.save(data)
.then(() => {
if (this.isDestroying || this.isDestroyed) {
return;
}
this.isEditing = false;
})
.catch(popupAjaxError);
}
async _confirmChanges() {
@ -90,57 +115,23 @@ export default class AdminUserFieldItem extends Component.extend(
});
}
@action
async save() {
const attrs = this.buffered.getProperties(
"name",
"description",
"field_type",
"editable",
"requirement",
"show_on_profile",
"show_on_user_card",
"searchable",
"options",
...this.adminCustomUserFields.additionalProperties
);
let confirm = true;
if (attrs.requirement === "for_all_users") {
confirm = await this._confirmChanges();
}
if (!confirm) {
return;
}
return this.userField
.save(attrs)
.then(() => {
if (this.isDestroying || this.isDestroyed) {
return;
}
this.set("isEditing", false);
this.commitBuffer();
})
.catch(popupAjaxError);
}
@action
edit() {
this.set("isEditing", true);
this._focusName();
this.isEditing = true;
}
@action
cancel() {
if (isEmpty(this.userField?.id)) {
this.destroyAction(this.userField);
if (this.isNewRecord) {
this.args.destroyAction(this.args.userField);
} else {
this.rollbackBuffer();
this.set("isEditing", false);
this.isEditing = false;
}
}
_focusName() {
schedule("afterRender", () =>
document.querySelector(".user-field-name")?.focus()
);
}
}

View File

@ -14,6 +14,7 @@ export default class ValueList extends Component {
newValue = "";
collection = null;
values = null;
onChange = null;
@reads("addKey") noneKey;
@ -21,7 +22,7 @@ export default class ValueList extends Component {
super.didReceiveAttrs(...arguments);
if (this.inputType === "array") {
this.set("collection", this.values || []);
this.set("collection", this.values ? [...this.values] : []);
return;
}
@ -114,6 +115,11 @@ export default class ValueList extends Component {
}
_saveValues() {
if (this.onChange) {
this.onChange([...this.collection]);
return;
}
if (this.inputType === "array") {
this.set("values", this.collection);
return;

View File

@ -28,6 +28,7 @@ export default class AdminUserFieldsController extends Controller {
createField() {
const f = this.store.createRecord("user-field", {
field_type: "text",
requirement: "optional",
position: MAX_FIELDS,
});
this.model.pushObject(f);

View File

@ -1,3 +1,4 @@
import { tracked } from "@glimmer/tracking";
import EmberObject from "@ember/object";
import { i18n } from "discourse/lib/computed";
import RestModel from "discourse/models/rest";
@ -19,6 +20,16 @@ export default class UserField extends RestModel {
static fieldTypeById(id) {
return this.fieldTypes().findBy("id", id);
}
@tracked field_type;
@tracked editable;
@tracked show_on_profile;
@tracked show_on_user_card;
@tracked searchable;
get fieldTypeName() {
return UserField.fieldTypes().find((ft) => ft.id === this.field_type).name;
}
}
class UserFieldType extends EmberObject {

View File

@ -1,4 +1,5 @@
<div class="user-fields">
<div class="admin-config-page__main-area">
<div class="user-fields">
<h2>{{i18n "admin.user_fields.title"}}</h2>
<p class="desc">{{i18n "admin.user_fields.help"}}</p>
@ -24,4 +25,5 @@
@icon="plus"
class="btn-primary"
/>
</div>
</div>

View File

@ -1,95 +0,0 @@
import { click, render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { exists, query } from "discourse/tests/helpers/qunit-helpers";
import I18n from "discourse-i18n";
module("Integration | Component | admin-user-field-item", function (hooks) {
setupRenderingTest(hooks);
test("user field without an id", async function (assert) {
await render(hbs`<AdminUserFieldItem @userField={{this.userField}} />`);
assert.ok(exists(".save"), "displays editing mode");
});
test("cancel action", async function (assert) {
this.set("userField", { id: 1, field_type: "text" });
this.set("isEditing", true);
this.set("destroyAction", () => {});
this.set("moveUpAction", () => {});
this.set("moveDownAction", () => {});
await render(hbs`
<AdminUserFieldItem
@isEditing={{this.isEditing}}
@destroyAction={{this.destroyAction}}
@moveUpAction={{this.moveUpAction}}
@moveDownAction={{this.moveDownAction}}
@userField={{this.userField}}
/>`);
await click(".cancel");
assert.ok(exists(".edit"));
});
test("edit action", async function (assert) {
this.set("userField", { id: 1, field_type: "text" });
this.set("destroyAction", () => {});
this.set("moveUpAction", () => {});
this.set("moveDownAction", () => {});
await render(hbs`
<AdminUserFieldItem
@destroyAction={{this.destroyAction}}
@moveUpAction={{this.moveUpAction}}
@moveDownAction={{this.moveDownAction}}
@userField={{this.userField}}
/>`);
await click(".edit");
assert.ok(exists(".save"));
});
test("field attributes are rendered correctly", async function (assert) {
this.set("userField", {
id: 1,
field_type: "text",
name: "foo",
description: "what is foo",
show_on_profile: true,
show_on_user_card: true,
searchable: true,
});
this.set("destroyAction", () => {});
this.set("moveUpAction", () => {});
this.set("moveDownAction", () => {});
await render(hbs`
<AdminUserFieldItem
@destroyAction={{this.destroyAction}}
@moveUpAction={{this.moveUpAction}}
@moveDownAction={{this.moveDownAction}}
@userField={{this.userField}}
/>`);
assert.strictEqual(query(".name").innerText, this.userField.name);
assert.strictEqual(
query(".description").innerText,
this.userField.description
);
assert.strictEqual(
query(".field-type").innerText,
I18n.t("admin.user_fields.field_types.text")
);
assert
.dom(".user-field-flags")
.hasText(
`${I18n.t("admin.user_fields.show_on_profile.enabled")}, ${I18n.t(
"admin.user_fields.show_on_user_card.enabled"
)}, ${I18n.t("admin.user_fields.searchable.enabled")}`
);
});
});

View File

@ -956,64 +956,16 @@ table.permalinks {
.user-fields {
h2 {
margin-bottom: 10px;
margin-bottom: 1em;
}
.user-field {
padding: 10px;
margin-bottom: 10px;
padding-block: 0.5em;
margin-bottom: 1em;
border-bottom: 1px solid var(--primary-low);
.form-display {
width: 25%;
float: left;
}
.form-element,
.form-element-desc {
float: left;
min-height: 30px;
padding: 0.25em 0;
&.input-area {
width: 75%;
.value-list,
.select-kit,
input[type="text"] {
width: 50%;
}
.value-list {
.select-kit {
width: 100%;
}
}
label {
font-weight: normal;
padding: 0.5em 0;
}
.label-text {
display: inline-flex;
flex-direction: column;
}
.description {
margin-top: 0.25em;
color: var(--primary-medium);
font-size: var(--font-down-1);
line-height: var(--line-height-large);
}
}
&.label-area {
width: 25%;
label {
margin: 0.5em 1em 0 0;
text-align: right;
font-weight: bold;
}
}
}
.controls {
float: right;
text-align: right;
}
.clearfix {
clear: both;
}

View File

@ -1,5 +1,4 @@
.form-kit__checkbox-group {
display: flex;
flex-direction: column;
gap: 0.75em;
gap: 0em;
}

View File

@ -0,0 +1,7 @@
.form-kit__control-custom {
.value-list {
.single-select.combobox {
width: 100%;
}
}
}

View File

@ -20,6 +20,7 @@
@import "_control-select";
@import "_control-custom";
@import "_control-textarea";
@import "_control-custom-value-list";
@import "_errors";
@import "_errors-summary";
@import "_field";

View File

@ -23,7 +23,7 @@ describe "Admin User Fields", type: :system, js: true do
user_fields_page.add_field(name: "Occupation", description: "")
expect(user_fields_page).to have_text(/Description can't be blank/)
expect(user_fields_page.form.field(:description)).to have_errors("Required")
end
it "makes sure new required fields are editable after signup" do
@ -40,7 +40,7 @@ describe "Admin User Fields", type: :system, js: true do
user_fields_page.choose_requirement("optional")
expect(form).to have_field(editable_label, checked: false, disabled: false)
expect(form).to have_field(editable_label, checked: true, disabled: false)
end
it "requires confirmation when applying required fields retroactively" do

View File

@ -67,6 +67,16 @@ module PageObjects
expect(self.value).to eq(expected_value)
end
def has_errors?(*messages)
within component do
messages.all? { |m| find(".form-kit__errors", text: m) }
end
end
def has_no_errors?
!has_css?(".form-kit__errors")
end
def control_type
component["data-control-type"]
end

View File

@ -8,6 +8,10 @@ module PageObjects
self
end
def form
PageObjects::Components::FormKit.new(".user-field .form-kit")
end
def choose_requirement(requirement)
form = page.find(".user-field")