DEV: Replace preferences/username route with a component (#19318)

That was a weird UX (why hide the preferences navigation?) and a deprecated implementation (manually rendering a template into a named outlet)

This PR replaces it with an inline component.
This commit is contained in:
Jarek Radosz 2022-12-07 11:16:01 +01:00 committed by GitHub
parent 207b764ea3
commit d3649873a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 196 additions and 176 deletions

View File

@ -0,0 +1,55 @@
{{#if this.editing}}
<form class="form-horizontal">
<div class="control-group">
<Input
{{on "input" this.onInput}}
@value={{this.newUsername}}
maxlength={{this.maxLength}}
class="input-xxlarge username-preference__input"
/>
<div class="instructions">
<p>
{{#if this.taken}}
{{i18n "user.change_username.taken"}}
{{/if}}
<span>{{this.errorMessage}}</span>
</p>
</div>
</div>
<div class="control-group">
<DButton
@action={{this.changeUsername}}
@type="submit"
@disabled={{this.saveDisabled}}
@translatedLabel={{this.saveButtonText}}
class="btn-primary username-preference__submit"
/>
<DModalCancel @close={{this.toggleEditing}} />
{{#if this.saved}}{{i18n "saved"}}{{/if}}
</div>
</form>
{{else}}
<div class="controls">
<span class="static username-preference__current-username">{{@user.username}}</span>
{{#if @user.can_edit_username}}
<DButton
@action={{this.toggleEditing}}
@actionParam={{@user}}
@icon="pencil-alt"
@title="user.username.edit"
class="btn-small username-preference__edit-username"
/>
{{/if}}
</div>
{{#if this.siteSettings.enable_mentions}}
<div class="instructions">
{{html-safe (i18n "user.username.short_instructions" username=@user.username)}}
</div>
{{/if}}
{{/if}}

View File

@ -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;
}
},
});
}
}

View File

@ -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));
},
});
},
},
});

View File

@ -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");

View File

@ -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"),
});
},
});

View File

@ -1,38 +0,0 @@
<DSection @pageClass="user-preferences" @tagName="">
<section class="user-preferences solo-preference">
<form class="form-horizontal">
<div class="control-group">
<div class="controls">
<h3>{{i18n "user.change_username.title"}}</h3>
</div>
</div>
<div class="control-group">
<label for="change_username" class="control-label">{{i18n "user.username.title"}}</label>
<div class="controls">
<TextField @value={{this.newUsername}} @id="change_username" @classNames="input-xxlarge" @maxlength={{this.maxLength}} @autofocus="autofocus" @insert-newline="changeUsername" />
</div>
<div class="instructions controls">
<p>
{{#if this.taken}}
{{i18n "user.change_username.taken"}}
{{/if}}
<span>{{this.errorMessage}}</span>
</p>
</div>
</div>
<div class="control-group">
<div class="controls">
<DButton @class="btn-primary" @action={{action "changeUsername"}} @type="submit" @disabled={{this.saveDisabled}} @translatedLabel={{this.saveButtonText}} />
<LinkTo @route="preferences" @model={{this.currentUser}} class="btn btn-flat -cancel">
{{i18n "cancel"}}
</LinkTo>
{{#if this.saved}}{{i18n "saved"}}{{/if}}
</div>
</div>
</form>
</section>
</DSection>

View File

@ -1,18 +1,6 @@
<div class="control-group pref-username">
<label class="control-label">{{i18n "user.username.title"}}</label>
<div class="controls">
<span class="static">{{this.model.username}}</span>
{{#if this.model.can_edit_username}}
<LinkTo @route="preferences.username" title={{i18n "user.username.edit"}} class="btn btn-default btn-small btn-icon pad-left no-text">
{{d-icon "pencil-alt"}}
</LinkTo>
{{/if}}
</div>
{{#if this.siteSettings.enable_mentions}}
<div class="instructions">
{{html-safe (i18n "user.username.short_instructions" username=this.model.username)}}
</div>
{{/if}}
<UsernamePreference @user={{this.model}} />
</div>
{{#unless this.siteSettings.discourse_connect_overrides_avatar}}

View File

@ -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) {

View File

@ -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");