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_profile;
@tracked show_on_user_card; @tracked show_on_user_card;
@tracked searchable; @tracked searchable;
@tracked requirement;
get fieldTypeName() { get fieldTypeName() {
return UserField.fieldTypes().find((ft) => ft.id === this.field_type).name; return UserField.fieldTypes().find((ft) => ft.id === this.field_type).name;

View File

@ -68,10 +68,15 @@ export default function () {
} }
); );
this.route("adminUserFields", { this.route(
path: "/user_fields", "adminUserFields",
resetNamespace: true, { path: "/user_fields", resetNamespace: true },
}); function () {
this.route("new");
this.route("edit", { path: "/:id/edit" });
this.route("index", { path: "/" });
}
);
this.route( this.route(
"adminEmojis", "adminEmojis",
{ path: "/emojis", resetNamespace: true }, { 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 DiscourseRoute from "discourse/routes/discourse";
import UserField from "admin/models/user-field"; import { i18n } from "discourse-i18n";
export default class AdminUserFieldsRoute extends DiscourseRoute { export default class AdminUserFieldsRoute extends DiscourseRoute {
model() { titleToken() {
return this.store.findAll("user-field"); return i18n("admin.user_fields.title");
}
setupController(controller, model) {
controller.setProperties({ model, fieldTypes: UserField.fieldTypes() });
} }
} }

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-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>
<div class="admin-config-page__main-area"> <div class="admin-config-page__main-area">
<div class="user-fields"> <div class="user-fields">
<h2>{{i18n "admin.user_fields.title"}}</h2> {{outlet}}
</div>
<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> </div>
</div> </div>

View File

@ -99,3 +99,10 @@ export const SITE_SETTING_REQUIRES_CONFIRMATION_TYPES = {
}; };
export const MAX_UNOPTIMIZED_CATEGORIES = 1000; 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/badges";
@import "common/admin/emails"; @import "common/admin/emails";
@import "common/admin/flags"; @import "common/admin/flags";
@import "common/admin/user_fields";
@import "common/admin/json_schema_editor"; @import "common/admin/json_schema_editor";
@import "common/admin/schema_field"; @import "common/admin/schema_field";
@import "common/admin/staff_logs"; @import "common/admin/staff_logs";

View File

@ -94,6 +94,16 @@
margin-bottom: 0.1em; 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 { .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") render_serialized(user_fields, UserFieldSerializer, root: "user_fields")
end end
def show
user_field = UserField.find(params[:id])
render_serialized(user_field, UserFieldSerializer)
end
def edit
end
def update def update
field_params = params[:user_field] field_params = params[:user_field]
field = UserField.where(id: params.require(:id)).first field = UserField.where(id: params.require(:id)).first

View File

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

View File

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

View File

@ -6412,7 +6412,7 @@ cs:
enabled: "zobrazeno na kartě uživatele" enabled: "zobrazeno na kartě uživatele"
disabled: "Nezobrazeno na kartě uživatele" disabled: "Nezobrazeno na kartě uživatele"
field_types: field_types:
text: "Text Field" text: "Text"
confirm: "Potvrzení" confirm: "Potvrzení"
dropdown: "Menu" dropdown: "Menu"
multiselect: "Více možností" 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." 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" new_theme: "New theme"
user_selectable: "User selectable" 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: plugins:
title: "Plugins" title: "Plugins"
installed: "Installed plugins" installed: "Installed plugins"
@ -6946,8 +6956,12 @@ en:
user_fields: user_fields:
title: "User Fields" title: "User Fields"
help: "Add fields that your users can fill out." 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."
create: "Create User Field" 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" untitled: "Untitled"
name: "Field Name" name: "Field Name"
type: "Field Type" 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?" 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: editable:
title: "Editable after signup" title: "Editable after signup"
enabled: "editable" enabled: "Editable"
disabled: "not editable" disabled: "Not editable"
show_on_profile: show_on_profile:
title: "Show on public profile" title: "Show on public profile"
enabled: "shown on profile" enabled: "Shown on profile"
disabled: "not shown on profile" disabled: "Not shown on profile"
show_on_user_card: show_on_user_card:
title: "Show on user card" title: "Show on user card"
enabled: "shown on user card" enabled: "Shown on user card"
disabled: "not shown on user card" disabled: "Not shown on user card"
searchable: searchable:
title: "Searchable" title: "Searchable"
enabled: "searchable" enabled: "Searchable"
disabled: "not searchable" disabled: "Not searchable"
field_types: field_types:
text: "Text Field" text: "Text"
confirm: "Confirmation" confirm: "Confirmation"
dropdown: "Dropdown" dropdown: "Dropdown"
multiselect: "Multiselect" multiselect: "Multiselect"

View File

@ -245,6 +245,9 @@ Discourse::Application.routes.draw do
resources :user_fields, resources :user_fields,
only: %i[index create update destroy], only: %i[index create update destroy],
constraints: AdminConstraint.new 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 resources :emojis, only: %i[index create destroy], constraints: AdminConstraint.new
get "emojis/new" => "emojis#index" get "emojis/new" => "emojis#index"
get "emojis/settings" => "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 SITE_SETTING_REQUIRES_CONFIRMATION_TYPES = #{SiteSettings::TypeSupervisor::REQUIRES_CONFIRMATION_TYPES.to_json};
export const MAX_UNOPTIMIZED_CATEGORIES = #{CategoryList::MAX_UNOPTIMIZED_CATEGORIES}; export const MAX_UNOPTIMIZED_CATEGORIES = #{CategoryList::MAX_UNOPTIMIZED_CATEGORIES};
export const USER_FIELD_FLAGS = #{UserField::FLAG_ATTRIBUTES};
JS JS
pretty_notifications = Notification.types.map { |n| " #{n[0]}: #{n[1]}," }.join("\n") 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 it "makes sure new required fields are editable after signup" do
user_fields_page.visit user_fields_page.visit
user_fields_page.click_add_field
page.find(".user-fields .btn-primary").click
form = page.find(".user-field") form = page.find(".user-field")
editable_label = I18n.t("admin_js.admin.user_fields.editable.title") 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 it "requires confirmation when applying required fields retroactively" do
user_fields_page.visit user_fields_page.visit
user_fields_page.click_add_field
page.find(".user-fields .btn-primary").click
form = page.find(".user-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 it "does not require confirmation if the field already applies to all users" do
user_fields_page.visit user_fields_page.visit
user_fields_page.click_edit
page.find(".user-field .edit").click
form = page.find(".user-field") 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")) form.choose(I18n.t("admin_js.admin.user_fields.requirement.#{requirement}.title"))
end 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: []) 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") form = page.find(".user-field")