aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFathi Boudra <fathi.boudra@linaro.org>2014-06-02 17:46:06 +0300
committerFathi Boudra <fathi.boudra@linaro.org>2014-06-02 17:46:06 +0300
commit21c0b8daf53a00f4da25d2d5ebbc5bc6fa09978c (patch)
treef5abc8422b5cc8e4db518039180baade00157154
parentd04e566d35c7831c40e7a155b13763bb6f866adb (diff)
Add Dashboard extension
Signed-off-by: Fathi Boudra <fathi.boudra@linaro.org>
-rw-r--r--extensions/Dashboard/Config.pm35
-rw-r--r--extensions/Dashboard/Extension.pm326
-rw-r--r--extensions/Dashboard/README.md70
-rw-r--r--extensions/Dashboard/lib/Config.pm75
-rw-r--r--extensions/Dashboard/lib/Legacy/Overlay.pm150
-rw-r--r--extensions/Dashboard/lib/Legacy/Schema.pm209
-rw-r--r--extensions/Dashboard/lib/Legacy/Util.pm403
-rw-r--r--extensions/Dashboard/lib/Overlay.pm278
-rw-r--r--extensions/Dashboard/lib/Util.pm31
-rw-r--r--extensions/Dashboard/lib/WebService.pm376
-rw-r--r--extensions/Dashboard/lib/Widget.pm167
-rw-r--r--extensions/Dashboard/template/en/default/admin/params/dashboard.html.tmpl20
-rw-r--r--extensions/Dashboard/template/en/default/filterexceptions.pl13
-rw-r--r--extensions/Dashboard/template/en/default/hook/global/user-error-errors.html.tmpl37
-rw-r--r--extensions/Dashboard/template/en/default/pages/dashboard.html.tmpl385
-rw-r--r--extensions/Dashboard/tools/client_example.sh31
-rw-r--r--extensions/Dashboard/tools/dashboard_client.py312
-rw-r--r--extensions/Dashboard/web/css/colorbox.css60
-rw-r--r--extensions/Dashboard/web/css/dashboard.css293
-rw-r--r--extensions/Dashboard/web/css/images/ajax-loader.gifbin0 -> 7970 bytes
-rw-r--r--extensions/Dashboard/web/css/images/bg_gradient.pngbin0 -> 1051 bytes
-rw-r--r--extensions/Dashboard/web/css/images/border1.pngbin0 -> 896 bytes
-rw-r--r--extensions/Dashboard/web/css/images/border2.pngbin0 -> 183 bytes
-rw-r--r--extensions/Dashboard/web/css/images/green.pngbin0 -> 708 bytes
-rw-r--r--extensions/Dashboard/web/css/images/green_b.pngbin0 -> 731 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_add_column.pngbin0 -> 502 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_add_column_b.pngbin0 -> 687 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_add_rss.pngbin0 -> 697 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_add_rss_b.pngbin0 -> 884 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_clear_workspace.pngbin0 -> 365 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_clear_workspace_b.pngbin0 -> 647 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_del_column.pngbin0 -> 350 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_del_column_b.pngbin0 -> 501 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_new_bugs.pngbin0 -> 880 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_new_bugs_b.pngbin0 -> 1034 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_new_rss.pngbin0 -> 697 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_new_rss_b.pngbin0 -> 884 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_new_text.pngbin0 -> 582 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_new_text_b.pngbin0 -> 668 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_new_widget.pngbin0 -> 389 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_new_widget_b.pngbin0 -> 557 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_new_xeyes.pngbin0 -> 616 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_new_xeyes_b.pngbin0 -> 806 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_overlay.pngbin0 -> 605 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_overlay_b.pngbin0 -> 794 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_package_get.pngbin0 -> 817 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_reset_columns.pngbin0 -> 634 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_reset_columns_b.pngbin0 -> 738 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_rss_link.pngbin0 -> 472 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_rss_link_b.pngbin0 -> 528 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_rss_popup.pngbin0 -> 392 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_rss_popup_b.pngbin0 -> 472 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_save.pngbin0 -> 491 bytes
-rw-r--r--extensions/Dashboard/web/css/images/icon_save_b.pngbin0 -> 658 bytes
-rw-r--r--extensions/Dashboard/web/css/images/internet_explorer/borderBottomCenter.pngbin0 -> 153 bytes
-rw-r--r--extensions/Dashboard/web/css/images/internet_explorer/borderBottomLeft.pngbin0 -> 473 bytes
-rw-r--r--extensions/Dashboard/web/css/images/internet_explorer/borderBottomRight.pngbin0 -> 470 bytes
-rw-r--r--extensions/Dashboard/web/css/images/internet_explorer/borderMiddleLeft.pngbin0 -> 154 bytes
-rw-r--r--extensions/Dashboard/web/css/images/internet_explorer/borderMiddleRight.pngbin0 -> 148 bytes
-rw-r--r--extensions/Dashboard/web/css/images/internet_explorer/borderTopCenter.pngbin0 -> 143 bytes
-rw-r--r--extensions/Dashboard/web/css/images/internet_explorer/borderTopLeft.pngbin0 -> 405 bytes
-rw-r--r--extensions/Dashboard/web/css/images/internet_explorer/borderTopRight.pngbin0 -> 465 bytes
-rw-r--r--extensions/Dashboard/web/css/images/loading.gifbin0 -> 9427 bytes
-rw-r--r--extensions/Dashboard/web/css/images/note_new.pngbin0 -> 436 bytes
-rw-r--r--extensions/Dashboard/web/css/images/orange.pngbin0 -> 779 bytes
-rw-r--r--extensions/Dashboard/web/css/images/orange_b.pngbin0 -> 785 bytes
-rw-r--r--extensions/Dashboard/web/css/images/oro.pngbin0 -> 472 bytes
-rw-r--r--extensions/Dashboard/web/css/images/prefs.pngbin0 -> 451 bytes
-rw-r--r--extensions/Dashboard/web/css/images/prefs_b.pngbin0 -> 533 bytes
-rw-r--r--extensions/Dashboard/web/css/images/red.pngbin0 -> 708 bytes
-rw-r--r--extensions/Dashboard/web/css/images/red_b.pngbin0 -> 731 bytes
-rw-r--r--extensions/Dashboard/web/css/images/reload.pngbin0 -> 466 bytes
-rw-r--r--extensions/Dashboard/web/css/images/reload_b.pngbin0 -> 563 bytes
-rw-r--r--extensions/Dashboard/web/css/images/resize_handler.pngbin0 -> 104 bytes
-rw-r--r--extensions/Dashboard/web/css/images/save.pngbin0 -> 494 bytes
-rw-r--r--extensions/Dashboard/web/css/images/save_b.pngbin0 -> 287 bytes
-rw-r--r--extensions/Dashboard/web/css/images/sizer_left.pngbin0 -> 199 bytes
-rw-r--r--extensions/Dashboard/web/css/images/sizer_mid.pngbin0 -> 245 bytes
-rw-r--r--extensions/Dashboard/web/css/images/sizer_right.pngbin0 -> 200 bytes
-rw-r--r--extensions/Dashboard/web/css/images/sizer_right_b.pngbin0 -> 536 bytes
-rw-r--r--extensions/Dashboard/web/css/images/ts_asc.gifbin0 -> 54 bytes
-rw-r--r--extensions/Dashboard/web/css/images/ts_bg.gifbin0 -> 64 bytes
-rw-r--r--extensions/Dashboard/web/css/images/ts_desc.gifbin0 -> 54 bytes
-rw-r--r--extensions/Dashboard/web/css/images/yellow.pngbin0 -> 773 bytes
-rw-r--r--extensions/Dashboard/web/js/colResizable-1.3.min.js2
-rw-r--r--extensions/Dashboard/web/js/dashboard.js1500
-rw-r--r--extensions/Dashboard/web/js/jquery.colorbox-min.js4
-rw-r--r--extensions/Dashboard/web/js/jquery.csv.min.js4
-rw-r--r--extensions/Dashboard/web/js/jquery.tablesorter-update.js988
-rw-r--r--extensions/Dashboard/web/js/widgets.js497
90 files changed, 6266 insertions, 0 deletions
diff --git a/extensions/Dashboard/Config.pm b/extensions/Dashboard/Config.pm
new file mode 100644
index 0000000..c89f84c
--- /dev/null
+++ b/extensions/Dashboard/Config.pm
@@ -0,0 +1,35 @@
+# -*- Mode: perl; indent-tabs-mode: nil -*-
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# The Initial Developer of the Original Code is "Nokia Corporation"
+# Portions created by the Initial Developer are Copyright (C) 2010 the
+# Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+# Jari Savolainen <ext-jari.a.savolainen@nokia.com>
+# Stephen Jayna <ext-stephen.jayna@nokia.com>
+
+package Bugzilla::Extension::Dashboard;
+use strict;
+
+use constant NAME => 'Dashboard';
+
+use constant REQUIRED_MODULES => [
+ {
+ package => 'HTML-Scrubber',
+ module => 'HTML::Scrubber',
+ version => 0.08,
+ },
+ {
+ package => 'XML-Feed',
+ module => 'XML::Feed',
+ version => 0.40,
+ },
+];
+
+use constant OPTIONAL_MODULES => [];
+
+__PACKAGE__->NAME;
diff --git a/extensions/Dashboard/Extension.pm b/extensions/Dashboard/Extension.pm
new file mode 100644
index 0000000..470496b
--- /dev/null
+++ b/extensions/Dashboard/Extension.pm
@@ -0,0 +1,326 @@
+# -*- Mode: perl; indent-tabs-mode: nil -*-
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# The Initial Developer of the Original Code is "Nokia Corporation"
+# Portions created by the Initial Developer are Copyright (C) 2010 the
+# Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+# Pami Ketolainen <pami.ketolainen@jollamobile.com>
+# David Wilson <ext-david.3.wilson@nokia.com>
+# Jari Savolainen <ext-jari.a.savolainen@nokia.com>
+# Stephen Jayna <ext-stephen.jayna@nokia.com>
+
+package Bugzilla::Extension::Dashboard;
+use strict;
+use base qw(Bugzilla::Extension);
+
+use POSIX qw(strftime);
+
+use Bugzilla::Config qw(SetParam write_params);
+use Bugzilla::Constants;
+use Bugzilla::Error;
+use Bugzilla::Util;
+use Bugzilla::User;
+
+use Bugzilla::Extension::Dashboard::Util;
+
+use JSON;
+
+our $VERSION = '1.00';
+
+
+# Disable client-side caching of this HTTP request.
+sub cgi_no_cache {
+ my $headers = {
+ -expires => 'Sat, 26 Jul 1997 05:00:00 GMT',
+ -Last_Modified => strftime('%a, %d %b %Y %H:%M:%S GMT', gmtime),
+ -Pragma => 'no-cache',
+ -Cache_Control => join(
+ ', ', qw(
+ private no-cache no-store must-revalidate max-age=0
+ pre-check=0 post-check=0)
+ )
+ };
+
+ while(my ($key, $value) = each(%$headers)) {
+ print Bugzilla->cgi->header($key, $value);
+ }
+}
+
+# Copypasta from colchange.cgi
+# Maps parameters that control columns to the names of columns.
+use constant COLUMN_PARAMS => {
+ 'useclassification' => ['classification'],
+ 'usebugaliases' => ['alias'],
+ 'usetargetmilestone' => ['target_milestone'],
+ 'useqacontact' => ['qa_contact', 'qa_contact_realname'],
+ 'usestatuswhiteboard' => ['status_whiteboard'],
+ 'usevotes' => ['votes'],
+};
+
+# We only show these columns if an object of this type exists in the
+# database.
+use constant COLUMN_CLASSES => {
+ 'Bugzilla::Flag' => 'flagtypes.name',
+ 'Bugzilla::Keyword' => 'keywords',
+};
+
+sub _get_columns {
+
+ my @columns;
+ my @hide;
+ if (BUGZILLA_VERSION =~ /^4\..*/) {
+ use Bugzilla::Search;
+ @columns = keys(%{Bugzilla::Search::COLUMNS()});
+ @hide = qw(relevance);
+ } else {
+ @columns = qw(bug_id opendate changeddate bug_severity priority
+ rep_platform assigned_to assigned_to_realname reporter
+ reporter_realname bug_status resolution classification alias
+ target_milestone qa_contact qa_contact_realname
+ status_whiteboard product component version op_sys short_desc
+ short_short_desc estimated_time remaining_time work_time
+ actual_time percentage_complete deadline);
+ if (Bugzilla->params->{"usevotes"}) {
+ push(@columns, "votes");
+ }
+ my @custom_fields = grep { $_->type != FIELD_TYPE_MULTI_SELECT }
+ Bugzilla->active_custom_fields;
+ push(@columns, map { $_->name } @custom_fields);
+
+ Bugzilla::Hook::process('colchange_columns', {'columns' => \@columns} );
+ }
+ foreach my $param (keys %{ COLUMN_PARAMS() }) {
+ next if Bugzilla->params->{$param};
+ foreach my $column (@{ COLUMN_PARAMS->{$param} }) {
+ push(@hide, $column);
+ }
+ }
+
+ foreach my $class (keys %{ COLUMN_CLASSES() }) {
+ eval("use $class; 1;") || die $@;
+ my $column = COLUMN_CLASSES->{$class};
+ push(@hide, $column) if !$class->any_exist;
+ }
+
+ if (!Bugzilla->user->is_timetracker) {
+ foreach my $column (TIMETRACKING_FIELDS) {
+ push(@hide, $column);
+ }
+ }
+
+ @columns = grep {my $col = $_; !scalar grep(/$col/, @hide)} @columns;
+ @columns = sort @columns;
+ return \@columns;
+}
+
+# Hook for page.cgi and dashboard
+sub page_before_template {
+ my ($self, $args) = @_;
+
+ return if ($args->{page_id} !~ /^dashboard\.html$/);
+ my $user = Bugzilla->login(LOGIN_REQUIRED);
+ user_can_access_dashboard(1);
+
+ cgi_no_cache;
+ my $vars = $args->{vars};
+
+ # Get the same list of columns as used in colchange.cgi
+
+ $vars->{'columns'} = _get_columns;
+
+ my $overlay_id = Bugzilla->cgi->param("overlay_id");
+ if (!defined $overlay_id) {
+ $overlay_id = Bugzilla->dbh->selectrow_array(
+ "SELECT id FROM dashboard_overlays WHERE owner_id = ? ".
+ "ORDER BY modified DESC", {}, Bugzilla->user->id);
+ }
+
+ my $config = {
+ user_id => int($user->id),
+ user_login => $user->login,
+ can_publish => $user->in_group(
+ Bugzilla->params->{dashboard_publish_group}),
+ rss_max_items => int(Bugzilla->params->{dashboard_rss_max_items}),
+ browsers_warn => Bugzilla->params->{"dashboard_browsers_warn"},
+ browsers_block => Bugzilla->params->{"dashboard_browsers_block"},
+ overlay_id => $overlay_id,
+ };
+
+ $vars->{dashboard_config} = JSON->new->utf8->pretty->encode($config);
+}
+
+sub db_schema_abstract_schema {
+ my ($self, $args) = @_;
+ my $schema = $args->{schema};
+
+ $schema->{dashboard_overlays} = {
+ FIELDS => [
+ id => {
+ TYPE => 'MEDIUMSERIAL',
+ NOTNULL => 1,
+ PRIMARYKEY => 1,
+ },
+ name => {
+ TYPE => 'TINYTEXT',
+ NOTNULL => 1,
+ },
+ description => {
+ TYPE => 'TINYTEXT',
+ },
+ columns => {
+ TYPE => 'TINYTEXT',
+ NOTNULL => 1,
+ },
+ created => {
+ TYPE => 'DATETIME',
+ NOTNULL => 1,
+ },
+ modified => {
+ TYPE => 'DATETIME',
+ NOTNULL => 1,
+ },
+ owner_id => {
+ TYPE => 'INT3',
+ NOTNULL => 1,
+ REFERENCES => {
+ TABLE => 'profiles',
+ COLUMN => 'userid',
+ DELETE => 'CASCADE',
+ },
+ },
+ pending => {
+ TYPE => 'BOOLEAN',
+ NOTNULL => 1,
+ DEFAULT => 0,
+ },
+ shared => {
+ TYPE => 'BOOLEAN',
+ NOTNULL => 1,
+ DEFAULT => 0,
+ },
+ workspace => {
+ TYPE => 'BOOLEAN',
+ NOTNULL => 1,
+ DEFAULT => 0,
+ }
+ ]
+ };
+
+ $schema->{dashboard_widgets} = {
+ FIELDS => [
+ id => {
+ TYPE => 'MEDIUMSERIAL',
+ NOTNULL => 1,
+ PRIMARYKEY => 1,
+ },
+ name => {
+ TYPE => 'TINYTEXT',
+ NOTNULL => 1,
+ },
+ type => {
+ TYPE => 'TINYTEXT',
+ NOTNULL => 1,
+ },
+ overlay_id => {
+ TYPE => 'INT3',
+ NOTNULL => 1,
+ REFERENCES => {
+ TABLE => 'dashboard_overlays',
+ COLUMN => 'id',
+ DELETE => 'CASCADE',
+ },
+ },
+ color => {
+ TYPE => 'TINYTEXT',
+ },
+ col => {
+ TYPE => 'INT1',
+ DEFAULT => 0,
+ },
+ pos => {
+ TYPE => 'INT1',
+ DEFAULT => 0,
+ },
+ height => {
+ TYPE => 'INT2',
+ DEFAULT => 100,
+ },
+ minimized => {
+ TYPE => 'BOOLEAN',
+ NOTNULL => 1,
+ DEFAUL => 0,
+ },
+ refresh => {
+ TYPE => 'INT2',
+ NOTNULL => 1,
+ DEFAULT => 0,
+ },
+ data => {
+ TYPE => 'MEDIUMTEXT',
+ }
+ ]
+ };
+}
+
+sub bb_common_links {
+ my ($self, $args) = @_;
+
+ return unless user_can_access_dashboard();
+
+ $args->{links}->{Dashboard} = [
+ {
+ text => 'Dashboard',
+ href => 'page.cgi?id=dashboard.html'
+ }
+ ];
+}
+
+sub bb_group_params {
+ my ($self, $args) = @_;
+ push(@{$args->{group_params}}, 'dashboard_user_group',
+ 'dashboard_publish_group');
+}
+
+sub config_add_panels {
+ my ($self, $args) = @_;
+ my $modules = $args->{panel_modules};
+ $modules->{Dashboard} = "Bugzilla::Extension::Dashboard::Config";
+}
+
+sub object_end_of_update {
+ my ($self, $args) = @_;
+ my ($new_obj, $old_obj, $changes) = @$args{qw(object old_object changes)};
+
+ # Update user group param if group name changes
+ if ($new_obj->isa("Bugzilla::Group") && defined $changes->{name}) {
+ if ($old_obj->name eq Bugzilla->params->{dashboard_user_group}) {
+ SetParam('dashboard_user_group', $new_obj->name);
+ write_params();
+ }
+ }
+}
+
+sub webservice {
+ my ($self, $args) = @_;
+ $args->{dispatch}->{Dashboard} = "Bugzilla::Extension::Dashboard::WebService";
+}
+
+
+sub webservice_error_codes {
+ my ($self, $args) = @_;
+ my $error_map = $args->{error_map};
+ $error_map->{'dashboard_access_denied'} = 10001;
+ $error_map->{'overlay_publish_denied'} = 10002;
+ $error_map->{'overlay_does_not_exist'} = 10003;
+ $error_map->{'widget_does_not_exist'} = 10004;
+ $error_map->{'overlay_access_denied'} = 10005;
+ $error_map->{'overlay_edit_denied'} = 10006;
+ $error_map->{'widget_edit_denied'} = 10007;
+}
+
+__PACKAGE__->NAME;
diff --git a/extensions/Dashboard/README.md b/extensions/Dashboard/README.md
new file mode 100644
index 0000000..fe406db
--- /dev/null
+++ b/extensions/Dashboard/README.md
@@ -0,0 +1,70 @@
+Dashboard Bugzilla Extension
+============================
+
+Dashboard is an extension to Bugzilla reporting system. Unified
+Dashboard-extension allows you to create and view versatile dashboard of
+reports and graphs. Dashboard is versatile mash-up of numerous sources in
+reporting system.
+
+Dashboard-extension has the purpose of gathering valuable information from
+reporting system into reports and graphs.
+
+
+Changes
+-------
+
+ 1.0 - The Dashboard extensions storage backend has been rewriten to use
+ database. Old file based storage is not compatible with this, and
+ currently there is no migration tools for existing overlays. Upgrading
+ will not destroy the old overlays, but they won't be available in the
+ new version.
+
+
+Concepts
+--------
+
+### Overlays
+
+Dashboards are arranged into 'overlays', these are a set of columns and widgets
+with an associated name and description. An overlay may be shared with others,
+or stored private to an individual user.
+
+When overlay is shared it goes first to 'pending' state and has to be approved
+and published by admin before it visible to all other users.
+
+### Widgets
+
+Widgets represent the basic unit of information in a dashboard. Various widget
+types are provided by default, for embedding a URL in an IFRAME, viewing RSS
+feeds, holding plain text, or listing Bugzila bugs.
+
+Each widget has an associated dialog that allows changing its settings.
+
+Widgets may be drag'n'dropped between columns, or dragged to change their
+height.
+
+
+Installation
+------------
+
+This extension requires [BayotBase](https://github.com/bayoteers/BayotBase)
+extension, so install it first.
+
+1. Put extension files in
+
+ extensions/Dashboard
+
+2. Run checksetup.pl
+
+3. Restart your webserver if needed (for exmple when running under mod_perl)
+
+
+License
+-------
+
+The code/extension is released under the [Mozilla Public License Version 2.0](
+http://mozilla.org/MPL/2.0/).
+
+The Initial Developer of the Original Code is "Nokia Corporation" Portions
+created by the Initial Developer are Copyright (C) 2011 the Initial Developer.
+All Rights Reserved.
diff --git a/extensions/Dashboard/lib/Config.pm b/extensions/Dashboard/lib/Config.pm
new file mode 100644
index 0000000..e0dd054
--- /dev/null
+++ b/extensions/Dashboard/lib/Config.pm
@@ -0,0 +1,75 @@
+# -*- Mode: perl; indent-tabs-mode: nil -*-
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# The Initial Developer of the Original Code is "Nokia Corporation"
+# Portions created by the Initial Developer are Copyright (C) 2010 the
+# Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+# Jari Savolainen <ext-jari.a.savolainen@nokia.com>
+
+package Bugzilla::Extension::Dashboard::Config;
+use strict;
+use warnings;
+
+use Bugzilla::Config::Common;
+
+sub get_param_list {
+ my ($class) = @_;
+
+ my @groups = sort @{Bugzilla->dbh->selectcol_arrayref(
+ "SELECT name FROM groups")};
+ my ($old_group) = grep {$_ eq 'dashboard_publisher'} @groups;
+
+ my @param_list = (
+ {
+ name => 'dashboard_user_group',
+ desc => 'User group that has access to dashboards',
+ type => 's',
+ choices => ['', @groups],
+ default => '',
+ },
+ {
+ name => 'dashboard_publish_group',
+ desc => 'User group that is allowed to publish dashboards',
+ type => 's',
+ choices => \@groups,
+ default => defined $old_group ? $old_group : 'admin',
+ },
+ {
+ name => 'dashboard_max_workspaces',
+ desc => 'Maximum number of temporary "workspace" overlays a user '.
+ 'may have before automatically deleting the oldest one.',
+ type => 't',
+ default => '5',
+ checker => \&check_numeric,
+ },
+ {
+ name => 'dashboard_browsers_warn',
+ desc => 'Regexp for browsers that are not recommended',
+ type => 't',
+ default => 'AppleWebkit',
+ checker => \&check_regexp,
+ },
+ {
+ name => 'dashboard_browsers_block',
+ desc => 'Regexp for browsers that are not supported',
+ type => 't',
+ default => 'MSIE\s\d\.\d',
+ checker => \&check_regexp,
+ },
+ {
+ name => 'dashboard_rss_max_items',
+ desc => 'How many items rss widget can display at a time',
+ type => 't',
+ default => '25',
+ checker => \&check_numeric,
+ },
+ );
+ return @param_list;
+}
+
+1;
diff --git a/extensions/Dashboard/lib/Legacy/Overlay.pm b/extensions/Dashboard/lib/Legacy/Overlay.pm
new file mode 100644
index 0000000..be397eb
--- /dev/null
+++ b/extensions/Dashboard/lib/Legacy/Overlay.pm
@@ -0,0 +1,150 @@
+# -*- Mode: perl; indent-tabs-mode: nil -*-
+#
+# The contents of this file are subject to the Mozilla Public
+# License Version 1.1 (the "License"); you may not use this file
+# except in compliance with the License. You may obtain a copy of
+# the License at http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS
+# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
+# implied. See the License for the specific language governing
+# rights and limitations under the License.
+#
+# The Original Code is the Bugzilla Bug Tracking System.
+#
+# The Initial Developer of the Original Code is Everything Solved, Inc.
+# Portions created by Everything Solved, Inc. are Copyright (C) 2007
+# Everything Solved, Inc. All Rights Reserved.
+#
+# Contributor(s):
+# David Wilson <ext-david.3.wilson@nokia.com>
+#
+
+package Bugzilla::Extension::Dashboard::Overlay;
+
+use strict;
+use File::Path qw(remove_tree);
+
+use Bugzilla::Extension::Dashboard::Legacy::Schema qw(OVERLAY_DEFS parse to_int);
+use Bugzilla::Extension::Dashboard::Legacy::Util;
+
+
+sub from_hash {
+ my ($class, $hash) = @_;
+ my $self = parse(OVERLAY_DEFS, $hash);
+ bless($self);
+}
+
+
+sub from_store {
+ my ($class, $user_id, $overlay_id) = @_;
+
+ $user_id = to_int($user_id);
+ $overlay_id = to_int($overlay_id);
+
+ # Ensure current user has right to open overlay.
+ if($user_id && $user_id != Bugzilla->user->id
+ && !Bugzilla->user->in_group('admin')) {
+ ThrowUserError('dashboard_illegal_id');
+ }
+
+ my $dir = get_overlay_dir($user_id, $overlay_id);
+ if(!-d $dir || !scalar dir_glob($dir, '*')) {
+ return undef;
+ }
+
+ my $self = overlay_from_dir($user_id, $dir);
+ $self->{id} = $overlay_id;
+ if(! $self->{shared}) {
+ $self->{owner} = $user_id;
+ }
+ bless($self);
+}
+
+
+sub delete {
+ my $self = shift;
+ if($self->{owner} != Bugzilla->user->id
+ && !Bugzilla->user->in_group('admin')) {
+ ThrowUserError('dashboard_illegal_id');
+ }
+
+ my $target_id = $self->{owner};
+ if($self->{shared}) {
+ $target_id = 0;
+ }
+
+ my $dir = get_overlay_dir($target_id, $self->{id});
+ remove_tree($dir);
+}
+
+
+sub publish {
+ my $self = shift;
+ if(! $self->{pending}) {
+ return;
+ } elsif(! Bugzilla->user->in_group('admin')) {
+ ThrowUserError('dashboard_illegal_id');
+ }
+
+ $self->{pending} = 0;
+ $self->save();
+}
+
+
+sub clone {
+ my ($self, $new_id) = @_;
+
+ my $new = $self->from_hash($self);
+ $new->{shared} = 0;
+ $new->{owner} = int(Bugzilla->user->id);
+ $new->{created} = time;
+ $new->{id} = to_int($new_id);
+ $new->{workspace} = 1;
+ $new->save();
+}
+
+
+sub save {
+ my $self = shift;
+
+ if(! $self->{created}) {
+ $self->{created} = time;
+ }
+
+ if(! $self->{owner}) {
+ $self->{owner} = int(Bugzilla->user->id);
+ }
+
+ if($self->{owner} != Bugzilla->user->id
+ && !Bugzilla->user->in_group('admin')) {
+ ThrowUserError('dashboard_illegal_id');
+ }
+
+ my $target_id = $self->{shared} ? 0 : $self->{owner};
+ if(! $self->{id}) {
+ $self->{id} = first_free_id(get_overlays_dir($target_id));
+ }
+
+ $self->{modified} = time;
+ $self->{pending} = $self->{shared} && !Bugzilla->user->in_group('admin');
+
+ my $dir = get_overlay_dir($target_id, $self->{id});
+ overlay_to_dir($dir, $self);
+ $self;
+}
+
+
+sub update_from {
+ my ($self, $hash) = @_;
+
+ my $other = $self->from_hash($hash);
+ delete @$other{qw(created owner id)};
+
+ while(my ($key, $value) = each(%$other)) {
+ $self->{$key} = $value;
+ }
+}
+
+
+1;
diff --git a/extensions/Dashboard/lib/Legacy/Schema.pm b/extensions/Dashboard/lib/Legacy/Schema.pm
new file mode 100644
index 0000000..7568c23
--- /dev/null
+++ b/extensions/Dashboard/lib/Legacy/Schema.pm
@@ -0,0 +1,209 @@
+# -*- Mode: perl; indent-tabs-mode: nil -*-
+#
+# The contents of this file are subject to the Mozilla Public
+# License Version 1.1 (the "License"); you may not use this file
+# except in compliance with the License. You may obtain a copy of
+# the License at http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS
+# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
+# implied. See the License for the specific language governing
+# rights and limitations under the License.
+#
+# The Original Code is the Unified Dashboard Bugzilla Extension.
+#
+# The Initial Developer of the Original Code is "Nokia Corporation"
+# Portions created by the Initial Developer are Copyright (C) 2010 the
+# Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+# David Wilson <ext-david.3.wilson@nokia.com>
+#
+
+package Bugzilla::Extension::Dashboard::Legacy::Schema;
+
+use strict;
+use Exporter 'import';
+use Bugzilla::Extension::Dashboard::Config;
+use Bugzilla::Util;
+use HTML::Scrubber;
+
+our @EXPORT = qw(
+ COLUMN_DEFS
+ OVERLAY_DEFS
+ parse
+ to_bool
+ to_color
+ to_int
+ to_text
+ WIDGET_DEFS
+);
+
+use constant WIDGET_DEFS => {
+ # Background colour.
+ color => { type => 'color', required => 1, default => 'gray' },
+ # Integer 0..cols widget column number.
+ col => { type => 'int', required => 1 },
+ # Widget height in pixels.
+ height => { type => 'int' },
+ # Widget ID
+ id => { type => 'int', required => 1, min => 1 },
+ # Bool 0..1, widget content is hidden.
+ minimized => { type => 'bool' },
+ # Password for RSS widget.
+ password => { type => 'text', private => 1 },
+ # Integer 0..rows widget position in column.
+ pos => { type => 'int', required => 1 },
+ # Refresh interval in seconds.
+ refresh => { type => 'int', default => 600, required => 1 },
+ # CSS selector for URL widget.
+ selector => { type => 'text' },
+ # Widget title.
+ title => { type => 'text', required => 1 },
+ # Contents of 'text' widget.
+ text => { type => 'text' },
+ # Type, one of 'url', 'rss', 'mybugs'.
+ type => { type => 'text', required => 1, choices => [ WIDGET_TYPES ]},
+ # URL for 'url' widget.
+ URL => { type => 'text' },
+ # Username for 'rss' widget.
+ username => { type => 'text', private => 1 }
+};
+
+use constant COLUMN_DEFS => {
+ # Width in percent.
+ width => { type => 'int', required => 1 }
+};
+
+use constant OVERLAY_DEFS => {
+ # List of columns.
+ columns => { type => 'list', item => COLUMN_DEFS, required => 1 },
+ # Seconds since epoch creation time.
+ created => { type => 'int' },
+ # Long description.
+ description => { type => 'text', required => 1, default => '' },
+ # Overlay ID, unique per user_id.
+ id => { type => 'int' },
+ # Seconds since epoch last save time.
+ modified => { type => 'int' },
+ # Short name.
+ name => { type => 'text', required => 1, min_length => 4 },
+ # Owner user ID.
+ owner => { type => 'int', },
+ # Shared overlay hasn't been approved yet?
+ pending => { type => 'int', default => 0 },
+ # Shared between all users?
+ shared => { type => 'bool', default => 0, required => 1 },
+ # User login. Only appears in get_overlays list.
+ user_login => { type => 'text' },
+ # User ID. '0' for shared overlays, otherways same as 'owner' field.
+ user_id => { type => 'int' },
+ # List of widgets.
+ widgets => { type => 'list', item => WIDGET_DEFS },
+ # Bool, overlay represents a user's workspace.
+ workspace => { type => 'bool', default => 0 },
+};
+
+use constant TYPE_CONVERTER_MAP => {
+ int => \&to_int,
+ bool => \&to_bool,
+ text => \&to_text,
+ color => \&to_color,
+};
+
+
+sub _parse_field {
+ my ($field, $def, $value) = @_;
+
+ if(defined($def->{min}) && $value < $def->{min}) {
+ my $min = $def->{min};
+ die "Field '$field' must be at least $min.";
+ } elsif(defined($def->{min_length}) && length($value) < $def->{min_length}) {
+ my $min = $def->{min_length};
+ die "Field '$field' must be at least $min long.";
+ } elsif(defined($def->{default}) && !defined($value)) {
+ $value = $def->{default};
+ } elsif($def->{required} && !defined($value)) {
+ die 'Missing required field: ' . $field;
+ } elsif($def->{choices} && defined($value)
+ && !grep($_ eq $value, @{$def->{choices}})) {
+ my $choices = join(', ', @{$def->{choices}});
+ die "Field $field invalid value '$value'; ".
+ "must be one of $choices";
+ }
+
+ my $converter = TYPE_CONVERTER_MAP->{$def->{type}};
+ return &$converter($value);
+}
+
+sub _parse_list {
+ my ($field, $def, $value) = @_;
+
+ if($def->{required} && !defined($value)) {
+ die "Field '$field' is required and must be an array.";
+ } elsif(! defined($value)) {
+ return [];
+ } elsif(! UNIVERSAL::isa($value, 'ARRAY')) {
+ die "Field '$field' must be an array.";
+ }
+
+ return [ map { parse($def->{item}, $_) } @$value ];
+}
+
+sub parse {
+ my ($defs, $fields, $check_required) = @_;
+
+ my $out = {};
+ while(my ($field, $def) = each(%$defs)) {
+ my $value = $fields->{$field};
+
+ if($def->{type} eq 'list') {
+ $out->{$field} = _parse_list($field, $def, $value);
+ } else {
+ $out->{$field} = _parse_field($field, $def, $value);
+ }
+ }
+
+ return $out;
+}
+
+
+#
+# Type conversion functions.
+#
+
+sub to_int {
+ my ($s) = @_;
+ detaint_signed($s);
+ return int($s || 0);
+}
+
+sub to_bool {
+ my ($s) = @_;
+ $s ||= '';
+ return int($s eq 'true' || ($s =~ /\d+/ && $s == 1));
+}
+
+sub to_text {
+ my ($s, $default) = @_;
+ if ($s) {
+ my $scrubber = HTML::Scrubber->new;
+ $scrubber->default(0);
+ return trim($scrubber->scrub($s));
+ }
+ return trim($default || '');
+}
+
+sub to_color {
+ my ($s) = @_;
+ $s ||= '';
+
+ if($s =~ /^(?:color-)?(gray|yellow|red|blue|white|orange|green)$/) {
+ return $1;
+ }
+
+ return 'gray';
+}
+
+
+1;
diff --git a/extensions/Dashboard/lib/Legacy/Util.pm b/extensions/Dashboard/lib/Legacy/Util.pm
new file mode 100644
index 0000000..a7604aa
--- /dev/null
+++ b/extensions/Dashboard/lib/Legacy/Util.pm
@@ -0,0 +1,403 @@
+# -*- Mode: perl; indent-tabs-mode: nil -*-
+#
+# The contents of this file are subject to the Mozilla Public
+# License Version 1.1 (the "License"); you may not use this file
+# except in compliance with the License. You may obtain a copy of
+# the License at http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS
+# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
+# implied. See the License for the specific language governing
+# rights and limitations under the License.
+#
+# The Original Code is the Unified Dashboard Bugzilla Extension.
+#
+# The Initial Developer of the Original Code is "Nokia Corporation"
+# Portions created by the Initial Developer are Copyright (C) 2010 the
+# Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+# David Wilson <ext-david.3.wilson@nokia.com>
+# Jari Savolainen <ext-jari.a.savolainen@nokia.com>
+#
+
+package Bugzilla::Extension::Dashboard::Legacy::Util;
+
+use strict;
+use warnings;
+
+use Exporter 'import';
+our @EXPORT = qw(
+ dir_glob
+ first_free_id
+ get_mtime
+ get_overlay_dir
+ get_overlays_dir
+ merge
+ migrate_workspace
+ overlay_from_dir
+ overlays_for_user
+ overlay_to_dir
+ trim_workspace_overlays
+);
+
+use Data::Dumper;
+use File::Basename;
+use File::Copy qw(move);
+use File::Path qw(mkpath remove_tree);
+use File::Spec;
+use List::Util qw(sum);
+use POSIX qw(getpid strftime);
+use Storable qw(store retrieve);
+
+use Bugzilla::Constants;
+use Bugzilla::Extension::Dashboard::Config;
+use Bugzilla::Extension::Dashboard::Legacy::Schema;
+use Bugzilla::User;
+use Bugzilla::Util;
+
+
+#
+# Data helper functions.
+#
+
+# Given a list of hashrefs, return a new hashref which is the result of
+# assigning the elements from each hash in order to an empty hash. Skips list
+# items that aren't hashrefs.
+sub merge {
+ my $out = {};
+ for my $hash (@_) {
+ if(! UNIVERSAL::isa($hash, 'HASH')) {
+ next;
+ }
+
+ while(my ($key, $value) = each(%$hash)) {
+ $out->{$key} = $value;
+ }
+ }
+ return $out;
+};
+
+
+#
+# Filesystem helper functions.
+#
+
+
+# Create a directory if it doesn't already exist.
+sub make_path {
+ my ($path) = @_;
+
+ if(-d $path) {
+ return;
+ }
+
+ mkpath($path, {
+ verbose => 0,
+ mode => 0755,
+ error => \my $err
+ });
+
+ if (@$err) {
+ for my $diag (@$err) {
+ my ($file, $message) = each %$diag;
+ print "Problem making $file: $message\n";
+ }
+ die("Couldn't create $path");
+ }
+}
+
+
+# Glob some pattern and detaint each returned result.
+sub dir_glob {
+ my @out;
+ foreach my $path (glob File::Spec->catfile(@_)) {
+ trick_taint $path;
+ push @out, $path;
+ }
+ return @out;
+}
+
+
+# Return the modification time of a path in seconds since epoch.
+sub get_mtime {
+ my ($path) = @_;
+ my @st = lstat $path;
+ return $st[9];
+}
+
+
+# Find an unused overlay ID we can use to store the loaded page's unsaved
+# changes to. When the user explicitly saves the workspace, this overlay is
+# destroyed.
+sub first_free_id {
+ my ($dir) = @_;
+ my $dest_dir;
+ my $found = 0;
+ my $id;
+
+ make_path($dir);
+
+ do {
+ $id = time() | getpid();
+ $dest_dir = File::Spec->catdir($dir, $id);
+ $found = mkdir($dest_dir);
+ if(! $found) {
+ sleep 1;
+ }
+ } until($found);
+
+ return int($id);
+}
+
+
+# Return a user's data directory.
+sub get_user_dir {
+ my ($user_id) = @_;
+ $user_id = Bugzilla->user->id if !defined($user_id);
+ trick_taint $user_id;
+
+ my $ext_dir = File::Spec->catdir(bz_locations()->{datadir},
+ 'extensions/Dashboard');
+ return File::Spec->catdir($ext_dir, $user_id);
+}
+
+
+# Return the directory containing the overlays for the given user.
+sub get_overlays_dir {
+ my ($user_id) = @_;
+ return File::Spec->catdir(get_user_dir($user_id), 'overlay');
+}
+
+
+# Return the directory containing a particular overlay.
+sub get_overlay_dir {
+ my ($user_id, $overlay_id) = @_;
+ return File::Spec->catdir(get_overlays_dir($user_id), $overlay_id);
+}
+
+
+#
+# Widget IO functions.
+#
+
+
+# Write a list of widgets to the given directory, then delete any other widget
+# files from the directory that weren't in the list.
+sub widgets_to_dir {
+ my ($dir, $widgets) = @_;
+
+ my $paths;
+ foreach my $widget (@{$widgets}) {
+ my $path = File::Spec->catfile($dir, $widget->{id} . '.widget');
+ store $widget, $path;
+ $paths->{$path} = 1;
+ }
+
+ my @old = dir_glob($dir, '*.widget');
+ unlink grep { !$paths->{$_} } @old;
+}
+
+
+# Return widgets from the given directory as an arrayref.
+sub widgets_from_dir {
+ my ($dir) = @_;
+ my @widgets = map { retrieve $_; } dir_glob($dir, '*.widget');
+ return [ map { parse(WIDGET_DEFS, $_) } @widgets ];
+}
+
+
+# Given an array ref of widget hashrefs, remove any private fields present.
+sub blank_private_fields {
+ my ($defs, $widgets) = @_;
+
+ my @private;
+ while(my ($field, $def) = each(%$defs)) {
+ push @private, $field if $def->{private};
+ }
+
+ map { delete @$_{@private} } @$widgets;
+}
+
+
+#
+# Overlay IO functions.
+#
+
+
+# Read an overlay from a directory.
+sub overlay_from_dir {
+ my ($user_id, $dir) = @_;
+
+ my $path = File::Spec->catfile($dir, 'overlay');
+ if(! -f $path) {
+ die "Cannot load, overlay file missing.";
+ }
+ my $overlay = retrieve($path);
+
+ $path = File::Spec->catfile($dir, 'preferences');
+ if(-f $path) {
+ $overlay = merge($overlay, retrieve($path));
+ }
+
+ normalize_columns($overlay);
+ $overlay->{widgets} = widgets_from_dir($dir);
+
+ if($user_id != $overlay->{user_id}) {
+ blank_private_fields(WIDGET_DEFS, $overlay->{widgets});
+ }
+
+ return $overlay;
+};
+
+
+# Write an overlay to a directory.
+sub overlay_to_dir {
+ my ($dir, $overlay) = @_;
+
+ my $tmp = $dir . '.tmp';
+ if(-d $tmp) {
+ remove_tree $tmp;
+ }
+ make_path $tmp;
+
+ normalize_columns($overlay);
+ $overlay = parse(OVERLAY_DEFS, $overlay);
+ $overlay->{modified} = time;
+
+ my $path = File::Spec->catfile($tmp, 'overlay');
+ if($overlay->{pending}) {
+ $path .= '.pending';
+ }
+
+ # Widgets are stored separately for now; copy $prefs to preserve the hash
+ # for any callers.
+ widgets_to_dir($tmp, $overlay->{widgets});
+ my $copy = merge($overlay);
+ delete $copy->{widgets};
+ store $copy, $path;
+
+ # Everything written, now drop any old dir and replace with the new one.
+ if(-d $dir) {
+ remove_tree $dir;
+ }
+ move $tmp, $dir;
+};
+
+
+# Return a list of all overlays for a user.
+sub overlays_for_user {
+ my ($user_id) = @_;
+ $user_id = Bugzilla->user->id if !defined($user_id);
+
+ my @overlays;
+ foreach my $dir (dir_glob(get_overlays_dir($user_id), '*')) {
+ my $active = File::Spec->catfile($dir, 'overlay');
+ my $prefs = File::Spec->catfile($dir, 'preferences');
+ my $pending = File::Spec->catfile($dir, 'overlay.pending');
+
+ my $base = {};
+ if(-f $prefs) {
+ $base = retrieve($prefs);
+ }
+
+ foreach my $path (($active, $pending)) {
+ next if !-f $path;
+ my $overlay = merge($base, retrieve($path));
+ $overlay->{id} = int(basename dirname $path);
+ $overlay->{user_id} = $user_id;
+ $overlay->{user_login} = user_id_to_login($overlay->{owner});
+ $overlay->{pending} = int($path eq $pending);
+ if(! $overlay->{modified}) {
+ $overlay->{modified} = get_mtime $path;
+ }
+ normalize_columns($overlay);
+ push @overlays, parse(OVERLAY_DEFS, $overlay);
+ }
+ }
+
+ return @overlays;
+}
+
+
+# Migrate an old style user workspace to a private overlay, if necessary.
+sub migrate_workspace {
+ my ($user_id) = @_;
+ $user_id = Bugzilla->user->id if !defined($user_id);
+
+ my $user_dir = get_user_dir($user_id);
+ my $prefs_path = File::Spec->catfile($user_dir, 'preferences');
+ if(! -f $prefs_path) {
+ return;
+ }
+
+ my $mtime = get_mtime $prefs_path;
+ my $time_str = strftime('%a, %Y-%m-%d %H:%M:%S', gmtime $mtime);
+
+ my $prefs = retrieve($prefs_path)
+ or die $!;
+ normalize_columns($prefs);
+
+ my $overlay = parse(OVERLAY_DEFS, merge($prefs, {
+ created => $mtime,
+ modified => $mtime,
+ description => 'Last active workspace from old Dashboard',
+ name => 'Workspace from ' . $time_str,
+ owner => $user_id,
+ widgets => widgets_from_dir($user_dir),
+ workspace => 1,
+ }));
+
+ # Must remove 'overlay' file first as it shadows overlay subdirectory.
+ my $overlay_path = File::Spec->catfile($user_dir, 'overlay');
+ unlink $overlay_path if(-f $overlay_path);
+
+ my $overlay_dir = get_overlay_dir($user_id, $mtime);
+ overlay_to_dir $overlay_dir, $overlay;
+ unlink $prefs_path, grep { -f $_ } $user_dir;
+}
+
+
+# Convert old Dashboard's list of column keys to a new style columns structure,
+# if necessary. Then normalize the column widths.
+sub normalize_columns {
+ my ($prefs) = @_;
+
+ if(! UNIVERSAL::isa($prefs->{columns}, "ARRAY")) {
+ my @keys = grep /^column[0-9]/, sort keys %{$prefs};
+ $prefs->{columns} = [ map { { width => $prefs->{$_} } } @keys ];
+ map { delete $prefs->{$_} } @keys;
+ }
+
+ if(! @{$prefs->{columns}}) {
+ $prefs->{columns} = [
+ { width => 33 },
+ { width => 33 },
+ { width => 33 }
+ ];
+ }
+
+ # If column totals don't add up to 100%, spread the difference out.
+ my $total = sum map { $_->{width} } @{$prefs->{columns}};
+ my $delta = int((100 - $total) / @{$prefs->{columns}});
+ map { $_->{width} += $delta } @{$prefs->{columns}};
+}
+
+
+# Remove all but the newest <Params.dashboard_max_workspaces> 'workspace'
+# overlays from a user's overlay directory.
+sub trim_workspace_overlays {
+ my @overlays = overlays_for_user(@_);
+ my @workspaces = grep { $_->{workspace} == 1 } @overlays;
+ @workspaces = sort { $b->{modified} <=> $a->{modified} } @workspaces;
+
+ while(@workspaces > Bugzilla->params->{'dashboard_max_workspaces'}) {
+ my $info = pop @workspaces;
+ my $overlay = Bugzilla::Extension::Dashboard::Overlay->from_store(
+ $info->{user_id}, $info->{id});
+ $overlay->delete();
+ }
+}
+
+
+1;
diff --git a/extensions/Dashboard/lib/Overlay.pm b/extensions/Dashboard/lib/Overlay.pm
new file mode 100644
index 0000000..56c37de
--- /dev/null
+++ b/extensions/Dashboard/lib/Overlay.pm
@@ -0,0 +1,278 @@
+# -*- Mode: perl; indent-tabs-mode: nil -*-
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# Copyright (C) 2012 Jolla Ltd.
+# Contact: Pami Ketolainen <pami.ketolainen@jollamobile.com>
+
+package Bugzilla::Extension::Dashboard::Overlay;
+
+use strict;
+
+use base qw(Bugzilla::Object);
+
+use Bugzilla::User;
+use Bugzilla::Error;
+
+use JSON;
+use List::Util qw(sum);
+
+use constant DB_TABLE => 'dashboard_overlays';
+
+
+use constant DB_COLUMNS => qw(
+ id
+ name
+ description
+ columns
+ created
+ modified
+ owner_id
+ pending
+ shared
+ workspace
+);
+
+use constant REQUIRED_CREATE_FIELDS => qw(
+ name
+);
+
+use constant UPDATE_COLUMNS => qw(
+ name
+ description
+ columns
+ modified
+ pending
+ shared
+ workspace
+);
+
+
+use constant NUMERIC_COLUMNS => qw(
+ owner_id
+ pending
+ shared
+ workspace
+);
+
+use constant DATE_COLUMNS => qw(
+ created
+ modified
+);
+
+use constant VALIDATORS => {
+ pending => \&Bugzilla::Object::check_boolean,
+ shared => \&Bugzilla::Object::check_boolean,
+ workspace => \&Bugzilla::Object::check_boolean,
+ columns => \&_check_columns,
+};
+
+use constant AUDIT_CREATES => 0;
+use constant AUDIT_UPDATES => 0;
+use constant AUDIT_REMOVES => 0;
+
+#############
+# Accessors #
+#############
+
+sub owner_id { return $_[0]->{'owner_id'}; }
+sub description { return $_[0]->{'description'}; }
+sub created { return $_[0]->{'created'}; }
+sub modified { return $_[0]->{'modified'}; }
+sub pending { return $_[0]->{'pending'}; }
+sub shared { return $_[0]->{'shared'}; }
+sub workspace { return $_[0]->{'workspace'}; }
+
+sub owner {
+ my $self = shift;
+ $self->{'owner'} ||= Bugzilla::User->new($self->owner_id);
+ return $self->{'owner'};
+}
+
+sub columns {
+ my $self = shift;
+ $self->{'column_list'} ||= JSON->new->utf8->decode($self->{'columns'});
+ return $self->{'column_list'};
+}
+
+sub widgets {
+ my $self = shift;
+ require Bugzilla::Extension::Dashboard::Widget;
+ $self->{'widgets'} ||= Bugzilla::Extension::Dashboard::Widget->match(
+ {overlay_id => $self->id});
+ return $self->{'widgets'};
+}
+
+############
+# Mutators #
+############
+
+sub set_name { $_[0]->set('name', $_[1]); }
+sub set_description { $_[0]->set('description', $_[1]); }
+sub set_pending { $_[0]->set('pending', $_[1]); }
+sub set_workspace { $_[0]->set('workspace', $_[1]); }
+sub set_columns { $_[0]->set('columns', $_[1]); }
+
+sub set_shared {
+ my ($self, $value) = @_;
+ if (!$self->shared && $value) {
+ $self->set('pending', 1);
+ } elsif ($self->shared && !$value) {
+ $self->set('pending', 0);
+ }
+ $self->set('shared', $value);
+}
+
+# These are here so that we can just pass nice hash to set_all
+sub set_created { }
+sub set_modified { }
+sub set_owner { }
+sub set_user_can_edit { }
+sub set_user_can_publish { }
+
+sub set_widgets {
+ my ($self, $widgets) = @_;
+
+ # Sort incoming to existing and new widgets
+ my %existing_widgets;
+ my @new_widgets;
+ my $modified = 0;
+ foreach my $widget (@{$widgets}) {
+ if (!defined $widget->{id} || $widget->{overlay_id} != $self->id) {
+ push(@new_widgets, $widget);
+ } else {
+ $existing_widgets{$widget->{id}} = $widget;
+ }
+ }
+
+ # Get the old widgets and clear cache list
+ my $db_widgets = $self->widgets;
+ $self->{widgets} = [];
+
+ # Update existing widgets and delete those not listed
+ foreach my $widget (@{$db_widgets}) {
+ my $params = $existing_widgets{$widget->id};
+ if (defined $params) {
+ delete $params->{id};
+ delete $params->{overlay_id};
+ $widget->set_all($params);
+ $widget->update();
+ push(@{$self->{widgets}}, $widget);
+ } else {
+ $widget->remove_from_db();
+ }
+ $modified = 1;
+ }
+
+ # Create new widgets
+ foreach my $params (@new_widgets) {
+ delete $params->{id};
+ $params->{overlay_id} = $self->id;
+ my $widget = Bugzilla::Extension::Dashboard::Widget->create($params);
+ push(@{$self->{widgets}}, $widget);
+ $modified = 1;
+ }
+
+ if ($modified) {
+ $self->{modified} = Bugzilla->dbh->selectrow_array(
+ 'SELECT LOCALTIMESTAMP(0)');
+ }
+}
+
+
+##############
+# Validators #
+##############
+
+sub _check_columns {
+ my ($invocant, $columns) = @_;
+ ThrowCodeError("zero_columns_in_overlay") if (! @{$columns});
+
+ # If column totals don't add up to 100%, spread the difference out.
+ my $total = sum @{ $columns };
+ my $delta = int((100 - $total) / @{ $columns });
+ map { $_ += $delta } @{ $columns };
+
+ return JSON->new->utf8->encode($columns);
+}
+
+###########
+# Methods #
+###########
+
+sub create {
+ my ($class, $params) = @_;
+
+ # 'created', 'modified' and 'owner_id' can't be set by the caller
+ $params->{created} = Bugzilla->dbh->selectrow_array(
+ 'SELECT LOCALTIMESTAMP(0)');
+ $params->{modified} = $params->{created};
+ $params->{owner_id} = Bugzilla->user->id;
+ $params->{pending} = 1;
+
+ # Set default columns if not provided
+ if (!$params->{columns}) {
+ $params->{columns} = [33, 33, 33];
+ }
+
+ my @widgets = @{delete $params->{widgets} || []};
+ my $overlay = $class->SUPER::create($params);
+
+ # Create widgets if provided
+ foreach my $widget (@widgets) {
+ require Bugzilla::Extension::Dashboard::Widget;
+ $widget->{overlay_id} = $overlay->id;
+ $widget = Bugzilla::Extension::Dashboard::Widget->create($widget);
+ push(@{$overlay->{widgets}}, $widget);
+ }
+ return $overlay;
+}
+
+sub _update_modified_ts {
+ my $self = shift;
+ my $dbh = Bugzilla->dbh;
+ my $modified_ts = $dbh->selectrow_array(
+ 'SELECT LOCALTIMESTAMP(0)');
+ $dbh->do('UPDATE dashboard_overlays SET modified = ? WHERE id = ?',
+ undef, ($modified_ts, $self->id));
+ $self->{modified} = $modified_ts;
+}
+
+sub update {
+ my $self = shift;
+ my($changes, $old) = $self->SUPER::update(@_);
+ if (scalar(keys %$changes)) {
+ $self->_update_modified_ts();
+ }
+ return $changes;
+}
+
+sub user_is_owner {
+ my $self = shift;
+ my $user = Bugzilla->user;
+ return 0 unless defined $user;
+ return $user->id == $self->owner_id;
+}
+
+sub user_can_edit {
+ my $self = shift;
+ return $self->user_is_owner;
+}
+
+sub user_can_access {
+ my $self = shift;
+ return $self->user_is_owner || ($self->shared && !$self->pending)
+ || $self->user_can_publish;
+}
+
+sub user_can_publish {
+ my $self = shift;
+ my $user = Bugzilla->user;
+ return 0 unless defined $user;
+ return $self->shared && $user->in_group(
+ Bugzilla->params->{dashboard_publish_group});
+}
+
+1;
diff --git a/extensions/Dashboard/lib/Util.pm b/extensions/Dashboard/lib/Util.pm
new file mode 100644
index 0000000..02c80bf
--- /dev/null
+++ b/extensions/Dashboard/lib/Util.pm
@@ -0,0 +1,31 @@
+# -*- Mode: perl; indent-tabs-mode: nil -*-
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# Copyright (C) 2013 Jolla Ltd.
+# Contact: Pami Ketolainen <pami.ketolainen@jollamobile.com>
+
+package Bugzilla::Extension::Dashboard::Util;
+use strict;
+use warnings;
+use base qw(Exporter);
+
+our @EXPORT = qw(
+ user_can_access_dashboard
+);
+
+use Bugzilla;
+use Bugzilla::Error;
+
+sub user_can_access_dashboard {
+ my ($throwerror) = @_;
+ my $ingroup = Bugzilla->user->in_group(
+ Bugzilla->params->{dashboard_user_group});
+
+ ThrowUserError("dashboard_access_denied") if ($throwerror && !$ingroup);
+ return $ingroup;
+}
+
+1;
diff --git a/extensions/Dashboard/lib/WebService.pm b/extensions/Dashboard/lib/WebService.pm
new file mode 100644
index 0000000..eed7fad
--- /dev/null
+++ b/extensions/Dashboard/lib/WebService.pm
@@ -0,0 +1,376 @@
+# -*- Mode: perl; indent-tabs-mode: nil -*-
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# Copyright (C) 2012 Jolla Ltd.
+# Contact: Pami Ketolainen <pami.ketolainen@jollamobile.com>
+
+package Bugzilla::Extension::Dashboard::WebService;
+
+use strict;
+use warnings;
+
+use base qw(Bugzilla::WebService);
+
+use XML::Feed;
+
+use Bugzilla::Constants;
+use Bugzilla::Error;
+use Bugzilla::Util;
+
+use Bugzilla::Extension::Dashboard::Overlay;
+use Bugzilla::Extension::Dashboard::Util;
+use Bugzilla::Extension::Dashboard::Widget;
+
+use constant WIDGET_FIELDS => {
+ id => 'int',
+ name => 'string',
+ overlay_id => 'int',
+ type => 'string',
+ color => 'string',
+ col => 'int',
+ pos => 'int',
+ height => 'int',
+ minimized => 'boolean',
+ refresh => 'int',
+};
+
+use constant OVERLAY_FIELDS => {
+ id => 'int',
+ name => 'string',
+ description => 'string',
+ created => 'dateTime',
+ modified => 'dateTime',
+ shared => 'boolean',
+ pending => 'boolean',
+ workspace => 'boolean',
+ user_can_edit => 'boolean',
+ user_can_publish => 'boolean',
+};
+
+
+###################
+# Overlay methods #
+###################
+
+sub _get_overlay {
+ my ($self, $id, $edit, $publish) = @_;
+ my $overlay = Bugzilla::Extension::Dashboard::Overlay->new($id);
+ ThrowUserError('overlay_does_not_exist', { id => $id, class => 'Overlay' })
+ unless defined $overlay;
+ my $user = Bugzilla->user;
+ ThrowUserError("overlay_access_denied", {id => $id })
+ unless $overlay->user_can_access;
+ if ($edit) {
+ ThrowUserError("overlay_edit_denied", {id => $id })
+ unless $overlay->user_can_edit;
+ }
+ if ($publish) {
+ ThrowUserError("overlay_publish_denied", {id => $id })
+ unless $overlay->user_can_publish;
+ }
+ return $overlay;
+}
+
+sub overlay_save {
+ my ($self, $params) = @_;
+ my $user = Bugzilla->login(LOGIN_REQUIRED);
+ user_can_access_dashboard(1);
+
+ # Publishing only via overlay_publish()
+ delete $params->{pending};
+ # Delete other extra stuff
+ delete $params->{owner};
+ delete $params->{user_can_edit};
+ delete $params->{user_can_publish};
+
+
+ my $overlay;
+ my $changes = {};
+ if (defined $params->{id}) {
+ my $id = delete $params->{id};
+ # Existing overlay
+ $overlay = $self->_get_overlay($id, 1);
+ $overlay->set_all($params);
+ $changes = $overlay->update();
+ } else {
+ # New overlay
+ $overlay = Bugzilla::Extension::Dashboard::Overlay->create($params);
+ }
+ return {
+ overlay => $self->_overlay_to_hash($overlay),
+ changes => $changes };
+}
+
+sub overlay_get {
+ my ($self, $params) = @_;
+ my $user = Bugzilla->login(LOGIN_REQUIRED);
+ user_can_access_dashboard(1);
+
+ ThrowCodeError('param_required', {
+ function => 'Dashboard.overlay.get',
+ param => 'id'})
+ unless defined $params->{id};
+
+ my $overlay = $self->_get_overlay($params->{id});
+ return $self->_overlay_to_hash($overlay);
+}
+
+sub overlay_list {
+ my $self = shift;
+ my $user = Bugzilla->login(LOGIN_REQUIRED);
+ user_can_access_dashboard(1);
+
+ my @overlays;
+ my @matches;
+
+ # TODO Make this a single query to get the ids and use new_from_list()
+ #
+ # Shared overlays and the ones pending publishing if user is admin
+ if ($user->in_group('admin')) {
+ push(@matches, @{Bugzilla::Extension::Dashboard::Overlay->match({
+ shared => 1})});
+ } else {
+ push(@matches, @{Bugzilla::Extension::Dashboard::Overlay->match({
+ shared => 1, pending => 0})});
+ }
+ # Users own overlays
+ push(@matches, @{Bugzilla::Extension::Dashboard::Overlay->match({
+ owner_id => $user->id})});
+ # Remove duplicates
+ my %ids;
+ while (my $overlay = shift @matches) {
+ if (!defined $ids{$overlay->id}) {
+ push(@overlays, $overlay);
+ $ids{$overlay->id} = 1;
+ }
+ }
+ # No need to get widgets for the list
+ @overlays = map { $self->_overlay_to_hash($_, {widgets=>1}) } @overlays;
+ return \@overlays;
+}
+
+sub overlay_delete {
+ my ($self, $params) = @_;
+ Bugzilla->login(LOGIN_REQUIRED);
+ user_can_access_dashboard(1);
+
+ ThrowCodeError('param_required', {
+ function => 'Dashboard.overlay_delete',
+ param => 'id'})
+ unless defined $params->{id};
+
+ my $overlay = $self->_get_overlay($params->{id}, 1);
+ $overlay->remove_from_db();
+ return $self->overlay_list();
+}
+
+sub overlay_publish {
+ my ($self, $params) = @_;
+ my $user = Bugzilla->login(LOGIN_REQUIRED);
+ user_can_access_dashboard(1);
+
+ ThrowCodeError('param_required', {
+ function => 'Dashboard.overlay_publish',
+ param => 'id'})
+ unless defined $params->{id};
+
+ my $overlay = $self->_get_overlay($params->{id}, 0, 1);
+
+ my $pending = $params->{withhold} ? 1 : 0;
+
+ if ($overlay->shared) {
+ $overlay->set_pending($pending);
+ $overlay->update();
+ }
+ return $self->type('boolean', $overlay->pending);
+}
+
+##################
+# Widget methods #
+##################
+
+sub _get_widget {
+ my ($self, $id, $edit) = @_;
+ my $widget = Bugzilla::Extension::Dashboard::Widget->new($id);
+ ThrowUserError('widget_does_not_exist', { id => $id })
+ unless defined $widget;
+ if ($edit) {
+ ThrowUserError('widget_edit_denied', { id => $id })
+ unless $widget->user_can_edit;
+ }
+ return $widget;
+}
+
+
+sub widget_save {
+ my ($self, $params) = @_;
+ Bugzilla->login(LOGIN_REQUIRED);
+ user_can_access_dashboard(1);
+
+ ThrowCodeError('params_required', {
+ function => 'Dashboard.widget_save',
+ params => ['id', 'overlay_id'] })
+ unless defined $params->{id} || defined $params->{overlay_id};
+
+ my $widget;
+ my $changes = {};
+ if (defined $params->{id}) {
+ my $id = delete $params->{id};
+ $widget = $self->_get_widget($id, 1);
+ $widget->set_all($params);
+ $changes = $widget->update();
+ } else {
+ $widget = Bugzilla::Extension::Dashboard::Widget->create($params);
+ }
+ return {
+ widget => $self->_widget_to_hash($widget),
+ changes => $changes};
+}
+
+sub widget_get {
+ my ($self, $params) = @_;
+ Bugzilla->login(LOGIN_REQUIRED);
+ user_can_access_dashboard(1);
+
+ ThrowCodeError('param_required', {
+ function => 'Dashboard.widget_get',
+ param => 'id'})
+ unless defined $params->{id};
+ my $widget = $self->_get_widget($params->{id});
+ return $self->_widget_to_hash($widget);
+}
+
+sub widget_list {
+ my ($self, $params) = @_;
+ Bugzilla->login(LOGIN_REQUIRED);
+ user_can_access_dashboard(1);
+
+ ThrowCodeError('param_required', {
+ function => 'Dashboard.widget_list',
+ param => 'overlay_id'})
+ unless defined $params->{overlay_id};
+ my $overlay = $self->_get_overlay($params->{overlay_id});
+
+ my @widgets = map { $self->_widget_to_hash($_) } @{$overlay->widgets};
+ return \@widgets;
+}
+
+sub widget_delete {
+ my ($self, $params) = @_;
+ Bugzilla->login(LOGIN_REQUIRED);
+ user_can_access_dashboard(1);
+
+ ThrowCodeError('param_required', {
+ function => 'Dashboard.widget_delete',
+ param => 'id'})
+ unless defined $params->{id};
+ my $widget = $self->_get_widget($params->{id}, 1);
+ $widget->remove_from_db();
+ return $self->_widget_to_hash($widget);
+}
+
+###################
+# Private helpers #
+###################
+sub _overlay_to_hash {
+ my ($self, $overlay, $exclude) = @_;
+ my %result;
+ while (my ($field, $type) = each %{(OVERLAY_FIELDS)}) {
+ next if $exclude->{$field};
+ $result{$field} = $self->type($type, $overlay->$field);
+ }
+ # owner, columns and widgets are special cases
+ if (!$exclude->{owner}) {
+ $result{owner} = {
+ id => $self->type('int', $overlay->owner->id),
+ login => $self->type('string', $overlay->owner->login),
+ name => $self->type('string', $overlay->owner->name),
+ };
+ }
+ if (!$exclude->{columns}) {
+ my @columns;
+ foreach my $col (@{$overlay->columns}) {
+ push(@columns, $self->type('int', $col));
+ }
+ $result{columns} = \@columns;
+ }
+ if (!$exclude->{widgets}) {
+ my @widgets;
+ foreach my $widget (@{$overlay->widgets}) {
+ push(@widgets, $self->_widget_to_hash(
+ $widget, $exclude->{widget}));
+ }
+ $result{widgets} = \@widgets;
+ }
+ return \%result;
+}
+
+
+sub _widget_to_hash {
+ my ($self, $widget, $exclude) = @_;
+ my %result;
+ while (my ($field, $type) = each %{(WIDGET_FIELDS)}) {
+ next if $exclude->{$field};
+ $result{$field} = $self->type($type, $widget->$field);
+ }
+ $result{data} = $widget->data;
+ return \%result
+}
+
+
+# Fetch an RSS/ATOM feed at the given URL, 'url', returning a parsed and
+# normalized representation.
+sub get_feed {
+ my ($self, $params) = @_;
+ Bugzilla->login(LOGIN_REQUIRED);
+
+ my $browser = LWP::UserAgent->new();
+ my $proxy_url = Bugzilla->params->{'proxy_url'};
+ if ($proxy_url) {
+ $browser->proxy(['http'], $proxy_url);
+ } else {
+ $browser->env_proxy();
+ }
+
+ $browser->timeout(10);
+ my $response = $browser->get($params->{url});
+ if($response->code != 200) {
+ die $response->status_line;
+ }
+
+ my $feed = XML::Feed->parse(\($response->content))
+ or die XML::Feed->errstr;
+
+ sub _format_time {
+ my ($dt) = @_;
+ if($dt) {
+ return $dt->datetime;
+ }
+ return '';
+ }
+
+ sub _ascii {
+ my $s = shift || '';
+ $s =~ s/[^[:ascii:]]//g;
+ $s;
+ }
+
+ return {
+ title => _ascii($feed->title),
+ link => $feed->link,
+ description => _ascii($feed->description),
+ tagline => _ascii($feed->tagline),
+ items => [ map { {
+ title => _ascii($_->title),
+ link => $_->link,
+ description => _ascii($_->content->body),
+ modified => _format_time($_->modified)
+ } } $feed->items ]
+ };
+}
+
+
+1;
diff --git a/extensions/Dashboard/lib/Widget.pm b/extensions/Dashboard/lib/Widget.pm
new file mode 100644
index 0000000..0937e8b
--- /dev/null
+++ b/extensions/Dashboard/lib/Widget.pm
@@ -0,0 +1,167 @@
+# -*- Mode: perl; indent-tabs-mode: nil -*-
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# Copyright (C) 2012 Jolla Ltd.
+# Contact: Pami Ketolainen <pami.ketolainen@jollamobile.com>
+
+use strict;
+
+package Bugzilla::Extension::Dashboard::Widget;
+
+use base qw(Bugzilla::Object);
+
+use JSON;
+
+use constant DB_TABLE => 'dashboard_widgets';
+
+use constant DB_COLUMNS => qw(
+ id
+ name
+ overlay_id
+ type
+ color
+ col
+ pos
+ height
+ minimized
+ refresh
+ data
+);
+
+use constant REQUIRED_CREATE_FIELDS => qw(
+ name
+ type
+ overlay_id
+);
+
+use constant UPDATE_COLUMNS => qw(
+ name
+ color
+ col
+ pos
+ height
+ minimized
+ refresh
+ data
+);
+
+use constant NUMERIC_COLUMNS => qw(
+ overlay_id
+ col
+ pos
+ height
+ minimized
+ refresh
+);
+
+use constant VALIDATORS => {
+ minimized => \&Bugzilla::Object::check_boolean,
+};
+
+use constant LIST_ORDER => 'pos';
+use constant AUDIT_CREATES => 0;
+use constant AUDIT_UPDATES => 0;
+use constant AUDIT_REMOVES => 0;
+
+#############
+# Accessors #
+#############
+
+sub type { return $_[0]->{'type'}; }
+sub color { return $_[0]->{'color'}; }
+sub col { return $_[0]->{'col'}; }
+sub pos { return $_[0]->{'pos'}; }
+sub height { return $_[0]->{'height'}; }
+sub minimized { return $_[0]->{'minimized'}; }
+sub refresh { return $_[0]->{'refresh'}; }
+sub overlay_id { return $_[0]->{'overlay_id'}; }
+
+sub overlay {
+ my $self = shift;
+ $self->{'overlay'} ||= Bugzilla::Extension::Dashboard::Overlay->new(
+ $self->overlay_id);
+ return $self->{'overlay'};
+}
+
+sub data {
+ my $self = shift;
+ my $data = $self->{'data'};
+ $data = "null" unless defined $data;
+ $self->{'parsed_data'} ||= JSON->new->utf8->decode($data);
+ return $self->{'parsed_data'};
+}
+
+############
+# Mutators #
+############
+
+sub set_name { $_[0]->set('name', $_[1]); }
+sub set_type { $_[0]->set('type', $_[1]); }
+sub set_color { $_[0]->set('color', $_[1]); }
+sub set_col { $_[0]->set('col', $_[1]); }
+sub set_pos { $_[0]->set('pos', $_[1]); }
+sub set_height { $_[0]->set('height', $_[1]); }
+sub set_minimized { $_[0]->set('minimized', $_[1]); }
+sub set_refresh { $_[0]->set('refresh', $_[1]); }
+
+# These are here so that we can just pass nice hash to set_all
+sub set_overlay_id { }
+
+sub set_data {
+ my ($self, $data) = @_;
+ $self->set('data', JSON->new->utf8->encode($data));
+ $self->{'parsed_data'} = $data;
+}
+
+##############
+# Validators #
+##############
+
+
+###########
+# Methods #
+###########
+
+sub create {
+ my ($class, $params) = @_;
+ # set some defaults
+ $params->{color} ||= "grey";
+
+ my $data = delete $params->{data};
+ $data = {} unless defined $data;
+ if(ref($data)) {
+ $data = JSON->new->utf8->encode($data);
+ }
+ $params->{data} = $data;
+ return $class->SUPER::create($params);
+}
+
+sub update {
+ my $self = shift;
+ my($changes, $old) = $self->SUPER::update(@_);
+ if (scalar(keys %$changes)) {
+ $self->overlay->_update_modified_ts();
+ }
+ return $changes;
+}
+
+sub user_is_owner {
+ my $self = shift;
+ my $user = Bugzilla->user;
+ return 0 unless defined $user;
+ return $user->id == $self->overlay->owner_id;
+}
+
+sub user_can_access {
+ my $self = shift;
+ return $self->overlay->user_can_read;
+}
+
+sub user_can_edit {
+ my $self = shift;
+ return $self->user_is_owner;
+}
+1;
diff --git a/extensions/Dashboard/template/en/default/admin/params/dashboard.html.tmpl b/extensions/Dashboard/template/en/default/admin/params/dashboard.html.tmpl
new file mode 100644
index 0000000..8f757b6
--- /dev/null
+++ b/extensions/Dashboard/template/en/default/admin/params/dashboard.html.tmpl
@@ -0,0 +1,20 @@
+[%#
+ # This Source Code Form is subject to the terms of the Mozilla Public
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ #
+ # The Initial Developer of the Original Code is "Nokia Corporation"
+ # Portions created by the Initial Developer are Copyright (C) 2010 the
+ # Initial Developer. All Rights Reserved.
+ #
+ # Contributor(s):
+ # Jari Savolainen <ext-jari.a.savolainen@nokia.com>
+ #%]
+[%
+ title = "Dashboard"
+ desc = "Dashboard extension"
+%]
+[% param_descs = {} %]
+[% FOREACH param IN panel.param_list %]
+ [% param_descs.${param.name} = param.desc %]
+[% END %]
diff --git a/extensions/Dashboard/template/en/default/filterexceptions.pl b/extensions/Dashboard/template/en/default/filterexceptions.pl
new file mode 100644
index 0000000..b5b8690
--- /dev/null
+++ b/extensions/Dashboard/template/en/default/filterexceptions.pl
@@ -0,0 +1,13 @@
+# -*- Mode: perl; indent-tabs-mode: nil -*-
+
+%::safe = (
+
+'hook/global/user-error-errors.html.tmpl' => [
+ 'id',
+],
+
+'pages/dashboard.html.tmpl' => [
+ "dashboard_config || 'null'",
+],
+
+);
diff --git a/extensions/Dashboard/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/Dashboard/template/en/default/hook/global/user-error-errors.html.tmpl
new file mode 100644
index 0000000..eef6d6e
--- /dev/null
+++ b/extensions/Dashboard/template/en/default/hook/global/user-error-errors.html.tmpl
@@ -0,0 +1,37 @@
+[%# This Source Code Form is subject to the terms of the Mozilla Public
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ #
+ # Copyright (C) 2012 Jolla Ltd.
+ # Contact: Pami Ketolainen <pami.ketolainen@jollamobile.com>
+ #%]
+[%# Note that error messages should generally be indented four spaces, like
+ # below, because when Bugzilla translates an error message into plain
+ # text, it takes four spaces off the beginning of the lines.
+ #%]
+[% IF error == "dashboard_access_denied" %]
+ [% title = "Access denied" %]
+ You do not have permission to access dashboard features on this
+ [% terms.Bugzilla %] installation.
+[% ELSIF error == "overlay_does_not_exist" %]
+ [% title = "Does not exists" %]
+ Overlay with id [% id %] does not exist
+[% ELSIF error == "overlay_access_denied" %]
+ [% title = "Not allowed to access overlay" %]
+ You are not allowed to access overlay with ID [% id %].
+ You are not the owner or it is not shared.
+[% ELSIF error == "overlay_edit_denied" %]
+ [% title = "Not allowed to edit overlay" %]
+ You are not allowed to modify overlay with ID [% id %].
+ You are not the owner.
+[% ELSIF error == "overlay_publish_denied" %]
+ [% title = "Not allowed to publish overlay" %]
+ You are not authorized to publish this overlay
+[% ELSIF error == "widget_does_not_exist" %]
+ [% title = "Does not exists" %]
+ Widget with id [% id %] does not exist
+[% ELSIF error == "widget_edit_denied" %]
+ [% title = "Not allowed to edit widget" %]
+ You are not allowed to modify widget [% id %].
+ You are probably not the owner.
+[% END %]
diff --git a/extensions/Dashboard/template/en/default/pages/dashboard.html.tmpl b/extensions/Dashboard/template/en/default/pages/dashboard.html.tmpl
new file mode 100644
index 0000000..a1c7e9e
--- /dev/null
+++ b/extensions/Dashboard/template/en/default/pages/dashboard.html.tmpl
@@ -0,0 +1,385 @@
+[%#
+ # This Source Code Form is subject to the terms of the Mozilla Public
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ #
+ # The Initial Developer of the Original Code is "Nokia Corporation"
+ # Portions created by the Initial Developer are Copyright (C) 2010 the
+ # Initial Developer. All Rights Reserved.
+ #
+ # Contributor(s):
+ # David Wilson <ext-david.3.wilson@nokia.com>
+ # Stephen Jayna <ext-stephen.jayna@nokia.com>
+ # Allan Savolainen <ext-jari.a.savolainen@nokia.com>
+ # Pami Ketolainen <pami.ketolainen@jollamobile.com>
+ #%]
+
+[% USE Bugzilla %]
+
+[% PROCESS "global/field-descs.none.tmpl" %]
+[% field_descs.short_short_desc = "Summary (first 60 characters)" %]
+[% field_descs.short_desc = "Full Summary" %]
+[% field_descs.assigned_to_realname = "Assignee Realname" %]
+[% field_descs.reporter_realname = "Reporter Realname" %]
+[% field_descs.qa_contact_realname = "QA Contact Realname" %]
+
+
+[% header = BLOCK %]
+ Dashboard:
+ <a href="#" id="overlay-info">
+ <span class="name"></span>
+ <span class="workspace">(workspace)</span>
+ <span class="shared">(shared)</span>
+ <span class="pending">(pending)</span>
+ <span class="unsaved">(unsaved)</span>
+ </a><br/>
+ <span id="buttons">
+ &nbsp;
+ <button name="newoverlay" type="button">New Overlay</button>
+ <button name="openoverlay" type="button">Open Overlay</button>
+ <button name="saveoverlay" type="button">Save Overlay</button>
+ <button name="saveoverlayas" type="button">Save Overlay As...</button>
+ <button name="deleteoverlay" type="button">Delete Overlay</button>
+ <button name="publishoverlay" type="button">Publish Overlay</button>
+ <button name="withholdoverlay" type="button">Withhold Overlay</button>
+ &nbsp;&nbsp;
+ <button name="overlaysettings" type="button">Overlay Settings</button>
+ &nbsp;&nbsp;
+ <select name="widgettype" title="Add widget"
+ class="ui-widget ui-corner-all">
+ <option value="url">URL</option>
+ <option value="rss">RSS</option>
+ <option value="bugs">[% terms.Bug %] list</option>
+ <option value="mybugs">My [% terms.Bugs %]</option>
+ <option value="text">Text</option>
+ [% Hook.process("widget-type") %]
+ </select>
+ <button name="addwidget" type="button">Add widget</button>
+ &nbsp;&nbsp;
+ <button name="addcolumn" type="button">Add column</button>
+ <button name="removecolumn" type="button">Remove column</button>
+ <button name="resetcolumns" type="button">Reset column widths</button>
+ </span>
+
+ &nbsp;
+ <span id="dashboard_notify"></p>
+[% END %]
+
+[% javascript = BLOCK %]
+ var DASHBOARD_CONFIG = [% dashboard_config || 'null' %];
+ var BUGLIST_COLUMNS = {
+ [% FOREACH column = columns %]
+ "[% column FILTER js %]": "[% (field_descs.${column} || column) FILTER js %]",
+ [% END %]
+ };
+[% END %]
+
+[% PROCESS global/header.html.tmpl
+ title = terms.Bugzilla _ " Dashboard"
+ style_urls = [
+ "extensions/Dashboard/web/css/dashboard.css",
+ "extensions/Dashboard/web/css/colorbox.css",]
+
+ javascript_urls = [
+ "extensions/Dashboard/web/js/colResizable-1.3.min.js",
+ "extensions/Dashboard/web/js/jquery.colorbox-min.js",
+ "extensions/Dashboard/web/js/jquery.tablesorter-update.js",
+ "extensions/Dashboard/web/js/jquery.csv.min.js",
+ "extensions/Dashboard/web/js/dashboard.js",
+ "extensions/Dashboard/web/js/widgets.js"]
+%]
+
+<table id="overlay">
+<tr id="overlay-header"></tr>
+<tr>
+ <td id="overlay-top" class="overlay-column" valign="top"></td>
+</tr>
+<tr id="overlay-columns"></tr>
+</table>
+
+<br clear="both">
+
+
+<div style="display: none;"> <!-- hide templates -->
+ <div align="center" id="dash-template-loader" class="loader ui-corner-all">
+ </div>
+
+ <!-- Overlay templates -->
+
+ <div id="dash-template-browser_warning">
+ <p style="color: red">
+ Your web browser is not fully tested with the Dashboard, please use
+ Firefox if you experience problems.
+ </p>
+ </div>
+
+ <div id="dash-template-browser_block">
+ <h2 style="text-align: center">Your web browser is not compatible with
+ Dashboard; please use Firefox instead.</h2>
+ </div>
+
+ <div id="dash-template-welcome-text">
+<p>To begin, please load an existing overlay using the Open icon in the toolbar
+above, or alternatively you may clear the workspace and begin building a new
+overlay from scratch.</p>
+
+<h3>The dashboard toolbar buttons:</h3>
+<ul>
+ <li>New Overlay - Opens a new empty dashboard overlay</li>
+ <li>Open Overlay - Fetches the list of existing overlays and opens a dialog
+ where you can open one of them.</li>
+ <li>Save Overlay - Saves the changes in current overlay. If this is a new
+ overlay, it will pop up a dialog for entering the overlay name and
+ description.</li>
+ <li>Save Overlay As... - Allows saving an existing overlay as a new
+ instance.</li>
+ <li>Delete Overlay - Deletes the currently opened overlay. This is only
+ visible if the overlay has been saved and you are the owner of the overlay.
+ </li>
+ <li>Publish Overlay - Publishes the shared overlay that is pending approval.
+ This is only visible to admins.</li>
+ <li>Overlay Settings - Opens up a dialog where you can change overlay name,
+ description and shared status.</li>
+ <br />
+ <li>Add Widget select box and button - Allows you to create a new widget of
+ the selected type.</li>
+ <br />
+ <li>Add column - Adds a new column to the overlay on the right.</li>
+ <li>Remove column - Removes the rightmost column from the overlay. Any
+ widgets the will be moved to the column before that.</li>
+ <li>Reset column widths - Makes the columns equaly spaced.</li>
+</ul>
+
+<h3>The overlay</h3>
+<p>The overlay is laid out in columns which each can contain one or more
+widgets. There is a top 'column' which spans the whole page width and below
+that, one or more (up to 4) columns side by side. The lower column widths can
+be changed by dragging from the column edge or resize handles at the top.</p>
+
+<p>Overlays can be saved as shared, so that other user can view it. When
+overlay is set as shared is it not visible to others than admin users, until
+admin approves and publishes the overlay.</p>
+
+<h3>Widgets</h3>
+<p>Widgets can be organized in overlay columns using drag and drop. The column
+width determines the widget width, but height can be adjusted by dragging the
+bottom edge of the widget.</p>
+
+<p>Widget title bar contains buttons for
+<ul>
+ <li>Refreshing the widget content.</li>
+ <li>Opening the widget settings dialog.</li>
+ <li>Removing the widget.</li>
+ <li>Minimizing the widget, so that only the title bar is wisible.</li>
+ <li>And maximizing the widget to full browser window.</li>
+</ul>
+</p>
+</div>
+
+ <div id="overlay-open" title="Load Overlay" class="settings-dialog">
+ <b>Shared overlays</b>
+ <ul class="overlay-list shared" width="100%"></ul>
+ <div class="pending-list">
+ <b>Pending overlays</b>
+ <ul class="overlay-list pending" width="100%"></ul>
+ </div>
+ <b>My overlays</b>
+ <ul class="overlay-list owner" width="100%"></ul>
+
+ </div>
+
+ <li id="template-overlay-entry" class="overlay-entry">
+ <div>
+ <div>
+ <button class="name" type="button"></button>
+ <button type="button">Details</button>
+ </div>
+ <div class="details">
+ <div class="states">
+ <span class="workspace">(workspace)</span>
+ <span class="shared">(shared)</span>
+ <span class="pending">(pending)</span>
+ </div>
+ <div class="description"></div>
+ <span class="modified"></span> - <span class="owner"></span>
+ </div>
+ </div>
+ <div>
+ </div>
+ </li>
+
+ <div id="overlay-settings" title="Overlay settings" class="settings-dialog">
+ <form>
+ <fieldset name="common">
+ <legend>Overlay</legend>
+ <ul class="settings">
+ <li>
+ <label>Name:</label>
+ <input name="name" class="settings-field"/>
+ </li>
+ <li>
+ <label>Description:</label>
+ <input name="description" class="settings-field" />
+ </li>
+ <li>
+ <label>Shared:</label>
+ <input name="shared" type="checkbox" class="settings-field" />
+ </li>
+ <!--li>
+ <label>Workspace:</label>
+ <input name="workspace" type="checkbox" class="settings-field" />
+ </li-->
+ </ul>
+ </fieldset>
+ </form>
+ </div>
+
+
+ <!-- Widget templates -->
+
+ <div class="widget ui-widget ui-corner-all" id="widget-template">
+ <div class="widget-header ui-widget-header ui-corner-top">
+ <span class="widget-title"></span>
+ <span class="widget-buttons">
+ <button name="refresh" title="Refresh" type="button">R</button>
+ <button name="edit" title="Edit" type="button">E</button>
+ <button name="remove" title="Remove" type="button">X</button>
+ <button name="minimize" title="Minimize" type="button">-</button>
+ <button name="maximize" title="Maximize" type="button">+</button>
+ </span>
+ </div>
+ <div class="settings-dialog" title="Widget settings" style="display:none;">
+ <form>
+ <fieldset name="common">
+ <legend>Widget</legend>
+ <li>
+ <label>Title:</label>
+ <input name="name" class="settings-field"/>
+ </li>
+ <li>
+ <label>Color:</label>
+ <select name="color" class="settings-field">
+ </select>
+ </li>
+ <li>
+ <label>Reload:</label>
+ <select name="refresh" class="settings-field">
+ <option value="0">no refresh</option>
+ <option value="15">every 15 seconds</option>
+ <option value="60">every minute</option>
+ <option value="300">every 5 minutes</option>
+ <option value="900">every 15 minutes</option>
+ <option value="1800">every 30 minutes</option>
+ </select>
+ </li>
+ </fieldset>
+ </form>
+ </div>
+ <div class="widget-container ui-widget-content ui-corner-bottom">
+ <div class="widget-status"></div>
+ <div class="widget-content"></div>
+ </div>
+ </div>
+
+ <p class="ui-state-error" id="widget-error-template">
+ Error occured: <span class="error-text"></span>
+ </p>
+
+ <!-- Text widget -->
+ <div class="text" id="widget-template-text">
+ </div>
+
+ <fieldset id="widget-settings-template-text">
+ <legend>Text</legend>
+ <textarea style="width: 100%; height: 10em;" name="text"
+ class="custom-field"></textarea>
+ </fieldset>
+
+ <!-- URL widget -->
+ <iframe id="widget-template-url" target="new" class="widget_iframe"
+ frameborder="0" scrolling="auto">
+ </iframe>
+
+ <fieldset id="widget-settings-template-url">
+ <legend>URL options</legend>
+ <li>
+ <label>URL:</label>
+ <input name="url" class="custom-field"/>
+ </li>
+
+ <li>
+ <label>Selector:</label>
+ <input name="selector" class="custom-field"/>
+ </li>
+ <li>
+ <small>Supported syntax:
+ <a target="_new" href="http://www.w3.org/TR/CSS2/selector.html#pattern-matching">CSS</a>,
+ <a target="_new" href="http://api.jquery.com/category/selectors/">jQuery</a>
+ </small>
+ </li>
+
+ <li>
+ <button type="button" name="loadurl">Load URL</button>
+ </li>
+ </fieldset>
+
+ <!-- RSS widget -->
+ <fieldset id="widget-settings-template-rss">
+ <legend>RSS options</legend>
+ <li>
+ <label>URL:</label>
+ <input name="url" class="custom-field"/>
+ </li>
+ <li>
+ <label>Username:</label>
+ <input name="username" class="custom-field"/>
+ </li>
+ <li>
+ <label>Password:</label>
+ <input type="password" name="password" class="custom-field"/>
+ </li>
+ </fieldset>
+
+ <div id="widget-template-rss" class="rss">
+ <h2><a target="_new"></a></h2>
+ <div class="feed-items"></div>
+ </div>
+
+ <div id="rss-template-item">
+ <div class="rss-title">
+ <h3><a target="_new"></a></h3>
+ </div>
+ <div class="updated">
+ <p>
+ Updated: <span class="updated-text"></span>
+ </p>
+ </div>
+ <div class="description-text">
+ </div>
+ </div>
+
+ <!-- Bugz widget -->
+ <fieldset id="widget-settings-template-bugs">
+ <legend>[% terms.Bug %] list options</legend>
+ <li class="buglist-query-entry">
+ <label>Query string:</label>
+ <input name="query" type="text" class="custom-field" /><br />
+ <input name="editquery" type="button" value="Edit query" />
+ </li>
+ <li>
+ <label>Columns:</label><br/>
+ <ul class="buglist-column-select"></ul>
+ </li>
+ </fieldset>
+
+ <select id="buglist-sort-template">
+ <option value="-1">---</option>
+ <option value="0">Asc</option>
+ <option value="1">Desc</option>
+ </select>
+
+ [% Hook.process("widget-template") %]
+
+</div> <!-- /hide templates -->
+
+[% PROCESS global/footer.html.tmpl %]
diff --git a/extensions/Dashboard/tools/client_example.sh b/extensions/Dashboard/tools/client_example.sh
new file mode 100644
index 0000000..f23b50f
--- /dev/null
+++ b/extensions/Dashboard/tools/client_example.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+
+# Example script for cloning an overlay and modifying some widget parameters.
+
+ARGS=--url=https://url.to/bugzilla/xmlrpc.cgi
+ARGS="$ARGS --username=your.bugzilla.username@your-company.com"
+ARGS="$ARGS --password=myPassword"
+ARGS="$ARGS --http_username=some.other.username"
+ARGS="$ARGS --quiet"
+
+call() {
+ ./dashboard_client.py $ARGS "$@"
+}
+
+# Stop on errors.
+set -e
+
+# Remove any existing widgets from the workspace.
+call clear_workspace
+
+# Load some predefined overlay.
+call load_overlay id=54 user_id=0
+
+# Change the color of widget 8 to yellow (use get_preferences for widget IDs).
+call save_widget id=8 color=yellow
+
+# Save as a new overlay.
+call save_overlay shared=1 name='test123' # description='my awesome overlay'
+
+# Create some new widget.
+# call save_widget type=rss URL=http://reddit.com/.rss col=1 pos=4
diff --git a/extensions/Dashboard/tools/dashboard_client.py b/extensions/Dashboard/tools/dashboard_client.py
new file mode 100644
index 0000000..4a0f383
--- /dev/null
+++ b/extensions/Dashboard/tools/dashboard_client.py
@@ -0,0 +1,312 @@
+#!/usr/bin/env python
+
+"""
+Simplistic client for talking to BAYOT Dashboard via XML-RPC.
+"""
+
+import commands
+import json
+import optparse
+import pprint
+import sys
+import urlparse
+import xmlrpclib
+
+
+class HttpAuthMixin(object):
+ """Mix-in class that provides HTTP Authorization header for
+ xmlrpclib.Transport or xmlrpclib.SafeTransport. This is required since
+ urllib fails to properly parse credentials embedded in the URL in 2 cases:
+ * If the password contains a slash.
+ * If a proxy is configured (it passes the full URL through to the
+ proxy, rather than synthesizing an Authorization: header).
+ """
+ _use_datetime = False
+ USERNAME = None
+ PASSWORD = None
+
+ def send_request(self, connection, handler, request_body):
+ connection.putrequest("POST", handler)
+ if not (self.USERNAME and self.PASSWORD):
+ return
+
+ s = ('%s:%s' % (self.USERNAME, self.PASSWORD)).encode('base64').strip()
+ connection.putheader('Authorization', 'Basic %s' % s)
+
+
+def make_transport(url, username, password):
+ """Return an xmlrpclib Transport instance suitable for communicating with
+ `url`.
+ """
+ parsed = urlparse.urlparse(url)
+ if parsed.scheme == 'https':
+ base = xmlrpclib.SafeTransport
+ else:
+ base = xmlrpclib.Transport
+
+ klass = type('Transport', (HttpAuthMixin, base), dict(
+ USERNAME=username,
+ PASSWORD=password
+ ))
+ return klass()
+
+
+def escape(s):
+ """Return the string `s` escaped for use on a shell command line.
+ """
+ return commands.mkarg(s).strip()
+
+
+def shell_format(out, name, obj):
+ """Given a dict, list, or simple value `obj`, format it into a set of UNIX
+ shell variable assignments written to file object `out`, using `name` as
+ the base variable name.
+ """
+ line = lambda s, *args: out.write((s % args) + '\n')
+
+ if isinstance(obj, dict):
+ shell_format(out, '%s_keys' % name,
+ ' '.join(sorted('%s_%s' % (name, k) for k in obj)))
+ for key, value in obj.iteritems():
+ shell_format(out, '%s_%s' % (name, key), value)
+ elif isinstance(obj, list):
+ shell_format(out, '%s_length' % name, len(obj))
+ for idx, value in enumerate(obj):
+ shell_format(out, '%s_%s' % (name, idx), value)
+ else:
+ line('%s=%s', name, escape(str(obj)))
+
+
+def pretty_format(out, name, obj):
+ """Use Python pprint to output `obj` in a human-readable form to `out`.
+ """
+ pprint.pprint(obj, stream=out)
+
+
+def json_format(out, name, obj):
+ """Use Python json module to output `obj` as JSON to `out`.
+ """
+ json.dump(obj, out)
+
+
+WIDGET_FIELDS = [
+ ('col', int, None, False),
+ ('color', unicode, None, False),
+ ('collapsible', bool, True, False),
+ ('controls', bool, True, False),
+ ('editable', bool, True, False),
+ ('height', int, None, False),
+ ('id', int, None, True),
+ ('maximizable', bool, True, False),
+ ('minimized', bool, False, False),
+ ('movable', bool, True, False),
+ ('pos', int, None, False),
+ ('refreshable', bool, True, False),
+ ('refresh', int, None, False),
+ ('removable', bool, True, False),
+ ('resizable', bool, True, False),
+ ('title', unicode, None, False),
+ ('type', unicode, None, False),
+ ('URL', unicode, None, False),
+]
+
+API = {
+ 'clear_workspace': {},
+ 'add_column': {},
+ 'delete_column': {},
+ 'load_overlay': {
+ 'params': [
+ ('user_id', int, None, True),
+ ('id', int, None, True)
+ ]
+ },
+ 'save_overlay': {
+ 'params': [
+ ('shared', bool, True, False),
+ ('name', unicode, None, True),
+ ('description', unicode, None, False)
+ ]
+ },
+ 'publish_overlay': {
+ 'params': [
+ ('user_id', int, None, True),
+ ('id', int, None, True)
+ ]
+ },
+ 'new_widget': {
+ 'params': WIDGET_FIELDS
+ },
+ 'save_widget': {
+ 'params': WIDGET_FIELDS
+ },
+ 'delete_overlay': {
+ 'params': [
+ ('user_id', int, None, True),
+ ('id', int, None, True)
+ ]
+ },
+ 'get_overlays': {
+ },
+ 'get_preferences': {
+ },
+ 'get_feed': {
+ 'params': [
+ ('url', str, None, True)
+ ]
+ }
+}
+
+
+def make_option_parser():
+ """Build an OptionParser, for printing a usage message or parsing the
+ command line.
+ """
+ parser = optparse.OptionParser()
+
+ def add(opt, help, default=None, **kwargs):
+ help += ' (default: %r)' % default
+ parser.add_option(opt, help=help, default=default, **kwargs)
+
+ add('--url', default='http://localhost:8011/xmlrpc.cgi',
+ help='URL to Bugzilla xmlrpc.cgi.')
+ add('--username', help='Username for Bugzilla account.')
+ add('--password', help='Password for Bugzilla account.')
+ add('--http_username', help='Optional username for HTTP authentication.')
+ add('--http_password', help='Optional password for HTTP authentication.')
+ add('--format', help='Output format; one of "json", "shell", or "pretty"',
+ default='pretty', choices=('json', 'shell', 'pretty'))
+ add('--quiet', help='Don\'t print operation result.', action='store_true',
+ default=False)
+ return parser
+
+
+def parse_options():
+ """Parse command-line arguments, printing a usage message on failure.
+ """
+ parser = make_option_parser()
+ opts, args = parser.parse_args()
+ if not opts.http_username:
+ opts.http_username = opts.username
+ if not opts.http_password:
+ opts.http_password = opts.password
+ return opts, args
+
+
+def usage(msg=None):
+ """Print a program usage message, optionally appending `msg` as an error
+ message.
+ """
+ parser = make_option_parser()
+ parser.usage = '%prog [options] <action> [args...]'
+ parser.print_help()
+
+ print
+ print 'An argument is a single key=value pair.'
+ print
+ print 'Example:'
+ print ' %s save_overlay overlay_shared=true overlay_name=test' %\
+ sys.argv[0]
+ print
+ print '<action> is one of:'
+ print
+ for name, spec in sorted(API.iteritems()):
+ print ' %s:' % name
+ params = spec.get('params', [])
+ if params:
+ for name, typ, default, required in params:
+ print ' %s (default: %s)' % (name, default)
+ else:
+ print ' (no parameters)'
+ print
+
+ if msg:
+ print 'ERROR:', msg
+ sys.exit(1)
+
+
+FALSE = ('0', 'false', 'no')
+TRUE = ('1', 'true', 'yes')
+
+def to_bool(s):
+ """Convert the string `s` to a boolean.
+ """
+ s = s.lower().strip()
+ if s in TRUE:
+ return True
+ elif s in FALSE:
+ return False
+ raise ValueError('%r is not a valid boolean value' % s)
+
+
+def parse_action_args(args, name, params):
+ """Given an argument list `args`, split up and convert "key=value" pairs,
+ returning a dictionary suitable for calling a Dashboard web service method
+ named `name`. `params` is the value of `name` in the `API` global variable.
+ """
+ name_map = dict((p[0], p[1:]) for p in params)
+ kwargs = {}
+
+ for arg in args:
+ try:
+ key, value = arg.split('=', 1)
+ except ValueError:
+ usage('Argument %r is not in key=value format.' % arg)
+
+ try:
+ typ, default, required = name_map[key]
+ except KeyError:
+ usage('Action %r does not take %r argument.' % (name, key))
+
+ if typ is int:
+ value = int(value)
+ elif typ is bool:
+ value = to_bool(value)
+ kwargs[key] = value
+
+ for arg, typ, default, required in params:
+ if required and arg not in kwargs:
+ usage('Action %r requires %r argument.' % (name, arg))
+
+ return kwargs
+
+
+def main():
+ """Main program implementation.
+ """
+ options, args = parse_options()
+
+ transport = make_transport(options.url,
+ options.http_username,
+ options.http_password)
+ server = xmlrpclib.ServerProxy(options.url, transport=transport)
+
+ if len(args) < 1:
+ usage('Must specify action')
+
+ action = args.pop(0)
+ if action not in API:
+ usage('Invalid action')
+
+ kwargs = parse_action_args(args,
+ action, API[action].get('params', {}))
+ kwargs['Bugzilla_login'] = options.username
+ kwargs['Bugzilla_password'] = options.password
+
+ try:
+ result = getattr(server.Dashboard, action)(kwargs)
+ except xmlrpclib.Fault, e:
+ print e.faultString.strip()
+ print
+ return 1
+
+ if options.quiet:
+ return
+ elif options.format == 'pretty':
+ pretty_format(sys.stdout, action, result)
+ elif options.format == 'json':
+ json_format(sys.stdout, action, result)
+ elif options.format == 'shell':
+ shell_format(sys.stdout, action, result)
+
+if __name__ == '__main__':
+ main()
diff --git a/extensions/Dashboard/web/css/colorbox.css b/extensions/Dashboard/web/css/colorbox.css
new file mode 100644
index 0000000..5610986
--- /dev/null
+++ b/extensions/Dashboard/web/css/colorbox.css
@@ -0,0 +1,60 @@
+/*
+ ColorBox Core Style
+ The following rules are the styles that are consistant between themes.
+ Avoid changing this area to maintain compatability with future versions of ColorBox.
+*/
+#colorbox, #cboxOverlay, #cboxWrapper{position:absolute; top:0; left:0; z-index:100000; overflow:hidden;}
+#cboxOverlay{position:fixed; width:100%; height:100%;}
+#cboxMiddleLeft, #cboxBottomLeft{clear:left;}
+#cboxContent{position:relative; overflow:hidden;}
+#cboxLoadedContent{overflow:auto;}
+#cboxLoadedContent iframe{display:block; width:100%; height:100%; border:0;}
+#cboxTitle{margin:0;}
+#cboxLoadingOverlay, #cboxLoadingGraphic{position:absolute; top:0; left:0; width:100%;}
+#cboxPrevious, #cboxNext, #cboxClose, #cboxSlideshow{cursor:pointer;}
+
+/*
+ ColorBox example user style
+ The following rules are ordered and tabbed in a way that represents the
+ order/nesting of the generated HTML, so that the structure easier to understand.
+*/
+#cboxOverlay{background:#fff;}
+
+#colorBox{}
+ #cboxTopLeft{width:25px; height:25px; background:url(images/border1.png) 0 0 no-repeat;}
+ #cboxTopCenter{height:25px; background:url(images/border1.png) 0 -50px repeat-x;}
+ #cboxTopRight{width:25px; height:25px; background:url(images/border1.png) -25px 0 no-repeat;}
+ #cboxBottomLeft{width:25px; height:25px; background:url(images/border1.png) 0 -25px no-repeat;}
+ #cboxBottomCenter{height:25px; background:url(images/border1.png) 0 -75px repeat-x;}
+ #cboxBottomRight{width:25px; height:25px; background:url(images/border1.png) -25px -25px no-repeat;}
+ #cboxMiddleLeft{width:25px; background:url(images/border2.png) 0 0 repeat-y;}
+ #cboxMiddleRight{width:25px; background:url(images/border2.png) -25px 0 repeat-y;}
+ #cboxContent{background:#fff;}
+ #cboxLoadedContent{margin-bottom:20px;}
+ #cboxTitle{position:absolute; bottom:0px; left:0; text-align:center; width:100%; color:#999;}
+ #cboxCurrent{position:absolute; bottom:0px; left:100px; color:#999;}
+ #cboxSlideshow{position:absolute; bottom:0px; right:42px; color:#444;}
+ #cboxPrevious{position:absolute; bottom:0px; left:0; color:#444;}
+ #cboxNext{position:absolute; bottom:0px; left:63px; color:#444;}
+ #cboxLoadingOverlay{background:url(images/loading.gif) 5px 5px no-repeat #fff;}
+ #cboxClose{position:absolute; bottom:0; right:0; display:block; color:#444;}
+
+/*
+ The following fixes png-transparency for IE6.
+ It is also necessary for png-transparency in IE7 & IE8 to avoid 'black halos' with the fade transition
+
+ Since this method does not support CSS background-positioning, it is incompatible with CSS sprites.
+ Colorbox preloads navigation hover classes to account for this.
+
+ !! Important Note: AlphaImageLoader src paths are relative to the HTML document,
+ while regular CSS background images are relative to the CSS document.
+*/
+.acboxIE #cboxTopLeft{background:transparent; filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src=images/internet_explorer/borderTopLeft.png, sizingMethod='scale');}
+.acboxIE #cboxTopCenter{background:transparent; filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src=images/internet_explorer/borderTopCenter.png, sizingMethod='scale');}
+.acboxIE #cboxTopRight{background:transparent; filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src=images/internet_explorer/borderTopRight.png, sizingMethod='scale');}
+.acboxIE #cboxBottomLeft{background:transparent; filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src=images/internet_explorer/borderBottomLeft.png, sizingMethod='scale');}
+.acboxIE #cboxBottomCenter{background:transparent; filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src=images/internet_explorer/borderBottomCenter.png, sizingMethod='scale');}
+.acboxIE #cboxBottomRight{background:transparent; filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src=images/internet_explorer/borderBottomRight.png, sizingMethod='scale');}
+.acboxIE #cboxMiddleLeft{background:transparent; filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src=images/internet_explorer/borderMiddleLeft.png, sizingMethod='scale');}
+.acboxIE #cboxMiddleRight{background:transparent; filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src=images/internet_explorer/borderMiddleRight.png, sizingMethod='scale');}
+
diff --git a/extensions/Dashboard/web/css/dashboard.css b/extensions/Dashboard/web/css/dashboard.css
new file mode 100644
index 0000000..dce0c7d
--- /dev/null
+++ b/extensions/Dashboard/web/css/dashboard.css
@@ -0,0 +1,293 @@
+#overlay {
+ width: 100%;
+}
+
+#overlay-top{
+ height: 20px;
+}
+
+#overlay-header .resize-guide {
+ background: url(images/sizer_mid.png) repeat-x;
+ height:16px;
+ position: relative;
+ content: "";
+}
+
+#overlay-header .resize-guide:after {
+ background: url(images/sizer_left.png) no-repeat;
+ height:16px;
+ width:16px;
+ position: absolute;
+ top: 0px;
+ left: 0px;
+ content: "";
+}
+
+#overlay-header .resize-guide:before {
+ background: url(images/sizer_right.png) no-repeat;
+ height:16px;
+ width:16px;
+ position: absolute;
+ top: 0px;
+ right: 0px;
+ content: "";
+}
+
+#overlay-columns .overlay-column {
+ height: 200px;
+}
+
+.overlay-column {
+ border: 1px solid transparent;
+ vertical-align: top;
+}
+.overlay-column-hint {
+ border: 1px dotted gray;
+ border-radius: 5px;
+}
+
+#overlay-info .unsaved {
+ color: red;
+ display: none;
+}
+
+.overlay-entry .details {
+ display: none;
+}
+.overlay-entry .states {
+ font-size: small;
+}
+.overlay-entry .states span {
+ display: none;
+}
+.overlay-entry .owner {
+ font-size: small;
+ font-style: italic;
+}
+.overlay-entry .modified {
+ font-size: small;
+ font-style: italic;
+}
+.overlay-entry .name {
+ width: 80%;
+ text-align: left;
+}
+
+.loader {
+ background: url(images/ajax-loader.gif) repeat-x;
+}
+
+div.widget {
+ padding: 2px;
+}
+
+.widget-container {
+ padding: 1px;
+}
+
+.widget-resize-helper {
+ border: dashed 2px gray;
+}
+
+.widget-content {
+ overflow: auto;
+ margin: 0px;
+ padding 0px;
+ background: white;
+ color: #000;
+ height: 100%;
+ width: 100%;
+}
+
+.settings-dialog li {
+ list-style-type: none;
+}
+
+.settings-dialog label {
+ width: 100px;
+ float: left;
+}
+
+.widget-header {
+ white-space: nowrap;
+ overflow: hidden;
+ cursor: move;
+}
+
+.widget-title {
+ line-height: 1.5em;
+ float: left;
+}
+
+.widget-buttons {
+ float: right;
+}
+
+.widget-buttons button {
+ width: 1em;
+ height: 1em;
+ vertical-align: middle;
+}
+
+#buttons button {
+ width: 1.5em;
+ height: 1.5em;
+ vertical-align: bottom;
+}
+#buttons select {
+ height: 1.5em;
+ vertical-align: bottom;
+}
+
+.ui-icon.icon-newoverlay {
+ /* jQuery UI document */
+ background-position: -32px -96px;
+}
+.ui-icon.icon-saveoverlay {
+ /* jQuery UI disk */
+ background-position: -96px -112px;
+}
+.ui-icon.icon-saveoverlayas {
+ /* jQuery UI disk */
+ background-position: -96px -112px;
+}
+.ui-icon.icon-openoverlay {
+ /* jQuery UI folder open */
+ background-position: -16px -96px;
+}
+.ui-icon.icon-deleteoverlay {
+ /* jQuery UI trash */
+ background-position: -176px -96px;
+}
+.ui-icon.icon-publishoverlay {
+ /* jQuery UI check */
+ background-position: -64px -144px;
+}
+.ui-icon.icon-withholdoverlay {
+ /* jQuery UI circle-close */
+ background-position: -32px -192px;
+}
+.ui-icon.icon-overlaysettings {
+ /* jQuery UI wrench */
+ background-position: -176px -112px;
+}
+
+.ui-icon.icon-addwidget {
+ /* jQuery UI plus */
+ background-position: -16px -128px;
+}
+
+/* TODO: draw matching icons which describe better the column actions */
+.ui-icon.icon-addcolumn {
+ background-position: -208px -32px;
+}
+.ui-icon.icon-removecolumn {
+ background-position: -240px -32px;
+}
+.ui-icon.icon-resetcolumns {
+ background-position: -160px -32px;
+}
+
+.widget-header-maximized {
+ padding: 4px;
+ text-align: center;
+}
+.widget-max {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ z-index: 9990;
+ background: #fff;
+}
+
+.url-widget .widget-content {
+ overflow-y: hidden;
+}
+
+.widget_iframe {
+ border: 0;
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+}
+
+.widget .rss h1,
+.widget .rss h2,
+.widget .rss h3,
+.widget .rss p {
+ color: #000;
+}
+
+.widget .rss h2 a {
+ border: 1px solid #ccc;
+ display: block;
+ padding: 5px;
+}
+
+.widget .rss_title {
+ border-left: 1px solid #ccc;
+ border-right: 1px solid #ccc;
+ border-bottom: 1px dotted #ccc;
+ display: block;
+ padding: 5px;
+}
+
+.widget .rss div {
+ border-left: 1px solid #ccc;
+ border-right: 1px solid #ccc;
+ border-bottom: 1px solid #ccc;
+ display: block;
+ padding: 5px;
+}
+
+.widget .rss a.open_popup {
+ width: 15px;
+ height: 15px;
+ margin-left: 5px;
+ float: right;
+ background: url(images/icon_rss_popup.png) center left no-repeat;
+}
+
+.rss {
+ font-size: smaller;
+}
+
+.widget .rss a:hover.open_popup {
+ background: url(images/icon_rss_popup_b.png) center left no-repeat;
+}
+
+.widget .rss a.open_link {
+ width: 15px;
+ height: 15px;
+ margin-left: 5px;
+ float: right;
+ background: url(images/icon_rss_link.png) center left no-repeat;
+}
+
+.widget .rss a:hover.open_link {
+ background: url(images/icon_rss_link_b.png) center left no-repeat;
+}
+
+.widget table.buglist {
+ width: 100%;
+}
+.widget table.buglist th .ui-icon {
+ float: right;
+}
+.widget table.buglist th {
+ white-space: nowrap;
+}
+.widget table.buglist td {
+ border: dotted 1px;
+}
+
+.settings-dialog .buglist-column-select select {
+ right: 0px;
+ position: absolute;
+ margin-right: 10%;
+}
diff --git a/extensions/Dashboard/web/css/images/ajax-loader.gif b/extensions/Dashboard/web/css/images/ajax-loader.gif
new file mode 100644
index 0000000..cb59a04
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/ajax-loader.gif
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/bg_gradient.png b/extensions/Dashboard/web/css/images/bg_gradient.png
new file mode 100644
index 0000000..e39598b
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/bg_gradient.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/border1.png b/extensions/Dashboard/web/css/images/border1.png
new file mode 100644
index 0000000..2d0a04d
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/border1.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/border2.png b/extensions/Dashboard/web/css/images/border2.png
new file mode 100644
index 0000000..be02ef4
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/border2.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/green.png b/extensions/Dashboard/web/css/images/green.png
new file mode 100644
index 0000000..861d8e7
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/green.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/green_b.png b/extensions/Dashboard/web/css/images/green_b.png
new file mode 100644
index 0000000..59401f9
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/green_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_add_column.png b/extensions/Dashboard/web/css/images/icon_add_column.png
new file mode 100644
index 0000000..a3d21f8
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_add_column.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_add_column_b.png b/extensions/Dashboard/web/css/images/icon_add_column_b.png
new file mode 100644
index 0000000..64ef6fd
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_add_column_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_add_rss.png b/extensions/Dashboard/web/css/images/icon_add_rss.png
new file mode 100644
index 0000000..26a85d0
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_add_rss.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_add_rss_b.png b/extensions/Dashboard/web/css/images/icon_add_rss_b.png
new file mode 100644
index 0000000..adc8420
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_add_rss_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_clear_workspace.png b/extensions/Dashboard/web/css/images/icon_clear_workspace.png
new file mode 100644
index 0000000..8b35bd5
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_clear_workspace.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_clear_workspace_b.png b/extensions/Dashboard/web/css/images/icon_clear_workspace_b.png
new file mode 100644
index 0000000..34d791c
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_clear_workspace_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_del_column.png b/extensions/Dashboard/web/css/images/icon_del_column.png
new file mode 100644
index 0000000..37f853e
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_del_column.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_del_column_b.png b/extensions/Dashboard/web/css/images/icon_del_column_b.png
new file mode 100644
index 0000000..35c74f1
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_del_column_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_new_bugs.png b/extensions/Dashboard/web/css/images/icon_new_bugs.png
new file mode 100644
index 0000000..e7ffe2f
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_new_bugs.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_new_bugs_b.png b/extensions/Dashboard/web/css/images/icon_new_bugs_b.png
new file mode 100644
index 0000000..5e2218c
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_new_bugs_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_new_rss.png b/extensions/Dashboard/web/css/images/icon_new_rss.png
new file mode 100644
index 0000000..26a85d0
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_new_rss.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_new_rss_b.png b/extensions/Dashboard/web/css/images/icon_new_rss_b.png
new file mode 100644
index 0000000..adc8420
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_new_rss_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_new_text.png b/extensions/Dashboard/web/css/images/icon_new_text.png
new file mode 100644
index 0000000..ce7db40
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_new_text.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_new_text_b.png b/extensions/Dashboard/web/css/images/icon_new_text_b.png
new file mode 100644
index 0000000..0530a35
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_new_text_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_new_widget.png b/extensions/Dashboard/web/css/images/icon_new_widget.png
new file mode 100644
index 0000000..80c4cda
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_new_widget.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_new_widget_b.png b/extensions/Dashboard/web/css/images/icon_new_widget_b.png
new file mode 100644
index 0000000..b9a0bf4
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_new_widget_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_new_xeyes.png b/extensions/Dashboard/web/css/images/icon_new_xeyes.png
new file mode 100644
index 0000000..62ca90e
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_new_xeyes.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_new_xeyes_b.png b/extensions/Dashboard/web/css/images/icon_new_xeyes_b.png
new file mode 100644
index 0000000..39ba402
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_new_xeyes_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_overlay.png b/extensions/Dashboard/web/css/images/icon_overlay.png
new file mode 100644
index 0000000..d1724eb
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_overlay.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_overlay_b.png b/extensions/Dashboard/web/css/images/icon_overlay_b.png
new file mode 100644
index 0000000..0ead21e
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_overlay_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_package_get.png b/extensions/Dashboard/web/css/images/icon_package_get.png
new file mode 100644
index 0000000..d371cc7
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_package_get.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_reset_columns.png b/extensions/Dashboard/web/css/images/icon_reset_columns.png
new file mode 100644
index 0000000..b511bc9
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_reset_columns.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_reset_columns_b.png b/extensions/Dashboard/web/css/images/icon_reset_columns_b.png
new file mode 100644
index 0000000..c892dfe
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_reset_columns_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_rss_link.png b/extensions/Dashboard/web/css/images/icon_rss_link.png
new file mode 100644
index 0000000..85d7d50
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_rss_link.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_rss_link_b.png b/extensions/Dashboard/web/css/images/icon_rss_link_b.png
new file mode 100644
index 0000000..6d3e134
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_rss_link_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_rss_popup.png b/extensions/Dashboard/web/css/images/icon_rss_popup.png
new file mode 100644
index 0000000..672da2c
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_rss_popup.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_rss_popup_b.png b/extensions/Dashboard/web/css/images/icon_rss_popup_b.png
new file mode 100644
index 0000000..90b7e06
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_rss_popup_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_save.png b/extensions/Dashboard/web/css/images/icon_save.png
new file mode 100644
index 0000000..27b7712
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_save.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/icon_save_b.png b/extensions/Dashboard/web/css/images/icon_save_b.png
new file mode 100644
index 0000000..2e51d44
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/icon_save_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/internet_explorer/borderBottomCenter.png b/extensions/Dashboard/web/css/images/internet_explorer/borderBottomCenter.png
new file mode 100644
index 0000000..12e0e9a
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/internet_explorer/borderBottomCenter.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/internet_explorer/borderBottomLeft.png b/extensions/Dashboard/web/css/images/internet_explorer/borderBottomLeft.png
new file mode 100644
index 0000000..b7a474a
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/internet_explorer/borderBottomLeft.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/internet_explorer/borderBottomRight.png b/extensions/Dashboard/web/css/images/internet_explorer/borderBottomRight.png
new file mode 100644
index 0000000..6b6cb15
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/internet_explorer/borderBottomRight.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/internet_explorer/borderMiddleLeft.png b/extensions/Dashboard/web/css/images/internet_explorer/borderMiddleLeft.png
new file mode 100644
index 0000000..8f248ac
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/internet_explorer/borderMiddleLeft.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/internet_explorer/borderMiddleRight.png b/extensions/Dashboard/web/css/images/internet_explorer/borderMiddleRight.png
new file mode 100644
index 0000000..336e19c
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/internet_explorer/borderMiddleRight.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/internet_explorer/borderTopCenter.png b/extensions/Dashboard/web/css/images/internet_explorer/borderTopCenter.png
new file mode 100644
index 0000000..7cb1da4
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/internet_explorer/borderTopCenter.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/internet_explorer/borderTopLeft.png b/extensions/Dashboard/web/css/images/internet_explorer/borderTopLeft.png
new file mode 100644
index 0000000..d733b6c
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/internet_explorer/borderTopLeft.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/internet_explorer/borderTopRight.png b/extensions/Dashboard/web/css/images/internet_explorer/borderTopRight.png
new file mode 100644
index 0000000..0d88683
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/internet_explorer/borderTopRight.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/loading.gif b/extensions/Dashboard/web/css/images/loading.gif
new file mode 100644
index 0000000..602ce3c
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/loading.gif
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/note_new.png b/extensions/Dashboard/web/css/images/note_new.png
new file mode 100644
index 0000000..04f2cf4
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/note_new.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/orange.png b/extensions/Dashboard/web/css/images/orange.png
new file mode 100644
index 0000000..59d01db
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/orange.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/orange_b.png b/extensions/Dashboard/web/css/images/orange_b.png
new file mode 100644
index 0000000..d34848f
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/orange_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/oro.png b/extensions/Dashboard/web/css/images/oro.png
new file mode 100644
index 0000000..85d7d50
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/oro.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/prefs.png b/extensions/Dashboard/web/css/images/prefs.png
new file mode 100644
index 0000000..57cf823
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/prefs.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/prefs_b.png b/extensions/Dashboard/web/css/images/prefs_b.png
new file mode 100644
index 0000000..4c0f705
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/prefs_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/red.png b/extensions/Dashboard/web/css/images/red.png
new file mode 100644
index 0000000..a3e8231
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/red.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/red_b.png b/extensions/Dashboard/web/css/images/red_b.png
new file mode 100644
index 0000000..19479da
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/red_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/reload.png b/extensions/Dashboard/web/css/images/reload.png
new file mode 100644
index 0000000..54c578e
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/reload.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/reload_b.png b/extensions/Dashboard/web/css/images/reload_b.png
new file mode 100644
index 0000000..5ca8cf3
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/reload_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/resize_handler.png b/extensions/Dashboard/web/css/images/resize_handler.png
new file mode 100644
index 0000000..53cee87
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/resize_handler.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/save.png b/extensions/Dashboard/web/css/images/save.png
new file mode 100644
index 0000000..e5c21e8
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/save.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/save_b.png b/extensions/Dashboard/web/css/images/save_b.png
new file mode 100644
index 0000000..e90a42c
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/save_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/sizer_left.png b/extensions/Dashboard/web/css/images/sizer_left.png
new file mode 100644
index 0000000..b3b5ea9
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/sizer_left.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/sizer_mid.png b/extensions/Dashboard/web/css/images/sizer_mid.png
new file mode 100644
index 0000000..a4684ba
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/sizer_mid.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/sizer_right.png b/extensions/Dashboard/web/css/images/sizer_right.png
new file mode 100644
index 0000000..cb8298e
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/sizer_right.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/sizer_right_b.png b/extensions/Dashboard/web/css/images/sizer_right_b.png
new file mode 100644
index 0000000..6dce056
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/sizer_right_b.png
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/ts_asc.gif b/extensions/Dashboard/web/css/images/ts_asc.gif
new file mode 100644
index 0000000..7415786
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/ts_asc.gif
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/ts_bg.gif b/extensions/Dashboard/web/css/images/ts_bg.gif
new file mode 100644
index 0000000..fac668f
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/ts_bg.gif
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/ts_desc.gif b/extensions/Dashboard/web/css/images/ts_desc.gif
new file mode 100644
index 0000000..3b30b3c
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/ts_desc.gif
Binary files differ
diff --git a/extensions/Dashboard/web/css/images/yellow.png b/extensions/Dashboard/web/css/images/yellow.png
new file mode 100644
index 0000000..9b3d406
--- /dev/null
+++ b/extensions/Dashboard/web/css/images/yellow.png
Binary files differ
diff --git a/extensions/Dashboard/web/js/colResizable-1.3.min.js b/extensions/Dashboard/web/js/colResizable-1.3.min.js
new file mode 100644
index 0000000..406f7b3
--- /dev/null
+++ b/extensions/Dashboard/web/js/colResizable-1.3.min.js
@@ -0,0 +1,2 @@
+//colResizable - by Alvaro Prieto Lauroba - MIT & GPL
+(function(a){function h(b){var c=a(this).data(q),d=m[c.t],e=d.g[c.i];e.ox=b.pageX;e.l=e[I]()[H];i[D](E+q,f)[D](F+q,g);P[z](x+"*{cursor:"+d.opt.dragCursor+K+J);e[B](d.opt.draggingClass);l=e;if(d.c[c.i].l)for(b=0;b<d.ln;b++)c=d.c[b],c.l=j,c.w=c[u]();return j}function g(b){i.unbind(E+q).unbind(F+q);a("head :last-child").remove();if(l){l[A](l.t.opt.draggingClass);var f=l.t,g=f.opt.onResize;l.x&&(e(f,l.i,1),d(f),g&&(b[G]=f[0],g(b)));f.p&&O&&c(f);l=k}}function f(a){if(l){var b=l.t,c=a.pageX-l.ox+l.l,f=b.opt.minWidth,g=l.i,h=1.5*b.cs+f+b.b,i=g==b.ln-1?b.w-h:b.g[g+1][I]()[H]-b.cs-f,f=g?b.g[g-1][I]()[H]+b.cs+f:h,c=s.max(f,s.min(i,c));l.x=c;l.css(H,c+p);if(b.opt.liveDrag&&(e(b,g),d(b),c=b.opt.onDrag))a[G]=b[0],c(a)}return j}function e(a,b,c){var d=l.x-l.l,e=a.c[b],f=a.c[b+1],g=e.w+d,d=f.w-d;e[u](g+p);f[u](d+p);a.cg.eq(b)[u](g+p);a.cg.eq(b+1)[u](d+p);if(c)e.w=g,f.w=d}function d(a){a.gc[u](a.w);for(var b=0;b<a.ln;b++){var c=a.c[b];a.g[b].css({left:c.offset().left-a.offset()[H]+c.outerWidth()+a.cs/2+p,height:a.opt.headerOnly?a.c[0].outerHeight():a.outerHeight()})}}function c(a,b){var c,d=0,e=0,f=[];if(b)if(a.cg[C](u),a.opt.flush)O[a.id]="";else{for(c=O[a.id].split(";");e<a.ln;e++)f[y](100*c[e]/c[a.ln]+"%"),b.eq(e).css(u,f[e]);for(e=0;e<a.ln;e++)a.cg.eq(e).css(u,f[e])}else{O[a.id]="";for(e in a.c)c=a.c[e][u](),O[a.id]+=c+";",d+=c;O[a.id]+=d}}function b(b){var e=">thead>tr>",f='"></div>',g=">tbody>tr:first>",i=">tr:first>",j="td",k="th",l=b.find(e+k+","+e+j);l.length||(l=b.find(g+k+","+i+k+","+g+j+","+i+j));b.cg=b.find("col");b.ln=l.length;b.p&&O&&O[b.id]&&c(b,l);l.each(function(c){var d=a(this),e=a(b.gc[z](w+"CRG"+f)[0].lastChild);e.t=b;e.i=c;e.c=d;d.w=d[u]();b.g[y](e);b.c[y](d);d[u](d.w)[C](u);if(c<b.ln-1)e.mousedown(h)[z](b.opt.gripInnerHtml)[z](w+q+'" style="cursor:'+b.opt.hoverCursor+f);else e[B]("CRL")[A]("CRG");e.data(q,{i:c,t:b[v](o)})});b.cg[C](u);d(b);b.find("td, th").not(l).not(N+"th, table td").each(function(){a(this)[C](u)})}var i=a(document),j=!1,k=null,l=k,m=[],n=0,o="id",p="px",q="CRZ",r=parseInt,s=Math,t=a.browser.msie,u="width",v="attr",w='<div class="',x="<style type='text/css'>",y="push",z="append",A="removeClass",B="addClass",C="removeAttr",D="bind",E="mousemove.",F="mouseup.",G="currentTarget",H="left",I="position",J="}</style>",K="!important;",L=":0px"+K,M="resize",N="table",O,P=a("head")[z](x+".CRZ{table-layout:fixed;}.CRZ td,.CRZ th{padding-"+H+L+"padding-right"+L+"overflow:hidden}.CRC{height:0px;"+I+":relative;}.CRG{margin-left:-5px;"+I+":absolute;z-index:5;}.CRG .CRZ{"+I+":absolute;background-color:red;filter:alpha(opacity=1);opacity:0;width:10px;height:100%;top:0px}.CRL{"+I+":absolute;width:1px}.CRD{ border-left:1px dotted black"+J);try{O=sessionStorage}catch(Q){}a(window)[D](M+"."+q,function(){for(a in m){var a=m[a],b,c=0;a[A](q);if(a.w!=a[u]()){a.w=a[u]();for(b=0;b<a.ln;b++)c+=a.c[b].w;for(b=0;b<a.ln;b++)a.c[b].css(u,s.round(1e3*a.c[b].w/c)/10+"%").l=1}d(a[B](q))}});a.fn.extend({colResizable:function(c){c=a.extend({draggingClass:"CRD",gripInnerHtml:"",liveDrag:j,minWidth:15,headerOnly:j,hoverCursor:"e-"+M,dragCursor:"e-"+M,postbackSafe:j,flush:j,marginLeft:k,marginRight:k,disable:j,onDrag:k,onResize:k},c);return this.each(function(){var d=c,e=a(this);if(d.disable){if(e=e[v](o),(d=m[e])&&d.is(N))d[A](q).gc.remove(),delete m[e]}else{var f=e.id=e[v](o)||q+n++;e.p=d.postbackSafe;if(e.is(N)&&!m[f])e[B](q)[v](o,f).before(w+'CRC"/>'),e.opt=d,e.g=[],e.c=[],e.w=e[u](),e.gc=e.prev(),d.marginLeft&&e.gc.css("marginLeft",d.marginLeft),d.marginRight&&e.gc.css("marginRight",d.marginRight),e.cs=r(t?this.cellSpacing||this.currentStyle.borderSpacing:e.css("border-spacing"))||2,e.b=r(t?this.border||this.currentStyle.borderLeftWidth:e.css("border-"+H+"-"+u))||1,m[f]=e,b(e)}})}})})(jQuery) \ No newline at end of file
diff --git a/extensions/Dashboard/web/js/dashboard.js b/extensions/Dashboard/web/js/dashboard.js
new file mode 100644
index 0000000..825dd98
--- /dev/null
+++ b/extensions/Dashboard/web/js/dashboard.js
@@ -0,0 +1,1500 @@
+/**
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * The Initial Developer of the Original Code is "Nokia Corporation"
+ * Portions created by the Initial Developer are Copyright (C) 2011 the
+ * Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ * David Wilson <ext-david.3.wilson@nokia.com>
+ * Jari Savolainen <ext-jari.a.savolainen@nokia.com>
+ * Pami Ketolainen <pami.ketolainen@jollamobile.com>
+ *
+ * @requires jQuery($), jQuery UI & sortable/draggable UI modules
+ */
+
+
+/**
+ * Return a self-referring URL with the given string appended as anchor text.
+ */
+function makeSelfUrl(obj)
+{
+ var url = window.location.toString().split('#')[0];
+ return url + '#?' + $.param(obj, true);
+}
+
+/**
+ * Left-pad a string with a character until it is a certain length.
+ * @param s
+ * The string.
+ * @param c
+ * The character, defaults to '0'.
+ * @param n
+ * The length, defaults to 2.
+ */
+function lpad(s, c, n)
+{
+ s = '' + s;
+ c = c || '0';
+ n = n || 2;
+
+ while(s.length < n) {
+ s = c + s;
+ }
+
+ return s;
+}
+
+
+/**
+ * Format a timestamp to string YYYY-MM-DD HH:MM:SS in local time.
+ * @param ts
+ * Integer seconds since UNIX epoch.
+ */
+function formatTime(ts)
+{
+ var dt = new Date(ts * 1000);
+ var dat = [1900 + dt.getYear(),
+ lpad(dt.getMonth()),
+ lpad(dt.getDay())].join('-');
+ var tim = [lpad(dt.getHours()),
+ lpad(dt.getMinutes()),
+ lpad(dt.getSeconds())].join(':');
+ return dat + ' ' + tim;
+}
+
+
+/**
+ * Compare two widgets (state hash) by position.
+ *
+ * Used to sort the widget array in order which can be easily pushed on overlay
+ */
+function widgetPosCmp(a, b)
+{
+ if (a.col == b.col) {
+ return a.pos - b.pos;
+ } else {
+ return a.col - b.col;
+ }
+}
+
+
+/**
+ * Clone a template.
+ *
+ * @param sel
+ * jQuery selector referencing template DOM element.
+ * @returns
+ * Cloned DOM element with id attribute removed.
+ */
+function cloneTemplate(sel)
+{
+ var cloned = $(sel).clone();
+ cloned.removeAttr('id');
+ return cloned;
+}
+
+
+/**
+ * Return integer seconds since UNIX epoch GMT.
+ */
+function now()
+{
+ return Math.floor((new Date).getTime());
+}
+
+
+
+
+
+
+
+
+/**
+ * Base widget. To keep things simple, currently combines state and visual
+ * rendering. Manages constructing a widget's DOM, cloning any templates,
+ * refresh timer, and rendering the widget's settings.
+ *
+ * The default render() prepopulates the widget's content element with a
+ * "#widget-template-<type>" and adds "#widget-settings-template-<type>" to the
+ * settings dialog. Both templates are expected to be defined in
+ * dashboard.html.tmpl.
+ *
+ * Any field value in the settings dialog template with class 'custom-field'
+ * and name attribute gets automatically transfered to and from
+ * widget.state.data. If something else is require, subclass shoud override
+ * _setCustomSetting() and _getCustomSettings() methods.
+ *
+ * In the simplest scenario the subclass only needs to implement reload() method
+ * to display the desired content in the widget content element.
+ *
+ * When templates are rendered, the click event of any <button> element with
+ * attribute 'name' is bound to onClick<Name>() method, where <Name> is the
+ * value of name attribute with first letter capitalized.
+ *
+ *
+ */
+var Widget = Base.extend({
+ /**
+ * Create an instance.
+ *
+ * @param dashboard
+ * Dashboard object.
+ * @param state
+ * Initial widget state.
+ */
+ constructor: function(dashboard, state)
+ {
+ // Shorthand.
+ this._dashboard = dashboard;
+
+ // Fired when widget is removed
+ this.onRemoveCb = new jQuery.Callbacks();
+ // Fired when widget is maximized
+ this.onMaximizeCb = new jQuery.Callbacks();
+ // Fired when the widget state changes
+ this.stateChangeCb = new jQuery.Callbacks();
+
+ this.id = Widget.counter++;
+
+ if(! this.TYPE) {
+ this.TYPE = this.constructor.TYPE;
+ }
+ if(! this.TEMPLATE_TYPE) {
+ this.TEMPLATE_TYPE = this.TYPE;
+ }
+
+ this.state = $.extend({
+ color: 'none',
+ minimized: false,
+ height: 100,
+ refresh: 0,
+ data: {}
+ }, state);
+ this.render();
+ this._applyState();
+ },
+
+ /**
+ * Updates the widget.state and fires stateChangeCb if changes were
+ * introduced.
+ *
+ * @param changes - New state values
+ */
+ updateState: function(changes)
+ {
+ var stateChanges = $.extend({}, changes);
+ var dataChanges = stateChanges.data;
+
+ // Remove unknown and unchanged values
+ for (var key in stateChanges) {
+ if (Widget.STATE_KEYS.indexOf(key) == -1 ||
+ this.state[key] == stateChanges[key]) {
+ delete stateChanges[key];
+ }
+ }
+ var changed = false;
+ if (!$.isEmptyObject(stateChanges)) {
+ $.extend(this.state, stateChanges);
+ changed = true;
+ }
+
+ // Update widget type specific data
+ changed = this._updateStateData(dataChanges) || changed;
+
+ // Apply changes and fire the event
+ if (changed) {
+ this.stateChangeCb.fire(this);
+ this._applyState();
+ }
+ return changed;
+ },
+
+ /**
+ * Updates the widget type specific state.data
+ *
+ * @param changes - Object containin values to update in state.data
+ * @returns True if something has changed in state.data
+ *
+ * Default implementation only compares state.data[key] == changes[key]
+ * so this needs to be overriden in the subclass if widget stores more
+ * complex values in the data object.
+ */
+ _updateStateData: function(changes)
+ {
+ var changes = $.extend({}, changes);
+ for (var key in changes) {
+ if (this.state.data[key] == changes[key]) {
+ delete changes[key];
+ }
+ }
+ if ($.isEmptyObject(changes)) {
+ return false;
+ } else {
+ $.extend(this.state.data, changes);
+ return true
+ }
+ },
+
+ /**
+ * Renders the widget ui from templates.
+ */
+ render: function()
+ {
+ // The top level container
+ this.element = cloneTemplate('#widget-template');
+ this.element.attr("id", "widget_" + this.id);
+ this.element.addClass(this.TYPE + '-widget');
+ // The header element, containing title and buttons
+ this.headerElement = this._child(".widget-header");
+ // The resizable container
+ this.containerElement = this._child(".widget-container");
+ // The actual widget content container
+ this.contentElement = this._child(".widget-content");
+ // Notification container
+ this.statusElement = this._child(".widget-status");
+
+ this._child(".widget-buttons [name='maximize']").button(
+ {text:false, icons:{primary:"ui-icon-circle-plus"}});
+ this._child(".widget-buttons [name='minimize']").button(
+ {text:false, icons:{primary:"ui-icon-circle-minus"}});
+ this._child(".widget-buttons [name='remove']").button(
+ {text:false, icons:{primary:"ui-icon-circle-close"}});
+ this._child(".widget-buttons [name='edit']").button(
+ {text:false, icons:{primary:"ui-icon-wrench"}});
+ this._child(".widget-buttons [name='refresh']").button(
+ {text:false, icons:{primary:"ui-icon-refresh"}});
+
+ // Populate content element with widget's template, if one exists.
+ var sel = '#widget-template-' + this.TEMPLATE_TYPE;
+ this.contentElement.append(cloneTemplate(sel));
+
+
+ // Prepare settings dialog
+ this.settingsDialog = this._child('.settings-dialog');
+ this.settingsDialog.attr("id", "widget_settings_" + this.id);
+ var colorSelect = $("[name='color']", this.settingsDialog);
+ for(var bg in Widget.COLORS) {
+ var fg = Widget.COLORS[bg];
+ var item = $('<option />');
+ item.html(bg);
+ item.attr("value", bg);
+ item.css({"background-color": bg, "color": fg});
+ colorSelect.append(item);
+ }
+
+ // Append custom widget settings
+ var sel = '#widget-settings-template-' + this.TEMPLATE_TYPE;
+ var template = cloneTemplate(sel);
+ this.settingsDialog.find("form").append(template);
+
+
+ // Bind any buttons to automatic callbacks
+ var widget = this;
+ this._child(":button").each(function() {
+ var name = $(this).attr("name");
+ if (!name) return;
+ var method = "onClick" + name[0].toUpperCase() + name.slice(1).toLowerCase();
+ // this is the button element in this context
+ $(this).click($.proxy(widget, method));
+ });
+
+ // Initialize the settings dialog
+ this.settingsDialog.dialog({
+ autoOpen: false,
+ modal: true,
+ width: 500,
+ zIndex: 9999,
+ buttons: {
+ "Apply": $.proxy(this, "_onSettingsApply"),
+ "Cancel": function(){ $(this).dialog("close") }
+ }
+ });
+
+ // Make widget resizable
+ this.containerElement.resizable({
+ handles:"s",
+ grid: [1, 16],
+ helper: "widget-resize-helper",
+ minHeight: Widget.MIN_HEIGHT,
+ start: $.proxy(this, "_onResizeStart"),
+ stop: $.proxy(this, "_onResizeStop")
+ });
+ },
+
+ _onResizeStart: function()
+ {
+ // Iframes can eat mouse events, so we need to hide them
+ $(".widget-content iframe").hide();
+ },
+
+ /**
+ * Handle the resize event
+ */
+ _onResizeStop: function()
+ {
+ $(".widget-content iframe").show();
+ // jquery ui resizable forces all dimensions, but we want width from
+ // the parent overaly column.
+ this.containerElement.css("width", "");
+ this.updateState({height: this.containerElement.height()});
+ },
+
+ /**
+ * Widget title bar maximize button click
+ */
+ onClickMaximize: function()
+ {
+ this.element.toggleClass("widget-max");
+ this.headerElement.toggleClass("widget-header-maximized");
+ this._child("button[name='remove']").toggle();
+ this._child("button[name='minimize']").toggle();
+ this._child("button[name='maximize'] .ui-button-icon-primary").toggleClass(
+ "ui-icon-circle-plus ui-icon-arrowthick-1-sw");
+
+ if (this.element.hasClass("widget-max")) {
+ this.containerElement.css("height", "100%");
+ if (this.state.minimized) {
+ this.headerElement.removeClass("ui-corner-bottom");
+ this.containerElement.slideDown("fast");
+ }
+ this.onMaximizeCb.fire(true);
+ } else {
+ this.containerElement.css("height", this.state.height);
+ if (this.state.minimized) {
+ this.headerElement.addClass("ui-corner-bottom");
+ this.containerElement.slideUp("fast");
+ }
+ this.onMaximizeCb.fire(false);
+ }
+
+ },
+
+ /**
+ * Widget title bar minimize button click
+ */
+ onClickMinimize: function()
+ {
+ if (this.state.minimized) {
+ this.headerElement.removeClass("ui-corner-bottom");
+ this.containerElement.slideDown("fast");
+ this.updateState({minimized: false});
+ } else {
+ this.headerElement.addClass("ui-corner-bottom");
+ this.containerElement.slideUp("fast");
+ this.updateState({minimized: true});
+ }
+ },
+
+ /**
+ * Widget title bar remove button click
+ */
+ onClickRemove: function()
+ {
+ if (confirm("Do you really want to remove this widget?")) {
+ this.destroy();
+ this.onRemoveCb.fire(this);
+ }
+ },
+
+ /**
+ * Widget title bar refresh button click
+ */
+ onClickRefresh: function()
+ {
+ this.reload();
+ },
+
+ /**
+ * Widget title bar edit button click
+ */
+ onClickEdit: function()
+ {
+
+ this._setSettings();
+ this.settingsDialog.dialog("open");
+ },
+
+ /**
+ * Handler for settings dialog apply button click.
+ */
+ _onSettingsApply: function()
+ {
+ var state = this._getSettings();
+ this.settingsDialog.dialog("close");
+ if (this.updateState(state)) {
+ this.reload();
+ }
+ },
+
+ /**
+ * Sets values in the widget settings dialog.
+ */
+ _setSettings: function()
+ {
+ var self = this;
+ this.settingsDialog.find(".settings-field").each(function() {
+ var key = $(this).attr("name");
+ if(!key) return;
+ $(this).val(self.state[key]);
+ });
+ this._setCustomSetting();
+ },
+
+ /**
+ * Sets the widget specific setting values in the settigns dialog.
+ *
+ * Default implementation sets value to each form element with class
+ * 'custom-field' in the settings dialog from this.state.data[<name>],
+ * where <name> is the form element name attribute. Override in subclass,
+ * if special processing is required.
+ */
+ _setCustomSetting: function()
+ {
+ var self = this;
+ this.settingsDialog.find(".custom-field").each(function() {
+ var key = $(this).attr("name");
+ if(!key) return;
+ $(this).val(self.state.data[key]);
+ });
+ },
+
+ /**
+ * Gets settings values from widget settings dialog.
+ */
+ _getSettings: function()
+ {
+ var state = {};
+ this.settingsDialog.find(".settings-field").each(function() {
+ var key = $(this).attr("name");
+ if(!key) return;
+ state[key] = $(this).val();
+ });
+ state.data = this._getCustomSettings();
+ return state;
+ },
+
+ /**
+ * Gets the widget type specific settings from the settings dialog
+ *
+ * Default implementation gets value (.val()) from each element with
+ * class 'custom-field'. Override in subclass if special processing is
+ * required.
+ *
+ * @returns Object presenting the state.data
+ */
+ _getCustomSettings: function()
+ {
+ var data = {};
+ this.settingsDialog.find(".custom-field").each(function() {
+ var key = $(this).attr("name");
+ if(!key) return;
+ data[key] = $(this).val();
+ });
+ return data;
+ },
+
+ /**
+ * Aplies the state to widget UI.
+ */
+ _applyState: function()
+ {
+ window.clearInterval(this._refreshInterval);
+ if(+this.state.refresh){
+ this._refreshInterval = window.setInterval($.proxy(this, "reload"),
+ 1000 * this.state.refresh)
+ }
+
+ var color = this.state.color == "none" ? "" : this.state.color;
+ this.headerElement.css({
+ "background": color,
+ "color": Widget.COLORS[color]
+ });
+ this._child(".widget-header, .widget-container")
+ .css("border-color", color);
+
+ this._child(".widget-title").html(this.state.name);
+
+ this.containerElement.css("height", this.state.height);
+
+ if (this.state.minimized) {
+ this.headerElement.addClass("ui-corner-bottom");
+ this.containerElement.hide();
+ }
+
+ this._applyCustomState();
+ },
+
+ /**
+ * Execute any actions required to apply widget specific settings
+ */
+ _applyCustomState: function()
+ {
+ // Implement in the subclass, if some actions are needed when state
+ // changes.
+ },
+
+ /**
+ * Reloads the widget content.
+ */
+ reload: function()
+ {
+ this.statusElement.empty();
+ },
+
+ /**
+ * Removes the widget
+ */
+
+ destroy: function() {
+ this.element.remove();
+ },
+
+ /**
+ * Displays error text in widget status box
+ */
+ error: function(text)
+ {
+ if (text != undefined) {
+ var clone = cloneTemplate('#widget-template-error');
+ $('.error-text', clone).text(text);
+ this.statusElement.html(clone);
+ } else {
+ this.statusElement.empty();
+ }
+ },
+ /**
+ * Display loader in widget status box
+ */
+ loader: function(on)
+ {
+ if (on) {
+ var clone = cloneTemplate("#dash-template-loader");
+ this.statusElement.html(clone);
+ } else {
+ this.statusElement.empty();
+ }
+ },
+
+ /**
+ * Return any matching child elements.
+ */
+ _child: function(sel)
+ {
+ return $(sel, this.element);
+ }
+
+}, /* class variables: */ {
+
+ /** Mapping of type name of constructor. */
+ _classes: {},
+
+ /** Counter for widget instances to provide unique ID */
+ counter: 0,
+
+ /** Allowed keys in Widget.state */
+ STATE_KEYS: ["id", "name", "overlay_id", "type", "color", "col", "pos",
+ "height", "minimized", "refresh"],
+
+ /** Minimum height for any widget. */
+ MIN_HEIGHT: 100,
+
+ /** Available colors, background -> foreground */
+ COLORS: {
+ 'none':'',
+ 'gray':'white',
+ 'yellow': 'black',
+ 'red': 'black',
+ 'blue': 'white',
+ 'white': 'black',
+ 'orange': 'black',
+ 'green': 'black'},
+
+ /**
+ * Register a Widget subclass for use with Widget.createInstance().
+ *
+ * @param type
+ * String type name, e.g. "rss".
+ * @param klass
+ * Constructor, i.e. the result of Widget.extend().
+ */
+ addClass: function(type, klass) {
+ klass.TYPE = type;
+ this._classes[type] = klass;
+ },
+
+ /**
+ * Create a Widget given a state object.
+ *
+ * @param dashboard
+ * Dashboard the widget is associated with.
+ * @param state
+ * Widget state object, including at least "type", which is the type
+ * of the widget to create.
+ */
+ createInstance: function(dashboard, state) {
+ var klass = this._classes[state.type];
+ return new klass(dashboard, state);
+ }
+});
+
+
+
+/**
+ * Overlay class which handles the dashboard columnt layout.
+ *
+ * Provides two callbacks
+ *
+ * columnChangeCb - fired when columns change
+ * widgetsMovedCb - fired when widget order has been changed
+ *
+ */
+var Overlay = Base.extend({
+ constructor: function(dashboard, columns)
+ {
+ this.columnChangeCb = new jQuery.Callbacks();
+ this.widgetsMovedCb = new jQuery.Callbacks();
+ this.dashboard = dashboard;
+ this.element = $("table#overlay");
+ this.setColumns(columns);
+ },
+
+ /**
+ * Resets the overlay container table to original condition
+ */
+ destroy: function()
+ {
+ this.element.colResizable({disable:true});
+ this._disableSortable();
+ this._child("#overlay-top").empty();
+ this._child("#overlay-columns").empty();
+ this._child("#overlay-header").empty();
+ },
+
+ setColumns: function(columns)
+ {
+ // Reset
+ this.destroy();
+
+ // Re initialize
+ this.columns = $.extend([], Overlay.DEFAULT_COLUMNS, columns);
+
+ for (var i = 0; i < this.columns.length; i++) {
+ this._createColumn(this.columns[i]);
+ }
+ this._enableSortable();
+ this._resetResizable();
+ },
+
+ /**
+ * Adds new column at the end
+ */
+ addColumn: function()
+ {
+ if (this.columns.length >= Overlay.MAX_COLUMNS) {
+ alert("Maximum of " + Overlay.MAX_COLUMNS + " columns reached");
+ return;
+ }
+ var width = Math.floor(100 / (this.columns.length + 1));
+ var shrink = Math.floor(width / this.columns.length);
+ var total = 0;
+ this._child("#overlay-header th").each(function(){
+ total += $(this).width();
+ });
+ this._child("#overlay-header th").each(function(){
+ $(this).css("width", "-=" + Math.ceil(total * (shrink / 100)));
+ });
+ this._createColumn(width);
+ this._updateColumnState();
+ this._resetResizable();
+ this._resetSortable();
+ },
+
+ /**
+ * Removes the last column and moves the widgets to previous column
+ */
+ removeColumn:function()
+ {
+ if (this.columns.length <= 1) {
+ alert("Cant remove the last column");
+ return;
+ }
+ var toBeRemoved = this._child("#overlay-columns > td").last();
+ var lastColumn = toBeRemoved.prev(".overlay-column");
+ if (toBeRemoved.children().size()) {
+ lastColumn.append(toBeRemoved.children());
+ this.widgetsMovedCb.fire(this.columns.length - 1,
+ lastColumn.sortable("toArray"));
+ }
+ toBeRemoved.sortable("destroy").remove();
+ this._child("#overlay-header > th").last().remove();
+ var count = this._child("#overlay-columns > td").size();
+ this._child("#overlay-top").attr("colspan", count);
+ this._updateColumnState();
+ this._resetResizable();
+ this._resetSortable();
+ },
+
+ /**
+ * Reset column widths to be even.
+ */
+ resetColumns: function()
+ {
+ var width = Math.floor(100 / this.columns.length);
+ this._child("#overlay-header > th").css("width", width + "%");
+ this._updateColumnState();
+ this._resetResizable();
+ },
+
+ /**
+ * Adds new widget in the overlay.
+ * Inspects the widget.state.col/pos attributes and places it accordingly,
+ * or as the first columns last, if col/pos does not match the overlay.
+ */
+ insertWidget: function(widget)
+ {
+ var col = widget.state.col;
+ var colElement = this._child(".overlay-column").eq(col);
+ if (!colElement.size()) {
+ colElement = this._child("#overlay-columns > td").first();
+ col = 1;
+ }
+ var posElement = colElement.find(".widget").eq(widget.state.pos);
+ if (posElement.size()) {
+ posElement.before(widget.element);
+ } else {
+ colElement.append(widget.element);
+ }
+ this.widgetsMovedCb.fire(col, colElement.sortable("toArray"));
+ widget.reload();
+ widget.onMaximizeCb.add($.proxy(this, "_onWidgetMaximize"));
+ this._resetSortable();
+ },
+
+ /**
+ * Disables and enables column resizing
+ */
+ _resetResizable: function()
+ {
+ this.element.colResizable({disable:true});
+ this.element.colResizable({
+ minWidth: Overlay.MIN_WIDTH,
+ onResize: $.proxy(this, "_updateColumnState")
+ });
+ },
+
+ /**
+ * Disables and enables drag and drop sorting
+ */
+ _resetSortable: function()
+ {
+ this._disableSortable();
+ this._enableSortable();
+ },
+
+ /**
+ * Enables drag and drop sorting
+ */
+ _enableSortable: function()
+ {
+ this._child("td.overlay-column").sortable({
+ connectWith: "td.overlay-column",
+ handle: ".widget-header",
+ opacity: 0.7,
+ tolerance: "pointer",
+ update: $.proxy(this, "_onSortUpdate"),
+ start: $.proxy(this, "_onSortStart"),
+ stop: $.proxy(this, "_onSortStop")
+ });
+ },
+
+ /**
+ * Disables drag and drop sorting
+ */
+ _disableSortable: function()
+ {
+ this._child("td.overlay-column").sortable("destroy");
+ },
+
+ /**
+ * Creates a new column.
+ * Separated from addColumn() so that the initial columns can be created
+ * with single sortable/resizable reset
+ */
+ _createColumn: function(width)
+ {
+ var total = this.element.width();
+ var index = this._child(".overlay-column").size()
+ var newcolumn = $("<td id='column_" + index + "' />");
+ newcolumn.addClass("overlay-column");
+ this._child("#overlay-columns").append(newcolumn);
+ var count = this._child("#overlay-columns > td").size();
+ this._child("#overlay-top").attr("colspan", count);
+ var header = $("<th><div class='resize-guide'/></th>");
+ header.css("width", width + "%");
+ this._child("#overlay-header").append(header);
+ },
+
+ /**
+ * Calculates the column widths and stores them in state
+ */
+ _updateColumnState: function()
+ {
+ var total = 0;
+ this._child("#overlay-columns > td").each(function(){
+ total += $(this).width();
+ });
+ var columns = [];
+ this._child("#overlay-columns > td").each(function(){
+ columns.push(Math.floor($(this).width() / total * 100));
+ });
+ this.columns = columns;
+ this.columnChangeCb.fire(this.columns);
+ },
+
+ /**
+ * Event handlers for drag and drop sort
+ */
+ _onSortUpdate: function(event, ui) {
+ var column = $(event.target)
+ // TODO change the top column id to 'column_0'
+ var col = Number(column.attr("id").split("_")[1] || 0);
+ this.widgetsMovedCb.fire(col, column.sortable("toArray"));
+ },
+ _onSortStart: function() {
+ this._child("td.overlay-column").addClass("overlay-column-hint");
+ },
+ _onSortStop: function() {
+ this._child("td.overlay-column").removeClass("overlay-column-hint");
+ },
+
+
+ /**
+ * Disables drag and drop sort when widget is maximized
+ */
+ _onWidgetMaximize: function(maximized)
+ {
+ if (maximized) {
+ this._disableSortable();
+ } else {
+ this._enableSortable();
+ }
+ },
+
+ /**
+ * Return any matching child elements.
+ */
+ _child: function(sel)
+ {
+ return $(sel, this.element);
+ }
+
+}, /* Class variables */ {
+ DEFAULT_COLUMNS: [100],
+ MAX_COLUMNS: 4,
+ MIN_WIDTH: 100
+});
+
+
+/**
+ * Dashboard 'model': this maintains the front end's notion of the workspace
+ * state, which includes column widths, widget instances, and other overlay
+ * settings.
+ *
+ */
+var Dashboard = Base.extend({
+ /**
+ * Create an instance.
+ *
+ * @param config
+ * Dashboard configuration object passed in via JSON object in
+ * dashboard.html.
+ */
+ constructor: function(config)
+ {
+ this.overlay = {};
+ this._oldOverlay = null;
+ this._unsavedChanges = false;
+ this.widgets = {};
+ this.buttons = {};
+ this.config = config || {};
+
+ this.initUI();
+ if (this.config.overlay_id) {
+ this.loadOverlay(config.overlay_id);
+ } else {
+ this.welcomeOverlay();
+ }
+ window.onbeforeunload = $.proxy(this, "_onBeforeUnload");
+ },
+
+ initUI: function()
+ {
+ var dashboard = this;
+ $("#buttons button").each(function(){
+ var $elem = $(this);
+ var name = $elem.attr("name");
+ dashboard.buttons[name] = $elem;
+ $elem.button({
+ text: false,
+ icons: {primary: "icon-" + name.toLowerCase()}
+ });
+ var callback = "onClick" + name[0].toUpperCase() + name.slice(1);
+ $elem.click($.proxy(dashboard, callback));
+ });
+
+ this.overlayInfo = $("#overlay-info");
+ this.overlayInfo.find(".workspace,.unsaved,.shared,.pending").hide();
+ this.widgetSelect = $("#buttons [name='widgettype']");
+ this.overlaySettings = $("#overlay-settings");
+ this.overlayList = $("#overlay-open");
+ this.notifyBox = $("#dashboard_notify");
+ },
+
+ /******************
+ * Button handlers.
+ */
+ onClickNewoverlay: function()
+ {
+ if (!this._confirmUnsaved()) return;
+ this.setOverlay(this._makeDefaultOverlay());
+ this._setUnsaved(false);
+ },
+
+ onClickAddwidget: function()
+ {
+ var type = this.widgetSelect.val();
+
+ var widget = this._createWidget({
+ name: 'Unnamed widget',
+ type: type
+ });
+ widget.onClickEdit();
+ },
+
+ onClickSaveoverlay: function()
+ {
+ if (!this.overlay.id) {
+ this._openOverlaySettings(true);
+ } else {
+ this.saveOverlay(false);
+ }
+ },
+
+ onClickSaveoverlayas: function()
+ {
+ if (this.overlay.id) {
+ this._oldOverlay = $.extend({}, this.overlay);
+ if (this.overlay.id) {
+ delete this.overlay.id;
+ this.overlay.name = "Copy of " + this._oldOverlay.name;
+ } else {
+ this.overlay.name = "";
+ }
+ this.overlay.description = "";
+ this.overlay.shared = false;
+ }
+ this._openOverlaySettings(true);
+ },
+
+ onClickOpenoverlay: function()
+ {
+ if (!this._confirmUnsaved()) return;
+ this.rpc("overlay_list").done($.proxy(this, "_openOverlayList"));
+ },
+
+ onClickDeleteoverlay: function()
+ {
+ if(!confirm("Do you really want to delete this overlay")) return;
+ var rpc = this.rpc('overlay_delete', {
+ id: this.overlay.id
+ });
+ rpc.done($.proxy(this, "_onDeleteOverlayDone"));
+ },
+ _onDeleteOverlayDone: function()
+ {
+ this.notify("Overlay deleted");
+ this._setUnsaved(false);
+ this.onClickNewoverlay();
+ },
+
+ onClickPublishoverlay: function()
+ {
+ if (!this.overlay.user_can_publish) {
+ alert("You are not allowed to publish this overlay!");
+ return;
+ }
+ var rpc = this.rpc('overlay_publish', {
+ id: this.overlay.id, withhold: 0
+ });
+ rpc.done($.proxy(this, "_onPublishOverlayDone"));
+
+ },
+ onClickWithholdoverlay: function()
+ {
+ if (!this.overlay.user_can_publish) {
+ alert("You are not allowed to withhold this overlay!");
+ return;
+ }
+ var rpc = this.rpc('overlay_publish', {
+ id: this.overlay.id, withhold: 1
+ });
+ rpc.done($.proxy(this, "_onPublishOverlayDone"));
+
+ },
+ _onPublishOverlayDone: function(pending)
+ {
+ this.overlay.pending = pending;
+ this.buttons.publishoverlay.toggle(pending);
+ this.buttons.withholdoverlay.toggle(!pending);
+ $(".pending", this.overlayInfo).toggle(this.overlay.shared && pending);
+ if (pending) {
+ this.notify("Overlay withheld");
+ } else {
+ this.notify("Overlay published");
+ }
+ },
+
+ // TODO Overlay UI object should bind directly to these buttons
+ onClickAddcolumn: function()
+ {
+ this.overlayUI.addColumn();
+ },
+ onClickRemovecolumn: function()
+ {
+ this.overlayUI.removeColumn();
+ },
+ onClickResetcolumns: function()
+ {
+ this.overlayUI.resetColumns();
+ },
+
+ onClickOverlaysettings: function()
+ {
+ this._openOverlaySettings(false);
+ },
+
+ /***********************************
+ * Overlay settings dialog handling.
+ */
+ _openOverlaySettings: function(save)
+ {
+ var overlay = this.overlay;
+ this.overlaySettings.find(".settings-field").each(function(){
+ var field = $(this);
+ var name = field.attr("name");
+ if (field.attr("type") == "checkbox") {
+ field.prop("checked", Boolean(overlay[name]));
+ } else {
+ field.val(overlay[name]);
+ }
+ });
+ var buttons = {
+ "Cancel": $.proxy(this, "_cancelOverlaySettings")
+ };
+ if (save) {
+ buttons["Save"] = $.proxy(this, "_saveOverlaySettings");
+ } else {
+ buttons["Apply"] = $.proxy(this, "_applyOverlaySettings");
+ }
+
+ this.overlaySettings.dialog({
+ modal: true,
+ width: 500,
+ zIndex: 9999,
+ buttons: buttons
+ });
+ },
+ _cancelOverlaySettings: function()
+ {
+ if (this._oldOverlay != null) this.overlay = this._oldOverlay;
+ this.overlaySettings.dialog("close");
+ },
+ _applyOverlaySettings: function()
+ {
+ var overlay = this.overlay;
+ var changed = false;
+ this.overlaySettings.find(".settings-field").each(function(){
+ var field = $(this);
+ var name = field.attr("name");
+ var value = null;
+ if (field.attr("type") == "checkbox") {
+ value = field.prop("checked");
+ } else {
+ value = field.val();
+ }
+ if (overlay[name] != value) {
+ overlay[name] = value;
+ changed = true;
+ }
+ });
+ this.overlaySettings.dialog("close");
+ if (changed) this._setUnsaved(true);
+ },
+ _saveOverlaySettings: function()
+ {
+ this._applyOverlaySettings();
+ this.saveOverlay(true);
+ },
+
+ /**
+ * Create initial empty overlay with welcome message
+ */
+ welcomeOverlay: function()
+ {
+ this.setOverlay($.extend(this._makeDefaultOverlay(),
+ {
+ columns: [25, 50, 25],
+ widgets: [{
+ col: 0,
+ pos: 0,
+ type: 'text',
+ name: 'Welcome to Dashboard',
+ height: 500,
+ data :{text: $('#dash-template-welcome-text').html()}
+ }]
+ }));
+ this._setUnsaved(false);
+ },
+
+ /**
+ * Create an empty overlay definition
+ */
+ _makeDefaultOverlay: function()
+ {
+ return {
+ name: 'New Overlay',
+ description: '',
+ workspace: false,
+ shared: false,
+ pending: true,
+ user_can_edit: true,
+ user_can_publish: false,
+ columns: this._makeColumns(3),
+ widgets: []
+ };
+ },
+
+ /**
+ * Saves the current overlay state
+ */
+ saveOverlay: function(asnew)
+ {
+ var rpc = this.rpc("overlay_save", this._makeSaveParams(asnew));
+ rpc.done($.proxy(this, "_saveDone"));
+ rpc.fail($.proxy(this, "_saveFail"));
+ },
+
+ _saveDone: function(result)
+ {
+ this.notify("Overlay saved");
+ this._oldOverlay = null;
+ this.setOverlay(result.overlay);
+ this._setUnsaved(false);
+ },
+ _saveFail: function(error)
+ {
+ if(this._oldOverlay != null) this.overlay = this._oldOverlay;
+ },
+
+ _openOverlayList: function(overlays)
+ {
+ this.overlayList.find("ul").empty();
+ this.overlayList.find(".pending-list").toggle(
+ DASHBOARD_CONFIG.can_publish);
+ this.overlayList.dialog({
+ modal: true,
+ width: 500,
+ zIndex: 9999,
+ position: {my: 'center top', at: 'center top'}
+ });
+ var list = this.overlayList.find("ul.shared");
+ for (var i = 0; i < overlays.length; i++) {
+ if (!overlays[i].shared || overlays[i].pending) continue;
+ list.append(this._createOverlayEntry(overlays[i]));
+ }
+ var list = this.overlayList.find("ul.owner");
+ list.empty();
+ for (var i = 0; i < overlays.length; i++) {
+ if (overlays[i].owner.id != this.config.user_id) continue;
+ list.append(this._createOverlayEntry(overlays[i]));
+ }
+ if (DASHBOARD_CONFIG.can_publish) {
+ list = this.overlayList.find("ul.pending");
+ for (var i = 0; i < overlays.length; i++) {
+ if (!overlays[i].pending) continue;
+ list.append(this._createOverlayEntry(overlays[i]));
+ }
+ }
+ },
+
+ /**
+ * Renders single overlay entry for the open dialog
+ */
+ _createOverlayEntry: function(overlay)
+ {
+ var elem = cloneTemplate("#template-overlay-entry");
+ var openButton = $(".name", elem);
+ openButton.html(overlay.name || "<i>-no name-</i>");
+ openButton.button({
+ icons:{primary:"ui-icon-folder-open"}
+ }).click({id: overlay.id}, $.proxy(this, "_onClickLoad"));
+
+ openButton.next().button({
+ text: false,
+ icons:{primary:"ui-icon-triangle-1-s"}
+ }).click(function(){
+ $(this).parent().next().slideToggle("fast");
+ });
+
+ openButton.parent().buttonset();
+
+ // jQuery.show() does not seem to work for some reason...
+ if (overlay.workspace) $("span.workspace", elem).css("display", "inline");
+ if (overlay.shared) {
+ $("span.shared", elem).css("display", "inline");
+ if (overlay.pending) $("span.pending", elem).css("display", "inline");
+ }
+ $(".owner", elem).text(overlay.owner.name);
+ $(".description", elem).text(overlay.description);
+ $(".modified", elem).text(overlay.modified);
+ return elem;
+ },
+
+ /**
+ * Overlay clicked in the open dialog.
+ */
+ _onClickLoad: function(event, ui) {
+ this.overlayList.dialog("close");
+ this.loadOverlay(event.data.id);
+ },
+
+ /**
+ * Loads overlay with given ID
+ */
+ loadOverlay: function(id)
+ {
+ this.rpc("overlay_get", {id: id}).done(
+ $.proxy(this, "_loadDone"));
+ },
+ _loadDone: function(overlay)
+ {
+ this.notify("Overlay loaded");
+ this.setOverlay(overlay);
+ this._setUnsaved(false);
+ },
+
+ /**
+ * Reset front-end state to match the overlay described by the given
+ * JSON object.
+ *
+ * @param workspace
+ * Overlay JSON, as represented by overlay_get RPC
+ */
+ setOverlay: function(overlay)
+ {
+ this.overlay = overlay;
+
+ // Destroy old widgets
+ for(var id in this.widgets) {
+ this.widgets[id].destroy();
+ delete this.widgets[id];
+ }
+
+ // Create overlay UI
+ this.overlayUI = new Overlay(this, overlay.columns);
+ this.overlayUI.columnChangeCb.add($.proxy(this, "_onColumnChange"));
+ // Create new widgets
+ overlay.widgets.sort(widgetPosCmp);
+ while(overlay.widgets.length) {
+ this._createWidget(overlay.widgets.shift());
+ }
+ this.overlayUI.widgetsMovedCb.add($.proxy(this, "_onWidgetsMoved"));
+
+ // Set overlay info
+ this.overlayInfo.attr("href",
+ "page.cgi?id=dashboard.html&overlay_id=" + (overlay.id || ""))
+ $(".name", this.overlayInfo).text(overlay.name);
+ $(".workspace", this.overlayInfo).toggle(overlay.workspace);
+ $(".shared", this.overlayInfo).toggle(overlay.shared);
+ $(".pending", this.overlayInfo).toggle(overlay.shared && overlay.pending);
+
+ // Set buttons
+ this.buttons.saveoverlay.toggle(overlay.user_can_edit);
+ this.buttons.deleteoverlay.toggle(overlay.id && overlay.user_can_edit);
+ this.buttons.publishoverlay.toggle(overlay.user_can_publish && overlay.pending);
+ this.buttons.withholdoverlay.toggle(overlay.user_can_publish && !overlay.pending);
+ },
+
+ /**
+ * Creates a widget and inserts it in the overlay ui.
+ */
+ _createWidget: function(state)
+ {
+ var widget = Widget.createInstance(this, state);
+ widget.onRemoveCb.add($.proxy(this, "_onWidgetRemove"));
+ widget.stateChangeCb.add($.proxy(this, "_onWidgetStateChange"));
+ this.widgets[widget.id] = widget;
+ this.overlayUI.insertWidget(widget);
+ return widget;
+ },
+
+ /*******************************************
+ * Column and widget postion change handling.
+ */
+ _onWidgetsMoved: function(col, widget_ids)
+ {
+ for (var i = 0; i < widget_ids.length; i++) {
+ var id = widget_ids[i].split("_")[1];
+ this.widgets[id].updateState({col: col, pos: i});
+ }
+ },
+ _onWidgetRemove: function(widget)
+ {
+ this._setUnsaved(true)
+ delete this.widgets[widget.id]
+ },
+ _onColumnChange: function(columns)
+ {
+ this._setUnsaved(true);
+ this.overlay.columns = columns;
+ },
+ _onWidgetStateChange: function(widget)
+ {
+ this._setUnsaved(true);
+ },
+
+ _setUnsaved: function(unsaved)
+ {
+ // If user can't edit the overlay, there won't be any changes that
+ // could be saved
+ if (!this.overlay.user_can_edit) unsaved = false;
+ $(".unsaved", this.overlayInfo).toggle(unsaved);
+ this._unsavedChanges = unsaved;
+
+ },
+
+ _confirmUnsaved: function()
+ {
+ if (this._unsavedChanges && this.overlay.user_can_edit) {
+ return confirm("There are unsaved changes. Continue?");
+ }
+ return true;
+ },
+
+ _onBeforeUnload: function()
+ {
+ if (this._unsavedChanges) {
+ return "There are unsaved changes, which would be lost.";
+ }
+ },
+
+ /********************************************
+ * Start an RPC to the Dashboard web service.
+ *
+ * @param method
+ * Method name.
+ * @param params
+ * Object containing key/value pairs to send to method.
+ * @returns
+ * RPC object.
+ */
+ rpc: function(method, params, cb)
+ {
+ var rpc = new Rpc('Dashboard', method, params);
+ rpc.fail(function(e) { alert(e.message ? e.message : e); });
+ return rpc;
+ },
+
+ /**
+ * Displays a notification in dashboard status box
+ */
+ notify: function(message)
+ {
+ this.notifyBox.text(message);
+ this.notifyBox.stop(true, true).show()
+ .delay(5000).fadeOut("slow");
+ },
+
+ /**
+ * Return an array of equally sized column objects.
+ *
+ * @param count
+ * Number of columns.
+ */
+ _makeColumns: function(count)
+ {
+ var cols = [];
+ var width = Math.floor(100 / count);
+ for(var i = 0; i < count; i++) {
+ cols.push(width);
+ }
+ return cols;
+ },
+
+ /**
+ * Return structure that can be saved through rpc
+ *
+ * @param asnew
+ * If true, removes the id values so that overlay gets saved as new one
+ */
+ _makeSaveParams: function(asnew)
+ {
+ var overlay = $.extend({}, this.overlay, {
+ widgets: this._getWidgetStates(asnew)
+ });
+ if (asnew) delete overlay.id;
+ return overlay;
+ },
+ /**
+ * Return an array describing state of widgets in the workspace, as
+ * understood by the save rpc methods
+ */
+ _getWidgetStates: function(asnew)
+ {
+ var states = [];
+ for (var id in this.widgets) {
+ var state = $.extend({}, this.widgets[id].state);
+ if (asnew) delete state.id;
+ states.push(state);
+ }
+ return states;
+ }
+});
+
+
+/**
+ * Display a warning if the user's browser doesn't match the configured regex.
+ * Block loading entirely if the browser is far too old.
+ */
+function checkBrowserQuality()
+{
+ var warn = DASHBOARD_CONFIG.browsers_warn;
+ var block = DASHBOARD_CONFIG.browsers_block;
+
+ if(warn && navigator.userAgent.match(RegExp(warn))) {
+ $('#dashboard_notify').html($('#browser_warning_template'));
+ } else if(block && navigator.userAgent.match(RegExp(block))) {
+ $('#dashboard').html($('#browser_block_template'));
+ throw ''; // Prevent further execution.
+ }
+}
+
+
+/**
+ * Main program implementation ('document.ready').
+ *
+ * Expects DASHBOARD_CONFIG global to be initialized by dashboard.html.tmpl
+ * (which itself is populated by Extension.pm). This global contains various
+ * configurables, and the initial workspace state (to avoid a redundant web
+ * service roundtrip).
+ */
+function main()
+{
+ checkBrowserQuality();
+ dashboard = new Dashboard(DASHBOARD_CONFIG);
+}
+
+$(document).ready(main);
diff --git a/extensions/Dashboard/web/js/jquery.colorbox-min.js b/extensions/Dashboard/web/js/jquery.colorbox-min.js
new file mode 100644
index 0000000..2752e53
--- /dev/null
+++ b/extensions/Dashboard/web/js/jquery.colorbox-min.js
@@ -0,0 +1,4 @@
+// ColorBox v1.3.14 - a full featured, light-weight, customizable lightbox based on jQuery 1.3+
+// Copyright (c) 2010 Jack Moore - jack@colorpowered.com
+// Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
+(function(b,ib){var t="none",M="LoadedContent",c=false,v="resize.",o="y",q="auto",e=true,L="nofollow",m="x";function f(a,c){a=a?' id="'+i+a+'"':"";c=c?' style="'+c+'"':"";return b("<div"+a+c+"/>")}function p(a,b){b=b===m?n.width():n.height();return typeof a==="string"?Math.round(/%/.test(a)?b/100*parseInt(a,10):parseInt(a,10)):a}function U(b){return a.photo||/\.(gif|png|jpg|jpeg|bmp)(?:\?([^#]*))?(?:#(\.*))?$/i.test(b)}function cb(a){for(var c in a)if(b.isFunction(a[c])&&c.substring(0,2)!=="on")a[c]=a[c].call(l);a.rel=a.rel||l.rel||L;a.href=a.href||b(l).attr("href");a.title=a.title||l.title;return a}function w(c,a){a&&a.call(l);b.event.trigger(c)}function jb(){var b,e=i+"Slideshow_",c="click."+i,f,k;if(a.slideshow&&h[1]){f=function(){F.text(a.slideshowStop).unbind(c).bind(V,function(){if(g<h.length-1||a.loop)b=setTimeout(d.next,a.slideshowSpeed)}).bind(W,function(){clearTimeout(b)}).one(c+" "+N,k);j.removeClass(e+"off").addClass(e+"on");b=setTimeout(d.next,a.slideshowSpeed)};k=function(){clearTimeout(b);F.text(a.slideshowStart).unbind([V,W,N,c].join(" ")).one(c,f);j.removeClass(e+"on").addClass(e+"off")};a.slideshowAuto?f():k()}}function db(c){if(!O){l=c;a=cb(b.extend({},b.data(l,r)));h=b(l);g=0;if(a.rel!==L){h=b("."+G).filter(function(){return (b.data(this,r).rel||this.rel)===a.rel});g=h.index(l);if(g===-1){h=h.add(l);g=h.length-1}}if(!u){u=E=e;j.show();if(a.returnFocus)try{l.blur();b(l).one(eb,function(){try{this.focus()}catch(a){}})}catch(f){}x.css({opacity:+a.opacity,cursor:a.overlayClose?"pointer":q}).show();a.w=p(a.initialWidth,m);a.h=p(a.initialHeight,o);d.position(0);X&&n.bind(v+P+" scroll."+P,function(){x.css({width:n.width(),height:n.height(),top:n.scrollTop(),left:n.scrollLeft()})}).trigger("scroll."+P);w(fb,a.onOpen);Y.add(H).add(I).add(F).add(Z).hide();ab.html(a.close).show()}d.load(e)}}var gb={transition:"elastic",speed:300,width:c,initialWidth:"600",innerWidth:c,maxWidth:c,height:c,initialHeight:"450",innerHeight:c,maxHeight:c,scalePhotos:e,scrolling:e,inline:c,html:c,iframe:c,photo:c,href:c,title:c,rel:c,opacity:.9,preloading:e,current:"image {current} of {total}",previous:"previous",next:"next",close:"close",open:c,returnFocus:e,loop:e,slideshow:c,slideshowAuto:e,slideshowSpeed:2500,slideshowStart:"start slideshow",slideshowStop:"stop slideshow",onOpen:c,onLoad:c,onComplete:c,onCleanup:c,onClosed:c,overlayClose:e,escKey:e,arrowKey:e},r="colorbox",i="cbox",fb=i+"_open",W=i+"_load",V=i+"_complete",N=i+"_cleanup",eb=i+"_closed",Q=i+"_purge",hb=i+"_loaded",A=b.browser.msie&&!b.support.opacity,X=A&&b.browser.version<7,P=i+"_IE6",x,j,B,s,bb,T,R,S,h,n,k,J,K,Z,Y,F,I,H,ab,C,D,y,z,l,g,a,u,E,O=c,d,G=i+"Element";d=b.fn[r]=b[r]=function(c,f){var a=this,d;if(!a[0]&&a.selector)return a;c=c||{};if(f)c.onComplete=f;if(!a[0]||a.selector===undefined){a=b("<a/>");c.open=e}a.each(function(){b.data(this,r,b.extend({},b.data(this,r)||gb,c));b(this).addClass(G)});d=c.open;if(b.isFunction(d))d=d.call(a);d&&db(a[0]);return a};d.init=function(){var l="hover",m="clear:left";n=b(ib);j=f().attr({id:r,"class":A?i+"IE":""});x=f("Overlay",X?"position:absolute":"").hide();B=f("Wrapper");s=f("Content").append(k=f(M,"width:0; height:0; overflow:hidden"),K=f("LoadingOverlay").add(f("LoadingGraphic")),Z=f("Title"),Y=f("Current"),I=f("Next"),H=f("Previous"),F=f("Slideshow").bind(fb,jb),ab=f("Close"));B.append(f().append(f("TopLeft"),bb=f("TopCenter"),f("TopRight")),f(c,m).append(T=f("MiddleLeft"),s,R=f("MiddleRight")),f(c,m).append(f("BottomLeft"),S=f("BottomCenter"),f("BottomRight"))).children().children().css({"float":"left"});J=f(c,"position:absolute; width:9999px; visibility:hidden; display:none");b("body").prepend(x,j.append(B,J));s.children().hover(function(){b(this).addClass(l)},function(){b(this).removeClass(l)}).addClass(l);C=bb.height()+S.height()+s.outerHeight(e)-s.height();D=T.width()+R.width()+s.outerWidth(e)-s.width();y=k.outerHeight(e);z=k.outerWidth(e);j.css({"padding-bottom":C,"padding-right":D}).hide();I.click(d.next);H.click(d.prev);ab.click(d.close);s.children().removeClass(l);b("."+G).live("click",function(a){if(!(a.button!==0&&typeof a.button!=="undefined"||a.ctrlKey||a.shiftKey||a.altKey)){a.preventDefault();db(this)}});x.click(function(){a.overlayClose&&d.close()});b(document).bind("keydown",function(b){if(u&&a.escKey&&b.keyCode===27){b.preventDefault();d.close()}if(u&&a.arrowKey&&!E&&h[1])if(b.keyCode===37&&(g||a.loop)){b.preventDefault();H.click()}else if(b.keyCode===39&&(g<h.length-1||a.loop)){b.preventDefault();I.click()}})};d.remove=function(){j.add(x).remove();b("."+G).die("click").removeData(r).removeClass(G)};d.position=function(f,d){function b(a){bb[0].style.width=S[0].style.width=s[0].style.width=a.style.width;K[0].style.height=K[1].style.height=s[0].style.height=T[0].style.height=R[0].style.height=a.style.height}var e,h=Math.max(document.documentElement.clientHeight-a.h-y-C,0)/2+n.scrollTop(),g=Math.max(n.width()-a.w-z-D,0)/2+n.scrollLeft();e=j.width()===a.w+z&&j.height()===a.h+y?0:f;B[0].style.width=B[0].style.height="9999px";j.dequeue().animate({width:a.w+z,height:a.h+y,top:h,left:g},{duration:e,complete:function(){b(this);E=c;B[0].style.width=a.w+z+D+"px";B[0].style.height=a.h+y+C+"px";d&&d()},step:function(){b(this)}})};d.resize=function(b){if(u){b=b||{};if(b.width)a.w=p(b.width,m)-z-D;if(b.innerWidth)a.w=p(b.innerWidth,m);k.css({width:a.w});if(b.height)a.h=p(b.height,o)-y-C;if(b.innerHeight)a.h=p(b.innerHeight,o);if(!b.innerHeight&&!b.height){b=k.wrapInner("<div style='overflow:auto'></div>").children();a.h=b.height();b.replaceWith(b.children())}k.css({height:a.h});d.position(a.transition===t?0:a.speed)}};d.prep=function(o){var e="hidden";function m(t){var q,f,o,e,m=h.length,s=a.loop;d.position(t,function(){if(u){A&&p&&k.fadeIn(100);k.show();w(hb);Z.show().html(a.title);if(m>1){typeof a.current==="string"&&Y.html(a.current.replace(/\{current\}/,g+1).replace(/\{total\}/,m)).show();I[s||g<m-1?"show":"hide"]().html(a.next);H[s||g?"show":"hide"]().html(a.previous);q=g?h[g-1]:h[m-1];o=g<m-1?h[g+1]:h[0];a.slideshow&&F.show();if(a.preloading){e=b.data(o,r).href||o.href;f=b.data(q,r).href||q.href;e=b.isFunction(e)?e.call(o):e;f=b.isFunction(f)?f.call(q):f;if(U(e))b("<img/>")[0].src=e;if(U(f))b("<img/>")[0].src=f}}K.hide();if(a.transition==="fade")j.fadeTo(l,1,function(){if(A)j[0].style.filter=c});else if(A)j[0].style.filter=c;n.bind(v+i,function(){d.position(0)});w(V,a.onComplete)}})}if(u){var p,l=a.transition===t?0:a.speed;n.unbind(v+i);k.remove();k=f(M).html(o);k.hide().appendTo(J.show()).css({width:function(){a.w=a.w||k.width();a.w=a.mw&&a.mw<a.w?a.mw:a.w;return a.w}(),overflow:a.scrolling?q:e}).css({height:function(){a.h=a.h||k.height();a.h=a.mh&&a.mh<a.h?a.mh:a.h;return a.h}()}).prependTo(s);J.hide();b("#"+i+"Photo").css({cssFloat:t,marginLeft:q,marginRight:q});X&&b("select").not(j.find("select")).filter(function(){return this.style.visibility!==e}).css({visibility:e}).one(N,function(){this.style.visibility="inherit"});a.transition==="fade"?j.fadeTo(l,0,function(){m(0)}):m(l)}};d.load=function(u){var n,c,s,q=d.prep;E=e;l=h[g];u||(a=cb(b.extend({},b.data(l,r))));w(Q);w(W,a.onLoad);a.h=a.height?p(a.height,o)-y-C:a.innerHeight&&p(a.innerHeight,o);a.w=a.width?p(a.width,m)-z-D:a.innerWidth&&p(a.innerWidth,m);a.mw=a.w;a.mh=a.h;if(a.maxWidth){a.mw=p(a.maxWidth,m)-z-D;a.mw=a.w&&a.w<a.mw?a.w:a.mw}if(a.maxHeight){a.mh=p(a.maxHeight,o)-y-C;a.mh=a.h&&a.h<a.mh?a.h:a.mh}n=a.href;K.show();if(a.inline){f().hide().insertBefore(b(n)[0]).one(Q,function(){b(this).replaceWith(k.children())});q(b(n))}else if(a.iframe){j.one(hb,function(){var c=b("<iframe name='"+(new Date).getTime()+"' frameborder=0"+(a.scrolling?"":" scrolling='no'")+(A?" allowtransparency='true'":"")+" style='width:100%; height:100%; border:0; display:block;'/>");c[0].src=a.href;c.appendTo(k).one(Q,function(){c[0].src='//about:blank'})});q(" ")}else if(a.html)q(a.html);else if(U(n)){c=new Image;c.onload=function(){var e;c.onload=null;c.id=i+"Photo";b(c).css({border:t,display:"block",cssFloat:"left"});if(a.scalePhotos){s=function(){c.height-=c.height*e;c.width-=c.width*e};if(a.mw&&c.width>a.mw){e=(c.width-a.mw)/c.width;s()}if(a.mh&&c.height>a.mh){e=(c.height-a.mh)/c.height;s()}}if(a.h)c.style.marginTop=Math.max(a.h-c.height,0)/2+"px";h[1]&&(g<h.length-1||a.loop)&&b(c).css({cursor:"pointer"}).click(d.next);if(A)c.style.msInterpolationMode="bicubic";setTimeout(function(){q(c)},1)};setTimeout(function(){c.src=n},1)}else n&&J.load(n,function(d,c,a){q(c==="error"?"Request unsuccessful: "+a.statusText:b(this).children())})};d.next=function(){if(!E){g=g<h.length-1?g+1:0;d.load()}};d.prev=function(){if(!E){g=g?g-1:h.length-1;d.load()}};d.close=function(){if(u&&!O){O=e;u=c;w(N,a.onCleanup);n.unbind("."+i+" ."+P);x.fadeTo("fast",0);j.stop().fadeTo("fast",0,function(){w(Q);k.remove();j.add(x).css({opacity:1,cursor:q}).hide();setTimeout(function(){O=c;w(eb,a.onClosed)},1)})}};d.element=function(){return b(l)};d.settings=gb;b(d.init)})(jQuery,this) \ No newline at end of file
diff --git a/extensions/Dashboard/web/js/jquery.csv.min.js b/extensions/Dashboard/web/js/jquery.csv.min.js
new file mode 100644
index 0000000..6f9ab3f
--- /dev/null
+++ b/extensions/Dashboard/web/js/jquery.csv.min.js
@@ -0,0 +1,4 @@
+(function(){function p(a){return function(c){return c.split(a)}}function q(a,c,f,i){return function(e){e=e.split(c);for(var g=[],d,b=0,h=e.length;b<h;b++)if(d=e[b].match(f)){d=d[0];for(var j=b;j<h;j++)if(e[j].charAt(e[j].length-1)==d)break;b=e.slice(b,j+1).join(a);b=b.replace(i[d],d);g.push(b.substr(1,b.length-2));b=j}else g.push(e[b]);return g}}function m(a,c,f){a=typeof a=="undefined"?",":a;c=typeof c=="undefined"?'"':c;f=typeof f=="undefined"?"\r\n":f;for(var i=c?c.split(""):[],e=RegExp("["+a+
+"]"),g=RegExp("^["+c+"]"),d=0,b={},h;h=i[d];d++)b[h]=RegExp(h+h,"g");return[RegExp("["+f+"]*$"),RegExp("["+f+"]["+f+"]*"),c?q(a,e,g,b):p(e)]}if("a,,b".split(",").length<3){var n=n||String.prototype.split;String.prototype.split=function(a,c){if(!(a instanceof RegExp))return n.apply(this,arguments);if(c===undefined||+c<0)c=false;else{c=Math.floor(+c);if(!c)return[]}var f=(a.global?"g":"")+(a.ignoreCase?"i":"")+(a.multiline?"m":""),i=RegExp("^"+a.source+"$",f),e=[],g=0,d=0,b;for(a.global||(a=RegExp(a.source,
+"g"+f));(!c||d++<=c)&&(b=a.exec(this));){if((f=!b[0].length)&&a.lastIndex>b.index)a.lastIndex=b.index;if(a.lastIndex>g){b.length>1&&b[0].replace(i,function(){for(var h=1;h<arguments.length-2;h++)if(arguments[h]===undefined)b[h]=undefined});e=e.concat(this.slice(g,b.index),b.index===this.length?[]:b.slice(1));g=a.lastIndex}f&&a.lastIndex++}return g===this.length?a.test("")?e:e.concat(""):c?e:e.concat(this.slice(g))}}jQuery.extend({csv:function(a,c,f){a=m(a,c,f);var i=a[0],e=a[1],g=a[2];return function(d){d=
+d.replace(i,"").split(e);for(var b=0,h=d.length;b<h;b++)d[b]=g(d[b]);return d}},csv2json:function(a,c,f){a=m(a,c,f);var i=a[0],e=a[1],g=a[2];return function(d){d=d.replace(i,"").split(e);for(var b=g(d[0]),h=b.length,j=[],l=1,r=d.length;l<r;l++){for(var s=g(d[l]),k=0,o={};k<h;k++)o[b[k]]=s[k];j.push(o)}return j}}})})(jQuery);
diff --git a/extensions/Dashboard/web/js/jquery.tablesorter-update.js b/extensions/Dashboard/web/js/jquery.tablesorter-update.js
new file mode 100644
index 0000000..cf4680b
--- /dev/null
+++ b/extensions/Dashboard/web/js/jquery.tablesorter-update.js
@@ -0,0 +1,988 @@
+/*
+ *
+ * TableSorter 2.0 - Client-side table sorting with ease!
+ * Version 2.0.5d (update)
+ * @requires jQuery v1.2.3
+ *
+ * Copyright (c) 2007 Christian Bach
+ * Examples and docs at: http://tablesorter.com
+ * Dual licensed under the MIT and GPL licenses:
+ * http://www.opensource.org/licenses/mit-license.php
+ * http://www.gnu.org/licenses/gpl.html
+ *
+ */
+/**
+ *
+ * @description Create a sortable table with multi-column sorting capabilitys
+ *
+ * @example $('table').tablesorter();
+ * @desc Create a simple tablesorter interface.
+ *
+ * @example $('table').tablesorter({ sortList:[[0,0],[1,0]] });
+ * @desc Create a tablesorter interface and sort on the first and secound column in ascending order.
+ *
+ * @example $('table').tablesorter({ headers: { 0: { sorter: false}, 1: {sorter: false} } } });
+ * @desc Create a tablesorter interface and disableing the first and secound column headers.
+ *
+ * @example $('table').tablesorter({ headers: { 0: {sorter: "digit"}, 1: {sorter: "currency"} } });
+ * @desc Create a tablesorter interface and set a column parser for the first and secound column.
+ *
+ *
+ * @param Object settings An object literal containing key/value pairs to provide optional settings.
+ *
+ * @option String cssHeader (optional) A string of the class name to be appended to sortable tr elements in the thead of the table.
+ * Default value: "header"
+ *
+ * @option String cssAsc (optional) A string of the class name to be appended to sortable tr elements in the thead on a ascending sort.
+ * Default value: "headerSortUp"
+ *
+ * @option String cssDesc (optional) A string of the class name to be appended to sortable tr elements in the thead on a descending sort.
+ * Default value: "headerSortDown"
+ *
+ * @option String sortInitialOrder (optional) A string of the inital sorting order can be asc or desc.
+ * Default value: "asc"
+ *
+ * @option String sortMultisortKey (optional) A string of the multi-column sort key.
+ * Default value: "shiftKey"
+ *
+ * @option String textExtraction (optional) A string of the text-extraction method to use.
+ * For complex html structures inside td cell set this option to "complex",
+ * on large tables the complex option can be slow.
+ * Default value: "simple"
+ *
+ * @option Object headers (optional) An array containing the forces sorting rules.
+ * This option let's you specify a default sorting rule.
+ * Default value: null
+ *
+ * @option Array sortList (optional) An array containing the forces sorting rules.
+ * This option let's you specify a default sorting rule.
+ * Default value: null
+ *
+ * @option Array sortForce (optional) An array containing forced sorting rules.
+ * This option let's you specify a default sorting rule, which is prepended to user-selected rules.
+ * Default value: null
+ *
+ * @option Array sortAppend (optional) An array containing forced sorting rules.
+ * This option let's you specify a default sorting rule, which is appended to user-selected rules.
+ * Default value: null
+ *
+ * @option Boolean widthFixed (optional) Boolean flag indicating if tablesorter should apply fixed widths to the table columns.
+ * This is usefull when using the pager companion plugin.
+ * This options requires the dimension jquery plugin.
+ * Default value: false
+ *
+ * @option Boolean cancelSelection (optional) Boolean flag indicating if tablesorter should cancel selection of the table headers text.
+ * Default value: true
+ *
+ * @option Boolean locale (optional) A locale String indicating which date format and decimal point to use.
+ * Default value: us
+ *
+ * @option Boolean debug (optional) Boolean flag indicating if tablesorter should display debuging information usefull for development.
+ *
+ * @option Boolean useUI (optional) Boolean flag indicating if tablesorter use the ui theme classes.
+ * Default value: false
+ * @type jQuery
+ *
+ * @name tablesorter
+ *
+ * @cat Plugins/Tablesorter
+ *
+ * @author Christian Bach/christian.bach@polyester.se
+ */
+
+(function($) {
+ $.extend({
+ tablesorter: new function() {
+
+ var parsers = [], widgets = [];
+
+ this.defaults = {
+ cssHeader: "header",
+ cssAsc: "headerSortUp",
+ cssDesc: "headerSortDown",
+ cssChildRow: "expand-child",
+ cssUI: {widget: "ui-widget ui-widget-content ui-corner-all", header: "ui-widget-header ui-corner-all", hover: "ui-state-hover", icon: "ui-icon", iconBoth: "ui-icon-arrowthick-2-n-s", iconDesc: "ui-icon-arrowthick-1-n", iconAsc: "ui-icon-arrowthick-1-s" },
+ sortInitialOrder: "asc",
+ sortMultiSortKey: "shiftKey",
+ sortForce: null,
+ sortAppend: null,
+ textExtraction: "simple",
+ parsers: {},
+ widgets: [],
+ widgetZebra: {css: ["even","odd"]},
+ headers: {},
+ widthFixed: false,
+ cancelSelection: true,
+ sortList: [],
+ headerList: [],
+ locale: "us",
+ format:{us: {decimal: '.', date: '/'}, en: {decimal: '.', date: '/'}, eu: {decimal: ',', date: '.'}, de: {decimal: ',', date: '.'}},
+ onRenderHeader: null,
+ selectorHeaders: 'thead th',
+ useUI: false,
+ debug: false
+ };
+
+ /* debuging utils */
+ function benchmark(s,d) {
+ log(s + "," + (new Date().getTime() - d.getTime()) + "ms");
+ }
+
+ this.benchmark = benchmark;
+
+ function log(s) {
+ if (typeof console != "undefined" && typeof console.debug != "undefined") {
+ console.log(s);
+ } else {
+ alert(s);
+ }
+ }
+
+ /* parsers utils */
+ function buildParserCache(table,$headers) {
+
+ if(table.config.debug) { var parsersDebug = ""; }
+
+ var rows = table.tBodies[0].rows;
+
+ if(table.tBodies[0].rows[0]) {
+
+ var list = [], cells = rows[0].cells, l = cells.length;
+
+ for (var i=0;i < l; i++) {
+ var p = false;
+
+ if($.metadata && ($($headers[i]).metadata() && $($headers[i]).metadata().sorter) ) {
+
+ p = getParserById($($headers[i]).metadata().sorter);
+
+ } else if((table.config.headers[i] && table.config.headers[i].sorter)) {
+
+ p = getParserById(table.config.headers[i].sorter);
+ }
+ if(!p) {
+ p = detectParserForColumn(table,cells[i]);
+ }
+
+ if(table.config.debug) { parsersDebug += "column:" + i + " parser:" +p.id + "\n"; }
+
+ list.push(p);
+ }
+ }
+
+ if(table.config.debug) { log(parsersDebug); }
+
+ return list;
+ };
+
+ function detectParserForColumn(table,node) {
+ var l = parsers.length;
+ for(var i=1; i < l; i++) {
+ if(parsers[i].is($.trim(getElementText(table.config,node)),table,node)) {
+ return parsers[i];
+ }
+ }
+ // 0 is always the generic parser (text)
+ return parsers[0];
+ }
+
+ function getParserById(name) {
+ var l = parsers.length;
+ for(var i=0; i < l; i++) {
+ if(parsers[i].id.toLowerCase() == name.toLowerCase()) {
+ return parsers[i];
+ }
+ }
+ return false;
+ }
+
+ /* utils */
+ function buildCache(table) {
+
+ if(table.config.debug) { var cacheTime = new Date(); }
+
+
+ var totalRows = (table.tBodies[0] && table.tBodies[0].rows.length) || 0,
+ totalCells = (table.tBodies[0].rows[0] && table.tBodies[0].rows[0].cells.length) || 0,
+ parsers = table.config.parsers,
+ cache = {row: [], normalized: []};
+
+ for (var i=0;i < totalRows; ++i) {
+
+ /** Add the table data to main data array */
+ var c = $(table.tBodies[0].rows[i]), cols = [];
+
+ // if this is a child row, add it to the last row's children and continue to the next row
+ if( c.hasClass(table.config.cssChildRow) ){
+ cache.row[cache.row.length-1] = cache.row[cache.row.length-1].add(c);
+ // go to the next for loop
+ continue;
+ }
+
+ cache.row.push(c);
+
+ for(var j=0; j < totalCells; ++j) {
+ cols.push(parsers[j].format(getElementText(table.config,c[0].cells[j]),table,c[0].cells[j]));
+ }
+
+ cols.push(cache.normalized.length); // add position for rowCache
+ cache.normalized.push(cols);
+ cols = null;
+ };
+
+ if(table.config.debug) { benchmark("Building cache for " + totalRows + " rows:", cacheTime); }
+
+ return cache;
+ };
+
+ function getElementText(config,node) {
+
+ if(!node) return "";
+
+ var t = "";
+
+ if(config.textExtraction == "simple") {
+ if(node.childNodes[0] && node.childNodes[0].hasChildNodes()) {
+ t = node.childNodes[0].innerHTML;
+ } else {
+ t = node.innerHTML;
+ }
+ } else {
+ if(typeof(config.textExtraction) == "function") {
+ t = config.textExtraction(node);
+ } else {
+ t = $(node).text();
+ }
+ }
+ return t;
+ }
+
+ function appendToTable(table,cache) {
+
+ if(table.config.debug) {var appendTime = new Date();}
+
+ var c = cache,
+ r = c.row,
+ n= c.normalized,
+ totalRows = n.length,
+ checkCell = (n[0].length-1),
+ tableBody = $(table.tBodies[0]),
+ rows = [];
+
+ for (var i=0;i < totalRows; i++) {
+ var pos = n[i][checkCell];
+ rows.push(r[pos]);
+ if(!table.config.appender) {
+
+ var o = r[pos];
+ var l = o.length;
+ for(var j=0; j < l; j++) {
+ tableBody[0].appendChild(o[j]);
+ }
+
+ //tableBody.append(r[n[i][checkCell]]);
+ }
+ }
+
+ if(table.config.appender) {
+
+ table.config.appender(table,rows);
+ }
+
+ rows = null;
+
+ if(table.config.debug) { benchmark("Rebuilt table:", appendTime); }
+
+ //apply table widgets
+ applyWidget(table);
+
+ // trigger sortend
+ setTimeout(function() {
+ $(table).trigger("sortEnd");
+ },0);
+
+ };
+
+ function buildHeaders(table) {
+
+ if(table.config.debug) { var time = new Date(); }
+
+ var config = table.config;
+ var meta = ($.metadata) ? true : false; //, tableHeadersRows = [];
+
+ //for(var i = 0; i < table.tHead.rows.length; i++) { tableHeadersRows[i]=0; };
+
+ $tableHeaders = $(config.selectorHeaders,table).each(function(index) {
+
+ this.column = index;
+ this.order = formatSortingOrder(config.sortInitialOrder);
+ this.count = this.order;
+
+ if(checkHeaderMetadata(this) || checkHeaderOptions(table,index)) this.sortDisabled = true;
+
+ if(!this.sortDisabled) {
+ if (config.useUI) {
+ // add span element
+ $(this).prepend('<span></span>').children("span").addClass(config.cssUI.icon);
+ $(this).hover(function () {$(this).addClass(config.cssUI.hover);}, function () {$(this).removeClass(config.cssUI.hover);});
+ }
+ else {
+ if( config.onRenderHeader ) config.onRenderHeader.apply(this);
+ }
+ }
+
+ // add cell to headerList
+ config.headerList[index]= this;
+ });
+
+ if(table.config.debug) { benchmark("Built headers:", time); log($tableHeaders); }
+
+ return $tableHeaders;
+
+ };
+
+ function setupCss(table, $headers) {
+ var c = table.config;
+
+ if(c.useUI) {
+ $(table).parent("div:first").addClass(c.cssUI["widget"]);
+ $headers.addClass(c.cssUI.header);
+ return [c.cssUI.iconDesc,c.cssUI.iconAsc,c.cssUI.iconBoth];
+
+ }
+ else {
+ $headers.addClass(c.cssHeader);
+ return [c.cssDesc,c.cssAsc];
+ }
+ }
+
+ function checkCellColSpan(table, rows, row) {
+ var arr = [], r = table.tHead.rows, c = r[row].cells;
+
+ for(var i=0; i < c.length; i++) {
+ var cell = c[i];
+
+ if ( cell.colSpan > 1) {
+ arr = arr.concat(checkCellColSpan(table, headerArr,row++));
+ } else {
+ if(table.tHead.length == 1 || (cell.rowSpan > 1 || !r[row+1])) {
+ arr.push(cell);
+ }
+ //headerArr[row] = (i+row);
+ }
+ }
+ return arr;
+ };
+
+ function checkHeaderMetadata(cell) {
+ if(($.metadata) && ($(cell).metadata().sorter === false)) { return true; };
+ return false;
+ }
+
+ function checkHeaderOptions(table,i) {
+ if((table.config.headers[i]) && (table.config.headers[i].sorter === false)) { return true; };
+ return false;
+ }
+
+ function applyWidget(table) {
+ var c = table.config.widgets;
+ var l = c.length;
+ for(var i=0; i < l; i++) {
+
+ getWidgetById(c[i]).format(table);
+ }
+
+ }
+
+ function getWidgetById(name) {
+ var l = widgets.length;
+ for(var i=0; i < l; i++) {
+ if(widgets[i].id.toLowerCase() == name.toLowerCase() ) {
+ return widgets[i];
+ }
+ }
+ };
+
+ function formatSortingOrder(v) {
+ if(typeof(v) != "Number") {
+ return (v.toLowerCase() == "desc") ? 1 : 0;
+ } else {
+ return (v == 1) ? 1 : 0;
+ }
+ }
+
+ function isValueInArray(v, a) {
+ var l = a.length;
+ for(var i=0; i < l; i++) {
+ if(a[i][0] == v) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ function setHeadersCss(table,$headers, list, css) {
+ var c = table.config;
+ // remove all header information
+ if(c.useUI) {
+ $headers.children("span").removeClass(css[0]).removeClass(css[1]).addClass(css[2]);
+ } else {
+ $headers.removeClass(css[0]).removeClass(css[1]);
+ }
+
+ var h = [];
+ $headers.each(function(offset) {
+ if(!this.sortDisabled) {
+ h[this.column] = $(this);
+ }
+ });
+
+ var l = list.length;
+ for(var i=0; i < l; i++) {
+ if (c.useUI) {
+ h[list[i][0]].children("span").removeClass(css[2]).addClass(css[list[i][1]]);
+ } else {
+ h[list[i][0]].addClass(css[list[i][1]]);
+ }
+ }
+ }
+
+ function fixColumnWidth(table,$headers) {
+ var c = table.config;
+ if(c.widthFixed) {
+ var colgroup = $('<colgroup>');
+ $("tr:first td",table.tBodies[0]).each(function() {
+ colgroup.append($('<col>').css('width',$(this).width()));
+ });
+ $(table).prepend(colgroup);
+ };
+ }
+
+ function updateHeaderSortCount(table,sortList) {
+ var c = table.config, l = sortList.length;
+ for(var i=0; i < l; i++) {
+ var s = sortList[i], o = c.headerList[s[0]];
+ o.count = s[1];
+ o.count++;
+ }
+ }
+
+ /* sorting methods */
+ function multisort(table,sortList,cache) {
+
+ if(table.config.debug) { var sortTime = new Date(); }
+
+ var dynamicExp = "var sortWrapper = function(a,b) {", l = sortList.length;
+
+ // TODO: inline functions.
+ for(var i=0; i < l; i++) {
+
+ var c = sortList[i][0];
+ var order = sortList[i][1];
+ //var s = (getCachedSortType(table.config.parsers,c) == "text") ? ((order == 0) ? "sortText" : "sortTextDesc") : ((order == 0) ? "sortNumeric" : "sortNumericDesc");
+ //var s = (table.config.parsers[c].type == "text") ? ((order == 0) ? makeSortText(c) : makeSortTextDesc(c)) : ((order == 0) ? makeSortNumeric(c) : makeSortNumericDesc(c));
+ var s = (table.config.parsers[c].type == "text") ? ((order == 0) ? makeSortFunction("text", "asc", c) : makeSortFunction("text", "desc", c)) : ((order == 0) ? makeSortFunction("numeric", "asc", c) : makeSortFunction("numeric", "desc", c));
+ var e = "e" + i;
+
+ dynamicExp += "var " + e + " = " + s; // + "(a[" + c + "],b[" + c + "]); ";
+ dynamicExp += "if(" + e + ") { return " + e + "; } ";
+ dynamicExp += "else { ";
+
+ }
+
+ // if value is the same keep orignal order
+ var orgOrderCol = cache.normalized[0].length - 1;
+ dynamicExp += "return a[" + orgOrderCol + "]-b[" + orgOrderCol + "];";
+
+ for(var i=0; i < l; i++) {
+ dynamicExp += "}; ";
+ }
+
+ dynamicExp += "return 0; ";
+ dynamicExp += "}; ";
+
+ if(table.config.debug) { benchmark("Evaling expression:" + dynamicExp, new Date()); }
+
+ eval(dynamicExp);
+
+ cache.normalized.sort(sortWrapper);
+
+ if(table.config.debug) { benchmark("Sorting on " + sortList.toString() + " and dir " + order+ " time:", sortTime); }
+
+ return cache;
+ };
+
+ function makeSortFunction(type, direction, index) {
+ var a = "a[" + index + "]", b = "b[" + index + "]";
+ if (type == 'text' && direction == 'asc') {
+ return "(" + a + " == " + b + " ? 0 : (" + a + " === null ? Number.POSITIVE_INFINITY : (" + b + " === null ? Number.NEGATIVE_INFINITY : (" + a + " < " + b + ") ? -1 : 1 )));";
+ } else if (type == 'text' && direction == 'desc') {
+ return "(" + a + " == " + b + " ? 0 : (" + a + " === null ? Number.POSITIVE_INFINITY : (" + b + " === null ? Number.NEGATIVE_INFINITY : (" + b + " < " + a + ") ? -1 : 1 )));";
+ } else if (type == 'numeric' && direction == 'asc') {
+ return "(" + a + " === null && " + b + " === null) ? 0 :(" + a + " === null ? Number.POSITIVE_INFINITY : (" + b + " === null ? Number.NEGATIVE_INFINITY : " + a + " - " + b + "));";
+ } else if (type == 'numeric' && direction == 'desc') {
+ return "(" + a + " === null && " + b + " === null) ? 0 :(" + a + " === null ? Number.POSITIVE_INFINITY : (" + b + " === null ? Number.NEGATIVE_INFINITY : " + b + " - " + a + "));";
+ }
+ };
+
+ function makeSortText(i) {
+ return "((a[" + i + "] < b[" + i + "]) ? -1 : ((a[" + i + "] > b[" + i + "]) ? 1 : 0));";
+ };
+
+ function makeSortTextDesc(i) {
+ return "((b[" + i + "] < a[" + i + "]) ? -1 : ((b[" + i + "] > a[" + i + "]) ? 1 : 0));";
+ };
+
+ function makeSortNumeric(i) {
+ return "a[" + i + "]-b[" + i + "];";
+ };
+
+ function makeSortNumericDesc(i) {
+ return "b[" + i + "]-a[" + i + "];";
+ };
+
+
+ function sortText(a,b) {
+ return ((a < b) ? -1 : ((a > b) ? 1 : 0));
+ };
+
+ function sortTextDesc(a,b) {
+ return ((b < a) ? -1 : ((b > a) ? 1 : 0));
+ };
+
+ function sortNumeric(a,b) {
+ return a-b;
+ };
+
+ function sortNumericDesc(a,b) {
+ return b-a;
+ };
+
+ function getCachedSortType(parsers,i) {
+ return parsers[i].type;
+ };
+
+ /* public methods */
+ this.construct = function(settings) {
+
+ return this.each(function() {
+
+ if(!this.tHead || !this.tBodies) return;
+
+ var $this, $document,$headers, cache, config, shiftDown = 0, sortOrder;
+
+ this.config = {};
+
+ config = $.extend(this.config, $.tablesorter.defaults, settings);
+
+ // store common expression for speed
+ $this = $(this);
+
+ // save the settings where they read
+ $.data(this, "tablesorter", config);
+
+ // build headers
+ $headers = buildHeaders(this);
+
+ // try to auto detect column type, and store in tables config
+ this.config.parsers = buildParserCache(this,$headers);
+
+ // build the cache for the tbody cells
+ cache = buildCache(this);
+
+ // get class names and setup UI if needed
+ var sortCSS = setupCss(this, $headers);
+
+ // fixate columns if the users supplies the fixedWidth option
+ fixColumnWidth(this);
+
+ // apply event handling to headers
+ // this is to big, perhaps break it out?
+ $headers.click(function(e) {
+
+ var totalRows = ($this[0].tBodies[0] && $this[0].tBodies[0].rows.length) || 0;
+
+ if(!this.sortDisabled && totalRows > 0) {
+
+ // Only call sortStart if sorting is enabled.
+ $this.trigger("sortStart");
+
+ // store exp, for speed
+ var $cell = $(this);
+
+ // get current column index
+ var i = this.column;
+
+ // get current column sort order
+ this.order = this.count++ % 2;
+
+ // user only whants to sort on one column
+ if(!e[config.sortMultiSortKey]) {
+
+ // flush the sort list
+ config.sortList = [];
+
+ if(config.sortForce != null) {
+ var a = config.sortForce;
+ for(var j=0; j < a.length; j++) {
+ if(a[j][0] != i) {
+ config.sortList.push(a[j]);
+ }
+ }
+ }
+
+ // add column to sort list
+ config.sortList.push([i,this.order]);
+
+ // multi column sorting
+ } else {
+ // the user has clicked on an all ready sortet column.
+ if(isValueInArray(i,config.sortList)) {
+
+ // revers the sorting direction for all tables.
+ for(var j=0; j < config.sortList.length; j++) {
+ var s = config.sortList[j], o = config.headerList[s[0]];
+ if(s[0] == i) {
+ o.count = s[1];
+ o.count++;
+ s[1] = o.count % 2;
+ }
+ }
+ } else {
+ // add column to sort list array
+ config.sortList.push([i,this.order]);
+ }
+ };
+ setTimeout(function() {
+ //set css for headers
+ setHeadersCss($this[0],$headers,config.sortList,sortCSS);
+ appendToTable($this[0],multisort($this[0],config.sortList,cache));
+ },1);
+ // stop normal event by returning false
+ return false;
+ }
+ // cancel selection
+ }).mousedown(function() {
+ if(config.cancelSelection) {
+ this.onselectstart = function() {return false;};
+ return false;
+ }
+ });
+
+ // apply easy methods that trigger binded events
+ $this.bind("update",function() {
+ var me = this;
+ setTimeout(function() {
+ // rebuild parsers.
+ me.config.parsers = buildParserCache(me,$headers);
+ // rebuild the cache map
+ cache = buildCache(me);
+ },1);
+ }).bind("updateCell",function(e,cell) {
+ var config = this.config;
+ // get position from the dom.
+ var pos = [(cell.parentNode.rowIndex - 1),cell.cellIndex];
+ // update cache
+ cache.normalized[pos[0]][pos[1]] = config.parsers[pos[1]].format(getElementText(config,cell),cell);
+
+ }).bind("sorton",function(e,list) {
+
+ $(this).trigger("sortStart");
+
+ config.sortList = list;
+
+ // update and store the sortlist
+ var sortList = config.sortList;
+
+ // update header count index
+ updateHeaderSortCount(this,sortList);
+
+ //set css for headers
+ setHeadersCss(this,$headers,sortList,sortCSS);
+
+ // sort the table and append it to the dom
+ appendToTable(this,multisort(this,sortList,cache));
+
+ }).bind("appendCache",function() {
+
+ appendToTable(this,cache);
+
+ }).bind("applyWidgetId",function(e,id) {
+
+ getWidgetById(id).format(this);
+
+ }).bind("applyWidgets",function() {
+ // apply widgets
+ applyWidget(this);
+ });
+
+ if($.metadata && ($(this).metadata() && $(this).metadata().sortlist)) {
+ config.sortList = $(this).metadata().sortlist;
+ }
+ // if user has supplied a sort list to constructor.
+ if(config.sortList.length > 0) {
+ $this.trigger("sorton",[config.sortList]);
+ }
+ else {
+ // apply widgets only if there is no sort list in constructor
+ applyWidget(this);
+ }
+ });
+ };
+
+ this.addParser = function(parser) {
+ var l = parsers.length, a = true;
+ for(var i=0; i < l; i++) {
+ if(parsers[i].id.toLowerCase() == parser.id.toLowerCase()) {
+ a = false;
+ }
+ }
+ if(a) { parsers.push(parser); };
+ };
+
+ this.addWidget = function(widget) {
+ widgets.push(widget);
+ };
+
+ this.formatDate = function(s,config) {
+ if (config.locale != "us") {
+ var datePoint = '\\'+config.format[config.locale]["date"];
+ s = s.replace(new RegExp('[\\-'+datePoint+']', 'g'), config.format["us"]["date"]);
+ }
+ return s;
+ };
+ this.formatDecimal = function(s,config) {
+ if (config.locale != "us") {
+ s = s.replace(config.format[config.locale]["decimal"], config.format["us"]["decimal"]);
+ }
+ return s;
+ };
+ this.formatFloat = function(s) {
+ var i = parseFloat(s);
+ return (isNaN(i)) ? 0 : i;
+ };
+ this.formatInt = function(s) {
+ var i = parseInt(s);
+ return (isNaN(i)) ? 0 : i;
+ };
+
+ this.isDigit = function(s,config) {
+ var decimalPoint = '\\' + config.format[config.locale]["decimal"];
+ var exp = '/(^[+]?0(' + decimalPoint +'0+)?$)|(^([-+]?[0-9]*)$)|(^([-+]?((0?[0-9]*)' + decimalPoint +'(0*[0-9]*)))$)|(^[-+]?[0-9]*' + decimalPoint +'0+$)/';
+ return RegExp(exp).test($.trim(s));
+ };
+
+ this.clearTableBody = function(table) {
+ if($.browser.msie) {
+ function empty() {
+ while ( this.firstChild ) this.removeChild( this.firstChild );
+ }
+ empty.apply(table.tBodies[0]);
+ } else {
+ table.tBodies[0].innerHTML = "";
+ }
+ };
+ }
+ });
+
+ // extend plugin scope
+ $.fn.extend({
+ tablesorter: $.tablesorter.construct
+ });
+
+ // make shortcut
+ var ts = $.tablesorter;
+
+ // add default parsers
+ ts.addParser({
+ id: "text",
+ is: function(s) {
+ return true;
+ },
+ format: function(s) {
+ return $.trim(s.toLowerCase());
+ },
+ type: "text"
+ });
+
+ ts.addParser({
+ id: "digit",
+ is: function(s,table) {
+ var c = table.config;
+ return $.tablesorter.isDigit(s,c);
+ },
+ format: function(s,table) {
+ var c = table.config;
+ s = $.tablesorter.formatDecimal(s,c);
+ return $.tablesorter.formatFloat(s);
+ },
+ type: "numeric"
+ });
+
+ ts.addParser({
+ id: "currency",
+ is: function(s) {
+ return /^[£$€?.,]/.test(s);
+ },
+ format: function(s,table) {
+ var c = table.config;
+ s = $.tablesorter.formatDecimal(s,c);
+ return $.tablesorter.formatFloat(s.replace(new RegExp(/[£$€]/g),""));
+ },
+ type: "numeric"
+ });
+
+ ts.addParser({
+ id: "ipAddress",
+ is: function(s) {
+ return /^\d{2,3}[\.]\d{1,3}[\.]\d{1,3}[\.]\d{1,3}$/.test(s);
+ },
+ format: function(s) {
+ var a = s.split("."), r = "", l = a.length;
+ for(var i = 0; i < l; i++) {
+ var item = a[i];
+ if(item.length == 2) {
+ r += "0" + item;
+ } else {
+ r += item;
+ }
+ }
+ return $.tablesorter.formatFloat(r);
+ },
+ type: "numeric"
+ });
+
+ ts.addParser({
+ id: "url",
+ is: function(s) {
+ return /^(https?|ftp|file):\/\/$/.test(s);
+ },
+ format: function(s) {
+ return jQuery.trim(s.replace(new RegExp(/(https?|ftp|file):\/\//),''));
+ },
+ type: "text"
+ });
+
+ ts.addParser({
+ id: "isoDate",
+ is: function(s) {
+ return /^\d{4}[\/-]\d{1,2}[\/-]\d{1,2}$/.test(s);
+ },
+ format: function(s) {
+ return $.tablesorter.formatFloat((s != "") ? new Date(s.replace(new RegExp(/-/g),"/")).getTime() : "0");
+ },
+ type: "numeric"
+ });
+
+ ts.addParser({
+ id: "percent",
+ is: function(s) {
+ return /\%$/.test($.trim(s));
+ },
+ format: function(s,table) {
+ var c = table.config;
+ s = $.tablesorter.formatDecimal(s,c);
+ return $.tablesorter.formatFloat(s.replace(new RegExp(/%/g),""));
+ },
+ type: "numeric"
+ });
+
+ ts.addParser({
+ id: "usLongDate",
+ is: function(s) {
+ return s.match(new RegExp(/^[A-Za-z]{3,10}\.? [0-9]{1,2}, ([0-9]{4}|'?[0-9]{2}) (([0-2]?[0-9]:[0-5][0-9])|([0-1]?[0-9]:[0-5][0-9]\s(AM|PM)))$/));
+ },
+ format: function(s) {
+ return $.tablesorter.formatFloat(new Date(s).getTime());
+ },
+ type: "numeric"
+ });
+
+ ts.addParser({
+ id: "mediumDate",
+ is: function(s,table) {
+ var c = table.config;
+ var datePoint = '\\' + c.format[c.locale]["date"];
+ var expStr = '\\d{1,2}[\\-'+datePoint+']\\d{1,2}[\\-'+datePoint+']\\d{4}';
+ return RegExp(expStr).test(s);
+ },
+ format: function(s,table) {
+ var c = table.config;
+ s = $.tablesorter.formatDate(s,c);
+ if(c.locale == "us") {
+ // reformat the string in ISO format
+ s = s.replace(/(\d{1,2})[\/](\d{1,2})[\/](\d{4})/, "$3/$1/$2");
+ } else if(c.locale == "en" || c.locale == "de" || c.locale == "eu") {
+ //reformat the string in ISO format
+ s = s.replace(/(\d{1,2})[\/](\d{1,2})[\/](\d{4})/, "$3/$2/$1");
+ }
+ return $.tablesorter.formatFloat((s != "") ? new Date(s).getTime() : 0);
+ },
+ type: "numeric"
+ });
+
+ ts.addParser({
+ id: "shortDate",
+ is: function(s,table) {
+ var c = table.config;
+ var datePoint = '\\'+c.format[c.locale]["date"];
+ var expStr = '\\d{1,2}[\\-'+datePoint+']\\d{1,2}[\\-'+datePoint+']\\d{2}';
+ return RegExp(expStr).test(s);
+ },
+ format: function(s,table) {
+ var c = table.config;
+ s = $.tablesorter.formatDate(s,c);
+ if(c.locale == "us") {
+ // reformat the string in non-ISO format
+ s = s.replace(/(\d{1,2})[\/](\d{1,2})[\/](\d{2})/, "$1/$2/$3");
+ } else if(c.locale == "en" || c.locale == "de" || c.locale == "eu") {
+ //reformat the string in non-ISO format
+ s = s.replace(/(\d{1,2})[\/](\d{1,2})[\/](\d{2})/, "$2/$1/$3");
+ }
+ return $.tablesorter.formatFloat((s != "") ? new Date(s).getTime() : 0);
+ },
+ type: "numeric"
+ });
+
+
+ ts.addParser({
+ id: "time",
+ is: function(s) {
+ return /^(([0-2]?[0-9]:[0-5][0-9])|([0-1]?[0-9]:[0-5][0-9]\s(am|pm)))$/.test(s);
+ },
+ format: function(s) {
+ return $.tablesorter.formatFloat(new Date("2000/01/01 " + s).getTime());
+ },
+ type: "numeric"
+ });
+
+ ts.addParser({
+ id: "metadata",
+ is: function(s) {
+ return false;
+ },
+ format: function(s,table,cell) {
+ var c = table.config, p = (!c.parserMetadataName) ? 'sortValue' : c.parserMetadataName;
+ return $(cell).metadata()[p];
+ },
+ type: "numeric"
+ });
+
+ // add default widgets
+ ts.addWidget({
+ id: "zebra",
+ format: function(table) {
+ if(table.config.debug) { var time = new Date(); }
+ var $tr, row = -1, odd;
+ // loop through the visible rows
+ $("tr:visible",table.tBodies[0]).each(function (i){
+ $tr = $(this);
+ // style children rows the same way the parent row was styled
+ if( !$tr.hasClass(table.config.cssChildRow) ) row++;
+ odd = (row%2 == 0);
+ $tr.removeClass(table.config.widgetZebra.css[odd?0:1]).addClass(table.config.widgetZebra.css[odd?1:0]);
+ });
+ if(table.config.debug) { $.tablesorter.benchmark("Applying Zebra widget", time); }
+ }
+ });
+})(jQuery); \ No newline at end of file
diff --git a/extensions/Dashboard/web/js/widgets.js b/extensions/Dashboard/web/js/widgets.js
new file mode 100644
index 0000000..f0f5a71
--- /dev/null
+++ b/extensions/Dashboard/web/js/widgets.js
@@ -0,0 +1,497 @@
+/**
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * Copyright (C) 2012 Jolla Ltd.
+ * Contact: Pami Ketolainen <pami.ketolainen@jollamobile.com>
+ *
+ * The Initial Developer of the Original Code is "Nokia Corporation"
+ * Portions created by the Initial Developer are Copyright (C) 2011 the
+ * Initial Developer. All Rights Reserved.
+ */
+
+
+/**
+ * URL widget implementation.
+ */
+Widget.addClass('url', Widget.extend({
+ // See Widget.render().
+ render: function()
+ {
+ this.base();
+ this._iframe = this._child('iframe')
+ this._iframe.load(this._onIframeLoad.bind(this));
+ },
+
+ /**
+ * Handle completion of IFRAME load by attempting to modify (and replace)
+ * the child document using the elements matched by the configured CSS
+ * selector, if any. This may fail due to browser same-origin policy (e.g.
+ * different domain).
+ */
+ _onIframeLoad: function()
+ {
+ try {
+ // Any property access will throw if same origin policy in effect.
+ var location = this._iframe[0].contentDocument.location;
+ } catch(e) {
+ if(window.console) {
+ console.error('_onIframeLoad: can\'t apply CSS: %o', e);
+ }
+ return;
+ }
+
+ var body = $('body', this._iframe[0].contentDocument);
+ if(this.state.data.selector) {
+ var matched = $(this.state.data.selector, body);
+ body.children().remove();
+ matched.appendTo(body);
+ matched.css('padding', '0px');
+ body.css('margin', '0px');
+ }
+ body.find('a').attr('target', '_blank');
+ $('html', this._iframe).css('margin', '0px');
+ },
+
+ onClickLoadurl: function()
+ {
+ var url = this.settingsDialog.find("[name='url']").val();
+ if (url) {
+ this._iframe.attr("src", url);
+ }
+ },
+
+ reload: function()
+ {
+ var url = !url ? this.state.data.url : url;
+ this._iframe.attr("src", this.state.data.url);
+ }
+}));
+
+
+/**
+ * RSS widget implementation.
+ */
+Widget.addClass('rss', Widget.extend({
+
+ // See Widget.reload().
+ reload: function()
+ {
+ this.loader(true);
+ if(! this.state.data.url) {
+ this.error('Please set a feed URL.');
+ return;
+ }
+ var rpc = new Rpc('Dashboard', 'get_feed', { url: this.state.data.url });
+ rpc.fail($.proxy(this, "error"));
+ rpc.done($.proxy(this, "_onReloadDone"));
+ },
+
+ /**
+ * Populate our template with the feed contents.
+ *
+ * @param feed
+ * Feed JSON object, as returned by get_feed RPC.
+ */
+ _onReloadDone: function(feed)
+ {
+ this.loader(false);
+ var items = this.contentElement.find(".feed-items");
+ items.empty();
+
+ if(feed.link) {
+ $('h2 a', this.contentElement).attr('href', feed.link);
+ } else {
+ $('h2 a', this.contentElement).attr('href', "");
+ }
+ $('h2 a', this.contentElement).text(feed.title);
+
+ var length = Math.min(feed.items.length,
+ DASHBOARD_CONFIG.rss_max_items);
+ for(var i = 0; i < length; i++) {
+ items.append(this._formatItem(feed.items[i]));
+ }
+ },
+
+ /**
+ * Format a single item.
+ *
+ * @param item
+ * Item JSON object as returned by get_feed RPC.
+ */
+ _formatItem: function(item)
+ {
+ var template = cloneTemplate('#rss-template-item');
+ $('h3 a', template).text(item.title);
+ $('h3 a', template).attr('href', item.link);
+ $('.updated-text', template).text(item.modified);
+ $('.description-text', template).text(this._sanitize(item.description));
+ return template;
+ },
+
+ _sanitize: function(html)
+ {
+ // TODO
+ html = html || '';
+ var s = html.replace(/^<.+>/, '');
+ return s.replace(/<.+/g, '');
+ }
+}));
+
+
+/**
+ * Text widget implementation.
+ */
+Widget.addClass('text', Widget.extend({
+ reload: function()
+ {
+ this.contentElement.find("div.text").html(this.state.data.text);
+ }
+}));
+
+/**
+ * Confirmation support to colorbox.close()
+ *
+ * Adds new configuration option to colorbox
+ *
+ * onCloseConfirm: callback
+ *
+ * Where callback is a function which should return true if it is ok to close
+ * the box.
+ */
+$.colorbox.originalClose = $.colorbox.close;
+$.colorbox.close = function() {
+ element = $.colorbox.element();
+ var confirmClose = element.data().colorbox.onCloseConfirm;
+ if (typeof confirmClose == "undefined") {
+ $.colorbox.originalClose();
+ } else {
+ if (confirmClose() == true) $.colorbox.originalClose();
+ }
+}
+
+/**
+ * Helper function to hide the header and footer from bugzilla page
+ *
+ * @param frame
+ * The iframe element
+ */
+function stripBugzillaPage(frame)
+{
+ var contents = $(frame).contents();
+ contents.find("div#header").hide();
+ contents.find("div#footer").hide();
+}
+
+/**
+ * Generic bugs widget class
+ */
+var BugsWidget = Widget.extend(
+{
+ DEFAULT_COLUMNS: ["bug_id", "bug_status", "short_desc"],
+ DEFAULT_SORT: [0, -1 , -1],
+
+ // See Widget.render().
+ render: function()
+ {
+ this.base();
+ this._queryField = $("input[name='query']", this.settingsDialog);
+ this._queryButton = $("input[name='editquery']", this.settingsDialog);
+ this._queryButton.click($.proxy(this, '_openQueryEditor'));
+
+ this._columnList = $("ul.buglist-column-select", this.settingsDialog);
+ for (var name in BUGLIST_COLUMNS) {
+ var item = $("<li/>");
+ var check = $("<input type='checkbox'/>");
+ check.attr("name", name);
+ item.append(check).append(BUGLIST_COLUMNS[name]);
+ item.append(cloneTemplate("#buglist-sort-template"));
+ this._columnList.append(item);
+ }
+ this._columnList.sortable();
+ },
+
+ _sortOrder: function()
+ {
+ return $.isEmptyObject(this.state.data.sort) ?
+ this.DEFAULT_SORT : this.state.data.sort;
+ },
+ _columnNames: function()
+ {
+ return $.isEmptyObject(this.state.data.columns) ?
+ this.DEFAULT_COLUMNS : this.state.data.columns;
+ },
+
+ _setCustomSetting: function()
+ {
+ this.base();
+ var columns = this._columnNames();
+ var sort = this._sortOrder();
+
+ // Iterate backwards so we can easily push the selected on top of the
+ // list in right order
+ for (var i = columns.length - 1; i >= 0; i--) {
+ var check = this._columnList.find("input[name='" + columns[i] + "']");
+ check.prop("checked", true);
+ var item = check.parent();
+ try {
+ $("select", item).val(sort[i]);
+ } catch(e) {
+ }
+ item.remove();
+ this._columnList.prepend(item);
+ }
+ },
+
+ _getCustomSettings: function()
+ {
+ var data = this.base();
+ data.columns = [];
+ data.sort = [];
+ this._columnList.find("input:checked").each(function(){
+ var check = $(this);
+ data.columns.push(check.attr("name"));
+ data.sort.push(Number(check.siblings("select").val()) || 0);
+ });
+ return data;
+ },
+
+ /**
+ * See Widget._updateStateData()
+ */
+ _updateStateData: function(changes)
+ {
+ var changed = false;
+ var changes = $.extend({}, changes);
+ // columns and sort in data are arrays so they need special checking
+ for (var key in {columns:1, sort:1}) {
+ var list = changes[key];
+ if(list == undefined) continue;
+ delete changes[key];
+
+ var orig = this.state.data[key] || [];
+ if (list.length != orig.length) {
+ this.state.data[key] = list;
+ changed = true;
+ continue;
+ }
+
+ for (var i = 0; i < list.length; i++) {
+ if (list[i] != orig[i]) {
+ this.state.data[key] = list;
+ changed = true;
+ continue;
+ }
+ }
+ }
+ // Call the base implementation for remaining simple values
+ return this.base(changes) || changed;
+ },
+
+ /**
+ * Open query editor box when edit query button is clicked
+ */
+ _openQueryEditor: function()
+ {
+ $.colorbox({
+ close: "Apply",
+ width: "90%",
+ height: "90%",
+ iframe: true,
+ fastIframe: false,
+ href: "query.cgi" + this._queryField.val(),
+ onCloseConfirm: $.proxy(this, '_confirmQueryClose'),
+ onCleanup: $.proxy(this, '_getSearchQuery'),
+ onComplete: $.proxy(this, '_onEditBoxReady')
+ });
+ },
+
+ /**
+ * Hide unneeded elements from the page in edit box
+ */
+ _onEditBoxReady: function()
+ {
+ var frame = $("#cboxContent iframe")
+ stripBugzillaPage(frame);
+ frame.load(function(event){stripBugzillaPage(event.target);});
+ },
+
+ /**
+ * Get the query string from buglist page open in edit box
+ */
+ _getSearchQuery: function()
+ {
+ try {
+ var loc = $("#cboxContent iframe").contents()[0].location;
+ if (loc.pathname.match("buglist.cgi")) {
+ this._queryField.val(loc.search);
+ }
+ } catch(e) {
+ if (window.console) console.error(e);
+ alert("Failed to get the query string");
+ }
+ },
+
+ /**
+ * Confirm that query edit box is on buglist page before closing
+ */
+ _confirmQueryClose: function()
+ {
+ var path = "";
+ try {
+ path = $("#cboxContent iframe").contents()[0].location.pathname;
+ } catch(e) {
+ if (window.console) console.error(e);
+ return true;
+ }
+ if (path.match("buglist.cgi") == null) {
+ return confirm(
+ "After entering the search parameters, "
+ + "you need to click 'search' to open "
+ + "the buglist before closing. "
+ + "Do you really want to close?");
+ } else {
+ return true;
+ }
+ },
+
+ // See Widget.reload().
+ reload: function()
+ {
+ this.loader(true);
+ if (this.state.data.query) {
+ // set ctype to csv in query parameters
+ params = getQueryParams(this.state.data.query);
+ params.ctype = "csv";
+
+ // Set columns
+ var columns = this._columnNames();
+ if ($.isEmptyObject(columns)) columns = this.DEFAULT_COLUMNS;
+ params.columnlist = columns.join(",");
+
+ // Create request to fetch the data and set result callbacks
+ var jqxhr = $.get("buglist.cgi" + getQueryString(params), {});
+ jqxhr.success(this._onReloadDone.bind(this));
+ jqxhr.error(this._onReloadFail.bind(this));
+ } else {
+ this.error("Set the query string in widget options");
+ }
+ },
+
+ /**
+ * Display an error message when bug list fetching
+ *
+ * @param error
+ * String error from backend.
+ */
+ _onReloadFail: function(error)
+ {
+ this.error(error);
+ },
+
+ /**
+ * Create the bug list table
+ *
+ * @param result
+ * Result JSON object, as returned by search RPC.
+ */
+ _onReloadDone: function(data)
+ {
+ this.loader(false);
+ try {
+ var buglist = $.csv()(data);
+ } catch(e) {
+ this.error("Failed to parse bug list");
+ return;
+ }
+ if (buglist.length == 1) {
+ var content = $("<p>Sorry, no bugs found</p>");
+ } else {
+ var tableorder = [];
+ var sort = this._sortOrder();
+ var columns = this._columnNames();
+ // Create table
+ var content = $("<table class='buglist tablesorter'/>");
+ content.append("<thead/>");
+ content.append("<tbody/>");
+ // Create header
+ var header = $("<tr/>");
+ for (var i = 0; i < buglist[0].length; i++)
+ {
+ var name = buglist[0][i];
+ var index = columns.indexOf(name);
+ if (sort[index] > -1) {
+ tableorder.push([i, sort[index]]);
+ }
+ header.append("<th>" + BUGLIST_COLUMNS[name] + "</th>");
+ }
+ $("thead", content).append(header);
+ // Create rows
+ for(var i = 1; i < buglist.length; i++)
+ {
+ var row = $("<tr/>");
+ for(var j = 0; j < buglist[i].length; j++)
+ {
+ var value = buglist[i][j];
+ var formatter = this["_format_" + buglist[0][j]];
+ if (formatter != undefined) {
+ value = formatter(value);
+ }
+ var cell = $("<td/>");
+ cell.append(value);
+ row.append(cell);
+ }
+ $("tbody", content).append(row);
+ }
+ // Make it pretty and sortable
+ content.tablesorter({sortList: tableorder, useUI: true});
+ }
+ this.contentElement.html(content);
+ },
+
+ /**
+ * Formatter for bug_id in data table
+ */
+ _format_bug_id: function(value)
+ {
+ var link = $("<a target='_blank'></a>");
+ link.text(value);
+ link.attr("href", "show_bug.cgi?id=" + value);
+ return link;
+ }
+});
+Widget.addClass('bugs', BugsWidget);
+
+/**
+ * My bugs widget implementation
+ */
+var MyBugsWidget = BugsWidget.extend({
+
+ TEMPLATE_TYPE: "bugs",
+
+ constructor: function(dashboard, state)
+ {
+ this.base(dashboard, state);
+ // Exact same query as the default "My bugs" search
+ this.state.data.query = getQueryString({
+ bug_status: ['UNCONFIRMED', 'NEW', 'ASSIGNED', 'REOPENED'],
+ email1: this._dashboard.config.user_login,
+ emailassigned_to1: 1,
+ emailreporter1: 1,
+ emailtype1: 'exact',
+ 'field0-0-0': 'bug_status',
+ 'type0-0-0': 'notequals',
+ 'value0-0-0': 'UNCONFIRMED',
+ 'field0-0-1': 'reporter',
+ 'type0-0-1': 'equals',
+ 'value0-0-1': this._dashboard.config.user_login
+ });
+ },
+ render: function()
+ {
+ this.base();
+ this.settingsDialog.find(".buglist-query-entry").hide();
+ }
+});
+Widget.addClass('mybugs', MyBugsWidget);