DEV: Convert UppyImageUploader to gjs (#31310)

This commit is contained in:
Jarek Radosz 2025-02-12 23:31:42 +01:00 committed by GitHub
parent a585fc5a24
commit 310cd513d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 255 additions and 249 deletions

View File

@ -1,6 +1,8 @@
<UppyImageUploader <UppyImageUploader
@imageUrl={{this.value}} @imageUrl={{this.value}}
@placeholderUrl={{this.setting.placeholder}} @placeholderUrl={{this.setting.placeholder}}
@onUploadDone={{fn (mut this.value)}}
@onUploadDeleted={{fn (mut this.value) null}}
@additionalParams={{hash for_site_setting=true}} @additionalParams={{hash for_site_setting=true}}
@type="site_setting" @type="site_setting"
@id={{concat "site-setting-image-uploader-" this.setting.setting}} @id={{concat "site-setting-image-uploader-" this.setting.setting}}

View File

@ -0,0 +1,216 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { guidFor } from "@ember/object/internals";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { isEmpty } from "@ember/utils";
import { modifier } from "ember-modifier";
import $ from "jquery";
import DButton from "discourse/components/d-button";
import PickFilesButton from "discourse/components/pick-files-button";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import { getURLWithCDN } from "discourse/lib/get-url";
import lightbox from "discourse/lib/lightbox";
import { authorizesOneOrMoreExtensions } from "discourse/lib/uploads";
import UppyUpload from "discourse/lib/uppy/uppy-upload";
import { i18n } from "discourse-i18n";
// Args: id, type, imageUrl, placeholderUrl, additionalParams, onUploadDone, onUploadDeleted,
export default class UppyImageUploader extends Component {
@service currentUser;
@service siteSettings;
@tracked imageFilesize;
@tracked imageFilename;
@tracked imageWidth;
@tracked imageHeight;
uppyUpload = new UppyUpload(getOwner(this), {
id: this.args.id,
type: this.args.type,
additionalParams: this.args.additionalParams,
validateUploadedFilesOptions: { imagesOnly: true },
uploadDropTargetOptions: () => ({
target: document.querySelector(
`#${this.args.id} .uploaded-image-preview`
),
}),
uploadDone: (upload) => {
this.imageFilesize = upload.human_filesize;
this.imageFilename = upload.original_filename;
this.imageWidth = upload.width;
this.imageHeight = upload.height;
this.args.onUploadDone(upload);
},
});
applyLightbox = modifier((element) => lightbox(element, this.siteSettings));
willDestroy() {
super.willDestroy(...arguments);
$.magnificPopup?.instance.close();
}
get disabled() {
return (
this.notAllowed ||
this.uppyUpload?.uploading ||
this.uppyUpload?.processing
);
}
get computedId() {
// without a fallback ID this will not be accessible
return this.args.id ? `${this.args.id}__input` : `${guidFor(this)}__input`;
}
get disabledReason() {
if (this.disabled && this.notAllowed) {
return i18n("post.errors.no_uploads_authorized");
}
}
get notAllowed() {
return !authorizesOneOrMoreExtensions(
this.currentUser?.staff,
this.siteSettings
);
}
get showingPlaceholder() {
return !this.args.imageUrl && this.args.placeholderUrl;
}
get placeholderStyle() {
if (isEmpty(this.args.placeholderUrl)) {
return htmlSafe("");
}
return htmlSafe(`background-image: url(${this.args.placeholderUrl})`);
}
get imageCdnUrl() {
if (isEmpty(this.args.imageUrl)) {
return htmlSafe("");
}
return getURLWithCDN(this.args.imageUrl);
}
get backgroundStyle() {
return htmlSafe(`background-image: url(${this.imageCdnUrl})`);
}
get imageBaseName() {
if (!isEmpty(this.args.imageUrl)) {
return this.args.imageUrl.split("/").slice(-1)[0];
}
}
@action
toggleLightbox() {
const lightboxElement = document.querySelector(
`#${this.args.id} a.lightbox`
);
if (lightboxElement) {
$(lightboxElement).magnificPopup("open");
}
}
@action
handleKeyboardActivation(event) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault(); // avoid space scrolling the page
const input = document.getElementById(this.computedId);
if (input && !this.disabled) {
input.click();
}
}
}
<template>
<div {{this.applyLightbox}} id={{@id}} class="image-uploader" ...attributes>
<div
class="uploaded-image-preview input-xxlarge"
style={{this.backgroundStyle}}
>
{{#if this.showingPlaceholder}}
<div
class="placeholder-overlay"
style={{this.placeholderStyle}}
></div>
{{/if}}
<div class="image-upload-controls">
<label
class="btn btn-default pad-left no-text
{{if this.disabled 'disabled'}}"
title={{this.disabledReason}}
for={{this.computedId}}
tabindex="0"
{{on "keydown" this.handleKeyboardActivation}}
>
{{icon "far-image"}}
<PickFilesButton
@registerFileInput={{this.uppyUpload.setup}}
@fileInputDisabled={{this.disabled}}
@acceptedFormatsOverride="image/*"
@fileInputId={{this.computedId}}
/>
</label>
{{#if @imageUrl}}
<DButton
@action={{@onUploadDeleted}}
@icon="trash-can"
class="btn-danger pad-left no-text"
/>
<DButton
@action={{this.toggleLightbox}}
@icon="discourse-expand"
@title="expand"
class="btn-default image-uploader-lightbox-btn no-text"
/>
{{/if}}
<span
class={{concatClass
"btn"
(unless this.uppyUpload.uploading "hidden")
}}
>
{{i18n "upload_selector.uploading"}}
{{this.uppyUpload.uploadProgress}}%
</span>
<span
class={{concatClass
"btn"
(unless this.uppyUpload.processing "hidden")
}}
>{{i18n "upload_selector.processing"}}</span>
</div>
{{#if @imageUrl}}
<a
href={{this.imageCdnUrl}}
title={{this.imageFilename}}
rel="nofollow ugc noopener"
class="lightbox"
>
<div class="meta">
<span class="informations">
{{this.imageWidth}}x{{this.imageHeight}}
{{this.imageFilesize}}
</span>
</div>
</a>
{{/if}}
</div>
</div>
</template>
}

View File

@ -1,65 +0,0 @@
<div
class="uploaded-image-preview input-xxlarge"
style={{this.backgroundStyle}}
>
{{#if this.showingPlaceholder}}
<div class="placeholder-overlay" style={{this.placeholderStyle}}></div>
{{/if}}
<div class="image-upload-controls">
<label
class="btn btn-default pad-left no-text {{if this.disabled 'disabled'}}"
title={{this.disabledReason}}
for={{this.computedId}}
tabindex="0"
{{on "keydown" this.handleKeyboardActivation}}
>
{{d-icon "far-image"}}
<PickFilesButton
@registerFileInput={{this.uppyUpload.setup}}
@fileInputDisabled={{this.disabled}}
@acceptedFormatsOverride="image/*"
@fileInputId={{this.computedId}}
/>
</label>
{{#if this.imageUrl}}
<DButton
@action={{action "trash"}}
@icon="trash-can"
class="btn-danger pad-left no-text"
/>
<DButton
@icon="discourse-expand"
@title="expand"
@disabled={{this.loadingLightbox}}
@action={{this.toggleLightbox}}
class="btn-default image-uploader-lightbox-btn no-text"
/>
{{/if}}
<span class="btn {{unless this.uppyUpload.uploading 'hidden'}}">{{i18n
"upload_selector.uploading"
}}
{{this.uppyUpload.uploadProgress}}%</span>
<span class="btn {{unless this.uppyUpload.processing 'hidden'}}">{{i18n
"upload_selector.processing"
}}</span>
</div>
{{#if this.imageUrl}}
<a
class="lightbox"
href={{this.imageCDNURL}}
title={{this.imageFilename}}
rel="nofollow ugc noopener"
>
<div class="meta">
<span class="informations">
{{this.imageWidth}}x{{this.imageHeight}}
{{this.imageFilesize}}
</span>
</div>
</a>
{{/if}}
</div>

View File

@ -1,157 +0,0 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import { or } from "@ember/object/computed";
import { guidFor } from "@ember/object/internals";
import { getOwner } from "@ember/owner";
import { next } from "@ember/runloop";
import { htmlSafe } from "@ember/template";
import { isEmpty } from "@ember/utils";
import { classNames } from "@ember-decorators/component";
import { on } from "@ember-decorators/object";
import $ from "jquery";
import discourseComputed from "discourse/lib/decorators";
import { getURLWithCDN } from "discourse/lib/get-url";
import lightbox from "discourse/lib/lightbox";
import { authorizesOneOrMoreExtensions } from "discourse/lib/uploads";
import UppyUpload from "discourse/lib/uppy/uppy-upload";
import { i18n } from "discourse-i18n";
@classNames("image-uploader")
export default class UppyImageUploader extends Component {
@or("notAllowed", "uppyUpload.uploading", "uppyUpload.processing") disabled;
uppyUpload = null;
@on("init")
setupUppyUpload() {
// The uppyUpload configuration depends on arguments. In classic components like
// this one, the arguments are not available during field initialization, so we have to
// defer until init(). When this component is glimmer-ified in future, this can be turned
// into a simple field initializer.
this.uppyUpload = new UppyUpload(getOwner(this), {
id: this.id,
type: this.type,
additionalParams: this.additionalParams,
validateUploadedFilesOptions: { imagesOnly: true },
uploadDropTargetOptions: () => ({
target: document.querySelector(`#${this.id} .uploaded-image-preview`),
}),
uploadDone: (upload) => {
this.setProperties({
imageFilesize: upload.human_filesize,
imageFilename: upload.original_filename,
imageWidth: upload.width,
imageHeight: upload.height,
});
// the value of the property used for imageUrl should be set
// in this callback. this should be done in cases where imageUrl
// is bound to a computed property of the parent component.
if (this.onUploadDone) {
this.onUploadDone(upload);
} else {
this.set("imageUrl", upload.url);
}
},
});
}
@discourseComputed("id")
computedId(id) {
// without a fallback ID this will not be accessible
return id ? `${id}__input` : `${guidFor(this)}__input`;
}
@discourseComputed("disabled", "notAllowed")
disabledReason(disabled, notAllowed) {
if (disabled && notAllowed) {
return i18n("post.errors.no_uploads_authorized");
}
}
@discourseComputed(
"currentUser.staff",
"siteSettings.{authorized_extensions,authorized_extensions_for_staff}"
)
notAllowed() {
return !authorizesOneOrMoreExtensions(
this.currentUser?.staff,
this.siteSettings
);
}
@discourseComputed("imageUrl", "placeholderUrl")
showingPlaceholder(imageUrl, placeholderUrl) {
return !imageUrl && placeholderUrl;
}
@discourseComputed("placeholderUrl")
placeholderStyle(url) {
if (isEmpty(url)) {
return htmlSafe("");
}
return htmlSafe(`background-image: url(${url})`);
}
@discourseComputed("imageUrl")
imageCDNURL(url) {
if (isEmpty(url)) {
return htmlSafe("");
}
return getURLWithCDN(url);
}
@discourseComputed("imageCDNURL")
backgroundStyle(url) {
return htmlSafe(`background-image: url(${url})`);
}
@discourseComputed("imageUrl")
imageBaseName(imageUrl) {
if (isEmpty(imageUrl)) {
return;
}
return imageUrl.split("/").slice(-1)[0];
}
@on("didRender")
_applyLightbox() {
next(() => lightbox(this.element, this.siteSettings));
}
@on("willDestroyElement")
_closeOnRemoval() {
if ($.magnificPopup?.instance) {
$.magnificPopup.instance.close();
}
}
@action
toggleLightbox() {
$(this.element.querySelector("a.lightbox"))?.magnificPopup("open");
}
@action
trash() {
// the value of the property used for imageUrl should be cleared
// in this callback. this should be done in cases where imageUrl
// is bound to a computed property of the parent component.
if (this.onUploadDeleted) {
this.onUploadDeleted();
} else {
this.setProperties({ imageUrl: null });
}
}
@action
handleKeyboardActivation(event) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault(); // avoid space scrolling the page
const input = document.getElementById(this.computedId);
if (input && !this.disabled) {
input.click();
}
}
}
}

View File

@ -1,5 +1,4 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { isBlank } from "@ember/utils"; import { isBlank } from "@ember/utils";
import UppyImageUploader from "discourse/components/uppy-image-uploader"; import UppyImageUploader from "discourse/components/uppy-image-uploader";
@ -23,7 +22,7 @@ export default class FKControlImage extends Component {
<template> <template>
<UppyImageUploader <UppyImageUploader
@id={{concat @field.id "-" @field.name}} @id="{{@field.id}}-{{@field.name}}"
@imageUrl={{this.imageUrl}} @imageUrl={{this.imageUrl}}
@onUploadDone={{this.setImage}} @onUploadDone={{this.setImage}}
@onUploadDeleted={{this.removeImage}} @onUploadDeleted={{this.removeImage}}

View File

@ -96,6 +96,11 @@
<div class="controls"> <div class="controls">
<UppyImageUploader <UppyImageUploader
@imageUrl={{this.model.profile_background_upload_url}} @imageUrl={{this.model.profile_background_upload_url}}
@onUploadDone={{fn (mut this.model.profile_background_upload_url)}}
@onUploadDeleted={{fn
(mut this.model.profile_background_upload_url)
null
}}
@type="profile_background" @type="profile_background"
@id="profile-background-uploader" @id="profile-background-uploader"
/> />
@ -116,6 +121,11 @@
<div class="controls"> <div class="controls">
<UppyImageUploader <UppyImageUploader
@imageUrl={{this.model.card_background_upload_url}} @imageUrl={{this.model.card_background_upload_url}}
@onUploadDone={{fn (mut this.model.card_background_upload_url)}}
@onUploadDeleted={{fn
(mut this.model.card_background_upload_url)
null
}}
@type="card_background" @type="card_background"
@id="profile-card-background-uploader" @id="profile-card-background-uploader"
/> />

View File

@ -43,9 +43,7 @@ class FieldHelper {
} }
get value() { get value() {
this.context this.context.dom(this.element).exists(`field '${this.name}' exists`);
.dom(this.element)
.exists(`Could not find element (name: ${this.name}).`);
switch (this.element.dataset.controlType) { switch (this.element.dataset.controlType) {
case "image": { case "image": {

View File

@ -1,67 +1,70 @@
import { click, render, triggerEvent } from "@ember/test-helpers"; import { click, render, triggerEvent } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { module, test } from "qunit"; import { module, test } from "qunit";
import UppyImageUploader from "discourse/components/uppy-image-uploader";
import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module("Integration | Component | uppy-image-uploader", function (hooks) { module("Integration | Component | uppy-image-uploader", function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
test("with image", async function (assert) { test("with image", async function (assert) {
await render(hbs` await render(<template>
<UppyImageUploader @type="avatar" @id="uploader" @imageUrl="/images/avatar.png" @placeholderUrl="/not/used.png" /> <UppyImageUploader
`); @type="avatar"
@id="uploader"
@imageUrl="/images/avatar.png"
@placeholderUrl="/not/used.png"
/>
</template>);
assert.dom(".d-icon-far-image").exists("displays the upload icon"); assert.dom(".d-icon-far-image").exists("displays the upload icon");
assert.dom(".d-icon-trash-can").exists("displays the trash icon"); assert.dom(".d-icon-trash-can").exists("displays the trash icon");
assert assert
.dom(".placeholder-overlay") .dom(".placeholder-overlay")
.doesNotExist("it does not display the placeholder image"); .doesNotExist("does not display the placeholder image");
await click(".image-uploader-lightbox-btn"); await click(".image-uploader-lightbox-btn");
assert.strictEqual( assert.dom(".mfp-container").exists("displays the image lightbox");
document.querySelectorAll(".mfp-container").length,
1,
"it displays the image lightbox"
);
}); });
test("without image", async function (assert) { test("without image", async function (assert) {
await render( await render(<template>
hbs`<UppyImageUploader @type="site_setting" @id="uploader" />` <UppyImageUploader @type="site_setting" @id="uploader" />
); </template>);
assert.dom(".d-icon-far-image").exists("displays the upload icon"); assert.dom(".d-icon-far-image").exists("displays the upload icon");
assert.dom(".d-icon-trash-can").doesNotExist("does not display trash icon"); assert.dom(".d-icon-trash-can").doesNotExist("does not display trash icon");
assert assert
.dom(".image-uploader-lightbox-btn") .dom(".image-uploader-lightbox-btn")
.doesNotExist("it does not display the button to open image lightbox"); .doesNotExist("does not display the button to open image lightbox");
}); });
test("with placeholder", async function (assert) { test("with placeholder", async function (assert) {
await render( await render(<template>
hbs`<UppyImageUploader @type="composer" @id="uploader" @placeholderUrl="/images/avatar.png" />` <UppyImageUploader
); @type="composer"
@id="uploader"
@placeholderUrl="/images/avatar.png"
/>
</template>);
assert.dom(".d-icon-far-image").exists("displays the upload icon"); assert.dom(".d-icon-far-image").exists("displays the upload icon");
assert.dom(".d-icon-trash-can").doesNotExist("does not display trash icon"); assert.dom(".d-icon-trash-can").doesNotExist("does not display trash icon");
assert assert
.dom(".image-uploader-lightbox-btn") .dom(".image-uploader-lightbox-btn")
.doesNotExist("it does not display the button to open image lightbox"); .doesNotExist("does not display the button to open image lightbox");
assert.dom(".placeholder-overlay").exists("displays the placeholder image"); assert.dom(".placeholder-overlay").exists("displays the placeholder image");
}); });
test("when dragging image", async function (assert) { test("when dragging image", async function (assert) {
await render( await render(<template>
hbs`
<UppyImageUploader @type="composer" @id="uploader1" /> <UppyImageUploader @type="composer" @id="uploader1" />
<UppyImageUploader @type="composer" @id="uploader2" /> <UppyImageUploader @type="composer" @id="uploader2" />
` </template>);
);
const dropImage = async (target) => { const dropImage = async (target) => {
const dataTransfer = new DataTransfer(); const dataTransfer = new DataTransfer();