diff options
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"> + + <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> + + <button name="overlaysettings" type="button">Overlay Settings</button> + + <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> + + <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> + + + <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 Binary files differnew file mode 100644 index 0000000..cb59a04 --- /dev/null +++ b/extensions/Dashboard/web/css/images/ajax-loader.gif diff --git a/extensions/Dashboard/web/css/images/bg_gradient.png b/extensions/Dashboard/web/css/images/bg_gradient.png Binary files differnew file mode 100644 index 0000000..e39598b --- /dev/null +++ b/extensions/Dashboard/web/css/images/bg_gradient.png diff --git a/extensions/Dashboard/web/css/images/border1.png b/extensions/Dashboard/web/css/images/border1.png Binary files differnew file mode 100644 index 0000000..2d0a04d --- /dev/null +++ b/extensions/Dashboard/web/css/images/border1.png diff --git a/extensions/Dashboard/web/css/images/border2.png b/extensions/Dashboard/web/css/images/border2.png Binary files differnew file mode 100644 index 0000000..be02ef4 --- /dev/null +++ b/extensions/Dashboard/web/css/images/border2.png diff --git a/extensions/Dashboard/web/css/images/green.png b/extensions/Dashboard/web/css/images/green.png Binary files differnew file mode 100644 index 0000000..861d8e7 --- /dev/null +++ b/extensions/Dashboard/web/css/images/green.png diff --git a/extensions/Dashboard/web/css/images/green_b.png b/extensions/Dashboard/web/css/images/green_b.png Binary files differnew file mode 100644 index 0000000..59401f9 --- /dev/null +++ b/extensions/Dashboard/web/css/images/green_b.png diff --git a/extensions/Dashboard/web/css/images/icon_add_column.png b/extensions/Dashboard/web/css/images/icon_add_column.png Binary files differnew file mode 100644 index 0000000..a3d21f8 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_add_column.png diff --git a/extensions/Dashboard/web/css/images/icon_add_column_b.png b/extensions/Dashboard/web/css/images/icon_add_column_b.png Binary files differnew file mode 100644 index 0000000..64ef6fd --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_add_column_b.png diff --git a/extensions/Dashboard/web/css/images/icon_add_rss.png b/extensions/Dashboard/web/css/images/icon_add_rss.png Binary files differnew file mode 100644 index 0000000..26a85d0 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_add_rss.png diff --git a/extensions/Dashboard/web/css/images/icon_add_rss_b.png b/extensions/Dashboard/web/css/images/icon_add_rss_b.png Binary files differnew file mode 100644 index 0000000..adc8420 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_add_rss_b.png diff --git a/extensions/Dashboard/web/css/images/icon_clear_workspace.png b/extensions/Dashboard/web/css/images/icon_clear_workspace.png Binary files differnew file mode 100644 index 0000000..8b35bd5 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_clear_workspace.png diff --git a/extensions/Dashboard/web/css/images/icon_clear_workspace_b.png b/extensions/Dashboard/web/css/images/icon_clear_workspace_b.png Binary files differnew file mode 100644 index 0000000..34d791c --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_clear_workspace_b.png diff --git a/extensions/Dashboard/web/css/images/icon_del_column.png b/extensions/Dashboard/web/css/images/icon_del_column.png Binary files differnew file mode 100644 index 0000000..37f853e --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_del_column.png diff --git a/extensions/Dashboard/web/css/images/icon_del_column_b.png b/extensions/Dashboard/web/css/images/icon_del_column_b.png Binary files differnew file mode 100644 index 0000000..35c74f1 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_del_column_b.png diff --git a/extensions/Dashboard/web/css/images/icon_new_bugs.png b/extensions/Dashboard/web/css/images/icon_new_bugs.png Binary files differnew file mode 100644 index 0000000..e7ffe2f --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_new_bugs.png diff --git a/extensions/Dashboard/web/css/images/icon_new_bugs_b.png b/extensions/Dashboard/web/css/images/icon_new_bugs_b.png Binary files differnew file mode 100644 index 0000000..5e2218c --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_new_bugs_b.png diff --git a/extensions/Dashboard/web/css/images/icon_new_rss.png b/extensions/Dashboard/web/css/images/icon_new_rss.png Binary files differnew file mode 100644 index 0000000..26a85d0 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_new_rss.png diff --git a/extensions/Dashboard/web/css/images/icon_new_rss_b.png b/extensions/Dashboard/web/css/images/icon_new_rss_b.png Binary files differnew file mode 100644 index 0000000..adc8420 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_new_rss_b.png diff --git a/extensions/Dashboard/web/css/images/icon_new_text.png b/extensions/Dashboard/web/css/images/icon_new_text.png Binary files differnew file mode 100644 index 0000000..ce7db40 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_new_text.png diff --git a/extensions/Dashboard/web/css/images/icon_new_text_b.png b/extensions/Dashboard/web/css/images/icon_new_text_b.png Binary files differnew file mode 100644 index 0000000..0530a35 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_new_text_b.png diff --git a/extensions/Dashboard/web/css/images/icon_new_widget.png b/extensions/Dashboard/web/css/images/icon_new_widget.png Binary files differnew file mode 100644 index 0000000..80c4cda --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_new_widget.png diff --git a/extensions/Dashboard/web/css/images/icon_new_widget_b.png b/extensions/Dashboard/web/css/images/icon_new_widget_b.png Binary files differnew file mode 100644 index 0000000..b9a0bf4 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_new_widget_b.png diff --git a/extensions/Dashboard/web/css/images/icon_new_xeyes.png b/extensions/Dashboard/web/css/images/icon_new_xeyes.png Binary files differnew file mode 100644 index 0000000..62ca90e --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_new_xeyes.png diff --git a/extensions/Dashboard/web/css/images/icon_new_xeyes_b.png b/extensions/Dashboard/web/css/images/icon_new_xeyes_b.png Binary files differnew file mode 100644 index 0000000..39ba402 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_new_xeyes_b.png diff --git a/extensions/Dashboard/web/css/images/icon_overlay.png b/extensions/Dashboard/web/css/images/icon_overlay.png Binary files differnew file mode 100644 index 0000000..d1724eb --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_overlay.png diff --git a/extensions/Dashboard/web/css/images/icon_overlay_b.png b/extensions/Dashboard/web/css/images/icon_overlay_b.png Binary files differnew file mode 100644 index 0000000..0ead21e --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_overlay_b.png diff --git a/extensions/Dashboard/web/css/images/icon_package_get.png b/extensions/Dashboard/web/css/images/icon_package_get.png Binary files differnew file mode 100644 index 0000000..d371cc7 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_package_get.png diff --git a/extensions/Dashboard/web/css/images/icon_reset_columns.png b/extensions/Dashboard/web/css/images/icon_reset_columns.png Binary files differnew file mode 100644 index 0000000..b511bc9 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_reset_columns.png diff --git a/extensions/Dashboard/web/css/images/icon_reset_columns_b.png b/extensions/Dashboard/web/css/images/icon_reset_columns_b.png Binary files differnew file mode 100644 index 0000000..c892dfe --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_reset_columns_b.png diff --git a/extensions/Dashboard/web/css/images/icon_rss_link.png b/extensions/Dashboard/web/css/images/icon_rss_link.png Binary files differnew file mode 100644 index 0000000..85d7d50 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_rss_link.png diff --git a/extensions/Dashboard/web/css/images/icon_rss_link_b.png b/extensions/Dashboard/web/css/images/icon_rss_link_b.png Binary files differnew file mode 100644 index 0000000..6d3e134 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_rss_link_b.png diff --git a/extensions/Dashboard/web/css/images/icon_rss_popup.png b/extensions/Dashboard/web/css/images/icon_rss_popup.png Binary files differnew file mode 100644 index 0000000..672da2c --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_rss_popup.png diff --git a/extensions/Dashboard/web/css/images/icon_rss_popup_b.png b/extensions/Dashboard/web/css/images/icon_rss_popup_b.png Binary files differnew file mode 100644 index 0000000..90b7e06 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_rss_popup_b.png diff --git a/extensions/Dashboard/web/css/images/icon_save.png b/extensions/Dashboard/web/css/images/icon_save.png Binary files differnew file mode 100644 index 0000000..27b7712 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_save.png diff --git a/extensions/Dashboard/web/css/images/icon_save_b.png b/extensions/Dashboard/web/css/images/icon_save_b.png Binary files differnew file mode 100644 index 0000000..2e51d44 --- /dev/null +++ b/extensions/Dashboard/web/css/images/icon_save_b.png diff --git a/extensions/Dashboard/web/css/images/internet_explorer/borderBottomCenter.png b/extensions/Dashboard/web/css/images/internet_explorer/borderBottomCenter.png Binary files differnew file mode 100644 index 0000000..12e0e9a --- /dev/null +++ b/extensions/Dashboard/web/css/images/internet_explorer/borderBottomCenter.png diff --git a/extensions/Dashboard/web/css/images/internet_explorer/borderBottomLeft.png b/extensions/Dashboard/web/css/images/internet_explorer/borderBottomLeft.png Binary files differnew file mode 100644 index 0000000..b7a474a --- /dev/null +++ b/extensions/Dashboard/web/css/images/internet_explorer/borderBottomLeft.png diff --git a/extensions/Dashboard/web/css/images/internet_explorer/borderBottomRight.png b/extensions/Dashboard/web/css/images/internet_explorer/borderBottomRight.png Binary files differnew file mode 100644 index 0000000..6b6cb15 --- /dev/null +++ b/extensions/Dashboard/web/css/images/internet_explorer/borderBottomRight.png diff --git a/extensions/Dashboard/web/css/images/internet_explorer/borderMiddleLeft.png b/extensions/Dashboard/web/css/images/internet_explorer/borderMiddleLeft.png Binary files differnew file mode 100644 index 0000000..8f248ac --- /dev/null +++ b/extensions/Dashboard/web/css/images/internet_explorer/borderMiddleLeft.png diff --git a/extensions/Dashboard/web/css/images/internet_explorer/borderMiddleRight.png b/extensions/Dashboard/web/css/images/internet_explorer/borderMiddleRight.png Binary files differnew file mode 100644 index 0000000..336e19c --- /dev/null +++ b/extensions/Dashboard/web/css/images/internet_explorer/borderMiddleRight.png diff --git a/extensions/Dashboard/web/css/images/internet_explorer/borderTopCenter.png b/extensions/Dashboard/web/css/images/internet_explorer/borderTopCenter.png Binary files differnew file mode 100644 index 0000000..7cb1da4 --- /dev/null +++ b/extensions/Dashboard/web/css/images/internet_explorer/borderTopCenter.png diff --git a/extensions/Dashboard/web/css/images/internet_explorer/borderTopLeft.png b/extensions/Dashboard/web/css/images/internet_explorer/borderTopLeft.png Binary files differnew file mode 100644 index 0000000..d733b6c --- /dev/null +++ b/extensions/Dashboard/web/css/images/internet_explorer/borderTopLeft.png diff --git a/extensions/Dashboard/web/css/images/internet_explorer/borderTopRight.png b/extensions/Dashboard/web/css/images/internet_explorer/borderTopRight.png Binary files differnew file mode 100644 index 0000000..0d88683 --- /dev/null +++ b/extensions/Dashboard/web/css/images/internet_explorer/borderTopRight.png diff --git a/extensions/Dashboard/web/css/images/loading.gif b/extensions/Dashboard/web/css/images/loading.gif Binary files differnew file mode 100644 index 0000000..602ce3c --- /dev/null +++ b/extensions/Dashboard/web/css/images/loading.gif diff --git a/extensions/Dashboard/web/css/images/note_new.png b/extensions/Dashboard/web/css/images/note_new.png Binary files differnew file mode 100644 index 0000000..04f2cf4 --- /dev/null +++ b/extensions/Dashboard/web/css/images/note_new.png diff --git a/extensions/Dashboard/web/css/images/orange.png b/extensions/Dashboard/web/css/images/orange.png Binary files differnew file mode 100644 index 0000000..59d01db --- /dev/null +++ b/extensions/Dashboard/web/css/images/orange.png diff --git a/extensions/Dashboard/web/css/images/orange_b.png b/extensions/Dashboard/web/css/images/orange_b.png Binary files differnew file mode 100644 index 0000000..d34848f --- /dev/null +++ b/extensions/Dashboard/web/css/images/orange_b.png diff --git a/extensions/Dashboard/web/css/images/oro.png b/extensions/Dashboard/web/css/images/oro.png Binary files differnew file mode 100644 index 0000000..85d7d50 --- /dev/null +++ b/extensions/Dashboard/web/css/images/oro.png diff --git a/extensions/Dashboard/web/css/images/prefs.png b/extensions/Dashboard/web/css/images/prefs.png Binary files differnew file mode 100644 index 0000000..57cf823 --- /dev/null +++ b/extensions/Dashboard/web/css/images/prefs.png diff --git a/extensions/Dashboard/web/css/images/prefs_b.png b/extensions/Dashboard/web/css/images/prefs_b.png Binary files differnew file mode 100644 index 0000000..4c0f705 --- /dev/null +++ b/extensions/Dashboard/web/css/images/prefs_b.png diff --git a/extensions/Dashboard/web/css/images/red.png b/extensions/Dashboard/web/css/images/red.png Binary files differnew file mode 100644 index 0000000..a3e8231 --- /dev/null +++ b/extensions/Dashboard/web/css/images/red.png diff --git a/extensions/Dashboard/web/css/images/red_b.png b/extensions/Dashboard/web/css/images/red_b.png Binary files differnew file mode 100644 index 0000000..19479da --- /dev/null +++ b/extensions/Dashboard/web/css/images/red_b.png diff --git a/extensions/Dashboard/web/css/images/reload.png b/extensions/Dashboard/web/css/images/reload.png Binary files differnew file mode 100644 index 0000000..54c578e --- /dev/null +++ b/extensions/Dashboard/web/css/images/reload.png diff --git a/extensions/Dashboard/web/css/images/reload_b.png b/extensions/Dashboard/web/css/images/reload_b.png Binary files differnew file mode 100644 index 0000000..5ca8cf3 --- /dev/null +++ b/extensions/Dashboard/web/css/images/reload_b.png diff --git a/extensions/Dashboard/web/css/images/resize_handler.png b/extensions/Dashboard/web/css/images/resize_handler.png Binary files differnew file mode 100644 index 0000000..53cee87 --- /dev/null +++ b/extensions/Dashboard/web/css/images/resize_handler.png diff --git a/extensions/Dashboard/web/css/images/save.png b/extensions/Dashboard/web/css/images/save.png Binary files differnew file mode 100644 index 0000000..e5c21e8 --- /dev/null +++ b/extensions/Dashboard/web/css/images/save.png diff --git a/extensions/Dashboard/web/css/images/save_b.png b/extensions/Dashboard/web/css/images/save_b.png Binary files differnew file mode 100644 index 0000000..e90a42c --- /dev/null +++ b/extensions/Dashboard/web/css/images/save_b.png diff --git a/extensions/Dashboard/web/css/images/sizer_left.png b/extensions/Dashboard/web/css/images/sizer_left.png Binary files differnew file mode 100644 index 0000000..b3b5ea9 --- /dev/null +++ b/extensions/Dashboard/web/css/images/sizer_left.png diff --git a/extensions/Dashboard/web/css/images/sizer_mid.png b/extensions/Dashboard/web/css/images/sizer_mid.png Binary files differnew file mode 100644 index 0000000..a4684ba --- /dev/null +++ b/extensions/Dashboard/web/css/images/sizer_mid.png diff --git a/extensions/Dashboard/web/css/images/sizer_right.png b/extensions/Dashboard/web/css/images/sizer_right.png Binary files differnew file mode 100644 index 0000000..cb8298e --- /dev/null +++ b/extensions/Dashboard/web/css/images/sizer_right.png diff --git a/extensions/Dashboard/web/css/images/sizer_right_b.png b/extensions/Dashboard/web/css/images/sizer_right_b.png Binary files differnew file mode 100644 index 0000000..6dce056 --- /dev/null +++ b/extensions/Dashboard/web/css/images/sizer_right_b.png diff --git a/extensions/Dashboard/web/css/images/ts_asc.gif b/extensions/Dashboard/web/css/images/ts_asc.gif Binary files differnew file mode 100644 index 0000000..7415786 --- /dev/null +++ b/extensions/Dashboard/web/css/images/ts_asc.gif diff --git a/extensions/Dashboard/web/css/images/ts_bg.gif b/extensions/Dashboard/web/css/images/ts_bg.gif Binary files differnew file mode 100644 index 0000000..fac668f --- /dev/null +++ b/extensions/Dashboard/web/css/images/ts_bg.gif diff --git a/extensions/Dashboard/web/css/images/ts_desc.gif b/extensions/Dashboard/web/css/images/ts_desc.gif Binary files differnew file mode 100644 index 0000000..3b30b3c --- /dev/null +++ b/extensions/Dashboard/web/css/images/ts_desc.gif diff --git a/extensions/Dashboard/web/css/images/yellow.png b/extensions/Dashboard/web/css/images/yellow.png Binary files differnew file mode 100644 index 0000000..9b3d406 --- /dev/null +++ b/extensions/Dashboard/web/css/images/yellow.png 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); |