DEV: upgrade avatar-selector modal to glimmer component (#24192)

* DEV: upgrade avatar-selector modal

* DEV: add system test for avatar selection in account preferences
This commit is contained in:
Kelv 2023-11-07 21:02:19 +08:00 committed by GitHub
parent 39e1b97a5d
commit 4a21411de2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 404 additions and 374 deletions

View File

@ -0,0 +1,141 @@
<DModal
@bodyClass="avatar-selector"
@closeModal={{@closeModal}}
@title={{i18n "user.change_avatar.title"}}
class="avatar-selector-modal"
>
<:body>
{{#if this.showSelectableAvatars}}
<div class="selectable-avatars">
{{#each this.selectableAvatars as |avatar|}}
<a
href
class="selectable-avatar"
{{on "click" (fn this.selectAvatar avatar)}}
>
{{bound-avatar-template avatar "huge"}}
</a>
{{/each}}
</div>
{{#if this.showAvatarUploader}}
<h4>{{i18n "user.change_avatar.use_custom"}}</h4>
{{/if}}
{{/if}}
{{#if this.showAvatarUploader}}
{{#if this.user.use_logo_small_as_avatar}}
<div class="avatar-choice">
<RadioButton
@id="logo-small"
@name="logo"
@value="logo"
@selection={{this.selected}}
@onChange={{this.onSelectedChanged}}
/>
<label class="radio" for="logo-small">
{{bound-avatar-template
this.siteSettings.site_logo_small_url
"large"
}}
{{i18n "user.change_avatar.logo_small"}}
</label>
</div>
{{/if}}
<div class="avatar-choice">
<RadioButton
@id="system-avatar"
@name="avatar"
@value="system"
@selection={{this.selected}}
@onChange={{this.onSelectedChanged}}
/>
<label class="radio" for="system-avatar">
{{bound-avatar-template this.user.system_avatar_template "large"}}
{{i18n "user.change_avatar.letter_based"}}
</label>
</div>
{{#if this.allowAvatarUpload}}
<div class="avatar-choice">
<RadioButton
@id="gravatar"
@name="avatar"
@value="gravatar"
@selection={{this.selected}}
@onChange={{this.onSelectedChanged}}
/>
<label class="radio" for="gravatar">
{{bound-avatar-template this.user.gravatar_avatar_template "large"}}
<span>
{{html-safe
(i18n
"user.change_avatar.gravatar"
gravatarName=this.siteSettings.gravatar_name
gravatarBaseUrl=this.siteSettings.gravatar_base_url
gravatarLoginUrl=this.siteSettings.gravatar_login_url
)
}}
{{this.user.email}}
</span>
</label>
<DButton
@action={{this.refreshGravatar}}
@translatedTitle={{i18n
"user.change_avatar.refresh_gravatar_title"
gravatarName=this.siteSettings.gravatar_name
}}
@disabled={{this.gravatarRefreshDisabled}}
@icon="sync"
class="btn-default avatar-selector-refresh-gravatar"
/>
{{#if this.gravatarFailed}}
<p class="error">
{{i18n
"user.change_avatar.gravatar_failed"
gravatarName=this.siteSettings.gravatar_name
}}
</p>
{{/if}}
</div>
<div class="avatar-choice">
<RadioButton
@id="uploaded-avatar"
@name="avatar"
@value="custom"
@selection={{this.selected}}
@onChange={{this.onSelectedChanged}}
/>
<label class="radio" for="uploaded-avatar">
{{#if this.user.custom_avatar_template}}
{{bound-avatar-template this.user.custom_avatar_template "large"}}
{{i18n "user.change_avatar.uploaded_avatar"}}
{{else}}
{{i18n "user.change_avatar.uploaded_avatar_empty"}}
{{/if}}
</label>
<AvatarUploader
@user_id={{this.user.id}}
@uploadedAvatarTemplate={{this.user.custom_avatar_template}}
@uploadedAvatarId={{this.user.custom_avatar_upload_id}}
@uploading={{this.uploading}}
@class="avatar-uploader"
@id="avatar-uploader"
@done={{this.uploadComplete}}
/>
</div>
{{/if}}
{{/if}}
</:body>
<:footer>
{{#if this.showAvatarUploader}}
<DButton
@action={{this.saveAvatarSelection}}
@disabled={{this.submitDisabled}}
@label="save"
class="btn-primary"
/>
<DModalCancel @close={{@closeModal}} />
{{/if}}
</:footer>
</DModal>

View File

@ -0,0 +1,175 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { allowsImages } from "discourse/lib/uploads";
import { isTesting } from "discourse-common/config/environment";
export default class AvatarSelectorModal extends Component {
@service currentUser;
@service siteSettings;
@tracked gravatarRefreshDisabled = false;
@tracked gravatarFailed = false;
@tracked uploading = false;
@tracked _selected = null;
get user() {
return this.args.model.user;
}
get selected() {
return this._selected ?? this.defaultSelection;
}
set selected(value) {
this._selected = value;
}
get submitDisabled() {
return this.selected === "logo" || this.uploading;
}
get selectableAvatars() {
const mode = this.siteSettings.selectable_avatars_mode;
const list = this.siteSettings.selectable_avatars;
return mode !== "disabled" ? (list ? list.split("|") : []) : null;
}
get showSelectableAvatars() {
return this.siteSettings.selectable_avatars_mode !== "disabled";
}
get showAvatarUploader() {
const mode = this.siteSettings.selectable_avatars_mode;
switch (mode) {
case "no_one":
return false;
case "tl1":
case "tl2":
case "tl3":
case "tl4":
const allowedTl = parseInt(mode.replace("tl", ""), 10);
return (
this.user.admin ||
this.user.moderator ||
this.user.trust_level >= allowedTl
);
case "staff":
return this.user.admin || this.user.moderator;
case "everyone":
default:
return true;
}
}
get defaultSelection() {
if (this.user.use_logo_small_as_avatar) {
return "logo";
} else if (this.user.avatar_template === this.user.system_avatar_template) {
return "system";
} else if (
this.user.avatar_template === this.user.gravatar_avatar_template
) {
return "gravatar";
} else {
return "custom";
}
}
get selectedUploadId() {
const selected = this.selected;
switch (selected) {
case "system":
return this.user.system_avatar_upload_id;
case "gravatar":
return this.user.gravatar_avatar_upload_id;
default:
return this.user.custom_avatar_upload_id;
}
}
get allowAvatarUpload() {
return (
this.siteSettingMatches &&
allowsImages(this.currentUser.staff, this.siteSettings)
);
}
get siteSettingMatches() {
const allowUploadedAvatars = this.siteSettings.allow_uploaded_avatars;
switch (allowUploadedAvatars) {
case "disabled":
return false;
case "staff":
return this.currentUser.staff;
case "admin":
return this.currentUser.admin;
default:
return (
this.currentUser.trust_level >= parseInt(allowUploadedAvatars, 10) ||
this.currentUser.staff
);
}
}
@action
onSelectedChanged(value) {
this.selected = value;
}
@action
async selectAvatar(url, event) {
event?.preventDefault();
try {
await this.user.selectAvatar(url);
window.location.reload();
} catch (error) {
popupAjaxError(error);
}
}
@action
uploadComplete() {
this.selected = "custom";
}
@action
async refreshGravatar() {
this.gravatarRefreshDisabled = true;
try {
const result = await ajax(
`/user_avatar/${this.user.username}/refresh_gravatar.json`,
{
type: "POST",
}
);
if (!result.gravatar_upload_id) {
this.gravatarFailed = true;
} else {
this.gravatarFailed = false;
this.user.setProperties({
gravatar_avatar_upload_id: result.gravatar_upload_id,
gravatar_avatar_template: result.gravatar_avatar_template,
});
}
} finally {
this.gravatarRefreshDisabled = false;
}
}
@action
async saveAvatarSelection() {
try {
await this.user.pickAvatar(this.selectedUploadId, this.selected);
if (!isTesting()) {
window.location.reload();
}
} catch (error) {
popupAjaxError(error);
}
}
}

View File

@ -1,200 +0,0 @@
import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { dependentKeyCompat } from "@ember/object/compat";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { setting } from "discourse/lib/computed";
import { allowsImages } from "discourse/lib/uploads";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { isTesting } from "discourse-common/config/environment";
import discourseComputed from "discourse-common/utils/decorators";
export default Controller.extend(ModalFunctionality, {
gravatarName: setting("gravatar_name"),
gravatarBaseUrl: setting("gravatar_base_url"),
gravatarLoginUrl: setting("gravatar_login_url"),
@discourseComputed("selected", "uploading")
submitDisabled(selected, uploading) {
return selected === "logo" || uploading;
},
@discourseComputed(
"siteSettings.selectable_avatars_mode",
"siteSettings.selectable_avatars"
)
selectableAvatars(mode, list) {
if (mode !== "disabled") {
return list ? list.split("|") : [];
}
},
@discourseComputed("siteSettings.selectable_avatars_mode")
showSelectableAvatars(mode) {
return mode !== "disabled";
},
@discourseComputed("siteSettings.selectable_avatars_mode")
showAvatarUploader(mode) {
switch (mode) {
case "no_one":
return false;
case "tl1":
case "tl2":
case "tl3":
case "tl4":
const allowedTl = parseInt(mode.replace("tl", ""), 10);
return (
this.user.admin ||
this.user.moderator ||
this.user.trust_level >= allowedTl
);
case "staff":
return this.user.admin || this.user.moderator;
case "everyone":
default:
return true;
}
},
@tracked _selected: null,
@dependentKeyCompat
get selected() {
return this._selected ?? this.defaultSelection;
},
set selected(value) {
this._selected = value;
},
@action
onSelectedChanged(value) {
this._selected = value;
},
get defaultSelection() {
if (this.get("user.use_logo_small_as_avatar")) {
return "logo";
} else if (
this.get("user.avatar_template") ===
this.get("user.system_avatar_template")
) {
return "system";
} else if (
this.get("user.avatar_template") ===
this.get("user.gravatar_avatar_template")
) {
return "gravatar";
} else {
return "custom";
}
},
@discourseComputed(
"selected",
"user.system_avatar_upload_id",
"user.gravatar_avatar_upload_id",
"user.custom_avatar_upload_id"
)
selectedUploadId(selected, system, gravatar, custom) {
switch (selected) {
case "system":
return system;
case "gravatar":
return gravatar;
default:
return custom;
}
},
@discourseComputed(
"selected",
"user.system_avatar_template",
"user.gravatar_avatar_template",
"user.custom_avatar_template"
)
selectedAvatarTemplate(selected, system, gravatar, custom) {
switch (selected) {
case "system":
return system;
case "gravatar":
return gravatar;
default:
return custom;
}
},
siteSettingMatches(value, user) {
switch (value) {
case "disabled":
return false;
case "staff":
return user.staff;
case "admin":
return user.admin;
default:
return user.trust_level >= parseInt(value, 10) || user.staff;
}
},
@discourseComputed("siteSettings.allow_uploaded_avatars")
allowAvatarUpload(allowUploadedAvatars) {
return (
this.siteSettingMatches(allowUploadedAvatars, this.currentUser) &&
allowsImages(this.currentUser.staff, this.siteSettings)
);
},
@action
selectAvatar(url, event) {
event?.preventDefault();
this.user
.selectAvatar(url)
.then(() => window.location.reload())
.catch(popupAjaxError);
},
actions: {
uploadComplete() {
this.set("selected", "custom");
},
refreshGravatar() {
this.set("gravatarRefreshDisabled", true);
return ajax(
`/user_avatar/${this.get("user.username")}/refresh_gravatar.json`,
{ type: "POST" }
)
.then((result) => {
if (!result.gravatar_upload_id) {
this.set("gravatarFailed", true);
} else {
this.set("gravatarFailed", false);
this.user.setProperties({
gravatar_avatar_upload_id: result.gravatar_upload_id,
gravatar_avatar_template: result.gravatar_avatar_template,
});
}
})
.finally(() => this.set("gravatarRefreshDisabled", false));
},
saveAvatarSelection() {
const selectedUploadId = this.selectedUploadId;
const type = this.selected;
this.user
.pickAvatar(selectedUploadId, type)
.then(() => {
if (!isTesting()) {
window.location.reload();
}
})
.catch(popupAjaxError);
},
},
});

View File

@ -1,10 +1,12 @@
import { action } from "@ember/object";
import showModal from "discourse/lib/show-modal";
import { inject as service } from "@ember/service";
import AvatarSelectorModal from "discourse/components/modal/avatar-selector";
import UserBadge from "discourse/models/user-badge";
import RestrictedUserRoute from "discourse/routes/restricted-user";
import I18n from "discourse-i18n";
export default RestrictedUserRoute.extend({
modal: service(),
model() {
const user = this.modelFor("user");
if (this.siteSettings.enable_badges) {
@ -37,6 +39,8 @@ export default RestrictedUserRoute.extend({
@action
showAvatarSelector(user) {
showModal("avatar-selector").setProperties({ user });
this.modal.show(AvatarSelectorModal, {
model: { user },
});
},
});

View File

@ -1,130 +0,0 @@
<DModalBody @title="user.change_avatar.title" @class="avatar-selector">
{{#if this.showSelectableAvatars}}
<div class="selectable-avatars">
{{#each this.selectableAvatars as |avatar|}}
<a
href
class="selectable-avatar"
{{on "click" (fn this.selectAvatar avatar)}}
>
{{bound-avatar-template avatar "huge"}}
</a>
{{/each}}
</div>
{{#if this.showAvatarUploader}}
<h4>{{html-safe (i18n "user.change_avatar.use_custom")}}</h4>
{{/if}}
{{/if}}
{{#if this.showAvatarUploader}}
{{#if this.user.use_logo_small_as_avatar}}
<div class="avatar-choice">
<RadioButton
@id="logo-small"
@name="logo"
@value="logo"
@selection={{this.selected}}
@onChange={{this.onSelectedChanged}}
/>
<label class="radio" for="logo-small">{{bound-avatar-template
this.siteSettings.site_logo_small_url
"large"
}}
{{html-safe (i18n "user.change_avatar.logo_small")}}</label>
</div>
{{/if}}
<div class="avatar-choice">
<RadioButton
@id="system-avatar"
@name="avatar"
@value="system"
@selection={{this.selected}}
@onChange={{this.onSelectedChanged}}
/>
<label class="radio" for="system-avatar">{{bound-avatar-template
this.user.system_avatar_template
"large"
}}
{{html-safe (i18n "user.change_avatar.letter_based")}}</label>
</div>
{{#if this.allowAvatarUpload}}
<div class="avatar-choice">
<RadioButton
@id="gravatar"
@name="avatar"
@value="gravatar"
@selection={{this.selected}}
@onChange={{this.onSelectedChanged}}
/>
<label class="radio" for="gravatar">{{bound-avatar-template
this.user.gravatar_avatar_template
"large"
}}
<span>{{html-safe
(i18n
"user.change_avatar.gravatar"
gravatarName=this.gravatarName
gravatarBaseUrl=this.gravatarBaseUrl
gravatarLoginUrl=this.gravatarLoginUrl
)
}}
{{this.user.email}}</span></label>
<DButton
@action={{action "refreshGravatar"}}
@translatedTitle={{i18n
"user.change_avatar.refresh_gravatar_title"
gravatarName=this.gravatarName
}}
@disabled={{this.gravatarRefreshDisabled}}
@icon="sync"
class="btn-default avatar-selector-refresh-gravatar"
/>
{{#if this.gravatarFailed}}
<p class="error">{{I18n
"user.change_avatar.gravatar_failed"
gravatarName=this.gravatarName
}}</p>
{{/if}}
</div>
<div class="avatar-choice">
<RadioButton
@id="uploaded-avatar"
@name="avatar"
@value="custom"
@selection={{this.selected}}
@onChange={{this.onSelectedChanged}}
/>
<label class="radio" for="uploaded-avatar">
{{#if this.user.custom_avatar_template}}
{{bound-avatar-template this.user.custom_avatar_template "large"}}
{{i18n "user.change_avatar.uploaded_avatar"}}
{{else}}
{{i18n "user.change_avatar.uploaded_avatar_empty"}}
{{/if}}
</label>
<AvatarUploader
@user_id={{this.user.id}}
@uploadedAvatarTemplate={{this.user.custom_avatar_template}}
@uploadedAvatarId={{this.user.custom_avatar_upload_id}}
@uploading={{this.uploading}}
@class="avatar-uploader"
@id="avatar-uploader"
@done={{action "uploadComplete"}}
/>
</div>
{{/if}}
{{/if}}
</DModalBody>
{{#if this.showAvatarUploader}}
<div class="modal-footer">
<DButton
@action={{action "saveAvatarSelection"}}
@disabled={{this.submitDisabled}}
@label="save"
class="btn-primary"
/>
<DModalCancel @close={{route-action "closeModal"}} />
</div>
{{/if}}

View File

@ -1,42 +0,0 @@
import EmberObject from "@ember/object";
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
module("Unit | Controller | avatar-selector", function (hooks) {
setupTest(hooks);
test("avatarTemplate", function (assert) {
const user = EmberObject.create({
avatar_template: "avatar",
system_avatar_template: "system",
gravatar_avatar_template: "gravatar",
system_avatar_upload_id: 1,
gravatar_avatar_upload_id: 2,
custom_avatar_upload_id: 3,
});
const controller = this.owner.lookup("controller:avatar-selector");
controller.setProperties({ user });
user.set("avatar_template", "system");
assert.strictEqual(
controller.selectedUploadId,
1,
"we are using system by default"
);
user.set("avatar_template", "gravatar");
assert.strictEqual(
controller.selectedUploadId,
2,
"we are using gravatar when set"
);
user.set("avatar_template", "avatar");
assert.strictEqual(
controller.selectedUploadId,
3,
"we are using custom when set"
);
});
});

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
module PageObjects
module Modals
class AvatarSelector < PageObjects::Modals::Base
BODY_SELECTOR = ".avatar-selector"
MODAL_SELECTOR = ".avatar-selector-modal"
def select_avatar_upload_option
body.choose("avatar", option: "custom")
end
def select_system_assigned_option
body.choose("avatar", option: "system")
end
def click_avatar_upload_button
body.find_button(I18n.t("js.user.change_avatar.upload_title")).click
end
def has_user_avatar_image_uploaded?
body.has_css?(".avatar[src*='uploads/default']")
end
end
end
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
module PageObjects
module Pages
class UserPreferencesAccount < PageObjects::Pages::Base
def visit(user)
page.visit("/u/#{user.username}/preferences/account")
self
end
def click_edit_avatar_button
page.find_button("edit-avatar").click
end
def open_avatar_selector_modal(user)
visit(user).click_edit_avatar_button
end
def has_custom_uploaded_avatar_image?
has_css?(".pref-avatar img.avatar[src*='user_avatar']")
end
def has_system_avatar_image?
has_css?(".pref-avatar img.avatar[src*='letter_avatar']")
end
end
end
end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
describe "User preferences for Account", type: :system do
fab!(:user) { Fabricate(:user) }
let(:user_account_preferences_page) { PageObjects::Pages::UserPreferencesAccount.new }
let(:avatar_selector_modal) { PageObjects::Modals::AvatarSelector.new }
before { sign_in(user) }
describe "avatar-selector modal" do
it "saves custom picture and system assigned pictures" do
user_account_preferences_page.open_avatar_selector_modal(user)
expect(avatar_selector_modal).to be_open
avatar_selector_modal.select_avatar_upload_option
file_path = File.absolute_path(file_from_fixtures("logo.jpg"))
attach_file(file_path) { avatar_selector_modal.click_avatar_upload_button }
expect(avatar_selector_modal).to have_user_avatar_image_uploaded
avatar_selector_modal.click_primary_button
expect(avatar_selector_modal).to be_closed
expect(user_account_preferences_page).to have_custom_uploaded_avatar_image
user_account_preferences_page.open_avatar_selector_modal(user)
avatar_selector_modal.select_system_assigned_option
avatar_selector_modal.click_primary_button
expect(avatar_selector_modal).to be_closed
expect(user_account_preferences_page).to have_system_avatar_image
end
end
end