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 @userField.id))}}
{{#if (or this.isEditing (not this.userField.id))}} <div class="admin-config-area user-field">
<AdminFormRow @label="admin.user_fields.type"> <div class="admin-config-area__primary-content">
<ComboBox <div class="admin-config-area-card">
@content={{this.fieldTypes}} <Form
@value={{this.buffered.field_type}} @data={{this.formData}}
@onChange={{fn (mut this.buffered.field_type)}} @onSubmit={{this.save}}
/> {{did-insert this._focusName}}
</AdminFormRow> 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"> <form.Field
<Input @name="name"
@value={{this.buffered.name}} @title={{i18n "admin.user_fields.name"}}
class="user-field-name" @format="large"
maxlength="255" @validation="required"
/> as |field|
</AdminFormRow> >
<field.Input class="user-field-name" maxlength="255" />
</form.Field>
<AdminFormRow @label="admin.user_fields.description"> <form.Field
<Input @name="description"
@value={{this.buffered.description}} @title={{i18n "admin.user_fields.description"}}
class="user-field-desc" @format="large"
maxlength="1000" @validation="required"
/> as |field|
</AdminFormRow> >
<field.Input class="user-field-desc" maxlength="1000" />
</form.Field>
{{#if this.bufferedFieldType.hasOptions}} {{#if
<AdminFormRow @label="admin.user_fields.options"> (or
<ValueList @values={{this.buffered.options}} @inputType="array" /> (eq transientData.field_type "dropdown")
</AdminFormRow> (eq transientData.field_type "multiselect")
{{/if}} )
}}
<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"> <form.Field
<label class="optional"> @name="requirement"
<RadioButton @title={{i18n "admin.user_fields.requirement.title"}}
@value="optional" @validation="required"
@name="requirement" @onSet={{this.setRequirement}}
@selection={{this.buffered.requirement}} @format="full"
@onChange={{action "changeRequirementType"}} as |field|
/> >
<span>{{i18n "admin.user_fields.requirement.optional.title"}}</span> <field.RadioGroup as |radioGroup|>
</label> <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"
}}</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"
}}</radio.Description>
</radioGroup.Radio>
</field.RadioGroup>
</form.Field>
<label class="for_all_users"> <form.CheckboxGroup
<RadioButton class="user-field-preferences"
@value="for_all_users" @title={{i18n "admin.user_fields.preferences"}}
@name="requirement" as |group|
@selection={{this.buffered.requirement}} >
@onChange={{action "changeRequirementType"}} <group.Field
/> @name="editable"
<div class="label-text"> @showTitle={{false}}
<span>{{i18n @title={{i18n "admin.user_fields.editable.title"}}
"admin.user_fields.requirement.for_all_users.title" as |field|
}}</span> >
<div class="description">{{i18n <field.Checkbox disabled={{this.editableDisabled}} />
"admin.user_fields.requirement.for_all_users.description" </group.Field>
}}</div> <group.Field
</div> @name="show_on_profile"
</label> @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 class="on_signup"> <form.Actions>
<RadioButton <form.Submit
@value="on_signup" class="save"
@name="requirement" @icon="check"
@selection={{this.buffered.requirement}} @label="admin.user_fields.save"
@onChange={{action "changeRequirementType"}} />
/> <form.Button
<div class="label-text"> @action={{this.cancel}}
<span>{{i18n "admin.user_fields.requirement.on_signup.title"}}</span> @label="admin.user_fields.cancel"
<div class="description">{{i18n class="btn-default"
"admin.user_fields.requirement.on_signup.description" />
}}</div> </form.Actions>
</div> </Form>
</label> </div>
</AdminFormRow> </div>
</div>
<AdminFormRow @label="admin.user_fields.preferences"> {{else}}
<label> <div class="user-field">
<Input
@type="checkbox"
@checked={{this.buffered.editable}}
disabled={{this.editableDisabled}}
/>
<span>{{i18n "admin.user_fields.editable.title"}}</span>
</label>
<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}}
@icon="check"
@label="admin.user_fields.save"
class="btn-primary save"
/>
<DButton
@action={{this.cancel}}
@icon="xmark"
@label="admin.user_fields.cancel"
class="btn-danger cancel"
/>
</AdminFormRow>
{{else}}
<div class="row"> <div class="row">
<div class="form-display"> <div class="form-display">
<b class="name">{{this.userField.name}}</b> <b class="name">{{@userField.name}}</b>
<br /> <br />
<span class="description">{{html-safe <span class="description">{{html-safe @userField.description}}</span>
this.userField.description
}}</span>
</div> </div>
<div class="form-display field-type">{{this.fieldName}}</div> <div class="form-display field-type">{{@userField.fieldTypeName}}</div>
<div class="form-element controls"> <div class="form-element controls">
<DButton <DButton
@action={{this.edit}} @action={{this.edit}}
@ -137,19 +166,19 @@
class="btn-default edit" class="btn-default edit"
/> />
<DButton <DButton
@action={{fn this.destroyAction this.userField}} @action={{fn @destroyAction @userField}}
@icon="trash-can" @icon="trash-can"
@label="admin.user_fields.delete" @label="admin.user_fields.delete"
class="btn-danger cancel" class="btn-danger cancel"
/> />
<DButton <DButton
@action={{fn this.moveUpAction this.userField}} @action={{fn @moveUpAction @userField}}
@icon="arrow-up" @icon="arrow-up"
@disabled={{this.cantMoveUp}} @disabled={{this.cantMoveUp}}
class="btn-default" class="btn-default"
/> />
<DButton <DButton
@action={{fn this.moveDownAction this.userField}} @action={{fn @moveDownAction @userField}}
@icon="arrow-down" @icon="arrow-down"
@disabled={{this.cantMoveDown}} @disabled={{this.cantMoveDown}}
class="btn-default" class="btn-default"
@ -157,5 +186,5 @@
</div> </div>
</div> </div>
<div class="row user-field-flags">{{this.flags}}</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 { action } from "@ember/object";
import { schedule } from "@ember/runloop"; import { schedule } from "@ember/runloop";
import { service } from "@ember/service"; import { service } from "@ember/service";
@ -6,78 +7,102 @@ import { isEmpty } from "@ember/utils";
import { tagName } from "@ember-decorators/component"; import { tagName } from "@ember-decorators/component";
import { Promise } from "rsvp"; import { Promise } from "rsvp";
import { popupAjaxError } from "discourse/lib/ajax-error"; 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 I18n from "discourse-i18n";
import UserField from "admin/models/user-field"; import UserField from "admin/models/user-field";
@tagName("") @tagName("")
export default class AdminUserFieldItem extends Component.extend( export default class AdminUserFieldItem extends Component {
bufferedProperty("userField")
) {
@service adminCustomUserFields; @service adminCustomUserFields;
@service dialog; @service dialog;
isEditing = false; @tracked isEditing = false;
@tracked
editableDisabled = this.args.userField.requirement === "for_all_users";
@propertyEqual("userField", "firstField") cantMoveUp; get fieldName() {
@propertyEqual("userField", "lastField") cantMoveDown; return UserField.fieldTypeById(this.fieldType)?.name;
@i18n("admin.user_fields.description") userFieldsDescription;
@discourseComputed("buffered.field_type")
bufferedFieldType(fieldType) {
return UserField.fieldTypeById(fieldType);
} }
didInsertElement() { get cantMoveUp() {
super.didInsertElement(...arguments); return this.args.userField.id === this.args.firstField?.id;
this._focusName();
} }
_focusName() { get cantMoveDown() {
schedule("afterRender", () => { return this.args.userField.id === this.args.lastField?.id;
document.querySelector(".user-field-name")?.focus();
});
} }
@discourseComputed("userField.field_type") get isNewRecord() {
fieldName(fieldType) { return isEmpty(this.args.userField?.id);
return UserField.fieldTypeById(fieldType)?.name;
} }
@discourseComputed( get flags() {
"userField.{editable,show_on_profile,show_on_user_card,searchable}" const flags = [
) "editable",
flags(userField) { "show_on_profile",
const ret = []; "show_on_user_card",
if (userField.editable) { "searchable",
ret.push(I18n.t("admin.user_fields.editable.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"));
}
return ret.join(", "); return flags
.map((flag) => {
if (this.args.userField[flag]) {
return I18n.t(`admin.user_fields.${flag}.enabled`);
}
})
.filter(Boolean)
.join(", ");
} }
@discourseComputed("buffered.requirement") @cached
editableDisabled(requirement) { get formData() {
return requirement === "for_all_users"; return this.args.userField.getProperties(
"field_type",
"name",
"description",
"requirement",
"editable",
"show_on_profile",
"show_on_user_card",
"searchable",
"options",
...this.adminCustomUserFields.additionalProperties
);
} }
@action @action
changeRequirementType(requirement) { setRequirement(value, { set }) {
this.buffered.set("requirement", requirement); set("requirement", value);
this.buffered.set("editable", requirement === "for_all_users");
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() { 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 @action
edit() { edit() {
this.set("isEditing", true); this.isEditing = true;
this._focusName();
} }
@action @action
cancel() { cancel() {
if (isEmpty(this.userField?.id)) { if (this.isNewRecord) {
this.destroyAction(this.userField); this.args.destroyAction(this.args.userField);
} else { } else {
this.rollbackBuffer(); this.isEditing = false;
this.set("isEditing", false);
} }
} }
_focusName() {
schedule("afterRender", () =>
document.querySelector(".user-field-name")?.focus()
);
}
} }

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { tracked } from "@glimmer/tracking";
import EmberObject from "@ember/object"; import EmberObject from "@ember/object";
import { i18n } from "discourse/lib/computed"; import { i18n } from "discourse/lib/computed";
import RestModel from "discourse/models/rest"; import RestModel from "discourse/models/rest";
@ -19,6 +20,16 @@ export default class UserField extends RestModel {
static fieldTypeById(id) { static fieldTypeById(id) {
return this.fieldTypes().findBy("id", 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 { class UserFieldType extends EmberObject {

View File

@ -1,27 +1,29 @@
<div class="user-fields"> <div class="admin-config-page__main-area">
<h2>{{i18n "admin.user_fields.title"}}</h2> <div class="user-fields">
<h2>{{i18n "admin.user_fields.title"}}</h2>
<p class="desc">{{i18n "admin.user_fields.help"}}</p> <p class="desc">{{i18n "admin.user_fields.help"}}</p>
{{#if this.model}} {{#if this.model}}
{{#each this.sortedFields as |uf|}} {{#each this.sortedFields as |uf|}}
<AdminUserFieldItem <AdminUserFieldItem
@userField={{uf}} @userField={{uf}}
@fieldTypes={{this.fieldTypes}} @fieldTypes={{this.fieldTypes}}
@firstField={{this.firstField}} @firstField={{this.firstField}}
@lastField={{this.lastField}} @lastField={{this.lastField}}
@destroyAction={{this.destroyField}} @destroyAction={{this.destroyField}}
@moveUpAction={{this.moveUp}} @moveUpAction={{this.moveUp}}
@moveDownAction={{this.moveDown}} @moveDownAction={{this.moveDown}}
/> />
{{/each}} {{/each}}
{{/if}} {{/if}}
<DButton <DButton
@disabled={{this.createDisabled}} @disabled={{this.createDisabled}}
@action={{this.createField}} @action={{this.createField}}
@label="admin.user_fields.create" @label="admin.user_fields.create"
@icon="plus" @icon="plus"
class="btn-primary" class="btn-primary"
/> />
</div>
</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 { .user-fields {
h2 { h2 {
margin-bottom: 10px; margin-bottom: 1em;
} }
.user-field { .user-field {
padding: 10px; padding-block: 0.5em;
margin-bottom: 10px; margin-bottom: 1em;
border-bottom: 1px solid var(--primary-low); border-bottom: 1px solid var(--primary-low);
.form-display { .form-display {
width: 25%; width: 25%;
float: left; 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 { .clearfix {
clear: both; clear: both;
} }

View File

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

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-select";
@import "_control-custom"; @import "_control-custom";
@import "_control-textarea"; @import "_control-textarea";
@import "_control-custom-value-list";
@import "_errors"; @import "_errors";
@import "_errors-summary"; @import "_errors-summary";
@import "_field"; @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: "") 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 end
it "makes sure new required fields are editable after signup" do 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") 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 end
it "requires confirmation when applying required fields retroactively" do it "requires confirmation when applying required fields retroactively" do

View File

@ -67,6 +67,16 @@ module PageObjects
expect(self.value).to eq(expected_value) expect(self.value).to eq(expected_value)
end 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 def control_type
component["data-control-type"] component["data-control-type"]
end end

View File

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