DEV: Modernize admin user fields (#29843)

This PR modernizes the user fields area of the admin UI. It is largely based on the work on the emoji section.
This commit is contained in:
Ted Johansson 2024-11-25 11:54:43 +08:00 committed by GitHub
parent 66409fa8b4
commit 88af23e1ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 712 additions and 477 deletions

View File

@ -0,0 +1,105 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
import AdminUserFieldItem from "admin/components/admin-user-field-item";
import UserField from "admin/models/user-field";
export default class AdminConfigAreasUserFieldsList extends Component {
@service dialog;
@service store;
@service toasts;
@service adminUserFields;
fieldTypes = UserField.fieldTypes();
get fields() {
return this.adminUserFields.userFields;
}
get sortedFields() {
return this.adminUserFields.sortedUserFields;
}
@action
moveUp(field) {
const idx = this.sortedFields.indexOf(field);
if (idx) {
const prev = this.sortedFields.objectAt(idx - 1);
const prevPos = prev.get("position");
prev.update({ position: field.get("position") });
field.update({ position: prevPos });
}
}
@action
moveDown(field) {
const idx = this.sortedFields.indexOf(field);
if (idx > -1) {
const next = this.sortedFields.objectAt(idx + 1);
const nextPos = next.get("position");
next.update({ position: field.get("position") });
field.update({ position: nextPos });
}
}
@action
destroyField(field) {
this.dialog.yesNoConfirm({
message: i18n("admin.user_fields.delete_confirm"),
didConfirm: () => {
this.#deleteField(field);
},
});
}
async #deleteField(field) {
try {
await field.destroyRecord();
this.fields.removeObject(field);
this.toasts.success({
duration: 3000,
data: {
message: i18n("admin.config_areas.user_fields.delete_successful"),
},
});
} catch (error) {
popupAjaxError(error);
}
}
<template>
<div class="container admin-user_fields">
{{#if this.fields}}
<table class="d-admin-table admin-flags__items">
<thead>
<th>{{i18n "admin.config_areas.user_fields.field"}}</th>
<th>{{i18n "admin.config_areas.user_fields.type"}}</th>
</thead>
<tbody>
{{#each this.sortedFields as |field|}}
<AdminUserFieldItem
@userField={{field}}
@fieldTypes={{this.fieldTypes}}
@destroyAction={{this.destroyField}}
@moveUpAction={{this.moveUp}}
@moveDownAction={{this.moveDown}}
/>
{{/each}}
</tbody>
</table>
{{else}}
<AdminConfigAreaEmptyList
@ctaLabel="admin.user_fields.add"
@ctaRoute="adminUserFields.new"
@ctaClass="admin-user_fields__add-emoji"
@emptyLabel="admin.user_fields.no_user_fields"
/>
{{/if}}
</div>
</template>
}

View File

@ -0,0 +1,134 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button";
import DropdownMenu from "discourse/components/dropdown-menu";
import { USER_FIELD_FLAGS } from "discourse/lib/constants";
import { i18n } from "discourse-i18n";
import UserField from "admin/models/user-field";
import DMenu from "float-kit/components/d-menu";
export default class AdminUserFieldItem extends Component {
@service adminUserFields;
@service adminCustomUserFields;
@service dialog;
@service router;
get fieldName() {
return UserField.fieldTypeById(this.fieldType)?.name;
}
get cantMoveUp() {
return this.args.userField.id === this.adminUserFields.firstField?.id;
}
get cantMoveDown() {
return this.args.userField.id === this.adminUserFields.lastField?.id;
}
get flags() {
return USER_FIELD_FLAGS.map((flag) => {
if (this.args.userField[flag]) {
return i18n(`admin.user_fields.${flag}.enabled`);
}
})
.filter(Boolean)
.join(", ");
}
@action
moveUp() {
this.args.moveUpAction(this.args.userField);
this.dMenu.close();
}
@action
moveDown() {
this.args.moveDownAction(this.args.userField);
this.dMenu.close();
}
@action
destroy() {
this.args.destroyAction(this.args.userField);
this.dMenu.close();
}
@action
onRegisterApi(api) {
this.dMenu = api;
}
@action
edit() {
this.router.transitionTo("adminUserFields.edit", this.args.userField);
}
<template>
<tr class="d-admin-row__content admin-user_field-item">
<td class="d-admin-row__overview">
<div
class="d-admin-row__overview-name admin-user_field-item__name"
>{{@userField.name}}</div>
<div class="d-admin-row__overview-about">{{htmlSafe
@userField.description
}}</div>
<div class="d-admin-row__overview-flags">{{this.flags}}</div>
</td>
<td class="d-admin-row__detail">
{{@userField.fieldTypeName}}
</td>
<td class="d-admin-row__controls">
<div class="d-admin-row__controls-options">
<DButton
class="btn-small admin-user_field-item__edit"
@action={{this.edit}}
@label="admin.user_fields.edit"
/>
<DMenu
@identifier="user_field-menu"
@title={{i18n "admin.config_areas.user_fields.more_options.title"}}
@icon="ellipsis-vertical"
@onRegisterApi={{this.onRegisterApi}}
>
<:content>
<DropdownMenu as |dropdown|>
{{#unless this.cantMoveUp}}
<dropdown.item>
<DButton
@label="admin.config_areas.user_fields.more_options.move_up"
@icon="arrow-up"
class="btn-transparent admin-user_field-item__move-up"
@action={{this.moveUp}}
/>
</dropdown.item>
{{/unless}}
{{#unless this.cantMoveDown}}
<dropdown.item>
<DButton
@label="admin.config_areas.user_fields.more_options.move_down"
@icon="arrow-down"
class="btn-transparent admin-user_field-item__move-down"
@action={{this.moveDown}}
/>
</dropdown.item>
{{/unless}}
<dropdown.item>
<DButton
@label="admin.config_areas.user_fields.delete"
@icon="trash-can"
class="btn-transparent admin-user_field-item__delete"
@action={{this.destroy}}
/>
</dropdown.item>
</DropdownMenu>
</:content>
</DMenu>
</div>
</td>
</tr>
</template>
}

View File

@ -1,195 +0,0 @@
{{#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>
<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>
<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
(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}}
<form.Field
@name="requirement"
@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"
}}</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>
<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>
<PluginOutlet
@name="after-admin-user-fields"
@outletArgs={{hash userField=@userField form=form}}
/>
<form.Actions>
<form.Submit
class="save"
@icon="check"
@label="admin.user_fields.save"
/>
<form.Button
@action={{this.cancel}}
@label="admin.user_fields.cancel"
class="btn-default"
/>
</form.Actions>
</Form>
</div>
</div>
</div>
{{else}}
<div class="user-field">
<div class="row">
<div class="form-display">
<b class="name">{{@userField.name}}</b>
<br />
<span class="description">{{html-safe @userField.description}}</span>
</div>
<div class="form-display field-type">{{@userField.fieldTypeName}}</div>
<div class="form-element controls">
<DButton
@action={{this.edit}}
@icon="pencil"
@label="admin.user_fields.edit"
class="btn-default edit"
/>
<DButton
@action={{fn @destroyAction @userField}}
@icon="trash-can"
@label="admin.user_fields.delete"
class="btn-danger cancel"
/>
<DButton
@action={{fn @moveUpAction @userField}}
@icon="arrow-up"
@disabled={{this.cantMoveUp}}
class="btn-default"
/>
<DButton
@action={{fn @moveDownAction @userField}}
@icon="arrow-down"
@disabled={{this.cantMoveDown}}
class="btn-default"
/>
</div>
</div>
<div class="row user-field-flags">{{this.flags}}</div>
</div>
{{/if}}

View File

@ -1,143 +0,0 @@
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";
import { isEmpty } from "@ember/utils";
import { tagName } from "@ember-decorators/component";
import { Promise } from "rsvp";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
import UserField from "admin/models/user-field";
@tagName("")
export default class AdminUserFieldItem extends Component {
@service adminCustomUserFields;
@service dialog;
@tracked isEditing = false;
@tracked
editableDisabled = this.args.userField.requirement === "for_all_users";
originalRequirement = this.args.userField.requirement;
get fieldName() {
return UserField.fieldTypeById(this.fieldType)?.name;
}
get cantMoveUp() {
return this.args.userField.id === this.args.firstField?.id;
}
get cantMoveDown() {
return this.args.userField.id === this.args.lastField?.id;
}
get isNewRecord() {
return isEmpty(this.args.userField?.id);
}
get flags() {
const flags = [
"editable",
"show_on_profile",
"show_on_user_card",
"searchable",
];
return flags
.map((flag) => {
if (this.args.userField[flag]) {
return i18n(`admin.user_fields.${flag}.enabled`);
}
})
.filter(Boolean)
.join(", ");
}
@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
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" &&
this.originalRequirement !== "for_all_users"
) {
confirm = await this._confirmChanges();
}
if (!confirm) {
return;
}
return this.args.userField
.save(data)
.then(() => {
if (this.isDestroying || this.isDestroyed) {
return;
}
this.originalRequirement = data.requirement;
this.isEditing = false;
})
.catch(popupAjaxError);
}
async _confirmChanges() {
return new Promise((resolve) => {
this.dialog.yesNoConfirm({
message: i18n("admin.user_fields.requirement.confirmation"),
didCancel: () => resolve(false),
didConfirm: () => resolve(true),
});
});
}
@action
edit() {
this.isEditing = true;
}
@action
cancel() {
if (this.isNewRecord) {
this.args.destroyAction(this.args.userField);
} else {
this.isEditing = false;
}
}
_focusName() {
schedule("afterRender", () =>
document.querySelector(".user-field-name")?.focus()
);
}
}

View File

@ -0,0 +1,269 @@
import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { schedule } from "@ember/runloop";
import { service } from "@ember/service";
import { eq, or } from "truth-helpers";
import Form from "discourse/components/form";
import PluginOutlet from "discourse/components/plugin-outlet";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
import ValueList from "admin/components/value-list";
import UserField from "admin/models/user-field";
export default class AdminUserFieldsForm extends Component {
@service dialog;
@service router;
@service adminUserFields;
@service adminCustomUserFields;
@service toasts;
@tracked
editableDisabled = this.args.userField.requirement === "for_all_users";
originalRequirement = this.args.userField.requirement;
userField;
get fieldTypes() {
return UserField.fieldTypes();
}
@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
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" &&
this.originalRequirement !== "for_all_users"
) {
confirm = await this._confirmChanges();
}
if (!confirm) {
return;
}
try {
const isNew = this.args.userField.isNew;
await this.args.userField.save(data);
this.originalRequirement = data.requirement;
if (isNew) {
this.adminUserFields.userFields.pushObject(this.args.userField);
}
this.router.transitionTo("adminUserFields.index");
this.toasts.success({
duration: 3000,
data: {
message: i18n("admin.config_areas.user_fields.save_successful"),
},
});
} catch (error) {
popupAjaxError(error);
}
}
@action
cancel() {
this.router.transitionTo("adminUserFields.index");
}
_focusName() {
schedule("afterRender", () =>
document.querySelector(".user-field-name")?.focus()
);
}
async _confirmChanges() {
return new Promise((resolve) => {
this.dialog.yesNoConfirm({
message: i18n("admin.user_fields.requirement.confirmation"),
didCancel: () => resolve(false),
didConfirm: () => resolve(true),
});
});
}
<template>
<Form
@data={{this.formData}}
@onSubmit={{this.save}}
{{didInsert 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 this.fieldTypes as |fieldType|}}
<select.Option
@value={{fieldType.id}}
>{{fieldType.name}}</select.Option>
{{/each}}
</field.Select>
</form.Field>
<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>
<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
(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}}
<form.Field
@name="requirement"
@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"
}}</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>
<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>
<PluginOutlet
@name="after-admin-user-fields"
@outletArgs={{hash userField=@userField form=form}}
/>
<form.Actions>
<form.Submit
class="save"
@icon="check"
@label="admin.user_fields.save"
/>
<form.Button
@action={{this.cancel}}
@label="admin.user_fields.cancel"
/>
</form.Actions>
</Form>
</template>
}

View File

@ -1,82 +0,0 @@
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { gte, sort } from "@ember/object/computed";
import { service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
const MAX_FIELDS = 30;
export default class AdminUserFieldsController extends Controller {
@service dialog;
fieldTypes = null;
fieldSortOrder = ["position"];
@gte("model.length", MAX_FIELDS) createDisabled;
@sort("model", "fieldSortOrder") sortedFields;
get firstField() {
return this.sortedFields[0];
}
get lastField() {
return this.sortedFields[this.sortedFields.length - 1];
}
@action
createField() {
const f = this.store.createRecord("user-field", {
field_type: "text",
requirement: "optional",
position: MAX_FIELDS,
});
this.model.pushObject(f);
}
@action
moveUp(f) {
const idx = this.sortedFields.indexOf(f);
if (idx) {
const prev = this.sortedFields.objectAt(idx - 1);
const prevPos = prev.get("position");
prev.update({ position: f.get("position") });
f.update({ position: prevPos });
}
}
@action
moveDown(f) {
const idx = this.sortedFields.indexOf(f);
if (idx > -1) {
const next = this.sortedFields.objectAt(idx + 1);
const nextPos = next.get("position");
next.update({ position: f.get("position") });
f.update({ position: nextPos });
}
}
@action
destroyField(f) {
const model = this.model;
// Only confirm if we already been saved
if (f.get("id")) {
this.dialog.yesNoConfirm({
message: i18n("admin.user_fields.delete_confirm"),
didConfirm: () => {
return f
.destroyRecord()
.then(function () {
model.removeObject(f);
})
.catch(popupAjaxError);
},
});
} else {
model.removeObject(f);
}
}
}

View File

@ -26,6 +26,7 @@ export default class UserField extends RestModel {
@tracked show_on_profile;
@tracked show_on_user_card;
@tracked searchable;
@tracked requirement;
get fieldTypeName() {
return UserField.fieldTypes().find((ft) => ft.id === this.field_type).name;

View File

@ -68,10 +68,15 @@ export default function () {
}
);
this.route("adminUserFields", {
path: "/user_fields",
resetNamespace: true,
});
this.route(
"adminUserFields",
{ path: "/user_fields", resetNamespace: true },
function () {
this.route("new");
this.route("edit", { path: "/:id/edit" });
this.route("index", { path: "/" });
}
);
this.route(
"adminEmojis",
{ path: "/emojis", resetNamespace: true },

View File

@ -0,0 +1,12 @@
import DiscourseRoute from "discourse/routes/discourse";
import { i18n } from "discourse-i18n";
export default class AdminUserFieldsEditRoute extends DiscourseRoute {
model(params) {
return this.store.find("user-field", params.id);
}
titleToken() {
return i18n("admin.user_fields.edit_header");
}
}

View File

@ -0,0 +1,20 @@
import { service } from "@ember/service";
import DiscourseRoute from "discourse/routes/discourse";
import { i18n } from "discourse-i18n";
const DEFAULT_VALUES = {
field_type: "text",
requirement: "optional",
};
export default class AdminUserFieldsNewRoute extends DiscourseRoute {
@service store;
async model() {
return this.store.createRecord("user-field", { ...DEFAULT_VALUES });
}
titleToken() {
return i18n("admin.user_fields.new_header");
}
}

View File

@ -1,12 +1,8 @@
import DiscourseRoute from "discourse/routes/discourse";
import UserField from "admin/models/user-field";
import { i18n } from "discourse-i18n";
export default class AdminUserFieldsRoute extends DiscourseRoute {
model() {
return this.store.findAll("user-field");
}
setupController(controller, model) {
controller.setProperties({ model, fieldTypes: UserField.fieldTypes() });
titleToken() {
return i18n("admin.user_fields.title");
}
}

View File

@ -0,0 +1,36 @@
import { tracked } from "@glimmer/tracking";
import { sort } from "@ember/object/computed";
import Service, { service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error";
export default class AdminUserFields extends Service {
@service store;
@tracked userFields = [];
@sort("userFields", "fieldSortOrder") sortedUserFields;
fieldSortOrder = ["position"];
constructor() {
super(...arguments);
this.#fetchUserFields();
}
async #fetchUserFields() {
try {
this.userFields = await this.store.findAll("user-field");
} catch (err) {
popupAjaxError(err);
}
}
get firstField() {
return this.sortedUserFields[0];
}
get lastField() {
return this.sortedUserFields[this.sortedUserFields.length - 1];
}
}

View File

@ -0,0 +1,8 @@
<BackButton @route="adminUserFields.index" @label="admin.user_fields.back" />
<div class="admin-config-area user-field">
<div class="admin-config-area__primary-content">
<div class="admin-config-area-card">
<AdminUserFieldsForm @userField={{this.model}} />
</div>
</div>
</div>

View File

@ -0,0 +1 @@
<AdminConfigAreas::UserFieldsList @userFields={{this.model}} />

View File

@ -0,0 +1,8 @@
<BackButton @route="adminUserFields.index" @label="admin.user_fields.back" />
<div class="admin-config-area user-field">
<div class="admin-config-area__primary-content">
<div class="admin-config-area-card">
<AdminUserFieldsForm @userField={{this.model}} />
</div>
</div>
</div>

View File

@ -1,29 +1,27 @@
<div class="admin-config-page__main-area">
<div class="user-fields">
<h2>{{i18n "admin.user_fields.title"}}</h2>
<div class="admin-user_fields admin-config-page">
<AdminPageHeader
@titleLabel="admin.user_fields.title"
@descriptionLabel="admin.user_fields.help"
@hideTabs={{true}}
@learnMoreUrl="https://meta.discourse.org/t/creating-and-configuring-custom-user-fields/113192"
>
<:breadcrumbs>
<DBreadcrumbsItem
@path="/admin/customize/user_fields"
@label={{i18n "admin.user_fields.title"}}
/>
</:breadcrumbs>
<:actions as |actions|>
<actions.Primary
@route="adminUserFields.new"
@label="admin.user_fields.add"
/>
</:actions>
</AdminPageHeader>
<p class="desc">{{i18n "admin.user_fields.help"}}</p>
{{#if this.model}}
{{#each this.sortedFields as |uf|}}
<AdminUserFieldItem
@userField={{uf}}
@fieldTypes={{this.fieldTypes}}
@firstField={{this.firstField}}
@lastField={{this.lastField}}
@destroyAction={{this.destroyField}}
@moveUpAction={{this.moveUp}}
@moveDownAction={{this.moveDown}}
/>
{{/each}}
{{/if}}
<DButton
@disabled={{this.createDisabled}}
@action={{this.createField}}
@label="admin.user_fields.create"
@icon="plus"
class="btn-primary"
/>
<div class="admin-config-page__main-area">
<div class="user-fields">
{{outlet}}
</div>
</div>
</div>

View File

@ -99,3 +99,10 @@ export const SITE_SETTING_REQUIRES_CONFIRMATION_TYPES = {
};
export const MAX_UNOPTIMIZED_CATEGORIES = 1000;
export const USER_FIELD_FLAGS = [
"editable",
"show_on_profile",
"show_on_user_card",
"searchable",
];

View File

@ -1078,6 +1078,7 @@ a.inline-editable-field {
@import "common/admin/badges";
@import "common/admin/emails";
@import "common/admin/flags";
@import "common/admin/user_fields";
@import "common/admin/json_schema_editor";
@import "common/admin/schema_field";
@import "common/admin/staff_logs";

View File

@ -94,6 +94,16 @@
margin-bottom: 0.1em;
}
}
&-flags {
color: var(--primary-high);
font-size: var(--font-down-1);
text-transform: lowercase;
&::first-letter {
text-transform: uppercase;
}
}
}
.d-admin-row__controls {

View File

@ -0,0 +1,10 @@
.admin-user_field-item {
&__delete.btn,
&__delete.btn:hover {
border-top: 1px solid var(--primary-low);
color: var(--danger);
svg {
color: var(--danger);
}
}
}

View File

@ -30,6 +30,14 @@ class Admin::UserFieldsController < Admin::AdminController
render_serialized(user_fields, UserFieldSerializer, root: "user_fields")
end
def show
user_field = UserField.find(params[:id])
render_serialized(user_field, UserFieldSerializer)
end
def edit
end
def update
field_params = params[:user_field]
field = UserField.where(id: params.require(:id)).first

View File

@ -5,6 +5,8 @@ class UserField < ActiveRecord::Base
include HasDeprecatedColumns
include HasSanitizableFields
FLAG_ATTRIBUTES = %w[editable show_on_profile show_on_user_card searchable].freeze
deprecate_column :required, drop_from: "3.3"
self.ignored_columns += %i[field_type]

View File

@ -4275,7 +4275,7 @@ bs_BA:
enabled: "prikazano na korisničkoj kartici"
disabled: "nije prikazano na korisničkoj kartici"
field_types:
text: "Text Field"
text: "Text"
confirm: "Confirmation"
dropdown: "Ispustiti"
site_text:

View File

@ -6412,7 +6412,7 @@ cs:
enabled: "zobrazeno na kartě uživatele"
disabled: "Nezobrazeno na kartě uživatele"
field_types:
text: "Text Field"
text: "Text"
confirm: "Potvrzení"
dropdown: "Menu"
multiselect: "Více možností"

View File

@ -5737,6 +5737,16 @@ en:
themes_description: "Themes are expansive customizations that change multiple elements of the style of your forum design, and often also include additional front-end features."
new_theme: "New theme"
user_selectable: "User selectable"
user_fields:
field: "Field"
type: "Type"
more_options:
title: "More options"
move_up: "Move up"
move_down: "Move down"
delete: "Delete"
delete_successful: "User field deleted."
save_successful: "User field saved."
plugins:
title: "Plugins"
installed: "Installed plugins"
@ -6946,8 +6956,12 @@ en:
user_fields:
title: "User Fields"
help: "Add fields that your users can fill out."
create: "Create User Field"
help: "Create custom user fields to collect extra details about your community members. You can choose what information is required during sign-up, what shows on profiles, and what users can update."
no_user_fields: "You don't have any custom user fields yet."
add: "Add user field"
back: "Back to user fields"
edit_header: "Edit User Field"
new_header: "Add User Field"
untitled: "Untitled"
name: "Field Name"
type: "Field Type"
@ -6976,23 +6990,23 @@ en:
confirmation: "This will prompt existing users to fill in this field and will not allow them to do anything else on your site until the field is filled. Proceed?"
editable:
title: "Editable after signup"
enabled: "editable"
disabled: "not editable"
enabled: "Editable"
disabled: "Not editable"
show_on_profile:
title: "Show on public profile"
enabled: "shown on profile"
disabled: "not shown on profile"
enabled: "Shown on profile"
disabled: "Not shown on profile"
show_on_user_card:
title: "Show on user card"
enabled: "shown on user card"
disabled: "not shown on user card"
enabled: "Shown on user card"
disabled: "Not shown on user card"
searchable:
title: "Searchable"
enabled: "searchable"
disabled: "not searchable"
enabled: "Searchable"
disabled: "Not searchable"
field_types:
text: "Text Field"
text: "Text"
confirm: "Confirmation"
dropdown: "Dropdown"
multiselect: "Multiselect"

View File

@ -245,6 +245,9 @@ Discourse::Application.routes.draw do
resources :user_fields,
only: %i[index create update destroy],
constraints: AdminConstraint.new
get "user_fields/new" => "user_fields#index"
get "user_fields/:id" => "user_fields#show"
get "user_fields/:id/edit" => "user_fields#edit"
resources :emojis, only: %i[index create destroy], constraints: AdminConstraint.new
get "emojis/new" => "emojis#index"
get "emojis/settings" => "emojis#index"

View File

@ -165,6 +165,8 @@ task "javascript:update_constants" => :environment do
export const SITE_SETTING_REQUIRES_CONFIRMATION_TYPES = #{SiteSettings::TypeSupervisor::REQUIRES_CONFIRMATION_TYPES.to_json};
export const MAX_UNOPTIMIZED_CATEGORIES = #{CategoryList::MAX_UNOPTIMIZED_CATEGORIES};
export const USER_FIELD_FLAGS = #{UserField::FLAG_ATTRIBUTES};
JS
pretty_notifications = Notification.types.map { |n| " #{n[0]}: #{n[1]}," }.join("\n")

View File

@ -28,8 +28,7 @@ describe "Admin User Fields", type: :system do
it "makes sure new required fields are editable after signup" do
user_fields_page.visit
page.find(".user-fields .btn-primary").click
user_fields_page.click_add_field
form = page.find(".user-field")
editable_label = I18n.t("admin_js.admin.user_fields.editable.title")
@ -45,8 +44,7 @@ describe "Admin User Fields", type: :system do
it "requires confirmation when applying required fields retroactively" do
user_fields_page.visit
page.find(".user-fields .btn-primary").click
user_fields_page.click_add_field
form = page.find(".user-field")
@ -65,8 +63,7 @@ describe "Admin User Fields", type: :system do
it "does not require confirmation if the field already applies to all users" do
user_fields_page.visit
page.find(".user-field .edit").click
user_fields_page.click_edit
form = page.find(".user-field")

View File

@ -18,8 +18,16 @@ module PageObjects
form.choose(I18n.t("admin_js.admin.user_fields.requirement.#{requirement}.title"))
end
def click_add_field
page.find(".admin-page-header__actions .btn-primary").click
end
def click_edit
page.find(".admin-user_field-item__edit").click
end
def add_field(name: nil, description: nil, requirement: nil, preferences: [])
page.find(".user-fields .btn-primary").click
click_add_field
form = page.find(".user-field")