DEV: Convert install-theme modal to component-based API (#22939)

This commit is contained in:
Isaac Janzen 2023-08-03 10:52:04 -05:00 committed by GitHub
parent 38d3208027
commit a820be117c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 475 additions and 453 deletions

View File

@ -0,0 +1,198 @@
<DModal
@bodyClass="install-theme"
class="admin-install-theme-modal"
@title={{i18n "admin.customize.theme.install"}}
@closeModal={{@closeModal}}
>
<:body>
{{#unless this.directRepoInstall}}
<div class="install-theme-items">
<InstallThemeItem
@value="popular"
@selection={{this.selection}}
@label="admin.customize.theme.install_popular"
/>
<InstallThemeItem
@value="local"
@selection={{this.selection}}
@label="admin.customize.theme.install_upload"
/>
<InstallThemeItem
@value="remote"
@selection={{this.selection}}
@label="admin.customize.theme.install_git_repo"
/>
<InstallThemeItem
@value="create"
@selection={{this.selection}}
@label="admin.customize.theme.install_create"
@showIcon={{true}}
/>
</div>
{{/unless}}
<div class="install-theme-content">
{{#if this.popular}}
<div class="popular-theme-items">
{{#each this.themes as |theme|}}
<div class="popular-theme-item" data-name={{theme.name}}>
<div class="popular-theme-name">
<a
href={{theme.meta_url}}
rel="noopener noreferrer"
target="_blank"
>
{{#if theme.component}}
{{d-icon
"puzzle-piece"
title="admin.customize.theme.component"
}}
{{/if}}
{{theme.name}}
</a>
<div class="popular-theme-description">
{{theme.description}}
</div>
</div>
<div class="popular-theme-buttons">
{{#if theme.installed}}
<span>{{i18n "admin.customize.theme.installed"}}</span>
{{else}}
<DButton
class="btn-small"
@label="admin.customize.theme.install"
@disabled={{this.installDisabled}}
@icon="upload"
@action={{fn this.installThemeFromList theme.value}}
/>
{{#if theme.preview}}
<a
href={{theme.preview}}
rel="noopener noreferrer"
target="_blank"
>
{{d-icon "desktop"}}
{{i18n "admin.customize.theme.preview"}}
</a>
{{/if}}
{{/if}}
</div>
</div>
{{/each}}
</div>
{{/if}}
{{#if this.local}}
<div class="inputs">
<input
{{on "change" this.uploadLocaleFile}}
type="file"
id="file-input"
accept=".dcstyle.json,application/json,.tar.gz,application/x-gzip,.zip,application/zip"
/>
<br />
<span class="description">
{{i18n "admin.customize.theme.import_file_tip"}}
</span>
</div>
{{/if}}
{{#if this.remote}}
<div class="inputs">
<div class="repo">
<div class="label">
{{i18n "admin.customize.theme.import_web_tip"}}
</div>
<Input
@value={{this.uploadUrl}}
placeholder={{this.urlPlaceholder}}
/>
</div>
<DButton
class="btn-small advanced-repo"
@action={{this.toggleAdvanced}}
@label="admin.customize.theme.import_web_advanced"
/>
{{#if this.advancedVisible}}
<div class="branch">
<div class="label">
{{i18n "admin.customize.theme.remote_branch"}}
</div>
<Input @value={{this.branch}} placeholder="main" />
</div>
{{/if}}
{{#if this.showPublicKey}}
<div class="public-key">
<div class="label">
{{i18n "admin.customize.theme.public_key"}}
</div>
<div class="public-key-text-wrapper">
<Textarea
class="public-key-value"
readonly={{true}}
@value={{this.publicKey}}
{{did-insert this.generatePublicKey}}
/>
<CopyButton @selector="textarea.public-key-value" />
</div>
</div>
{{/if}}
</div>
{{/if}}
{{#if this.create}}
<div class="inputs">
<div class="label">{{i18n "admin.customize.theme.create_name"}}</div>
<Input @value={{this.name}} placeholder={{this.placeholder}} />
<div class="label">{{i18n "admin.customize.theme.create_type"}}</div>
<ComboBox
@valueProperty="value"
@content={{this.createTypes}}
@value={{this.selectedType}}
@onChange={{this.updateSelectedType}}
/>
</div>
{{/if}}
{{#if this.directRepoInstall}}
<div class="repo">
<div class="label">
{{html-safe
(i18n
"admin.customize.theme.direct_install_tip" name=this.uploadName
)
}}
</div>
<pre><code>{{this.uploadUrl}}</code></pre>
</div>
{{/if}}
</div>
</:body>
<:footer>
{{#unless this.popular}}
<div class="modal-footer">
{{#if this.duplicateRemoteThemeWarning}}
<div class="install-theme-warning">
⚠️
{{this.duplicateRemoteThemeWarning}}
</div>
{{/if}}
{{#if this.themeCannotBeInstalled}}
<div class="install-theme-warning">
⚠️
{{this.themeCannotBeInstalled}}
</div>
{{/if}}
<DButton
@action={{this.installTheme}}
@disabled={{this.installDisabled}}
class="btn
{{if this.themeCannotBeInstalled 'btn-danger' 'btn-primary'}}"
@label={{this.submitLabel}}
/>
<DButton
class="btn-flat d-modal-cancel"
@action={{@closeModal}}
@label="cancel"
/>
</div>
{{/unless}}
</:footer>
</DModal>

View File

@ -0,0 +1,229 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { COMPONENTS, THEMES } from "admin/models/theme";
import { POPULAR_THEMES } from "discourse-common/lib/popular-themes";
import { ajax } from "discourse/lib/ajax";
import I18n from "I18n";
import { inject as service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error";
const MIN_NAME_LENGTH = 4;
export default class InstallTheme extends Component {
@service store;
@tracked selection = this.args.model.selection || "popular";
@tracked uploadUrl = this.args.model.uploadUrl;
@tracked uploadName = this.args.model.uploadName;
@tracked selectedType = this.args.model.selectedType;
@tracked advancedVisible = false;
@tracked loading = false;
@tracked localFile;
@tracked publicKey;
@tracked branch;
@tracked duplicateRemoteThemeWarning;
@tracked themeCannotBeInstalled;
@tracked name;
recordType = "theme";
get createTypes() {
return [
{ name: I18n.t("admin.customize.theme.theme"), value: THEMES },
{ name: I18n.t("admin.customize.theme.component"), value: COMPONENTS },
];
}
get showPublicKey() {
return this.uploadUrl?.match?.(/^ssh:\/\/.+@.+$|.+@.+:.+$/);
}
get submitLabel() {
if (this.themeCannotBeInstalled) {
return "admin.customize.theme.create_placeholder";
}
return `admin.customize.theme.${this.create ? "create" : "install"}`;
}
get component() {
return this.selectedType === COMPONENTS;
}
get local() {
return this.selection === "local";
}
get remote() {
return this.selection === "remote";
}
get create() {
return this.selection === "create";
}
get directRepoInstall() {
return this.selection === "directRepoInstall";
}
get popular() {
return this.selection === "popular";
}
get nameTooShort() {
return !this.name || this.name.length < MIN_NAME_LENGTH;
}
get installDisabled() {
return (
this.loading ||
(this.remote && !this.uploadUrl) ||
(this.local && !this.localFile) ||
(this.create && this.nameTooShort)
);
}
get placeholder() {
if (this.component) {
return I18n.t("admin.customize.theme.component_name");
} else {
return I18n.t("admin.customize.theme.theme_name");
}
}
get themes() {
return POPULAR_THEMES.map((t) => {
if (
this.args.model.installedThemes.some((theme) =>
this.themeHasSameUrl(theme, t.value)
)
) {
t.installed = true;
}
return t;
});
}
themeHasSameUrl(theme, url) {
const themeUrl = theme.remote_theme && theme.remote_theme.remote_url;
return (
themeUrl &&
url &&
url.replace(/\.git$/, "") === themeUrl.replace(/\.git$/, "")
);
}
willDestroy() {
this.args.model.clearParams?.();
}
@action
async generatePublicKey() {
try {
const pair = await ajax("/admin/themes/generate_key_pair", {
type: "POST",
});
this.publicKey = pair.public_key;
} catch (e) {
popupAjaxError(e);
}
}
@action
toggleAdvanced() {
this.advancedVisible = !this.advancedVisible;
}
@action
uploadLocaleFile(event) {
this.localFile = event.target.files[0];
}
@action
updateSelectedType(type) {
this.args.model.updateSelectedType(type);
this.selectedType = type;
}
@action
installThemeFromList(url) {
this.uploadUrl = url;
this.installTheme();
}
@action
async installTheme() {
if (this.create) {
this.loading = true;
const theme = this.store.createRecord(this.recordType);
try {
await theme.save({ name: this.name, component: this.component });
this.args.model.addTheme(theme);
this.args.closeModal();
} catch {
popupAjaxError;
} finally {
this.loading = false;
}
return;
}
let options = {
type: "POST",
};
if (this.local) {
options.processData = false;
options.contentType = false;
options.data = new FormData();
options.data.append("theme", this.localFile);
}
if (this.remote || this.popular || this.directRepoInstall) {
const duplicate = this.args.model.content.find((theme) =>
this.themeHasSameUrl(theme, this.uploadUrl)
);
if (duplicate && !this.duplicateRemoteThemeWarning) {
const warning = I18n.t("admin.customize.theme.duplicate_remote_theme", {
name: duplicate.name,
});
this.duplicateRemoteThemeWarning = warning;
return;
}
options.data = {
remote: this.uploadUrl,
branch: this.branch,
public_key: this.publicKey,
};
}
// User knows that theme cannot be installed, but they want to continue
// to force install it.
if (this.themeCannotBeInstalled) {
options.data["force"] = true;
}
// Used by theme-creator
if (this.args.model.userId) {
options.data["user_id"] = this.args.model.userId;
}
try {
this.loading = true;
const result = await ajax("/admin/themes/import", options);
const theme = this.store.createRecord(this.recordType, result.theme);
this.args.model.addTheme(theme);
this.args.closeModal();
} catch (e) {
if (!this.publicKey || this.themeCannotBeInstalled) {
return popupAjaxError(e);
}
this.themeCannotBeInstalled = I18n.t(
"admin.customize.theme.force_install"
);
} finally {
this.loading = false;
}
}
}

View File

@ -1,249 +0,0 @@
import { alias, equal, match } from "@ember/object/computed";
import { COMPONENTS, THEMES } from "admin/models/theme";
import Controller, { inject as controller } from "@ember/controller";
import discourseComputed from "discourse-common/utils/decorators";
import { observes } from "@ember-decorators/object";
import I18n from "I18n";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { POPULAR_THEMES } from "discourse-common/lib/popular-themes";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { action, set } from "@ember/object";
const MIN_NAME_LENGTH = 4;
export default class AdminInstallThemeController extends Controller.extend(
ModalFunctionality
) {
@controller adminCustomizeThemes;
@controller("adminCustomizeThemes") themesController;
@equal("selection", "popular") popular;
@equal("selection", "local") local;
@equal("selection", "remote") remote;
@equal("selection", "create") create;
@equal("selection", "directRepoInstall") directRepoInstall;
selection = "popular";
loading = false;
keyGenUrl = "/admin/themes/generate_key_pair";
importUrl = "/admin/themes/import";
recordType = "theme";
@match("uploadUrl", /^ssh:\/\/.+@.+$|.+@.+:.+$/) checkPrivate;
localFile = null;
uploadUrl = null;
uploadName = null;
advancedVisible = false;
@alias("themesController.currentTab") selectedType;
@equal("selectedType", COMPONENTS) component;
urlPlaceholder = "https://github.com/discourse/sample_theme";
createTypes = [
{ name: I18n.t("admin.customize.theme.theme"), value: THEMES },
{ name: I18n.t("admin.customize.theme.component"), value: COMPONENTS },
];
@discourseComputed("themesController.installedThemes")
themes(installedThemes) {
return POPULAR_THEMES.map((t) => {
if (
installedThemes.some((theme) => this.themeHasSameUrl(theme, t.value))
) {
set(t, "installed", true);
}
return t;
});
}
@discourseComputed(
"loading",
"remote",
"uploadUrl",
"local",
"localFile",
"create",
"nameTooShort"
)
installDisabled(
isLoading,
isRemote,
uploadUrl,
isLocal,
localFile,
isCreate,
nameTooShort
) {
return (
isLoading ||
(isRemote && !uploadUrl) ||
(isLocal && !localFile) ||
(isCreate && nameTooShort)
);
}
@discourseComputed("name")
nameTooShort(name) {
return !name || name.length < MIN_NAME_LENGTH;
}
@discourseComputed("component")
placeholder(component) {
if (component) {
return I18n.t("admin.customize.theme.component_name");
} else {
return I18n.t("admin.customize.theme.theme_name");
}
}
@observes("checkPrivate")
privateWasChecked() {
const checked = this.checkPrivate;
if (checked && !this._keyLoading && !this.publicKey) {
this._keyLoading = true;
ajax(this.keyGenUrl, { type: "POST" })
.then((pair) => {
this.set("publicKey", pair.public_key);
})
.catch(popupAjaxError)
.finally(() => {
this._keyLoading = false;
});
}
}
@discourseComputed("selection", "themeCannotBeInstalled")
submitLabel(selection, themeCannotBeInstalled) {
if (themeCannotBeInstalled) {
return "admin.customize.theme.create_placeholder";
}
return `admin.customize.theme.${
selection === "create" ? "create" : "install"
}`;
}
@discourseComputed("checkPrivate", "publicKey")
showPublicKey(checkPrivate, publicKey) {
return checkPrivate && publicKey;
}
onClose() {
this.setProperties({
duplicateRemoteThemeWarning: null,
localFile: null,
uploadUrl: null,
publicKey: null,
branch: null,
selection: "popular",
});
this.themesController.setProperties({
repoName: null,
repoUrl: null,
});
}
themeHasSameUrl(theme, url) {
const themeUrl = theme.remote_theme && theme.remote_theme.remote_url;
return (
themeUrl &&
url &&
url.replace(/\.git$/, "") === themeUrl.replace(/\.git$/, "")
);
}
@action
uploadLocaleFile() {
this.set("localFile", $("#file-input")[0].files[0]);
}
@action
toggleAdvanced() {
this.toggleProperty("advancedVisible");
}
@action
installThemeFromList(url) {
this.set("uploadUrl", url);
this.send("installTheme");
}
@action
installTheme() {
if (this.create) {
this.set("loading", true);
const theme = this.store.createRecord(this.recordType);
theme
.save({ name: this.name, component: this.component })
.then(() => {
this.themesController.send("addTheme", theme);
this.send("closeModal");
})
.catch(popupAjaxError)
.finally(() => this.set("loading", false));
return;
}
let options = {
type: "POST",
};
if (this.local) {
options.processData = false;
options.contentType = false;
options.data = new FormData();
options.data.append("theme", this.localFile);
}
if (this.remote || this.popular || this.directRepoInstall) {
const duplicate = this.themesController.model.content.find((theme) =>
this.themeHasSameUrl(theme, this.uploadUrl)
);
if (duplicate && !this.duplicateRemoteThemeWarning) {
const warning = I18n.t("admin.customize.theme.duplicate_remote_theme", {
name: duplicate.name,
});
this.set("duplicateRemoteThemeWarning", warning);
return;
}
options.data = {
remote: this.uploadUrl,
branch: this.branch,
public_key: this.publicKey,
};
}
// User knows that theme cannot be installed, but they want to continue
// to force install it.
if (this.themeCannotBeInstalled) {
options.data["force"] = true;
}
if (this.get("model.user_id")) {
// Used by theme-creator
options.data["user_id"] = this.get("model.user_id");
}
this.set("loading", true);
ajax(this.importUrl, options)
.then((result) => {
const theme = this.store.createRecord(this.recordType, result.theme);
this.adminCustomizeThemes.send("addTheme", theme);
this.send("closeModal");
})
.then(() => {
this.set("publicKey", null);
})
.catch((error) => {
if (!this.publicKey || this.themeCannotBeInstalled) {
return popupAjaxError(error);
}
this.set(
"themeCannotBeInstalled",
I18n.t("admin.customize.theme.force_install")
);
})
.finally(() => this.set("loading", false));
}
}

View File

@ -1,13 +1,14 @@
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import Route from "@ember/routing/route";
import showModal from "discourse/lib/show-modal";
import I18n from "I18n";
import InstallThemeModal from "../components/modal/install-theme";
import { next } from "@ember/runloop";
export default class AdminCustomizeThemesRoute extends Route {
@service dialog;
@service router;
@service modal;
queryParams = {
repoUrl: null,
@ -18,44 +19,73 @@ export default class AdminCustomizeThemesRoute extends Route {
return this.store.findAll("theme");
}
@action
routeRefreshModel() {
this.refresh();
}
setupController(controller, model) {
super.setupController(controller, model);
controller.set("editingTheme", false);
if (controller.repoUrl) {
next(() => {
showModal("admin-install-theme", {
admin: true,
}).setProperties({
uploadUrl: controller.repoUrl,
uploadName: controller.repoName,
selection: "directRepoInstall",
this.modal.show(InstallThemeModal, {
model: {
uploadUrl: controller.repoUrl,
uploadName: controller.repoName,
selection: "directRepoInstall",
clearParams: this.clearParams,
...this.installThemeOptions(model),
},
});
});
}
}
installThemeOptions(model) {
return {
selectedType: this.controller.currentTab,
userId: model.userId,
content: model.content,
installedThemes: this.controller.installedThemes,
addTheme: this.addTheme,
updateSelectedType: this.updateSelectedType,
};
}
@action
routeRefreshModel() {
this.refresh();
}
@action
installModal() {
const currentTheme = this.controllerFor("adminCustomizeThemes.show").model;
if (currentTheme?.warnUnassignedComponent) {
const currentTheme = this.modelFor("adminCustomizeThemes");
if (this.currentModel?.warnUnassignedComponent) {
this.dialog.yesNoConfirm({
message: I18n.t("admin.customize.theme.unsaved_parent_themes"),
didConfirm: () => {
currentTheme.set("recentlyInstalled", false);
showModal("admin-install-theme", { admin: true });
this.modal.show(InstallThemeModal, {
model: { ...this.installThemeOptions(currentTheme) },
});
},
});
} else {
showModal("admin-install-theme", { admin: true });
this.modal.show(InstallThemeModal, {
model: { ...this.installThemeOptions(currentTheme) },
});
}
}
@action
updateSelectedType(type) {
this.controller.set("currentTab", type);
}
@action
clearParams() {
this.controller.setProperties({
repoUrl: null,
repoName: null,
});
}
@action
addTheme(theme) {
this.refresh();

View File

@ -1,186 +0,0 @@
<DModalBody @class="install-theme" @title="admin.customize.theme.install">
{{#unless this.directRepoInstall}}
<div class="install-theme-items">
<InstallThemeItem
@value="popular"
@selection={{this.selection}}
@label="admin.customize.theme.install_popular"
/>
<InstallThemeItem
@value="local"
@selection={{this.selection}}
@label="admin.customize.theme.install_upload"
/>
<InstallThemeItem
@value="remote"
@selection={{this.selection}}
@label="admin.customize.theme.install_git_repo"
/>
<InstallThemeItem
@value="create"
@selection={{this.selection}}
@label="admin.customize.theme.install_create"
@showIcon={{true}}
/>
</div>
{{/unless}}
<div class="install-theme-content">
{{#if this.popular}}
<div class="popular-theme-items">
{{#each this.themes as |theme|}}
<div class="popular-theme-item" data-name={{theme.name}}>
<div class="popular-theme-name">
<a
href={{theme.meta_url}}
rel="noopener noreferrer"
target="_blank"
>
{{#if theme.component}}
{{d-icon
"puzzle-piece"
title="admin.customize.theme.component"
}}
{{/if}}
{{theme.name}}
</a>
<div class="popular-theme-description">
{{theme.description}}
</div>
</div>
<div class="popular-theme-buttons">
{{#if theme.installed}}
<span>{{i18n "admin.customize.theme.installed"}}</span>
{{else}}
<DButton
@class="btn-small"
@label="admin.customize.theme.install"
@disabled={{this.installDisabled}}
@icon="upload"
@action={{action "installThemeFromList" theme.value}}
/>
{{#if theme.preview}}
<a
href={{theme.preview}}
rel="noopener noreferrer"
target="_blank"
>{{d-icon "desktop"}}
{{i18n "admin.customize.theme.preview"}}</a>
{{/if}}
{{/if}}
</div>
</div>
{{/each}}
</div>
{{/if}}
{{#if this.local}}
<div class="inputs">
<input
onchange={{action "uploadLocaleFile"}}
type="file"
id="file-input"
accept=".dcstyle.json,application/json,.tar.gz,application/x-gzip,.zip,application/zip"
/><br />
<span class="description">{{i18n
"admin.customize.theme.import_file_tip"
}}</span>
</div>
{{/if}}
{{#if this.remote}}
<div class="inputs">
<div class="repo">
<div class="label">{{i18n
"admin.customize.theme.import_web_tip"
}}</div>
<Input
@value={{this.uploadUrl}}
placeholder={{this.urlPlaceholder}}
/>
</div>
<DButton
@class="btn-small advanced-repo"
@action={{action "toggleAdvanced"}}
@label="admin.customize.theme.import_web_advanced"
/>
{{#if this.advancedVisible}}
<div class="branch">
<div class="label">{{i18n
"admin.customize.theme.remote_branch"
}}</div>
<Input @value={{this.branch}} placeholder="main" />
</div>
{{/if}}
{{#if this.showPublicKey}}
<div class="public-key">
<div class="label">{{i18n "admin.customize.theme.public_key"}}</div>
<div class="public-key-text-wrapper">
<Textarea
class="public-key-value"
readonly={{true}}
@value={{this.publicKey}}
/>
<CopyButton @selector="textarea.public-key-value" />
</div>
</div>
{{/if}}
</div>
{{/if}}
{{#if this.create}}
<div class="inputs">
<div class="label">{{i18n "admin.customize.theme.create_name"}}</div>
<Input @value={{this.name}} placeholder={{this.placeholder}} />
<div class="label">{{i18n "admin.customize.theme.create_type"}}</div>
<ComboBox
@valueProperty="value"
@content={{this.createTypes}}
@value={{this.selectedType}}
@onChange={{action (mut this.selectedType)}}
/>
</div>
{{/if}}
{{#if this.directRepoInstall}}
<div class="repo">
<div class="label">{{html-safe
(i18n
"admin.customize.theme.direct_install_tip" name=this.uploadName
)
}}</div>
<pre><code>{{this.uploadUrl}}</code></pre>
</div>
{{/if}}
</div>
</DModalBody>
{{#unless this.popular}}
<div class="modal-footer">
{{#if this.duplicateRemoteThemeWarning}}
<div class="install-theme-warning">
⚠️
{{this.duplicateRemoteThemeWarning}}
</div>
{{/if}}
{{#if this.themeCannotBeInstalled}}
<div class="install-theme-warning">
⚠️
{{this.themeCannotBeInstalled}}
</div>
{{/if}}
<DButton
@action={{action "installTheme"}}
@disabled={{this.installDisabled}}
@class="btn {{if this.themeCannotBeInstalled 'btn-danger' 'btn-primary'}}"
@label={{this.submitLabel}}
/>
<DModalCancel @close={{route-action "closeModal"}} />
</div>
{{/unless}}

View File

@ -48,7 +48,6 @@ const KNOWN_LEGACY_MODALS = [
"tag-upload",
"topic-summary",
"user-status",
"admin-install-theme",
"admin-penalize-user",
"admin-theme-change",
"site-setting-default-categories",

View File

@ -30,6 +30,7 @@ acceptance("Admin - Themes - Install modal", function (needs) {
await click(".create-actions .btn-primary");
await click("#remote");
await click(".install-theme-content .inputs .advanced-repo");
assert.strictEqual(query(urlInput).value, "", "url input is reset");
assert.strictEqual(query(branchInput).value, "", "branch input is reset");
assert.notOk(query(publicKey), "hide public key");