diff --git a/app/assets/javascripts/discourse/app/components/username-preference.hbs b/app/assets/javascripts/discourse/app/components/username-preference.hbs
new file mode 100644
index 0000000000..42cb6f854d
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/username-preference.hbs
@@ -0,0 +1,55 @@
+{{#if this.editing}}
+
+{{else}}
+
+ {{@user.username}}
+
+ {{#if @user.can_edit_username}}
+
+ {{/if}}
+
+
+ {{#if this.siteSettings.enable_mentions}}
+
+ {{html-safe (i18n "user.username.short_instructions" username=@user.username)}}
+
+ {{/if}}
+{{/if}}
diff --git a/app/assets/javascripts/discourse/app/components/username-preference.js b/app/assets/javascripts/discourse/app/components/username-preference.js
new file mode 100644
index 0000000000..b2ac7012de
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/username-preference.js
@@ -0,0 +1,100 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import DiscourseURL, { userPath } from "discourse/lib/url";
+import { empty, or } from "@ember/object/computed";
+import { setting } from "discourse/lib/computed";
+import I18n from "I18n";
+import User from "discourse/models/user";
+import { isEmpty } from "@ember/utils";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+import { inject as service } from "@ember/service";
+import { action } from "@ember/object";
+
+export default class UsernamePreference extends Component {
+ @service siteSettings;
+ @service dialog;
+
+ @tracked editing = false;
+ @tracked newUsername = this.args.user.username;
+ @tracked errorMessage = null;
+ @tracked saving = false;
+ @tracked taken = false;
+
+ @setting("max_username_length") maxLength;
+ @setting("min_username_length") minLength;
+ @empty("newUsername") newUsernameEmpty;
+
+ @or("saving", "newUsernameEmpty", "taken", "unchanged", "errorMessage")
+ saveDisabled;
+
+ get unchanged() {
+ return this.newUsername === this.args.user.username;
+ }
+
+ get saveButtonText() {
+ return this.saving ? I18n.t("saving") : I18n.t("user.change");
+ }
+
+ @action
+ toggleEditing() {
+ this.editing = !this.editing;
+
+ this.newUsername = this.args.user.username;
+ this.errorMessage = null;
+ this.saving = false;
+ this.taken = false;
+ }
+
+ @action
+ async onInput(event) {
+ this.newUsername = event.target.value;
+ this.taken = false;
+ this.errorMessage = null;
+
+ if (isEmpty(this.newUsername)) {
+ return;
+ }
+
+ if (this.newUsername === this.args.user.username) {
+ return;
+ }
+
+ if (this.newUsername.length < this.minLength) {
+ this.errorMessage = I18n.t("user.name.too_short");
+ return;
+ }
+
+ const result = await User.checkUsername(
+ this.newUsername,
+ undefined,
+ this.args.user.id
+ );
+
+ if (result.errors) {
+ this.errorMessage = result.errors.join(" ");
+ } else if (result.available === false) {
+ this.taken = true;
+ }
+ }
+
+ @action
+ changeUsername() {
+ return this.dialog.yesNoConfirm({
+ title: I18n.t("user.change_username.confirm"),
+ didConfirm: async () => {
+ this.saving = true;
+
+ try {
+ await this.args.user.changeUsername(this.newUsername);
+ DiscourseURL.redirectTo(
+ userPath(this.newUsername.toLowerCase() + "/preferences")
+ );
+ } catch (e) {
+ popupAjaxError(e);
+ } finally {
+ this.saving = false;
+ }
+ },
+ });
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/username.js b/app/assets/javascripts/discourse/app/controllers/preferences/username.js
deleted file mode 100644
index cb756dbb52..0000000000
--- a/app/assets/javascripts/discourse/app/controllers/preferences/username.js
+++ /dev/null
@@ -1,91 +0,0 @@
-import DiscourseURL, { userPath } from "discourse/lib/url";
-import discourseComputed, { observes } from "discourse-common/utils/decorators";
-import { empty, or } from "@ember/object/computed";
-import { propertyEqual, setting } from "discourse/lib/computed";
-import Controller from "@ember/controller";
-import I18n from "I18n";
-import User from "discourse/models/user";
-import { isEmpty } from "@ember/utils";
-import { popupAjaxError } from "discourse/lib/ajax-error";
-import { inject as service } from "@ember/service";
-
-export default Controller.extend({
- dialog: service(),
- taken: false,
- saving: false,
- errorMessage: null,
- newUsername: null,
-
- maxLength: setting("max_username_length"),
- minLength: setting("min_username_length"),
- newUsernameEmpty: empty("newUsername"),
- saveDisabled: or(
- "saving",
- "newUsernameEmpty",
- "taken",
- "unchanged",
- "errorMessage"
- ),
- unchanged: propertyEqual("newUsername", "username"),
-
- @observes("newUsername")
- checkTaken() {
- let newUsername = this.newUsername;
-
- if (newUsername && newUsername.length < this.minLength) {
- this.set("errorMessage", I18n.t("user.name.too_short"));
- } else {
- this.set("taken", false);
- this.set("errorMessage", null);
-
- if (isEmpty(this.newUsername)) {
- return;
- }
- if (this.unchanged) {
- return;
- }
-
- User.checkUsername(newUsername, undefined, this.get("model.id")).then(
- (result) => {
- if (result.errors) {
- this.set("errorMessage", result.errors.join(" "));
- } else if (result.available === false) {
- this.set("taken", true);
- }
- }
- );
- }
- },
-
- @discourseComputed("saving")
- saveButtonText(saving) {
- if (saving) {
- return I18n.t("saving");
- }
- return I18n.t("user.change");
- },
-
- actions: {
- changeUsername() {
- if (this.saveDisabled) {
- return;
- }
-
- return this.dialog.yesNoConfirm({
- title: I18n.t("user.change_username.confirm"),
- didConfirm: () => {
- this.set("saving", true);
- this.model
- .changeUsername(this.newUsername)
- .then(() => {
- DiscourseURL.redirectTo(
- userPath(this.newUsername.toLowerCase() + "/preferences")
- );
- })
- .catch(popupAjaxError)
- .finally(() => this.set("saving", false));
- },
- });
- },
- },
-});
diff --git a/app/assets/javascripts/discourse/app/routes/app-route-map.js b/app/assets/javascripts/discourse/app/routes/app-route-map.js
index 24baedb7a5..b30a365ca6 100644
--- a/app/assets/javascripts/discourse/app/routes/app-route-map.js
+++ b/app/assets/javascripts/discourse/app/routes/app-route-map.js
@@ -175,7 +175,6 @@ export default function () {
this.route("apps");
this.route("sidebar");
- this.route("username");
this.route("email");
this.route("second-factor");
this.route("second-factor-backup");
diff --git a/app/assets/javascripts/discourse/app/routes/preferences-username.js b/app/assets/javascripts/discourse/app/routes/preferences-username.js
deleted file mode 100644
index 1bf9b425a7..0000000000
--- a/app/assets/javascripts/discourse/app/routes/preferences-username.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import RestrictedUserRoute from "discourse/routes/restricted-user";
-
-export default RestrictedUserRoute.extend({
- showFooter: true,
-
- model() {
- return this.modelFor("user");
- },
-
- renderTemplate() {
- return this.render({ into: "user" });
- },
-
- // A bit odd, but if we leave to /preferences we need to re-render that outlet
- deactivate() {
- this._super(...arguments);
- this.render("preferences", { into: "user", controller: "preferences" });
- },
-
- setupController(controller, user) {
- controller.setProperties({
- model: user,
- newUsername: user.get("username"),
- });
- },
-});
diff --git a/app/assets/javascripts/discourse/app/templates/preferences-username.hbs b/app/assets/javascripts/discourse/app/templates/preferences-username.hbs
deleted file mode 100644
index 9d4e1ec997..0000000000
--- a/app/assets/javascripts/discourse/app/templates/preferences-username.hbs
+++ /dev/null
@@ -1,38 +0,0 @@
-
-
-
diff --git a/app/assets/javascripts/discourse/app/templates/preferences/account.hbs b/app/assets/javascripts/discourse/app/templates/preferences/account.hbs
index c8a33c91fd..90f67422aa 100644
--- a/app/assets/javascripts/discourse/app/templates/preferences/account.hbs
+++ b/app/assets/javascripts/discourse/app/templates/preferences/account.hbs
@@ -1,18 +1,6 @@
-
- {{this.model.username}}
- {{#if this.model.can_edit_username}}
-
- {{d-icon "pencil-alt"}}
-
- {{/if}}
-
- {{#if this.siteSettings.enable_mentions}}
-
- {{html-safe (i18n "user.username.short_instructions" username=this.model.username)}}
-
- {{/if}}
+
{{#unless this.siteSettings.discourse_connect_overrides_avatar}}
diff --git a/app/assets/javascripts/discourse/tests/acceptance/preferences-test.js b/app/assets/javascripts/discourse/tests/acceptance/preferences-test.js
index 6f60d307d7..9b6e3089cb 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/preferences-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/preferences-test.js
@@ -111,11 +111,6 @@ acceptance("User Preferences", function (needs) {
"apps tab isn't there when you have no authorized apps"
);
});
-
- test("username", async function (assert) {
- await visit("/u/eviltrout/preferences/username");
- assert.ok(exists("#change_username"), "it has the input element");
- });
});
acceptance("Custom User Fields", function (needs) {
diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-preferences-account-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-preferences-account-test.js
index e33b9ccb45..83d8162c6c 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/user-preferences-account-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/user-preferences-account-test.js
@@ -1,14 +1,17 @@
import { test } from "qunit";
import I18n from "I18n";
import sinon from "sinon";
-import { click, visit } from "@ember/test-helpers";
+import { click, fillIn, visit } from "@ember/test-helpers";
import {
acceptance,
exists,
query,
} from "discourse/tests/helpers/qunit-helpers";
import DiscourseURL from "discourse/lib/url";
-import { fixturesByUrl } from "discourse/tests/helpers/create-pretender";
+import pretender, {
+ fixturesByUrl,
+ response,
+} from "discourse/tests/helpers/create-pretender";
import { cloneJSON } from "discourse-common/lib/object";
acceptance("User Preferences - Account", function (needs) {
@@ -58,6 +61,41 @@ acceptance("User Preferences - Account", function (needs) {
pickAvatarRequestData = null;
});
+ test("changing username", async function (assert) {
+ const stub = sinon
+ .stub(DiscourseURL, "redirectTo")
+ .withArgs("/u/good_trout/preferences");
+
+ pretender.put("/u/eviltrout/preferences/username", (data) => {
+ assert.strictEqual(data.requestBody, "new_username=good_trout");
+
+ return response({
+ id: fixturesByUrl["/u/eviltrout.json"].user.id,
+ username: "good_trout",
+ });
+ });
+
+ await visit("/u/eviltrout/preferences/account");
+
+ assert.strictEqual(
+ query(".username-preference__current-username").innerText,
+ "eviltrout"
+ );
+
+ await click(".username-preference__edit-username");
+
+ assert.strictEqual(query(".username-preference__input").value, "eviltrout");
+ assert.true(query(".username-preference__submit").disabled);
+
+ await fillIn(query(".username-preference__input"), "good_trout");
+ assert.false(query(".username-preference__submit").disabled);
+
+ await click(".username-preference__submit");
+ await click(".dialog-container .btn-primary");
+
+ sinon.assert.calledOnce(stub);
+ });
+
test("Delete dialog", async function (assert) {
sinon.stub(DiscourseURL, "redirectAbsolute");