FEATURE: New "Plugins" admin section with extensibility support

This commit is contained in:
Robin Ward 2015-02-06 17:32:59 -05:00
parent 96b15cbba6
commit 3d7b534564
18 changed files with 208 additions and 49 deletions

View file

@ -0,0 +1,12 @@
export default Ember.Component.extend({
tagName: 'li',
classNameBindings: ['active'],
router: function() {
return this.container.lookup('router:main');
}.property(),
active: function() {
return this.get('router').isActive(this.get('route'));
}.property('router.url', 'route')
});

View file

@ -0,0 +1,6 @@
export default Ember.ArrayController.extend({
adminRoutes: function() {
return this.get('model').map(p => p.admin_route).compact();
}.property()
});

View file

@ -0,0 +1,12 @@
export default Ember.Route.extend({
model() {
return Discourse.ajax("/admin/plugins.json");
},
actions: {
showSettings() {
this.transitionTo('adminSiteSettingsCategory', 'plugins');
}
}
});

View file

@ -1,7 +1,7 @@
export default { export default {
resource: 'admin', resource: 'admin',
map: function() { map() {
this.route('dashboard', { path: '/' }); this.route('dashboard', { path: '/' });
this.resource('adminSiteSettings', { path: '/site_settings' }, function() { this.resource('adminSiteSettings', { path: '/site_settings' }, function() {
this.resource('adminSiteSettingsCategory', { path: 'category/:category_id'} ); this.resource('adminSiteSettingsCategory', { path: 'category/:category_id'} );

View file

@ -4,25 +4,26 @@
<div class="full-width"> <div class="full-width">
<ul class="nav nav-pills"> <ul class="nav nav-pills">
<li>{{#link-to 'admin.dashboard'}}{{i18n 'admin.dashboard.title'}}{{/link-to}}</li> {{admin-nav-item route='admin.dashboard' label='admin.dashboard.title'}}
{{#if currentUser.admin}} {{#if currentUser.admin}}
<li>{{#link-to 'adminSiteSettings'}}{{i18n 'admin.site_settings.title'}}{{/link-to}}</li> {{admin-nav-item route='adminSiteSettings' label='admin.site_settings.title'}}
{{/if}} {{/if}}
<li>{{#link-to 'adminUsersList'}}{{i18n 'admin.users.title'}}{{/link-to}}</li> {{admin-nav-item route='adminUsersList' label='admin.users.title'}}
{{#if showBadges}} {{#if showBadges}}
<li>{{#link-to 'adminBadges.index'}}{{i18n 'admin.badges.title'}}{{/link-to}}</li> {{admin-nav-item route='adminBadges.index' label='admin.badges.title'}}
{{/if}} {{/if}}
{{#if currentUser.admin}} {{#if currentUser.admin}}
<li>{{#link-to 'adminGroups'}}{{i18n 'admin.groups.title'}}{{/link-to}}</li> {{admin-nav-item route='adminGroups' label='admin.groups.title'}}
{{/if}} {{/if}}
<li>{{#link-to 'adminEmail'}}{{i18n 'admin.email.title'}}{{/link-to}}</li> {{admin-nav-item route='adminEmail' label='admin.email.title'}}
<li>{{#link-to 'adminFlags'}}{{i18n 'admin.flags.title'}}{{/link-to}}</li> {{admin-nav-item route='adminFlags' label='admin.flags.title'}}
<li>{{#link-to 'adminLogs'}}{{i18n 'admin.logs.title'}}{{/link-to}}</li> {{admin-nav-item route='adminLogs' label='admin.logs.title'}}
{{#if currentUser.admin}} {{#if currentUser.admin}}
<li>{{#link-to 'adminCustomize.colors'}}{{i18n 'admin.customize.title'}}{{/link-to}}</li> {{admin-nav-item route='adminCustomize.colors' label='admin.customize.title'}}
<li>{{#link-to 'admin.api'}}{{i18n 'admin.api.title'}}{{/link-to}}</li> {{admin-nav-item route='admin.api' label='admin.api.title'}}
<li>{{#link-to 'admin.backups'}}{{i18n 'admin.backups.title'}}{{/link-to}}</li> {{admin-nav-item route='admin.backups' label='admin.backups.title'}}
{{/if}} {{/if}}
{{admin-nav-item route='adminPlugins.index' label='admin.plugins.title'}}
{{plugin-outlet "admin-menu" tagName="li"}} {{plugin-outlet "admin-menu" tagName="li"}}
</ul> </ul>

View file

@ -0,0 +1 @@
{{#link-to route}}{{i18n label}}{{/link-to}}

View file

@ -0,0 +1,36 @@
{{#if length}}
{{d-button label="admin.plugins.change_settings"
icon="gear"
class='settings-button pull-right'
action="showSettings"}}
<h3>{{i18n "admin.plugins.installed"}}</h3>
<br/>
<table>
<tbody>
<thead>
<tr>
<th>{{i18n "admin.plugins.name"}}</th>
<th>{{i18n "admin.plugins.version"}}</th>
</tr>
</thead>
{{#each plugin in controller}}
<tr>
<td>
{{#if plugin.admin_route}}
{{#link-to plugin.admin_route.full_location}}{{plugin.name}}{{/link-to}}
{{else}}
{{plugin.name}}
{{/if}}
</td>
<td>{{plugin.version}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{else}}
<p>{{i18n "admin.plugins.none_installed"}}</p>
{{/if}}

View file

@ -0,0 +1,13 @@
<div class="admin-nav pull-left">
<ul class="nav nav-stacked">
{{admin-nav-item route='adminPlugins.index' label="admin.plugins.title"}}
{{#each route in adminRoutes}}
{{admin-nav-item route=route.full_location label=route.label}}
{{/each}}
</ul>
</div>
<div class="admin-detail pull-left">
{{outlet}}
</div>

View file

@ -11,7 +11,7 @@
</div> </div>
</div> </div>
<div class="site-settings-nav pull-left"> <div class="admin-nav pull-left">
<ul class="nav nav-stacked"> <ul class="nav nav-stacked">
{{#each category in controller}} {{#each category in controller}}
{{#link-to 'adminSiteSettingsCategory' category.nameKey tagName='li' class=category.nameKey}} {{#link-to 'adminSiteSettingsCategory' category.nameKey tagName='li' class=category.nameKey}}
@ -26,7 +26,7 @@
</ul> </ul>
</div> </div>
<div class="site-settings-detail pull-left"> <div class="admin-detail pull-left">
{{outlet}} {{outlet}}
</div> </div>

View file

@ -44,7 +44,7 @@ export default Ember.DefaultResolver.extend({
parseName: parseName, parseName: parseName,
normalize: function(fullName) { normalize(fullName) {
var split = fullName.split(':'); var split = fullName.split(':');
if (split.length > 1) { if (split.length > 1) {
var discourseBase = 'discourse/' + split[0] + 's/'; var discourseBase = 'discourse/' + split[0] + 's/';
@ -68,14 +68,14 @@ export default Ember.DefaultResolver.extend({
return this._super(fullName); return this._super(fullName);
}, },
customResolve: function(parsedName) { customResolve(parsedName) {
// If we end with the name we want, use it. This allows us to define components within plugins. // If we end with the name we want, use it. This allows us to define components within plugins.
var suffix = parsedName.type + 's/' + parsedName.fullNameWithoutType, const suffix = parsedName.type + 's/' + parsedName.fullNameWithoutType,
dashed = Ember.String.dasherize(suffix), dashed = Ember.String.dasherize(suffix),
moduleName = Ember.keys(requirejs.entries).find(function(e) { moduleName = Ember.keys(requirejs.entries).find(function(e) {
return (e.indexOf(suffix, e.length - suffix.length) !== -1) || return (e.indexOf(suffix, e.length - suffix.length) !== -1) ||
(e.indexOf(dashed, e.length - dashed.length) !== -1); (e.indexOf(dashed, e.length - dashed.length) !== -1);
}); });
var module; var module;
if (moduleName) { if (moduleName) {
@ -85,27 +85,27 @@ export default Ember.DefaultResolver.extend({
return module; return module;
}, },
resolveView: function(parsedName) { resolveView(parsedName) {
return this.findLoadingView(parsedName) || this.customResolve(parsedName) || this._super(parsedName); return this.findLoadingView(parsedName) || this.customResolve(parsedName) || this._super(parsedName);
}, },
resolveHelper: function(parsedName) { resolveHelper(parsedName) {
return this.customResolve(parsedName) || this._super(parsedName); return this.customResolve(parsedName) || this._super(parsedName);
}, },
resolveController: function(parsedName) { resolveController(parsedName) {
return this.customResolve(parsedName) || this._super(parsedName); return this.customResolve(parsedName) || this._super(parsedName);
}, },
resolveComponent: function(parsedName) { resolveComponent(parsedName) {
return this.customResolve(parsedName) || this._super(parsedName); return this.customResolve(parsedName) || this._super(parsedName);
}, },
resolveRoute: function(parsedName) { resolveRoute(parsedName) {
return this.findLoadingRoute(parsedName) || this.customResolve(parsedName) || this._super(parsedName); return this.findLoadingRoute(parsedName) || this.customResolve(parsedName) || this._super(parsedName);
}, },
resolveTemplate: function(parsedName) { resolveTemplate(parsedName) {
return this.findPluginTemplate(parsedName) || return this.findPluginTemplate(parsedName) ||
this.findMobileTemplate(parsedName) || this.findMobileTemplate(parsedName) ||
this.findTemplate(parsedName) || this.findTemplate(parsedName) ||
@ -125,23 +125,23 @@ export default Ember.DefaultResolver.extend({
return _loadingView; return _loadingView;
}), }),
findPluginTemplate: function(parsedName) { findPluginTemplate(parsedName) {
var pluginParsedName = this.parseName(parsedName.fullName.replace("template:", "template:javascripts/")); var pluginParsedName = this.parseName(parsedName.fullName.replace("template:", "template:javascripts/"));
return this.findTemplate(pluginParsedName); return this.findTemplate(pluginParsedName);
}, },
findMobileTemplate: function(parsedName) { findMobileTemplate(parsedName) {
if (Discourse.Mobile.mobileView) { if (Discourse.Mobile.mobileView) {
var mobileParsedName = this.parseName(parsedName.fullName.replace("template:", "template:mobile/")); var mobileParsedName = this.parseName(parsedName.fullName.replace("template:", "template:mobile/"));
return this.findTemplate(mobileParsedName); return this.findTemplate(mobileParsedName);
} }
}, },
findTemplate: function(parsedName) { findTemplate(parsedName) {
var withoutType = parsedName.fullNameWithoutType, const withoutType = parsedName.fullNameWithoutType,
slashedType = withoutType.replace(/\./g, '/'), slashedType = withoutType.replace(/\./g, '/'),
decamelized = withoutType.decamelize(), decamelized = withoutType.decamelize(),
templates = Ember.TEMPLATES; templates = Ember.TEMPLATES;
return this._super(parsedName) || return this._super(parsedName) ||
templates[slashedType] || templates[slashedType] ||
@ -152,7 +152,7 @@ export default Ember.DefaultResolver.extend({
this.findUnderscoredTemplate(parsedName); this.findUnderscoredTemplate(parsedName);
}, },
findUnderscoredTemplate: function(parsedName) { findUnderscoredTemplate(parsedName) {
var decamelized = parsedName.fullNameWithoutType.decamelize(); var decamelized = parsedName.fullNameWithoutType.decamelize();
var underscored = decamelized.replace(/\-/g, "_"); var underscored = decamelized.replace(/\-/g, "_");
return Ember.TEMPLATES[underscored]; return Ember.TEMPLATES[underscored];
@ -160,14 +160,22 @@ export default Ember.DefaultResolver.extend({
// Try to find a template within a special admin namespace, e.g. adminEmail => admin/templates/email // Try to find a template within a special admin namespace, e.g. adminEmail => admin/templates/email
// (similar to how discourse lays out templates) // (similar to how discourse lays out templates)
findAdminTemplate: function(parsedName) { findAdminTemplate(parsedName) {
var decamelized = parsedName.fullNameWithoutType.decamelize(); var decamelized = parsedName.fullNameWithoutType.decamelize();
if (decamelized.indexOf('admin') === 0) {
if (decamelized.indexOf('components') === 0) {
const compTemplate = Ember.TEMPLATES['admin/templates/' + decamelized];
if (compTemplate) { return compTemplate; }
}
if (decamelized.indexOf('admin') === 0 || decamelized.indexOf('javascripts/admin') === 0) {
decamelized = decamelized.replace(/^admin\_/, 'admin/templates/'); decamelized = decamelized.replace(/^admin\_/, 'admin/templates/');
decamelized = decamelized.replace(/^admin\./, 'admin/templates/'); decamelized = decamelized.replace(/^admin\./, 'admin/templates/');
decamelized = decamelized.replace(/\./g, '_'); decamelized = decamelized.replace(/\./g, '_');
var dashed = decamelized.replace(/_/g, '-');
return Ember.TEMPLATES[decamelized] || Ember.TEMPLATES[dashed]; const dashed = decamelized.replace(/_/g, '-');
return Ember.TEMPLATES[decamelized] ||
Ember.TEMPLATES[dashed] ||
Ember.TEMPLATES[dashed.replace('admin-', 'admin/')];
} }
} }

View file

@ -6,7 +6,7 @@ export default {
name: 'dynamic-route-builders', name: 'dynamic-route-builders',
after: 'register-discourse-location', after: 'register-discourse-location',
initialize: function(container, app) { initialize(container, app) {
app.DiscoveryCategoryRoute = buildCategoryRoute('latest'); app.DiscoveryCategoryRoute = buildCategoryRoute('latest');
app.DiscoveryParentCategoryRoute = buildCategoryRoute('latest'); app.DiscoveryParentCategoryRoute = buildCategoryRoute('latest');
app.DiscoveryCategoryNoneRoute = buildCategoryRoute('latest', {no_subcategories: true}); app.DiscoveryCategoryNoneRoute = buildCategoryRoute('latest', {no_subcategories: true});

View file

@ -97,7 +97,8 @@ Discourse.Route.reopenClass({
}, },
mapRoutes: function() { mapRoutes: function() {
var resources = {}; var resources = {},
paths = {};
// If a module is defined as `route-map` in discourse or a plugin, its routes // If a module is defined as `route-map` in discourse or a plugin, its routes
// will be built automatically. You can supply a `resource` property to // will be built automatically. You can supply a `resource` property to
@ -115,6 +116,7 @@ Discourse.Route.reopenClass({
if (!resources[mapObj.resource]) { resources[mapObj.resource] = []; } if (!resources[mapObj.resource]) { resources[mapObj.resource] = []; }
resources[mapObj.resource].push(mapObj.map); resources[mapObj.resource].push(mapObj.map);
if (mapObj.path) { paths[mapObj.resource] = mapObj.path; }
} }
}); });
@ -129,13 +131,32 @@ Discourse.Route.reopenClass({
delete resources.root; delete resources.root;
} }
// Apply other resources next var segments = {},
standalone = [];
Object.keys(resources).forEach(function(r) { Object.keys(resources).forEach(function(r) {
router.resource(r, function() { var m = /^([^\.]+)\.(.*)$/.exec(r);
if (m) {
segments[m[1]] = m[2];
} else {
standalone.push(r);
}
});
// Apply other resources next. A little hacky but works!
standalone.forEach(function(r) {
router.resource(r, {path: paths[r]}, function() {
var res = this; var res = this;
resources[r].forEach(function(m) { resources[r].forEach(function(m) { m.call(res); });
m.call(res);
}); var s = segments[r];
if (s) {
var full = r + '.' + s;
res.resource(s, {path: paths[full]}, function() {
var nestedRes = this;
resources[full].forEach(function(m) { m.call(nestedRes); });
});
}
}); });
}); });

View file

@ -181,7 +181,7 @@ td.flaggers td {
} }
} }
.site-settings-nav { .admin-nav {
width: 18.018%; width: 18.018%;
margin-top: 30px; margin-top: 30px;
.nav-stacked { .nav-stacked {
@ -193,7 +193,7 @@ td.flaggers td {
} }
} }
.site-settings-detail { .admin-detail {
width: 76.5765%; width: 76.5765%;
min-height: 800px; min-height: 800px;
margin-left: 0; margin-left: 0;

View file

@ -0,0 +1,8 @@
class Admin::PluginsController < Admin::AdminController
def index
# json = Discourse.plugins.map(&:metadata)
render_serialized(Discourse.plugins, AdminPluginSerializer)
end
end

View file

@ -0,0 +1,26 @@
class AdminPluginSerializer < ApplicationSerializer
attributes :name,
:version,
:admin_route
def name
object.metadata.name
end
def version
object.metadata.version
end
def admin_route
route = object.admin_route
return unless route
ret = route.slice(:location, :label)
ret[:full_location] = "adminPlugins.#{ret[:location]}"
ret
end
def include_admin_route?
admin_route.present?
end
end

View file

@ -1665,6 +1665,14 @@ en:
all_users: "All Users" all_users: "All Users"
note_html: "Keep this key <strong>secret</strong>, all users that have it may create arbitrary posts as any user." note_html: "Keep this key <strong>secret</strong>, all users that have it may create arbitrary posts as any user."
plugins:
title: "Plugins"
installed: "Installed Plugins"
name: "Name"
none_installed: "You don't have any plugins installed."
version: "Version"
change_settings: "Change Settings"
backups: backups:
title: "Backups" title: "Backups"
menu: menu:

View file

@ -34,6 +34,8 @@ Discourse::Application.routes.draw do
namespace :admin, constraints: StaffConstraint.new do namespace :admin, constraints: StaffConstraint.new do
get "" => "admin#index" get "" => "admin#index"
get 'plugins' => 'plugins#index'
resources :site_settings, constraints: AdminConstraint.new do resources :site_settings, constraints: AdminConstraint.new do
collection do collection do
get "category/:id" => "site_settings#index" get "category/:id" => "site_settings#index"

View file

@ -6,6 +6,7 @@ require_dependency 'plugin/auth_provider'
class Plugin::Instance class Plugin::Instance
attr_accessor :path, :metadata attr_accessor :path, :metadata
attr_reader :admin_route
# Memoized array readers # Memoized array readers
[:assets, :auth_providers, :color_schemes, :initializers, :javascripts, :styles].each do |att| [:assets, :auth_providers, :color_schemes, :initializers, :javascripts, :styles].each do |att|
@ -39,6 +40,10 @@ class Plugin::Instance
end end
end end
def add_admin_route(label, location)
@admin_route = {label: label, location: location}
end
def enabled? def enabled?
return @enabled_site_setting ? SiteSetting.send(@enabled_site_setting) : true return @enabled_site_setting ? SiteSetting.send(@enabled_site_setting) : true
end end