Flattened searching: generalized data retrieval via public service
authorLebbeous Fogle-Weekley <lebbeous@esilibrary.com>
Tue, 20 Mar 2012 22:44:42 +0000 (18:44 -0400)
committerMike Rylander <mrylander@gmail.com>
Fri, 30 Mar 2012 21:55:54 +0000 (17:55 -0400)
For a better overview of what this feature is about than what I could
write here, see docs/TechRef/Flattener/design.txt in this commit.

This is the first new feature (as far as I know) to take advantage of
PCRUD fleshing. Very briefly, imagine issuing a query to PCRUD with lots
of arbitrarily deep fleshing, and getting back a set of flat rows with
the fields you need for display/editing/whatever all neatly picked out
as if ready to be displayed in a table or grid-based UI.

FlattenerGrid, which knows how to use this, can potentially replace and avoid
lots of relatively complex (AutoGrid + custom middle layer
methods)-powered interfaces.  AutoGrid interfaces that just work with
one fieldmapper class at a time, more or less, can just keep doing what
they're doing. Little or no advantage to switcihing to flattened data
in that case.

FlattenerGrid is CRUD-complete and has lots of the same features as
AutoGrid, where they make sense, such as the line numbers, checkboxes,
the columnpicker, multisort, etc.  Sample instance at
Open-ILS/exmpales/flattener_test.tt2 .

Signed-off-by: Lebbeous Fogle-Weekley <lebbeous@esilibrary.com>
Signed-off-by: Bill Erickson <berick@esilibrary.com>
Signed-off-by: Mike Rylander <mrylander@gmail.com>

22 files changed:
Open-ILS/examples/apache/eg_vhost.conf
Open-ILS/examples/apache/startup.pl
Open-ILS/examples/tt2/flattener_test.tt2 [new file with mode: 0644]
Open-ILS/src/extras/ils_events.xml
Open-ILS/src/perlmods/lib/OpenILS/Application/Fielder.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Flattener.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/WWW/FlatFielder.pm [new file with mode: 0644]
Open-ILS/src/templates/acq/picklist/user_request.tt2
Open-ILS/src/templates/conify/global/config/barcode_completion.tt2
Open-ILS/src/templates/conify/global/config/circ_limit_set.tt2
Open-ILS/src/templates/conify/global/config/circ_matrix_matchpoint.tt2
Open-ILS/src/templates/conify/global/config/hold_matrix_matchpoint.tt2
Open-ILS/src/templates/vandelay/inc/import_errors.tt2
Open-ILS/web/js/dojo/openils/FlattenerStore.js [new file with mode: 0644]
Open-ILS/web/js/dojo/openils/widget/AutoGrid.js
Open-ILS/web/js/dojo/openils/widget/FlattenerFilterDialog.js [new file with mode: 0644]
Open-ILS/web/js/dojo/openils/widget/FlattenerGrid.js [new file with mode: 0644]
Open-ILS/web/js/dojo/openils/widget/GridColumnPicker.js
Open-ILS/web/js/dojo/openils/widget/PCrudFilterDialog.js
Open-ILS/web/js/dojo/openils/widget/_GridHelperColumns.js [new file with mode: 0644]
Open-ILS/xsl/FlatFielder2HTML.xsl [new file with mode: 0644]
docs/TechRef/Flattener/design.txt [new file with mode: 0644]

index 688f10b..9fd59b2 100644 (file)
@@ -59,6 +59,14 @@ OSRFGatewayConfig /openils/conf/opensrf_core.xml
     Allow from All
 </Location>
 
+# Flattener service
+<Location /opac/extras/flattener>
+    SetHandler perl-script
+    PerlHandler OpenILS::WWW::FlatFielder
+    PerlSendHeader On
+    Allow from All
+</Location>
+
 # ----------------------------------------------------------------------------------
 # Replace broken cover images with a transparent GIF by default
 # ----------------------------------------------------------------------------------
index bb3fa16..f7755b6 100755 (executable)
@@ -13,6 +13,7 @@ use OpenILS::WWW::TemplateBatchBibUpdate qw( /openils/conf/opensrf_core.xml );
 use OpenILS::WWW::EGWeb;
 use OpenILS::WWW::PasswordReset ('/openils/conf/opensrf_core.xml');
 use OpenILS::WWW::IDL2js ('/openils/conf/opensrf_core.xml');
+use OpenILS::WWW::FlatFielder;
 
 # - Uncoment the following 2 lines to make use of the IP redirection code
 # - The IP file should to contain a map with the following format:
diff --git a/Open-ILS/examples/tt2/flattener_test.tt2 b/Open-ILS/examples/tt2/flattener_test.tt2
new file mode 100644 (file)
index 0000000..8f610f6
--- /dev/null
@@ -0,0 +1,49 @@
+[% WRAPPER base.tt2 %]
+[% ctx.page_title = 'Flattener Test' %]
+<!--
+        -->
+<script type="text/javascript">
+    dojo.require("dijit.form.Button");
+    dojo.require("openils.widget.FlattenerGrid");
+</script>
+<div dojoType="dijit.layout.ContentPane" layoutAlign="client">
+    <div dojoType="dijit.layout.ContentPane"
+         layoutAlign="top" class="oils-header-panel">
+        <div>Flattener Test</div>
+        <div>
+            <button dojoType="dijit.form.Button"
+                onClick="grid.showCreateDialog()">New Thing</button>
+            <button dojoType="dijit.form.Button"
+                onClick="grid.deleteSelected()">Delete Selected Thing</button>
+        </div>
+    </div>
+    <!-- <div class="oils-acq-basic-roomy">
+        blah, a dropdown or something here (optional; typical interfaces might
+        have a filtering org select here. Then again, why not use
+        showLoadFilter on the Grid instead?)
+    </div> -->
+    <table
+        id="gridNode"
+        jsid="grid"
+        dojoType="openils.widget.FlattenerGrid"
+        columnPersistKey='"conify.flattener_test"'
+        autoHeight="10"
+        editOnEnter="true"
+        editStyle="pane"
+        showLoadFilter="true"
+        fmClass="'acp'"
+        defaultSort="['call_number']"
+        mapExtras="{copy_status: {path: 'status.name', filter: true}}"
+        query="{'copy_status': ['Available','Reshelving','In process'],'circ_lib': 'BR1'}">
+        <thead>
+            <tr>
+                 <th field="barcode" fpath="barcode" ffilter="true">Barcode</th>
+                <th field="circ_lib_name" fpath="circ_lib.name" ffilter="true">Circulation Library Name</th>
+                <th field="circ_lib" fpath="circ_lib.shortname" ffilter="true">Circulation Library</th>
+                <th field="call_number" fpath="call_number.label" ffilter="true"></th>
+                <th field="shelving_loc" fpath="location.name" ffilter="true">Shelving Location</th>
+            </tr>
+        </thead>
+    </table>
+</div>
+[% END %]
index 59ff421..9708e9e 100644 (file)
        <event code='3' textcode='NO_CHANGE'>
                <desc xml:lang="en-US">No change occurred</desc>
        </event>
+
+    <event code='4' textcode='CACHE_MISS'>
+        <desc xml:lang="en-US">A cached object could not be retrieved by the given reference.</desc>
+    </event>
+
        <event code='1000' textcode='LOGIN_FAILED'>
                <desc xml:lang="en-US">User login failed</desc>
        </event>
index 341b569..343c285 100644 (file)
@@ -23,6 +23,8 @@ use XML::LibXML;
 use XML::LibXML::XPathContext;
 use XML::LibXSLT;
 
+use OpenILS::Application::Flattener;
+
 our %namespace_map = (
     oils_persist=> {ns => 'http://open-ils.org/spec/opensrf/IDL/persistence/v1'},
     oils_obj    => {ns => 'http://open-ils.org/spec/opensrf/IDL/objects/v1'},
@@ -153,6 +155,164 @@ sub generate_methods {
     };
 }
 
+sub register_map {
+    my ($self, $conn, $auth, $hint, $map) = @_;
+
+    my $e = new_editor(authtoken => $auth);
+    return $e->event unless $e->checkauth;
+
+    $key = 'flat_search_' . md5_hex(
+        $hint .
+        OpenSRF::Utils::JSON->perl2JSON( $map )
+    );
+
+    $cache->put_cache( $key => { hint => $hint, map => $map } => $cache_timeout );
+}
+
+__PACKAGE__->register_method(
+    method          => 'register_map',
+    api_name        => 'open-ils.fielder.flattened_search.prepare',
+    argc            => 3,
+    signature       => {
+        params => [
+            {name => "auth", type => "string", desc => "auth token"},
+            {name => "hint", type => "string",
+                desc => "fieldmapper class hint of core object"},
+            {name => "map", type => "object", desc => q{
+                path-field mapping structure. See documentation under
+                docs/TechRef/Flattener in the Evergreen source tree.} }
+        ],
+        return => {
+            desc => q{
+                A key used to reference a prepared flattened search on subsequent
+                calls to open-ils.fielder.flattened_search.execute},
+            type => "string"
+        }
+    }
+);
+
+sub execute_registered_flattened_search {
+    my $self = shift;
+    my $conn = shift;
+    my $auth = shift;
+    my $key  = shift;
+
+    my $e = new_editor(authtoken => $auth);
+    return $e->event unless $e->checkauth;
+    $e->disconnect;
+
+    my $blob = $cache->get_cache( $key ) or
+        return new OpenILS::Event('CACHE_MISS');
+
+    flattened_search( $self, $conn, $auth, $blob->{hint}, $blob->{map}, @_ )
+        if (ref($blob) and $blob->{hint} and $blob->{map});
+}
+
+__PACKAGE__->register_method(
+    method          => 'execute_registered_flattened_search',
+    api_name        => 'open-ils.fielder.flattened_search.execute',
+    stream          => 1,
+    argc            => 5,
+    signature       => {
+        params => [
+            {name => "auth", type => "string", desc => "auth token"},
+            {name => "key", type => "string",
+                desc => "Key for a registered map provided by open-ils.fielder.flattened_search.prepare"},
+            {name => "where", type => "object", desc => q{
+                simplified query clause (like the 'where' clause of a
+                json_query, but different). See documentation under
+                docs/TechRef/Flattener in the Evergreen source tree.} },
+            {name => "slo", type => "object", desc => q{
+                simplified sort/limit/offset object. See documentation under
+                docs/TechRef/Flattener in the Evergreen source tree.} }
+        ],
+        return => {
+            desc => q{
+                A stream of objects flattened to your specifications. See
+                documentation under docs/TechRef/Flattener in the Evergreen
+                source tree.},
+            type => "object"
+        }
+    }
+);
+
+sub flattened_search {
+    my ($self, $conn, $auth, $hint, $map, $where, $slo) = @_;
+
+    # All but the last argument really are necessary.
+    $slo ||= {};
+
+    my $e = new_editor(authtoken => $auth);
+    return $e->event unless $e->checkauth;
+
+    # Process the map to normalize it, and to get all our joins and fleshing
+    # structure into the jffolo.
+    my $jffolo;
+    ($map, $jffolo) =
+        OpenILS::Application::Flattener::process_map($hint, $map);
+
+    # Process the suppied where clause, using our map, to make the
+    # filter.
+    my $filter = OpenILS::Application::Flattener::prepare_filter($map, $where);
+
+    # Process the supplied sort/limit/offset clause and use it to finish the
+    # jffolo.
+    $jffolo = OpenILS::Application::Flattener::finish_jffolo(
+        $hint, $map, $jffolo, $slo
+    );
+
+    # Reach out and touch pcrud (could be cstore, if we wanted to offer
+    # this as a private service).
+    my $pcrud = create OpenSRF::AppSession("open-ils.pcrud");
+    my $req = $pcrud->request(
+        "open-ils.pcrud.search.$hint", $auth, $filter, $jffolo
+    );
+
+    # Stream back flattened results.
+    while (my $resp = $req->recv(timeout => 60)) {
+        $conn->respond(
+            OpenILS::Application::Flattener::process_result(
+                $map, $resp->content
+            )
+        );
+    }
+
+    # Clean up.
+    $pcrud->kill_me;
+
+    return;
+}
+
+__PACKAGE__->register_method(
+    method          => 'flattened_search',
+    api_name        => 'open-ils.fielder.flattened_search',
+    stream          => 1,
+    argc            => 5,
+    signature       => {
+        params => [
+            {name => "auth", type => "string", desc => "auth token"},
+            {name => "hint", type => "string",
+                desc => "fieldmapper class hint of core object"},
+            {name => "map", type => "object", desc => q{
+                path-field mapping structure. See documentation under
+                docs/TechRef/Flattener in the Evergreen source tree.} },
+            {name => "where", type => "object", desc => q{
+                simplified query clause (like the 'where' clause of a
+                json_query, but different). See documentation under
+                docs/TechRef/Flattener in the Evergreen source tree.} },
+            {name => "slo", type => "object", desc => q{
+                simplified sort/limit/offset object. See documentation under
+                docs/TechRef/Flattener in the Evergreen source tree.} }
+        ],
+        return => {
+            desc => q{
+                A stream of objects flattened to your specifications. See
+                documentation under docs/TechRef/Flattener in the Evergreen
+                source tree.},
+            type => "object"
+        }
+    }
+);
 
 1;
 
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Flattener.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Flattener.pm
new file mode 100644 (file)
index 0000000..a6c77d3
--- /dev/null
@@ -0,0 +1,401 @@
+package OpenILS::Application::Flattener;
+
+# This package is not meant to be registered as a stand-alone OpenSRF
+# application, but to be used by high level methods in other services.
+
+use base qw/OpenILS::Application/;
+
+use strict;
+use warnings;
+
+use OpenSRF::EX qw/:try/;
+use OpenSRF::Utils::Logger qw/:logger/;
+use OpenILS::Utils::CStoreEditor q/:funcs/;
+use OpenSRF::Utils::JSON;
+
+sub _fm_link_from_class {
+    my ($class, $field) = @_;
+
+    return Fieldmapper->publish_fieldmapper->{$class}{links}{$field};
+}
+
+sub _flattened_search_single_flesh_wad {
+    my ($hint, $path)  = @_;
+
+    $path = [ @$path ]; # clone for processing here
+    my $class = OpenSRF::Utils::JSON->lookup_class($hint);
+
+    my $flesh_depth = 0;
+    my $flesh_fields = {};
+
+    pop @$path; # last part is just field
+
+    my $piece;
+
+    while ($piece = shift @$path) {
+        my $link = _fm_link_from_class($class, $piece);
+        if ($link) {
+            $flesh_fields->{$hint} ||= [];
+            push @{ $flesh_fields->{$hint} }, $piece;
+            $hint = $link->{class};
+            $class = OpenSRF::Utils::JSON->lookup_class($hint);
+            $flesh_depth++;
+        } else {
+            throw OpenSRF::EX::ERROR("no link $piece on $class");
+        }
+    }
+
+    return {
+        flesh => $flesh_depth,
+        flesh_fields => $flesh_fields
+    };
+}
+
+# returns a join clause AND a string representing the deepest join alias
+# generated.
+sub _flattened_search_single_join_clause {
+    my ($column_name, $hint, $path)  = @_;
+
+    my $class = OpenSRF::Utils::JSON->lookup_class($hint);
+    my $last_ident = $class->Identity;
+
+    $path = [ @$path ]; # clone for processing here
+
+    pop @$path; # last part is just field
+
+    my $core_join = {};
+    my $last_join;
+    my $piece;
+    my $alias;  # yes, we need it out at this scope.
+
+    while ($piece = shift @$path) {
+        my $link = _fm_link_from_class($class, $piece);
+        if ($link) {
+            $hint = $link->{class};
+            $class = OpenSRF::Utils::JSON->lookup_class($hint);
+
+            my $reltype = $link->{reltype};
+            my $field = $link->{key};
+            if ($link->{map}) {
+                # XXX having a non-blank value for map means we'll need
+                # an additional level of join. TODO.
+                throw OpenSRF::EX::ERROR(
+                    "support not yet implemented for links like '$piece' with" .
+                    " non-blank 'map' IDL attribute"
+                );
+            }
+
+            $alias = "__${column_name}_${hint}";
+            my $new_join;
+            if ($reltype eq "has_a") {
+                $new_join = {
+                    class => $hint,
+                    fkey => $piece,
+                    field => $field
+                };
+            } elsif ($reltype eq "has_many" or $reltype eq "might_have") {
+                $new_join = {
+                    class => $hint,
+                    fkey => $last_ident,
+                    field => $field
+                };
+            } else {
+                throw OpenSRF::EX::ERROR("unexpected reltype for link $piece");
+            }
+
+            if ($last_join) {
+                $last_join->{join}{$alias} = $new_join;
+            } else {
+                $core_join->{$alias} = $new_join;
+            }
+
+            $last_ident = $class->Identity;
+            $last_join = $new_join;
+        } else {
+            throw new OpenSRF::EX::ERROR("no link '$piece' on $class");
+        }
+    }
+
+    return ($core_join, $alias);
+}
+
+# When $value is a string (short form of a column definition), it is assumed to
+# be a dot-delimited path.  This will be normalized into a hash (long form)
+# containing and path key, whose value will be made into an array, and true
+# values for sort/filter/display.
+#
+# When $value is already a hash (long form), just make an array of the path key
+# and explicity set any sort/filter/display values not present to 0.
+#
+sub _flattened_search_normalize_map_column {
+    my ($value) = @_;
+
+    if (ref $value eq "HASH") {
+        foreach (qw/sort filter display/) {
+            $value->{$_} = 0 unless exists $value->{$_};
+        }
+        $value->{path} = [split /\./, $value->{path}];
+    } else {
+        $value = {
+            path => [split /\./, $value],
+            sort => 1,
+            filter => 1,
+            display => 1
+        };
+    }
+
+    return $value;
+}
+
+sub _flattened_search_merge_flesh_wad {
+    my ($old, $new) = @_;
+
+    $old->{flesh} ||= 0;
+    $old->{flesh} = $old->{flesh} > $new->{flesh} ? $old->{flesh} : $new->{flesh};
+
+    $old->{flesh_fields} ||= {};
+    foreach my $key (keys %{$new->{flesh_fields}}) {
+        if ($old->{flesh_fields}{$key}) {
+            # For easy bonus points, somebody could take the following block
+            # and make it use Set::Scalar so it's more semantic, which would
+            # mean a new Evergreen dependency.
+            #
+            # The nonobvious point of the following code is to merge the
+            # arrays at $old->{flesh_fields}{$key} and
+            # $new->{flesh_fields}{$key}, treating the arrays as sets.
+
+            my %hash = map { $_ => 1 } (
+                @{ $old->{flesh_fields}{$key} },
+                @{ $new->{flesh_fields}{$key} }
+            );
+            $old->{flesh_fields}{$key} = [ keys(%hash) ];
+        } else {
+            $old->{flesh_fields}{$key} = $new->{flesh_fields}{$key};
+        }
+    }
+}
+
+sub _flattened_search_merge_join_clause {
+    my ($old, $new) = @_;
+
+    %$old = ( %$old, %$new );
+}
+
+sub _flattened_search_expand_filter_column {
+    my ($o, $key, $map) = @_;
+
+    if ($map->{$key}) {
+        my $table = $map->{$key}{last_join_alias};
+        my $column = $map->{$key}{path}[-1];
+
+        if ($table) {
+            $table = "+" . $table;
+            $o->{$table} ||= {};
+
+            $o->{$table}{$column} = $o->{$key};
+            delete $o->{$key};
+
+            return $o->{$table}{$column};
+        } else {    # field must be on core class
+            if ($column ne $key) {
+                $o->{$column} = $o->{$key};
+                delete $o->{$key};
+            }
+            return $o->{$column};
+        }
+    } else {
+        return $o->{$key};
+    }
+}
+
+sub _flattened_search_recursively_apply_map_to_filter {
+    my ($o, $map, $state) = @_;
+
+    $state ||= {};
+
+    if (ref $o eq "HASH") {
+        foreach my $key (keys %$o) {
+            # XXX this business about "in_expr" may prove inadequate, but it's
+            # intended to avoid trying to map things like "between" in
+            # constructs like:
+            #   {"somecolumn": {"between": [1,10]}}
+            # and to that extent, it works.
+
+            if (not $state->{in_expr} and $key =~ /^[a-z]/) {
+                $state->{in_expr} = 1;
+
+                _flattened_search_recursively_apply_map_to_filter(
+                    _flattened_search_expand_filter_column($o, $key, $map),
+                    $map, $state
+                );
+
+                $state->{in_expr} = 0;
+            } else {
+                _flattened_search_recursively_apply_map_to_filter(
+                    $o->{$key}, $map, $state
+                );
+            }
+        }
+    } elsif (ref $o eq "ARRAY") {
+        _flattened_search_recursively_apply_map_to_filter(
+            $_, $map, $state
+        ) foreach @$o;
+    } # else scalar, nothing to do?
+}
+
+# returns a normalized version of the map, and the jffolo (see below)
+sub process_map {
+    my ($hint, $map) = @_;
+
+    $map = { %$map };   # clone map, to work on new copy
+
+    my $jffolo = {    # jffolo: join/flesh/flesh_fields/order_by/limit/offset
+        join => {}
+    };
+
+    foreach my $k (keys %$map) {
+        my $column = $map->{$k} =
+            _flattened_search_normalize_map_column($map->{$k});
+
+        # For display columns, we'll need fleshing.
+        if ($column->{display}) {
+            _flattened_search_merge_flesh_wad(
+                $jffolo,
+                _flattened_search_single_flesh_wad($hint, $column->{path})
+            );
+        }
+
+        # For filter or sort columns, we'll need joining.
+        if ($column->{filter} or $column->{sort}) {
+            my ($clause, $last_join_alias) =
+                _flattened_search_single_join_clause($k,$hint,$column->{path});
+
+            $map->{$k}{last_join_alias} = $last_join_alias;
+            _flattened_search_merge_join_clause($jffolo->{join}, $clause);
+        }
+    }
+
+    return ($map, $jffolo);
+}
+
+# return a filter clause for PCRUD or cstore, by processing the supplied
+# simplifed $where clause using $map.
+sub prepare_filter {
+    my ($map, $where) = @_;
+
+    my $filter = {%$where};
+
+    _flattened_search_recursively_apply_map_to_filter($filter, $map);
+
+    return $filter;
+}
+
+# Return a jffolo with sort/limit/offset from the simplified sort hash (slo)
+# mixed in.  limit and offset are copied as-is.  sort is translated into
+# an order_by that calls simplified column named by their real names by checking
+# the map.
+sub finish_jffolo {
+    my ($core_hint, $map, $jffolo, $slo) = @_;
+
+    $jffolo = { %$jffolo }; # clone
+    $slo = { %$slo };       # clone
+
+    $jffolo->{limit} = $slo->{limit} if exists $slo->{limit};
+    $jffolo->{offset} = $slo->{offset} if exists $slo->{offset};
+
+    return $jffolo unless $slo->{sort};
+
+    # The slo has a special format for 'sort' that gives callers what they
+    # need, but isn't as flexible as json_query's 'order_by'.
+    #
+    # "sort": [{"column1": "asc"}, {"column2": "desc"}]
+    #   or
+    # "sort": ["column1", {"column2": "desc"}]
+    #   or
+    # "sort": {"onlycolumn": "asc"}
+    #   or
+    # "sort": "onlycolumn"
+
+    $jffolo->{order_by} = [];
+
+    # coerce from optional simpler format (see comment blob above)
+    $slo->{sort} = [ $slo->{sort} ] unless ref $slo->{sort} eq "ARRAY";
+
+    foreach my $exp (@{ $slo->{sort} }) {
+        $exp = { $exp => "asc" } unless ref $exp;
+
+        # XXX By assuming that each sort expression is (at most) a single
+        # key/value pair, we preclude the ability to use transforms and the
+        # like for now.
+
+        my ($key) = keys(%$exp);
+
+        if ($map->{$key}) {
+            my $class = $map->{$key}{last_join_alias} || $core_hint;
+            push @{ $jffolo->{order_by} }, {
+                class => $class,
+                field => $map->{$key}{path}[-1],
+                direction => $exp->{$key}
+            };
+        }
+
+        # If the key wasn't defined in the map, we'll leave it out of our
+        # order_by clause.
+    }
+
+    return $jffolo;
+}
+
+# Given a map and a fieldmapper object, return a flat representation as
+# specified by the map's display fields
+sub process_result {
+    my ($map, $fmobj) = @_;
+
+    if (not ref $fmobj) {
+        throw OpenSRF::EX::ERROR(
+            "process_result() was passed an inappropriate second argument"
+        );
+    }
+
+    my $flatrow = {};
+
+    while (my ($key, $mapping) = each %$map) {
+        next unless $mapping->{display};
+
+        my @path = @{ $mapping->{path} };
+        my $field = pop @path;
+
+        my $objs = [$fmobj];
+        while (my $step = shift @path) {
+            $objs = [ map { $_->$step } @$objs ];
+            last unless ref $$objs[0];
+        }
+
+        # We can get arrays of values be either:
+        #  - ending on a $field within a has_many reltype
+        #  - passing through a path that is a has_many reltype
+        if (@$objs > 1 or ref $$objs[0] eq 'ARRAY') {
+            $flatrow->{$key} = [];
+            for my $o (@$objs) {
+                push @{ $flatrow->{$key} }, extract_field_value( $o, $field );
+            }
+        } else {
+            $flatrow->{$key} = extract_field_value( $$objs[0], $field );
+        }
+    }
+
+    return $flatrow;
+}
+
+sub extract_field_value {
+    my $obj = shift;
+    my $field = shift;
+
+    if (ref $obj eq 'ARRAY') {
+        # has_many links return arrays
+        return ( map {$_->$field} @$obj );
+    }
+    return ref $obj ? $obj->$field : undef;
+}
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/FlatFielder.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/FlatFielder.pm
new file mode 100644 (file)
index 0000000..0e5fc98
--- /dev/null
@@ -0,0 +1,242 @@
+package OpenILS::WWW::FlatFielder;
+
+use strict;
+use warnings;
+
+use Apache2::Log;
+use Apache2::Const -compile => qw(
+    OK HTTP_NOT_ACCEPTABLE HTTP_PAYMENT_REQUIRED HTTP_INTERNAL_SERVER_ERROR :log
+);
+use XML::LibXML;
+use XML::LibXSLT;
+use Text::Glob;
+use CGI qw(:all -utf8);
+
+use OpenSRF::Utils::JSON;
+use OpenSRF::AppSession;
+use OpenSRF::Utils::SettingsClient;
+
+use OpenILS::Application::AppUtils;
+my $U = 'OpenILS::Application::AppUtils';
+
+
+my $_parser = new XML::LibXML;
+my $_xslt = new XML::LibXSLT;
+
+# BEGIN package globals
+
+# We'll probably never need this fanciness for autosuggest, but
+# you can add handlers for different requested content-types here, and
+# you can weight them to control what matches requests for things like
+# 'application/*'
+
+
+sub html_ish_output {
+    my ($r, $args, $xslt) = @_;
+    $args->{'stylesheet'} =
+        OpenSRF::Utils::SettingsClient->new->config_value(dirs => 'xsl') . '/' . $xslt;
+    print data_to_xml($args);
+    return Apache2::Const::OK;
+}
+
+my $_output_handler_dispatch = {
+    "text/csv" => {
+        "prio" => 0,
+        "code" => sub {
+            my ($r, $args) = @_;
+            $r->headers_out->set("Content-Disposition" => "attachment; filename=FlatSearch.csv");
+            # Anecdotally, IE 8 needs name= here to provoke downloads, where
+            # other browswers respect filename= in Content-Disposition.  Also,
+            # we might want to make the filename choosable by CGI param later?
+            # Or vary it by timestamp?
+            $r->content_type('text/csv; name=FlatSearch.csv; charset=utf-8');
+            print_data_as_csv($args, \*STDOUT);
+            return Apache2::Const::OK;
+        }
+    },
+    "text/html" => {
+        "prio" => 0,
+        "code" => sub {
+            $_[0]->content_type("text/html; charset=utf-8");
+            print html_ish_output( @_, 'FlatFielder2HTML.xsl' );
+            return Apache2::Const::OK;
+        }
+    },
+    "application/xml" => {
+        "prio" => 0,
+        "code" => sub {
+            my ($r, $args) = @_;
+            $r->content_type("application/xml; charset=utf-8");
+            print data_to_xml($args);
+            return Apache2::Const::OK;
+        }
+    },
+    "application/json" => {
+        "prio" => 1,
+        "code" => sub {
+            my ($r, $args) = @_;
+            $r->content_type("application/json; charset=utf-8");
+            print data_to_json($args);
+            return Apache2::Const::OK;
+        }
+    }
+};
+
+my @_output_handler_types = sort {
+    $_output_handler_dispatch->{$a}->{prio} <=>
+        $_output_handler_dispatch->{$b}->{prio}
+} keys %$_output_handler_dispatch;
+
+# END package globals
+
+=comment
+
+<FlatSearch hint='foo' identifier='bar' label='Foo Bar' FS_key='ad1awe43a3a2a3ra32a23ra32ra23rar23a23r'>
+  <row ordinal='1'>
+    <column name='fiz'>YAY!</column>
+    <column name='faz'>boooo</column>
+  </row>
+  <row ordinal='2'>
+    <column name='fiz'>WHEEE!</column>
+    <column name='faz'>noooo</column>
+  </row>
+</FlatSearch>
+
+=cut
+
+sub data_to_xml {
+    my ($args) = @_;
+
+    my $dom = new XML::LibXML::Document("1.0", "UTF-8");
+    my $fs = $dom->createElement("FlatSearch");
+    $fs->setAttribute("hint", $args->{hint}) if $args->{hint};
+    $fs->setAttribute("identifier", $args->{id_field}) if $args->{id_field};
+    $fs->setAttribute("label", $args->{label_field}) if $args->{label_field};
+    $fs->setAttribute("FS_key", $args->{key}) if $args->{key};
+    $dom->setDocumentElement($fs);
+
+    my $rownum = 1;
+    for my $i (@{$$args{data}}) {
+        my $item = $dom->createElement("row");
+        $item->setAttribute('ordinal', $rownum);
+        $rownum++;
+        for my $k (keys %$i) {
+            my $val = $dom->createElement('column');
+            $val->setAttribute('name', $k);
+            $val->appendText($i->{$k});
+            $item->addChild($val);
+        }
+        $fs->addChild($item);
+    }
+
+    # XML::LibXML::Document::toString() returns an encoded byte string, which
+    # is why we don't need to binmode STDOUT, ':utf8'.
+
+    return $_xslt->parse_stylesheet(
+        $_parser->parse_file( $$args{stylesheet} )
+    )->transform(
+        $dom
+    )->toString if ($$args{stylesheet}); # configured transform, early return
+
+    return $dom->toString();
+}
+
+sub print_data_as_csv {
+    my ($args, $fh) = @_;
+
+    my @keys = sort keys %{ $$args{data}[0] };
+    return unless @keys;
+
+    my $csv = new Text::CSV({ always_quote => 1, eol => "\r\n" });
+
+    $csv->print($fh, \@keys);
+
+    for my $row (@{$$args{data}}) {
+        $csv->print($fh, [map { $row->{$_} } @keys]);
+    }
+}
+
+sub data_to_json {
+    my ($args) = @_;
+
+    # Turns out we don't want the data structure you'd use to initialize an
+    # itemfilereadstore or similar. We just want rows.
+
+#    return OpenSRF::Utils::JSON->perl2JSON({
+#        ($$args{hint} ? (hint => $$args{hint}) : ()),
+#        ($$args{id_field} ? (identifier => $$args{id_field}) : ()),
+#        ($$args{label_field} ? (label => $$args{label_field}) : ()),
+#        ($$args{key} ? (FS_key => $$args{key}) : ()),
+#        items => $$args{data}
+#    });
+    return OpenSRF::Utils::JSON->perl2JSON($args->{data});
+}
+
+# Given data and the Apache request object, this sub picks a sub from a
+# dispatch table based on the list of content-type encodings that the client
+# has indicated it will accept, and calls that sub, which will deliver
+# a response of appropriately encoded data.
+sub output_handler {
+    my ($r, $args) = @_;
+
+    my @types = split /,/, $r->headers_in->{Accept};
+
+    if ($$args{format}) {
+        unshift @types, $$args{format};
+    }
+
+    foreach my $media_range (@types) {
+        $media_range =~ s/;.+$//; # keep type, subtype. lose parameters.
+
+        my ($match) = grep {
+            Text::Glob::match_glob($media_range, $_)
+        } @_output_handler_types;
+
+        if ($match) {
+            return $_output_handler_dispatch->{$match}{code}->($r, $args);
+        }
+    }
+
+    return Apache2::Const::HTTP_NOT_ACCEPTABLE;
+}
+
+sub handler {
+    my $r = shift;
+    my $cgi = new CGI;
+
+    my %args;
+    $args{format} = $cgi->param('format');
+    $args{auth} = $cgi->param('ses');
+    $args{hint} = $cgi->param('hint');
+    $args{map} = OpenSRF::Utils::JSON->JSON2perl($cgi->param('map'));
+    $args{where} = OpenSRF::Utils::JSON->JSON2perl($cgi->param('where'));
+    $args{slo} = OpenSRF::Utils::JSON->JSON2perl($cgi->param('slo'));
+    $args{key} = $cgi->param('key');
+    $args{id_field} = $cgi->param('identifier');
+    $args{label_field} = $cgi->param('label');
+
+    my $fielder = OpenSRF::AppSession->create('open-ils.fielder');
+    if ($args{map}) {
+        $args{data} = $fielder->request(
+            'open-ils.fielder.flattened_search.atomic',
+            @args{qw/auth hint map where slo/}
+        )->gather(1);
+    } else {
+        $args{data} = $fielder->request(
+            'open-ils.fielder.flattened_search.execute.atomic',
+            @args{qw/auth key where slo/}
+        )->gather(1);
+
+        if (ref $args{data} and $args{data}[0] and
+            $U->event_equals($args{data}[0], 'CACHE_MISS')) {
+
+            # You have to pay the cache! I kill me.
+            return Apache2::Const::HTTP_PAYMENT_REQUIRED;
+        }
+    }
+
+    return output_handler( $r, \%args );
+
+}
+
+1;
index 2a392c0..9518f3c 100644 (file)
@@ -51,7 +51,7 @@
                     defaultCellWidth='"auto"'
                     showPaginator='true'
                     showColumnPicker='true'
-                    columnPickerPrefix='"acq.picklist.user_request"'>
+                    columnPersistKey='"acq.picklist.user_request"'>
                     <thead>
                         <tr>
                             <th field='title' get='getTitle' formatter='formatTitle'/>
index 6928b94..593d578 100644 (file)
@@ -16,7 +16,7 @@
             editStyle='pane'
             editOnEnter='true'
             showColumnPicker='true'
-            columnPickerPrefix='"conify.config.barcode_completion"'>
+            columnPersistKey='"conify.config.barcode_completion"'>
     </table>
 </div>
 
index 2138706..9f9b90f 100644 (file)
@@ -19,7 +19,7 @@
             editStyle='pane'
             editOnEnter='true'
             showColumnPicker='true'
-            columnPickerPrefix='"conify.config.circ_limit_set"'>
+            columnPersistKey='"conify.config.circ_limit_set"'>
     </table>
 </div>
 
index 67386f0..6bdfced 100644 (file)
@@ -16,7 +16,7 @@
             editStyle='pane'
             editOnEnter='true'
             showColumnPicker='true'
-            columnPickerPrefix='"conify.config.circ_matrix_matchpoint"'>
+            columnPersistKey='"conify.config.circ_matrix_matchpoint"'>
             <thead>
                 <tr>
                     <th field="hard_due_date" formatter="format_hard_due_date">
index 0823484..def6292 100644 (file)
@@ -15,7 +15,7 @@
             editStyle='pane'
             editOnEnter='true'
             showColumnPicker='true'
-            columnPickerPrefix='"conify.config.hold_matrix_matchpoint"'>
+            columnPersistKey='"conify.config.hold_matrix_matchpoint"'>
     </table>
     <div></div>
 </div>
index 821363c..bf5e639 100644 (file)
@@ -31,7 +31,7 @@
                 query="{id: '*'}"
                 hidePaginator='true'
                 showColumnPicker='true'
-                columnPickerPrefix='"vandelay.item.import_error"'
+                columnPersistKey='"vandelay.item.import_error"'
                 fmClass='vii'>
                 <thead>
                     <tr>
@@ -65,7 +65,7 @@
                 query="{id: '*'}"
                 showPaginator='true'
                 showColumnPicker='true'
-                columnPickerPrefix='"vandelay.item.import_error"'
+                columnPersistKey='"vandelay.item.import_error"'
                 fmClass='vii'>
                 <thead>
                     <tr>
diff --git a/Open-ILS/web/js/dojo/openils/FlattenerStore.js b/Open-ILS/web/js/dojo/openils/FlattenerStore.js
new file mode 100644 (file)
index 0000000..e5cbdd7
--- /dev/null
@@ -0,0 +1,490 @@
+if (!dojo._hasResource["openils.FlattenerStore"]) {
+    dojo._hasResource["openils.FlattenerStore"] = true;
+
+    dojo.provide("openils.FlattenerStore");
+
+    dojo.require("DojoSRF");
+    dojo.require("openils.User");
+    dojo.require("openils.Util");
+
+    /* An exception class specific to openils.FlattenerStore */
+    function FlattenerStoreError(message) { this.message = message; }
+    FlattenerStoreError.prototype.toString = function() {
+        return "openils.FlattenerStore: " + this.message;
+    };
+
+    dojo.declare(
+        "openils.FlattenerStore", null, {
+
+        "_last_fetch": null,        /* used internally */
+        "_flattener_url": "/opac/extras/flattener",
+
+        /* Everything between here and the constructor can be specified in
+         * the constructor's args object. */
+
+        "fmClass": null,
+        "mapClause": null,
+        "sloClause": null,
+        "limit": 25,
+        "offset": 0,
+        "baseSort": null,
+        "defaultSort": null,
+
+        "constructor": function(/* object */ args) {
+            dojo.mixin(this, args);
+            this._current_items = {};
+        },
+
+        /* turn dojo-style sort into flattener-style sort */
+        "_prepare_sort": function(dsort) {
+            if (!dsort || !dsort.length)
+                return this.baseSort || this.defaultSort || [];
+
+            return (this.baseSort || []).concat(
+                dsort.map(
+                    function(d) {
+                        var o = {};
+                        o[d.attribute] = d.descending ? "desc" : "asc";
+                        return o;
+                    }
+                )
+            );
+        },
+
+        "_prepare_flattener_params": function(req) {
+            var params = {
+                "hint": this.fmClass,
+                "ses": openils.User.authtoken
+            };
+
+            /* If we're asked for a specific identity, we don't use
+             * any query or sort/count/start (sort/limit/offset).  */
+            if ("identity" in req) {
+                var where = {};
+                where[this.fmIdentifier] = req.identity;
+
+                params.where = dojo.toJson(where);
+            } else {
+                var limit = (!isNaN(req.count) && req.count != Infinity) ?
+                    req.count : this.limit;
+                var offset = (!isNaN(req.start) && req.start != Infinity) ?
+                    req.start : this.offset;
+
+                dojo.mixin(
+                    params, {
+                        "where": dojo.toJson(req.query),
+                        "slo": dojo.toJson({
+                            "sort": this._prepare_sort(req.sort),
+                            "limit": limit,
+                            "offset": offset
+                        })
+                    }
+                );
+            }
+
+            if (this.mapKey) { /* XXX TODO, get a map key */
+                params.key = this.mapKey;
+            } else {
+                params.map = dojo.toJson(this.mapClause);
+            }
+
+            for (var key in params)
+                console.debug("flattener param " + key + " -> " + params[key]);
+
+            return params;
+        },
+
+        "_display_attributes": function() {
+            var self = this;
+
+            return openils.Util.objectProperties(this.mapClause).filter(
+                function(key) { return self.mapClause[key].display; }
+            );
+        },
+
+        "_get_map_key": function() {
+            //console.debug("mapClause: " + dojo.toJson(this.mapClause));
+            this.mapKey = fieldmapper.standardRequest(
+                ["open-ils.fielder",
+                    "open-ils.fielder.flattened_search.prepare"], {
+                    "params": [openils.User.authtoken, this.fmClass,
+                        this.mapClause],
+                    "async": false
+                }
+            );
+        },
+
+        /* *** Begin dojo.data.api.Read methods *** */
+
+        "getValue": function(
+            /* object */ item,
+            /* string */ attribute,
+            /* anything */ defaultValue) {
+            //console.log("getValue(" + lazy(item) + ", " + attribute + ", " + defaultValue + ")")
+            if (!this.isItem(item))
+                throw new FlattenerStoreError("getValue(): bad item " + item);
+            else if (typeof attribute != "string")
+                throw new FlattenerStoreError("getValue(): bad attribute");
+
+            var value = item[attribute];
+            return (typeof value == "undefined") ? defaultValue : value;
+        },
+
+        "getValues": function(/* object */ item, /* string */ attribute) {
+            //console.log("getValues(" + item + ", " + attribute + ")");
+            if (!this.isItem(item) || typeof attribute != "string")
+                throw new FlattenerStoreError("bad arguments");
+
+            var result = this.getValue(item, attribute, []);
+            return dojo.isArray(result) ? result : [result];
+        },
+
+        "getAttributes": function(/* object */ item) {
+            //console.log("getAttributes(" + item + ")");
+            if (!this.isItem(item))
+                throw new FlattenerStoreError("getAttributes(): bad args");
+            else
+                return this._display_attributes();
+        },
+
+        "hasAttribute": function(/* object */ item, /* string */ attribute) {
+            //console.log("hasAttribute(" + item + ", " + attribute + ")");
+            if (!this.isItem(item) || typeof attribute != "string") {
+                throw new FlattenerStoreError("hasAttribute(): bad args");
+            } else {
+                return dojo.indexOf(this._display_attributes(), attribute) > -1;
+            }
+        },
+
+        "containsValue": function(
+            /* object */ item,
+            /* string */ attribute,
+            /* anything */ value) {
+            //console.log("containsValue(" + item + ", " + attribute + ", " + value + ")");
+            if (!this.isItem(item) || typeof attribute != "string")
+                throw new FlattenerStoreError("bad data");
+            else
+                return (
+                    dojo.indexOf(this.getValues(item, attribute), value) >= -1
+                );
+        },
+
+        "isItem": function(/* anything */ something) {
+            //console.log("isItem(" + lazy(something) + ")");
+            if (typeof something != "object" || something === null)
+                return false;
+
+            var fields = this._display_attributes();
+
+            for (var i = 0; i < fields.length; i++) {
+                var cur = fields[i];
+                if (!(cur in something))
+                    return false;
+            }
+            return true;
+        },
+
+        "isItemLoaded": function(/* anything */ something) {
+            /* XXX if 'something' is not an item at all, are we just supposed
+             * to return false or throw an exception? */
+            return this.isItem(something) && (
+                something[this.fmIdentifier] in this._current_items
+            );
+        },
+
+        "close": function(/* object */ request) { /* no-op */ return; },
+
+        "getLabel": function(/* object */ item) {
+            console.warn("[unimplemented] getLabel()");
+        },
+
+        "getLabelAttributes": function(/* object */ item) {
+            console.warn("[unimplemented] getLabelAttributes()");
+        },
+
+        "loadItem": function(/* object */ keywordArgs) {
+            if (!keywordArgs.force && this.isItemLoaded(keywordArgs.item))
+                return;
+
+            keywordArgs.identity = this.getIdentity(keywordArgs.item);
+            return this.fetchItemByIdentity(keywordArgs);
+        },
+
+        "fetch": function(/* request-object */ req) {
+            //  Respect the following properties of the *req* object:
+            //
+            //      query    a dojo-style query, which will need modest
+            //                  translation for our server-side service
+            //      count    an int
+            //      onBegin  a callback that takes the number of items
+            //                  that this call to fetch() *could* have
+            //                  returned, with a higher limit. We do
+            //                  tricks with this.
+            //      onItem   a callback that takes each item as we get it
+            //      onComplete  a callback that takes the list of items
+            //                      after they're all fetched
+            //
+            //  The onError callback is ignored for now (haven't thought
+            //  of anything useful to do with it yet).
+            //
+            //  The Read API also charges this method with adding an abort
+            //  callback to the *req* object for the caller's use, but
+            //  the one we provide does nothing but issue an alert().
+
+            //console.log("fetch(" + dojo.toJson(req) + ")");
+            var self = this;
+            var callback_scope = req.scope || dojo.global;
+
+            if (!this.mapKey) {
+                try {
+                    this._get_map_key();
+                } catch (E) {
+                    if (req.onError)
+                        req.onError.call(callback_scope, E);
+                    else
+                        throw E;
+                }
+            }
+
+            var post_params = this._prepare_flattener_params(req);
+
+            if (!post_params) {
+                if (typeof req.onComplete == "function")
+                    req.onComplete.call(callback_scope, [], req);
+                return;
+            }
+
+            var process_fetch = function(obj, when) {
+                if (when < self._last_fetch) /* Stale response. Discard. */
+                    return;
+
+                self._retried_map_key_already = false;
+
+                /* The following is apparently the "right" way to call onBegin,
+                 * and is very necessary (at least in Dojo 1.3.3) to get
+                 * the Grid's fetch-more-when-I-need-it logic to work
+                 * correctly. *grumble* crummy documentation *snarl!*
+                 */
+                if (typeof req.onBegin == "function") {
+                    /* We lie to onBegin like this because we don't know how
+                     * many more rows we might be able to fetch if the
+                     * user keeps scrolling.  Once we get a number of
+                     * results that is less than the limit we asked for,
+                     * we stop exaggerating, and the grid is smart enough to
+                     * know we're at the end and it does the right thing. */
+                    var might_be_a_lie = req.start;
+                    if (obj.length >= req.count)
+                        might_be_a_lie += obj.length + req.count;
+                    else
+                        might_be_a_lie += obj.length;
+
+                    req.onBegin.call(callback_scope, might_be_a_lie, req);
+                }
+
+                dojo.forEach(
+                    obj,
+                    function(item) {
+                        /* Cache items internally. */
+                        self._current_items[item[self.fmIdentifier]] = item;
+
+                        if (typeof req.onItem == "function")
+                            req.onItem.call(callback_scope, item, req);
+                    }
+                );
+
+                if (typeof req.onComplete == "function")
+                    req.onComplete.call(callback_scope, obj, req);
+            };
+
+            req.abort = function() {
+                throw new FlattenerStoreError(
+                    "The 'abort' operation is not supported"
+                );
+            };
+
+            var fetch_time = this._last_fetch = (new Date().getTime());
+
+            dojo.xhrPost({
+                "url": this._flattener_url,
+                "content": post_params,
+                "handleAs": "json",
+                "sync": false,
+                "preventCache": true,
+                "headers": {"Accept": "application/json"},
+                "load": function(obj) { process_fetch(obj, fetch_time); },
+                "error": function(response, ioArgs) {
+                    if (response.status == 402) {   /* 'Payment Required' stands
+                                                       in for cache miss */
+                        if (self._retried_map_key_already) {
+                            var e = new FlattenerStoreError(
+                                "Server won't cache flattener map?"
+                            );
+                            if (typeof req.onError == "function")
+                                req.onError.call(callback_scope, e);
+                            else
+                                throw e;
+                        } else {
+                            self._retried_map_key_already = true;
+                            delete self.mapKey;
+                            return self.fetch(req);
+                        }
+                    }
+                }
+            });
+
+            return req;
+        },
+
+        /* *** Begin dojo.data.api.Identity methods *** */
+
+        "getIdentity": function(/* object */ item) {
+            if (!this.isItem(item))
+                throw new FlattenerStoreError("not an item");
+
+            return item[this.fmIdentifier];
+        },
+
+        "getIdentityAttributes": function(/* object */ item) {
+            // console.log("getIdentityAttributes(" + item + ")");
+            return [this.fmIdentifier];
+        },
+
+        "fetchItemByIdentity": function(/* object */ keywordArgs) {
+            var callback_scope = keywordArgs.scope || dojo.global;
+            var identity = keywordArgs.identity;
+
+            if (typeof identity == "undefined")
+                throw new FlattenerStoreError(
+                    "fetchItemByIdentity() needs identity in keywordArgs"
+                );
+
+            /* First of force's two implications:
+             * fetch even if already loaded. */
+            if (this._current_items[identity] && !keywordArgs.force) {
+                keywordArgs.onItem.call(
+                    callback_scope, this._current_items[identity]
+                );
+
+                return;
+            }
+
+            var post_params = this._prepare_flattener_params(keywordArgs);
+
+            var process_fetch_one = dojo.hitch(
+                this, function(obj, when) {
+                    if (when < this._last_fetch) /* Stale response. Discard. */
+                        return;
+
+                    if (dojo.isArray(obj)) {
+                        if (obj.length <= 1) {
+                            obj = obj.pop() || null;    /* safe enough */
+                            /* Second of force's two implications: call setValue
+                             * ourselves.  Makes a DataGrid update. */
+                            if (keywordArgs.force && obj &&
+                                (origitem = this._current_items[identity])) {
+                                for (var prop in origitem)
+                                    this.setValue(origitem, prop, obj[prop]);
+                            }
+                            if (keywordArgs.onItem)
+                                keywordArgs.onItem.call(callback_scope, obj);
+                        } else {
+                            var e = new FlattenerStoreError("Too many results");
+                            if (keywordArgs.onError)
+                                keywordArgs.onError.call(callback_scope, e);
+                            else
+                                throw e;
+                        }
+                    } else {
+                        var e = new FlattenerStoreError("Bad response");
+                        if (keywordArgs.onError)
+                            keywordArgs.onError.call(callback_scope, e);
+                        else
+                            throw e;
+                    }
+                }
+            );
+
+            var fetch_time = this._last_fetch = (new Date().getTime());
+
+            dojo.xhrPost({
+                "url": this._flattener_url,
+                "content": post_params,
+                "handleAs": "json",
+                "sync": false,
+                "preventCache": true,
+                "headers": {"Accept": "application/json"},
+                "load": function(obj){ process_fetch_one(obj, fetch_time); }
+            });
+        },
+
+        /* dojo.data.api.Write - only very partially implemented, because
+         * for FlattenerGrid, the intended client of this store, we don't
+         * need most of the methods. */
+
+        "deleteItem": function(item) {
+            //console.log("deleteItem()");
+
+            var identity = this.getIdentity(item);
+            delete this._current_items[identity];   /* safe even if missing */
+
+            this.onDelete(item);
+        },
+
+        "setValue": function(item, attribute, value) {
+            /* Silently do nothing when setValue()'s caller wants to change
+             * the identifier.  They must be confused anyway. */
+            if (attribute == this.fmIdentifier)
+                return;
+
+            var old_value = dojo.clone(item[attribute]);
+
+            item[attribute] = dojo.clone(value);
+            this.onSet(item, attribute, old_value, value);
+        },
+
+        "setValues": function(item, attribute, values) {
+            console.warn("[unimplemented] setValues()");    /* unneeded */
+        },
+
+        "newItem": function(keywordArgs, parentInfo) {
+            console.warn("[unimplemented] newItem()");    /* unneeded */
+        },
+
+        "unsetAttribute": function() {
+            console.warn("[unimplemented] unsetAttribute()");   /* unneeded */
+        },
+
+        "save": function() {
+            console.warn("[unimplemented] save()"); /* unneeded */
+        },
+
+        "revert": function() {
+            console.warn("[unimplemented] revert()");   /* unneeded */
+        },
+
+        "isDirty": function() { /* I /think/ this will be ok for our purposes */
+            console.info("[stub] isDirty() will always return false");
+
+            return false;
+        },
+
+        /* dojo.data.api.Notification - Keep these no-op methods because
+         * clients will dojo.connect() to them.  */
+
+        "onNew" : function(item) { /* no-op */ },
+        "onDelete" : function(item) { /* no-op */ },
+        "onSet": function(item, attr, oldval, newval) { /* no-op */ },
+
+        /* *** Classes implementing any Dojo APIs do this to list which
+         *     APIs they're implementing. *** */
+
+        "getFeatures": function() {
+            return {
+                "dojo.data.api.Read": true,
+                "dojo.data.api.Identity": true,
+                "dojo.data.api.Notification": true,
+                "dojo.data.api.Write": true     /* well, only partly */
+            };
+        }
+    });
+}
index e570d98..4ea8178 100644 (file)
@@ -7,11 +7,12 @@ if(!dojo._hasResource['openils.widget.AutoGrid']) {
     dojo.require('openils.widget.EditPane');
     dojo.require('openils.widget.EditDialog');
     dojo.require('openils.widget.GridColumnPicker');
+    dojo.require('openils.widget._GridHelperColumns');
     dojo.require('openils.Util');
 
     dojo.declare(
         'openils.widget.AutoGrid',
-        [dojox.grid.DataGrid, openils.widget.AutoWidget],
+        [dojox.grid.DataGrid, openils.widget.AutoWidget, openils.widget._GridHelperColumns],
         {
 
             /* if true, pop up an edit dialog when user hits Enter on a give row */
@@ -25,12 +26,8 @@ if(!dojo._hasResource['openils.widget.AutoGrid']) {
             suppressFields : null,
             suppressEditFields : null,
             suppressFilterFields : null,
-            hideSelector : false,
-            hideLineNumber : false,
-            selectorWidth : '1.5',
-            lineNumberWidth : '1.5',
             showColumnPicker : false,
-            columnPickerPrefix : null,
+            columnPersistKey : null,
             displayLimit : 15,
             displayOffset : 0,
             requiredFields : null,
@@ -42,39 +39,21 @@ if(!dojo._hasResource['openils.widget.AutoGrid']) {
             /* by default, don't show auto-generated (sequence) fields */
             showSequenceFields : false, 
 
-            // style the cells in the line number column
-            onStyleRow : function(row) {
-                if (!this.hideLineNumber) {
-                    var cellIdx = this.hideSelector ? 0 : 1;
-                    dojo.addClass(this.views.views[0].getCellNode(row.index, cellIdx), 'autoGridLineNumber');
-                }
-            },
-
             startup : function() {
+                var _this = this;
                 this.selectionMode = 'single';
                 this.sequence = openils.widget.AutoGrid.sequence++;
                 openils.widget.AutoGrid.gridCache[this.sequence] = this;
                 this.inherited(arguments);
                 this.initAutoEnv();
-                this.attr('structure', this._compileStructure());
+
+                this.setStructure(this._compileStructure());
+                this._startupGridHelperColumns();
+                this.setStructure(this.structure); // required after _startupGridHelper()
+
                 this.setStore(this.buildAutoStore());
                 this.cachedQueryOpts = {};
                 this._showing_create_pane = false;
-
-                if(this.showColumnPicker) {
-                    if(!this.columnPickerPrefix) {
-                        console.error("No columnPickerPrefix defined");
-                    } else {
-                        var picker = new openils.widget.GridColumnPicker(
-                            openils.User.authtoken, this.columnPickerPrefix, this);
-                        if(openils.User.authtoken) {
-                            picker.load();
-                        } else {
-                            openils.Util.addOnLoad(function() { picker.load() });
-                        }
-                    }
-                }
-
                 this.overrideEditWidgets = {};
                 this.overrideEditWidgetClass = {};
                 this.overrideWidgetArgs = {};
@@ -84,15 +63,6 @@ if(!dojo._hasResource['openils.widget.AutoGrid']) {
                 else if(this.singleEditStyle) 
                     this._applySingleEditStyle();
 
-                if(!this.hideSelector) {
-                    dojo.connect(this, 'onHeaderCellClick', 
-                        function(e) {
-                            if(e.cell.index == 0)
-                                this.toggleSelectAll();
-                        }
-                    );
-                }
-
                 if(!this.hidePaginator) {
                     var self = this;
                     this.paginator = new dijit.layout.ContentPane();
@@ -133,10 +103,10 @@ if(!dojo._hasResource['openils.widget.AutoGrid']) {
                                 href : 'javascript:void(0);', 
                                 onclick : function() { 
                                     if (!self.filterDialog) {
-                                        self.filterDialog = new openils.widget.PCrudFilterDialog({fmClass:self.fmClass, suppressFilterFields:self.suppressFilterFields})
+                                        self.filterDialog = new openils.widget.PCrudFilterDialog(
+                                            {fmClass:self.fmClass, suppressFilterFields:self.suppressFilterFields})
                                         self.filterDialog.onApply = function(filter) {
-                                            self.resetStore();
-                                            self.loadAll(self.cachedQueryOpts, filter);
+                                            self.refresh(self.cachedQueryOpts, filter);
                                         };
                                         self.filterDialog.startup();
                                     }
@@ -164,17 +134,6 @@ if(!dojo._hasResource['openils.widget.AutoGrid']) {
                 dojo.style(this.loadProgressIndicator, 'visibility', 'visible');
             },
 
-            /* Don't allow sorting on the selector column */
-            canSort : function(rowIdx) {
-                if(rowIdx == 1 && !this.hideSelector)
-                    return false;
-                if(this.hideSelector && rowIdx == 1 && !this.hideLineNumber)
-                    return false;
-                if(!this.hideSelector && rowIdx == 2 && !this.hideLineNumber)
-                    return false;
-                return true;
-            },
-
             _compileStructure : function() {
                 var existing = (this.structure && this.structure[0].cells[0]) ? 
                     this.structure[0].cells[0] : [];
@@ -193,29 +152,6 @@ if(!dojo._hasResource['openils.widget.AutoGrid']) {
                     fields.push(entry);
                 }
 
-                if(!this.hideSelector) {
-                    // insert the selector column
-                    pushEntry({
-                        field : '+selector',
-                        formatter : function(rowIdx) { return self._formatRowSelectInput(rowIdx); },
-                        get : function(rowIdx, item) { if(item) return rowIdx; },
-                        width : this.selectorWidth,
-                        name : '&#x2713',
-                        nonSelectable : true
-                    });
-                }
-
-                if(!this.hideLineNumber) {
-                    // insert the line number column
-                    pushEntry({
-                        field : '+lineno',
-                        get : function(rowIdx, item) { if(item) return 1 + rowIdx; },
-                        width : this.lineNumberWidth,
-                        name : '#',
-                        nonSelectable : false
-                    });
-                }
-
                 if(!this.fieldOrder) {
                     /* no order defined, start with any explicit grid fields */
                     for(var e in existing) {
@@ -262,60 +198,6 @@ if(!dojo._hasResource['openils.widget.AutoGrid']) {
                 return [{cells: [fields]}];
             },
 
-            toggleSelectAll : function() {
-                var selected = this.getSelectedRows();
-                for(var i = 0; i < this.rowCount; i++) {
-                    if(selected[0])
-                        this.deSelectRow(i);
-                    else
-                        this.selectRow(i);
-                }
-            },
-
-            getSelectedRows : function() {
-                var rows = []; 
-                dojo.forEach(
-                    dojo.query('[name=autogrid.selector]', this.domNode),
-                    function(input) {
-                        if(input.checked)
-                            rows.push(input.getAttribute('row'));
-                    }
-                );
-                return rows;
-            },
-
-            getFirstSelectedRow : function() {
-                return this.getSelectedRows()[0];
-            },
-
-            getSelectedItems : function() {
-                var items = [];
-                var self = this;
-                dojo.forEach(this.getSelectedRows(), function(idx) { items.push(self.getItem(idx)); });
-                return items;
-            },
-
-            selectRow : function(rowIdx) {
-                var inputs = dojo.query('[name=autogrid.selector]', this.domNode);
-                for(var i = 0; i < inputs.length; i++) {
-                    if(inputs[i].getAttribute('row') == rowIdx) {
-                        if(!inputs[i].disabled)
-                            inputs[i].checked = true;
-                        break;
-                    }
-                }
-            },
-
-            deSelectRow : function(rowIdx) {
-                var inputs = dojo.query('[name=autogrid.selector]', this.domNode);
-                for(var i = 0; i < inputs.length; i++) {
-                    if(inputs[i].getAttribute('row') == rowIdx) {
-                        inputs[i].checked = false;
-                        break;
-                    }
-                }
-            },
-
             /**
              * @return {Array} List of every fieldmapper object in the data store
              */
@@ -359,14 +241,6 @@ if(!dojo._hasResource['openils.widget.AutoGrid']) {
                 );
             },
 
-            _formatRowSelectInput : function(rowIdx) {
-                if(rowIdx === null || rowIdx === undefined) return '';
-                var s = "<input type='checkbox' name='autogrid.selector' row='" + rowIdx + "'";
-                if(this.disableSelectorForRow && this.disableSelectorForRow(rowIdx)) 
-                    s += " disabled='disabled'";
-                return s + "/>";
-            },
-
             _applySingleEditStyle : function() {
                 this.onMouseOverRow = function(e) {};
                 this.onMouseOutRow = function(e) {};
@@ -605,19 +479,71 @@ if(!dojo._hasResource['openils.widget.AutoGrid']) {
                 this.setStore(this.buildAutoStore());
             },
 
-            refresh : function() {
+            refresh : function(opts, search) {
+                opts = opts || this.cachedQueryOpts;
+                search = search || this.cachedQuerySearch;
                 this.resetStore();
                 if (this.dataLoader)
                     this.dataLoader()
                 else
-                    this.loadAll(this.cachedQueryOpts, this.cachedQuerySearch);
+                    this._loadAll(opts, search);
+            },
+
+            // called after a sort change occurs within the column picker
+            cpSortHandler : function(fields) {
+                console.log("AutoGrod cpSortHandler(): " + js2JSON(fields));
+                // user-defined sort handler
+                if (this.onSortChange) { 
+                    this.onSortChange(fields)
+
+                // default sort handler
+                } else { 
+                    if (!this.cachedQueryOpts) 
+                        this.cachedQueryOpts = {};
+                    var order_by = '';
+                    dojo.forEach(fields, function(f) {
+                        if (order_by) order_by += ',';
+                        order_by += f.field + ' ' + f.direction
+                    });
+                    this.cachedQueryOpts.order_by = {};
+                    this.cachedQueryOpts.order_by[this.fmClass] = order_by;
+                    this.refresh();
+                }
             },
 
             loadAll : function(opts, search) {
+                var _this = this;
+
+                // first we have to load the column picker to determine the sort fields.
+               
+                if(this.showColumnPicker && !this.columnPicker) {
+                    if(!this.columnPersistKey) {
+                        console.error("No columnPersistKey defined");
+                        this.columnPicker = {};
+                    } else {
+                        this.columnPicker = new openils.widget.GridColumnPicker(
+                            openils.User.authtoken, this.columnPersistKey, this);
+                        this.columnPicker.onSortChange = function(fields) {_this.cpSortHandler(fields)};
+                        this.columnPicker.onLoad = function(cpOpts) {
+                            _this.cachedQueryOpts = opts;
+                            _this.cachedQuerySearch = search;
+                            _this.cpSortHandler(cpOpts.sortFields); // calls refresh() -> _loadAll()
+                        };
+                        this.columnPicker.load();
+                        return;
+                    }
+                }
+
+                // column picker not wanted or already loaded
+                this._loadAll(opts, search);
+            },
+
+            _loadAll : function(opts, search) {
+                var self = this;
+
                 dojo.require('openils.PermaCrud');
                 if(this.loadProgressIndicator)
                     dojo.style(this.loadProgressIndicator, 'visibility', 'visible');
-                var self = this;
                 opts = dojo.mixin(
                     {limit : this.displayLimit, offset : this.displayOffset}, 
                     opts || {}
diff --git a/Open-ILS/web/js/dojo/openils/widget/FlattenerFilterDialog.js b/Open-ILS/web/js/dojo/openils/widget/FlattenerFilterDialog.js
new file mode 100644 (file)
index 0000000..af6b7df
--- /dev/null
@@ -0,0 +1,57 @@
+if (!dojo._hasResource["openils.widget.FlattenerFilterDialog"]) {
+    dojo._hasResource["openils.widget.FlattenerFilterDialog"] = true;
+
+    dojo.provide("openils.widget.FlattenerFilterDialog");
+    dojo.require("openils.widget.PCrudFilterDialog");
+
+    dojo.declare(
+        "openils.widget.FlattenerFilterDialog",
+        [openils.widget.PCrudFilterDialog], {
+            "mapTerminii": null,
+
+            "constructor": function(args) {
+                dojo.mixin(this, args);
+            },
+
+            "_buildFieldStore": function() {
+                var self = this;
+
+                if (!this.mapTerminii)
+                    throw new Error("No mapTerminii list; can't proceed");
+
+                var realFieldList = dojo.clone(this.mapTerminii).filter(
+                    function(o) {
+                        if (self.suppressFilterFields &&
+                            dojo.indexOf(
+                                self.suppressFilterFields, o.simple_name
+                            ) >= -1
+                        ) {
+                            return false;
+                        }
+
+                        return o.isfilter;
+                    }
+                );
+
+                this.fieldStore = new dojo.data.ItemFileReadStore({
+                    "data": {
+                        "identifier": "simple_name",
+                        "name": "label",
+                        "items": realFieldList.map(
+                            function(item) {
+                                return {
+                                    "label": item.label,
+                                    "name": item.name,
+                                    "type": item.datatype,
+                                    "fmClass": item.fmClass,
+                                    "simple_name": item.simple_name,
+                                    "indirect": item.indirect
+                                };
+                            }
+                        )
+                    }
+                });
+            }
+        }
+    );
+}
diff --git a/Open-ILS/web/js/dojo/openils/widget/FlattenerGrid.js b/Open-ILS/web/js/dojo/openils/widget/FlattenerGrid.js
new file mode 100644 (file)
index 0000000..b3ea3f1
--- /dev/null
@@ -0,0 +1,689 @@
+if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
+    dojo.provide("openils.widget.FlattenerGrid");
+
+    dojo.require("DojoSRF");
+    dojo.require("dojox.grid.DataGrid");
+    dojo.require("openils.FlattenerStore");
+    dojo.require("openils.PermaCrud");
+    dojo.require("openils.widget.GridColumnPicker");
+    dojo.require("openils.widget.EditDialog");  /* includes EditPane */
+    dojo.require("openils.widget._GridHelperColumns");
+
+    dojo.declare(
+        "openils.widget.FlattenerGrid",
+        [dojox.grid.DataGrid, openils.widget._GridHelperColumns], {
+            /* These potential constructor arguments are useful to
+             * FlattenerGrid in their own right */
+            "columnReordering": true,
+            "columnPersistKey": null,
+            "showLoadFilter": false,    /* use FlattenerFilterDialog */
+
+            /* These potential constructor arguments maybe useful to
+             * FlattenerGrid in their own right, and are passed to
+             * FlattenerStore. */
+            "fmClass": null,
+            "fmIdentifier": null,
+            "mapExtras": null,
+            "defaultSort": null,  /* whatever any part of the UI says will
+                                     /replace/ this */
+            "baseSort": null,     /* will contains what the columnpicker
+                                     dictates, and precedes whatever the column
+                                     headers provide. */
+
+            /* These potential constructor arguments are for functionality
+             * copied from AutoGrid */
+            "editOnEnter": false,       /* also implies edit-on-dblclick */
+            "editStyle": "dialog",      /* "dialog" or "pane" */
+            "requiredFields": null,     /* affects create/edit dialogs */
+            "suppressEditFields": null, /* affects create/edit dialogs */
+
+            /* _generateMap() lives to interpret the attributes of the
+             * FlattenerGrid dijit itself plus those definined in
+             * <table>
+             *  <thead>
+             *   <tr>
+             *    <th field="foo" ...>
+             * to build the map to hand to the FlattenerStore, which in turn
+             * uses it to query the flattener service.
+             */
+            "_generateMap": function() {
+                var map = this.mapClause = {};
+                var fields = this.structure[0].cells[0];
+
+                /* These are the fields defined in thead -> tr -> [th,th,...].
+                 * For purposes of building the map, where each field has
+                 * three boolean attributes "display", "sort" and "filter",
+                 * assume "display" and "sort" are always true for these.
+                 * That doesn't mean that at the UI level we can't hide a
+                 * column later.
+                 *
+                 * If you need extra fields in the map for which display
+                 * or sort should *not* be true, use mapExtras.
+                 */
+                dojo.forEach(
+                    fields, function(field) {
+                        if (field.field.match(/^\+/))
+                            return; /* special fields e.g. checkbox/line # */
+
+                        map[field.field] = {
+                            "display": true,
+                            "filter": (field.ffilter || false),
+                            "sort": true,
+                            "path": field.fpath || field.field
+                        };
+                        /* The following attribute is not for the flattener
+                         * service's benefit, but for other uses. We capture
+                         * the hardcoded <th> value (the header label) if any.*/
+                        if (field.name)
+                            map[field.field]._label = field.name;
+                    }
+                );
+
+                if (this.mapExtras) {
+                    /* It's not particularly useful to add simple fields, i.e.
+                     *  circ_lib: "circ_lib.name"
+                     * to mapExtras, because by convention used elsewhere in
+                     * Flattener, that gives all attributes, including
+                     * display, a true value. Still, be consistent to avoid
+                     * stumping users.
+                     */
+                    for (var key in this.mapExtras) {
+                        if (typeof this.mapExtras[key] != "object") {
+                            this.mapExtras[key] = {
+                                "path": this.mapExtras[key],
+                                "sort": true,
+                                "filter": true,
+                                "display": true
+                            };
+                        }
+                    }
+                    dojo.mixin(map, this.mapExtras);
+                }
+
+                /* Do this now, since we don't want a silently added
+                 * identifier attribute in the terminii list (see its uses). */
+                this._calculateMapTerminii();
+                this._supplementHeaderNames();
+
+                /* make sure we always have a field for fm identifier */
+                if (!map[this.fmIdentifier]) {
+                    map[this.fmIdentifier] = {
+                        "path": this.fmIdentifier,
+                        "display": true,    /* Flattener displays it to us,
+                                               but we don't display to user. */
+                        "sort": false,
+                        "filter": true
+                    };
+                }
+
+                return map;
+            },
+
+            "_cleanMapForStore": function(map) {
+                var clean = dojo.clone(map);
+
+                for (var column in clean) {
+                    openils.Util.objectProperties(clean[column]).filter(
+                        function(k) { return k.match(/^_/); }
+                    ).forEach(
+                        function(k) { delete clean[column][k]; }
+                    );
+                }
+
+                return clean;
+            },
+
+            /* The FlattenerStore doesn't need this, but it has at least two
+             * uses: 1) FlattenerFilterDialog, 2) setting column header labels
+             * to IDL defaults.
+             *
+             * To call these 'Terminii' can be misleading. In certain
+             * (actually probably common) cases, they won't really be the last
+             * field in a path, but the next-to-last. Read on. */
+            "_calculateMapTerminii": function() {
+                function _fm_is_selector_for_class(hint, field) {
+                    var cl = fieldmapper.IDL.fmclasses[hint];
+                    return (cl.field_map[cl.pkey].selector == field);
+                }
+
+                function _follow_to_end(hint, path) {
+                    var last_field, last_hint;
+                    var orig_path = dojo.clone(path);
+                    var field;
+
+                    while (field = path.shift()) {
+                        /* XXX this assumes we have the whole IDL loaded. I
+                         * guess we could teach this to work by loading classes
+                         * on demand when we don't have the whole IDL loaded. */
+                        var field_def =
+                            fieldmapper.IDL.fmclasses[hint].field_map[field];
+
+                        if (field_def["class"] && path.length) {
+                            last_field = field;
+                            last_hint = hint;
+
+                            hint = field_def["class"];
+                        } else if (path.length) {
+                            /* There are more fields left but we can't follow
+                             * the chain via IDL any further. */
+                            throw new Error(
+                                "_calculateMapTerminii can't parse path " +
+                                orig_path + " (at " + field + ")"
+                            );
+                        } else {
+                            break;  /* keeps field defined after loop */
+                        }
+                    }
+
+                    var datatype = field_def.datatype;
+                    var indirect = false;
+                    /* Back off the last field in the path if it's a selector
+                     * for its class, because the preceding field will be
+                     * a better thing to hand to AutoFieldWidget.
+                     */
+                    if (orig_path.length > 1 &&
+                            _fm_is_selector_for_class(hint, field)) {
+                        hint = last_hint;
+                        field = last_field;
+                        datatype = "link";
+                        indirect = true;
+                    }
+
+                    return {
+                        "fmClass": hint,
+                        "name": field,
+                        "label": field_def.label,
+                        "datatype": datatype,
+                        "indirect": indirect
+                    };
+                }
+
+                this.mapTerminii = [];
+                for (var column in this.mapClause) {
+                    var terminus = dojo.mixin(
+                        _follow_to_end(
+                            this.fmClass,
+                            this.mapClause[column].path.split(/\./)
+                        ), {
+                            "simple_name": column,
+                            "isfilter": this.mapClause[column].filter
+                        }
+                    );
+                    if (this.mapClause[column]._label)
+                        terminus.label = this.mapClause[column]._label;
+
+                    this.mapTerminii.push(terminus);
+                }
+            },
+
+            "_supplementHeaderNames": function() {
+                /* You'd be surprised how rarely this make sense in Flattener
+                 * use cases, but if we didn't give a particular header cell
+                 * (<th>) a display name (the innerHTML of that <th>), then
+                 * use the IDL to provide the label of the terminus of the
+                 * flattener path for that column. It may be better than using
+                 * the raw field name. */
+                var self = this;
+                this.structure[0].cells[0].forEach(
+                    function(header) {
+                        if (!header.name) {
+                            header.name = self.mapTerminii.filter(
+                                function(t) {
+                                    return t.simple_name == header.field;
+                                }
+                            )[0].label;
+                        }
+                    }
+                );
+            },
+
+            "constructor": function(args) {
+                dojo.mixin(this, args);
+
+                this.fmIdentifier = this.fmIdentifier ||
+                    fieldmapper.IDL.fmclasses[this.fmClass].pkey;
+            },
+
+            "startup": function() {
+
+                /* Save original query for further filtering later */
+                this._baseQuery = dojo.clone(this.query);
+                this._startupGridHelperColumns();
+
+                if (!this.columnPicker) {
+                    this.columnPicker =
+                        new openils.widget.GridColumnPicker(
+                            null, this.columnPersistKey, this);
+                    this.columnPicker.onLoad = dojo.hitch(
+                        this, function(opts) { this._finishStartup(opts.sortFields) });
+
+                    this.columnPicker.onSortChange = dojo.hitch(this,
+                        /* directly after, this.update() is called by the
+                           column picker, causing a re-fetch */
+                        function(fields) {
+                            this.store.baseSort = this._mapCPSortFields(fields)
+                        }
+                    );
+
+                    this.columnPicker.load();
+                }
+
+                this.inherited(arguments);
+            },
+
+            /*  Maps ColumnPicker sort fields to the correct format.
+                If no sort fields specified, falls back to defaultSort */
+            "_mapCPSortFields": function(sortFields) {
+                var sort = this.defaultSort;
+                if (sortFields.length) {
+                    sort = sortFields.map(function(f) {
+                        a = {};
+                        a[f.field] = f.direction;
+                        return a;
+                    });
+                }
+                return sort;
+            },
+
+            "_finishStartup": function(sortFields) {
+
+                this.setStore(
+                    new openils.FlattenerStore({
+                        "fmClass": this.fmClass,
+                        "fmIdentifier": this.fmIdentifier,
+                        "mapClause": (this.mapClause ||
+                            this._cleanMapForStore(this._generateMap())),
+                        "baseSort": this.baseSort,
+                        "defaultSort": this._mapCPSortFields(sortFields)
+                    }), this.query
+                );
+
+                this._showing_create_pane = false;
+
+                this.overrideEditWidgets = {};
+                this.overrideEditWidgetClass = {};
+                this.overrideWidgetArgs = {};
+
+                if (this.editOnEnter)
+                    this._applyEditOnEnter();
+                else if (this.singleEditStyle)
+                    this._applySingleEditStyle();
+
+                /* Like AutoGrid's paginator, but we'll never have Back/Next
+                 * links.  Just a place to hold misc links */
+                this._setupLinks();
+            },
+
+
+            "_setupLinks": function() {
+                this.linkHolder = new dijit.layout.ContentPane();
+                dojo.place(this.linkHolder.domNode, this.domNode, "before");
+
+                if (this.showLoadFilter) {
+                    dojo.require("openils.widget.FlattenerFilterDialog");
+                    this.filterDialog =
+                        new openils.widget.FlattenerFilterDialog({
+                            "fmClass": this.fmClass,
+                            "mapTerminii": this.mapTerminii
+                        });
+
+                    this.filterDialog.onApply = dojo.hitch(
+                        this, function(filter) {
+                            this.filter(
+                                dojo.mixin(filter, this._baseQuery),
+                                true    /* re-render */
+                            );
+                        }
+                    );
+
+                    this.filterDialog.startup();
+                    dojo.create(
+                        "a", {
+                            "innerHTML": "Filter",  /* XXX i18n */
+                            "href": "javascript:void(0);",
+                            "onclick": dojo.hitch(this, function() {
+                                this.filterDialog.show();
+                            })
+                        }, this.linkHolder.domNode
+                    );
+                }
+            },
+
+            /* ******** below are methods mostly copied but
+             * slightly changed from AutoGrid ******** */
+
+            "_applySingleEditStyle": function() {
+                this.onMouseOverRow = function(e) {};
+                this.onMouseOutRow = function(e) {};
+                this.onCellFocus = function(cell, rowIndex) {
+                    this.selection.deselectAll();
+                    this.selection.select(this.focus.rowIndex);
+                };
+            },
+
+            /* capture keydown and launch edit dialog on enter */
+            "_applyEditOnEnter": function() {
+                this._applySingleEditStyle();
+
+                dojo.connect(
+                    this, "onRowDblClick", function(e) {
+                        if (this.editStyle == "pane")
+                            this._drawEditPane(
+                                this.selection.getFirstSelected(),
+                                this.focus.rowIndex
+                            );
+                        else
+                            this._drawEditDialog(
+                                this.selection.getFirstSelected(),
+                                this.focus.rowIndex
+                            );
+                    }
+                );
+
+                dojo.connect(
+                    this, "onKeyDown", function(e) {
+                        if (e.keyCode == dojo.keys.ENTER) {
+                            this.selection.deselectAll();
+                            this.selection.select(this.focus.rowIndex);
+                            if (this.editStyle == "pane")
+                                this._drawEditPane(
+                                    this.selection.getFirstSelected(),
+                                    this.focus.rowIndex
+                                );
+                            else
+                                this._drawEditDialog(
+                                    this.selection.getFirstSelected(),
+                                    this.focus.rowIndex
+                                );
+                        }
+                    }
+                );
+            },
+
+            "_makeEditPane": function(storeItem, rowIndex, onPostSubmit, onCancel) {
+                var grid = this;
+                var fmObject = (new openils.PermaCrud()).retrieve(
+                    this.fmClass,
+                    this.store.getIdentity(storeItem)
+                );
+
+                var pane = new openils.widget.EditPane({
+                    "fmObject": fmObject,
+                    "hideSaveButton": this.editReadOnly,
+                    "readOnly": this.editReadOnly,
+                    "overrideWidgets": this.overrideEditWidgets,
+                    "overrideWidgetClass": this.overrideEditWidgetClass,
+                    "overrideWidgetArgs": this.overrideWidgetArgs,
+                    "disableWidgetTest": this.disableWidgetTest,
+                    "requiredFields": this.requiredFields,
+                    "suppressFields": this.suppressEditFields,
+                    "onPostSubmit": function() {
+                        /* ask the store to call flattener specially to get
+                         * the flat row related to only this fmobj */
+                        grid.store.loadItem({"force": true, "item": storeItem});
+
+                        if (grid.onPostUpdate)
+                            grid.onPostUpdate(storeItem, rowIndex);
+
+                        setTimeout(
+                            function() {
+                                try {
+                                    grid.views.views[0].getCellNode(
+                                        rowIndex, 0
+                                    ).focus();
+                                } catch (E) { }
+                            }, 200
+                        );
+                        if (onPostSubmit)
+                            onPostSubmit();
+                    },
+                    "onCancel": function() {
+                        setTimeout(
+                            function() {
+                                grid.views.views[0].getCellNode(
+                                    rowIndex, 0
+                                ).focus();
+                            }, 200
+                        );
+                        if (onCancel)
+                            onCancel();
+                    }
+                });
+
+                if (typeof this.editPaneOnSubmit == "function")
+                    pane.onSubmit = this.editPaneOnSubmit;
+
+                pane.fieldOrder = this.fieldOrder;
+                pane.mode = "update";
+                return pane;
+            },
+
+            "_makeCreatePane": function(onPostSubmit, onCancel) {
+                var grid = this;
+                var pane = new openils.widget.EditPane({
+                    "fmClass": this.fmClass,
+                    "overrideWidgets": this.overrideEditWidgets,
+                    "overrideWidgetClass": this.overrideEditWidgetClass,
+                    "overrideWidgetArgs": this.overrideWidgetArgs,
+                    "disableWidgetTest": this.disableWidgetTest,
+                    "requiredFields": this.requiredFields,
+                    "suppressFields": this.suppressEditFields,
+                    "onPostSubmit": function(req, cudResults) {
+                        var fmObject = cudResults[0];
+                        if (grid.onPostCreate)
+                            grid.onPostCreate(fmObject);
+                        if (fmObject) {
+                            grid.store.fetchItemByIdentity({
+                                "identity": fmObject[grid.fmIdentifier](),
+                                "onItem": function(item) {
+                                    grid.store.onNew(item);
+                                }
+                            });
+                        }
+
+                        setTimeout(
+                            function() {
+                                try {
+                                    grid.selection.select(grid.rowCount - 1);
+                                    grid.views.views[0].getCellNode(
+                                        grid.rowCount - 1, 1
+                                    ).focus();
+                                } catch (E) { }
+                            }, 200
+                        );
+
+                        if (onPostSubmit)
+                            onPostSubmit(fmObject);
+                    },
+                    "onCancel": function() { if (onCancel) onCancel(); }
+                });
+
+                if (typeof this.createPaneOnSubmit == "function")
+                    pane.onSubmit = this.createPaneOnSubmit;
+                pane.fieldOrder = this.fieldOrder;
+                pane.mode = "create";
+                return pane;
+            },
+
+            /**
+             * Creates an EditPane with a copy of the data from the provided store
+             * item for cloning said item
+             * @param {Object} storeItem Dojo data item
+             * @param {Number} rowIndex The Grid row index of the item to be cloned
+             * @param {Function} onPostSubmit Optional callback for post-submit behavior
+             * @param {Function} onCancel Optional callback for clone cancelation
+             * @return {Object} The clone EditPane
+             */
+            "_makeClonePane": function(storeItem,rowIndex,onPostSubmit,onCancel) {
+                var clonePane = this._makeCreatePane(onPostSubmit, onCancel);
+                var origPane = this._makeEditPane(storeItem, rowIndex);
+                clonePane.startup();
+                origPane.startup();
+                dojo.forEach(
+                    origPane.fieldList, function(field) {
+                        if (field.widget.widget.attr('disabled'))
+                            return;
+
+                        var w = clonePane.fieldList.filter(
+                            function(i) { return (i.name == field.name) }
+                        )[0];
+
+                        // sync widgets
+                        w.widget.baseWidgetValue(field.widget.widget.attr('value'));
+
+                        // async widgets
+                        w.widget.onload = function() {
+                            w.widget.baseWidgetValue(
+                                field.widget.widget.attr('value')
+                            )
+                        };
+                    }
+                );
+                origPane.destroy();
+                return clonePane;
+            },
+
+
+            "_drawEditDialog": function(storeItem, rowIndex) {
+                var done = dojo.hitch(this, function() { this.hideDialog(); });
+                var pane = this._makeEditPane(storeItem, rowIndex, done, done);
+                this.editDialog = new openils.widget.EditDialog({editPane:pane});
+                this.editDialog.startup();
+                this.editDialog.show();
+            },
+
+            /**
+             * Generates an EditDialog for object creation and displays it to the user
+             */
+            "showCreateDialog": function() {
+                var done = dojo.hitch(this, function() { this.hideDialog(); });
+                var pane = this._makeCreatePane(done, done);
+                this.editDialog = new openils.widget.EditDialog({editPane:pane});
+                this.editDialog.startup();
+                this.editDialog.show();
+            },
+
+            "_drawEditPane": function(storeItem, rowIndex) {
+                var done = dojo.hitch(this, function() { this.hidePane(); });
+
+                dojo.style(this.domNode, "display", "none");
+
+                this.editPane = this._makeEditPane(storeItem, rowIndex, done, done);
+                this.editPane.startup();
+                dojo.place(this.editPane.domNode, this.domNode, "before");
+
+                if (this.onEditPane)
+                    this.onEditPane(this.editPane);
+            },
+
+            "showClonePane": function(onPostSubmit) {
+                var done = dojo.hitch(this, function() { this.hidePane(); });
+                var row = this.getFirstSelectedRow();
+
+                if (!row)
+                    return;
+
+                if (onPostSubmit) {
+                    postSubmit = dojo.hitch(
+                        this, function(result) {
+                            onPostSubmit(this.getItem(row), result);
+                            this.hidePane();
+                        }
+                    );
+                } else {
+                    postSubmit = done;
+                }
+
+                dojo.style(this.domNode, "display", "none");
+                this.editPane = this._makeClonePane(
+                    this.getItem(row), row, postSubmit, done
+                );
+                dojo.place(this.editPane.domNode, this.domNode, "before");
+                if (this.onEditPane)
+                    this.onEditPane(this.editPane);
+            },
+
+            "showCreatePane": function() {
+                if (this._showing_create_pane)
+                    return;
+                this._showing_create_pane = true;
+
+                var done = dojo.hitch(
+                    this, function() {
+                        this._showing_create_pane = false;
+                        this.hidePane();
+                    }
+                );
+
+                dojo.style(this.domNode, "display", "none");
+
+                this.editPane = this._makeCreatePane(done, done);
+                this.editPane.startup();
+
+                dojo.place(this.editPane.domNode, this.domNode, "before");
+
+                if (this.onEditPane)
+                    this.onEditPane(this.editPane);
+            },
+
+            "hideDialog": function() {
+                this.editDialog.hide();
+                this.editDialog.destroy();
+                delete this.editDialog;
+                this.update();
+            },
+
+            "hidePane": function() {
+                this.domNode.parentNode.removeChild(this.editPane.domNode);
+                this.editPane.destroy();
+                delete this.editPane;
+                dojo.style(this.domNode, "display", "block");
+                this.update();
+            },
+
+            "deleteSelected": function() {
+                var self = this;
+
+                this.getSelectedItems().forEach(
+                    function(item) {
+                        var fmobj = new fieldmapper[self.fmClass]();
+                        fmobj[self.fmIdentifier](
+                            self.store.getIdentity(item)
+                        );
+                        (new openils.PermaCrud()).eliminate(
+                            fmobj, {
+                                "oncomplete": function() {
+                                    self.store.deleteItem(item);
+                                }
+                            }
+                        );
+                    }
+                );
+            }
+        }
+    );
+
+    /* monkey patch so we can get more attributes from each column in the
+     * markup that specifies grid columns (table->thead->tr->[td,...])
+     */
+    (function() {
+        var b = dojox.grid.cells._Base;
+        var orig_mf = b.markupFactory;
+
+        b.markupFactory = function(node, cellDef) {
+            orig_mf(node, cellDef);
+
+            dojo.forEach(
+                ["fpath", "ffilter"], function(a) {
+                    var value = dojo.attr(node, a);
+                    if (value)
+                        cellDef[a] = value;
+                }
+            );
+        };
+    })();
+
+    /* the secret to successfully subclassing dojox.grid.DataGrid */
+    openils.widget.FlattenerGrid.markupFactory =
+        dojox.grid.DataGrid.markupFactory;
+}
index f9f858e..250bca5 100644 (file)
@@ -20,6 +20,7 @@ if(!dojo._hasResource["openils.widget.GridColumnPicker"]) {
 
     dojo.require('dijit.Dialog');
     dojo.require('dijit.form.Button');
+    dojo.require('dijit.form.NumberSpinner');
     dojo.require('openils.User');
     dojo.require('openils.Event');
     dojo.require('openils.Util');
@@ -30,27 +31,68 @@ if(!dojo._hasResource["openils.widget.GridColumnPicker"]) {
 
         USER_PERSIST_SETTING : 'ui.grid_columns',
 
-        constructor : function (authtoken, persistPrefix, grid, structure) {
-            this.dialog = this.buildDialog();
+        constructor : function (authtoken, persistKey, grid, structure) {
+            var _this = this;
             this.grid = grid;
-            this.structure = structure;
-            if(!structure) 
-                this.structure = this.grid.attr('structure');
+            this.persistKey = this.USER_PERSIST_SETTING+'.'+persistKey;
+            this.authtoken = authtoken || openils.User.authtoken;
+            this.structure = structure || this.grid.structure;
+            this.cells = this.structure[0].cells[0].slice();
+
+            this.dialog = this.buildDialog();
             this.dialogTable = this.dialog.containerNode.getElementsByTagName('tbody')[0];
-            this.baseCellList = this.structure[0].cells[0].slice();
-            this.build();
-            this.authtoken = authtoken;
-            this.savedColums = null;
-            this.persistPrefix = persistPrefix;
-            this.setting = null;
 
-            var self = this;
+            // replace: called after any sort changes
+            this.onSortChange = function(list) {console.log('onSortChange()')}
+            // replace:  called after user settings are first retrieved
+            this.onLoad = function(opts) {console.log('onLoad()')};
+
+            // internal onload handler
+            this.loaded = false;
+            this._onLoad = function(opts) {_this.loaded = true; _this.onLoad(opts)};
+
             this.grid.onHeaderContextMenu = function(e) { 
-                self.dialog.show(); 
+                _this.build();
+                _this.dialog.show(); 
                 dojo.stopEvent(e);
             };
         },
 
+        // determine the visible sorting from the 
+        // view and update our list of cells to match
+        refreshCells : function() {
+            var cells = this.cells;
+            this.cells = [];
+            var _this = this;
+
+            dojo.forEach(
+                 _this.grid.views.views[0].structure.cells[0],
+                 function(vCell) {
+                    for (var i = 0; i < cells.length; i++) {
+                        if (cells[i].field == vCell.field) {
+                            cells[i]._visible = true;
+                            _this.cells.push(cells[i]);
+                            break;
+                        }
+                    }
+                }
+            );
+
+            // Depending on how the grid structure is built, there may be
+            // cells in the structure that are not yet in the view.  Push
+            // any remaining cells onto the end.
+            dojo.forEach(
+                cells,
+                function(cell) {
+                    existing = _this.cells.filter(function(s){return s.field == cell.field})[0]
+                    if (!existing) {
+                        cell._visible = false;
+                        _this.cells.push(cell);
+                    }
+                }
+            );
+        },
+
         buildDialog : function() {
             var self = this;
             
@@ -58,15 +100,33 @@ if(!dojo._hasResource["openils.widget.GridColumnPicker"]) {
 
             var dialog = new dijit.Dialog({title : 'Column Picker'});
             var table = dojo.create('table', {'class':'oils-generic-table', innerHTML : 
-                "<thead><tr><th width='33%'>Column</th><th width='33%'>Display</th><th width='33%'>Auto Width</th></tr></thead>" +
-                "<tbody><tr><td><div name='cancel_button'></div></td><td><div name='save_button'></div></td></tr></tbody></table>" });
+                "<table><thead><tr><th width='30%'>Column</th><th width='23%'>Display</th>" +
+                "<th width='23%'>Auto Width</th><th width='23%'>Sort Priority</th></tr></thead>" +
+                "<tbody />"});
 
-            dialog.containerNode.appendChild(table);
+            var tDiv = dojo.create('div', {style : 'height:400px; overflow-y:auto;'});
+            tDiv.appendChild(table);
 
-            var button = new dijit.form.Button({label:'Save'}, dojo.query('[name=save_button]', table)[0]);
+            var bDiv = dojo.create('div', {style : 'text-align:right; width:100%;',
+                innerHTML : "<span name='cancel_button'></span><span name='save_button'></span>"});
+
+            var textDiv = dojo.create('div', {style : 'padding:5px; margin-top:5px; border-top:1px solid #333', 
+                innerHTML :
+                    "<i>A Sort Priority of '0' means no sorting is applied.<br/>" +
+                    "<i>Apply a negative Sort Priority for descending sort."});
+            
+            var wrapper = dojo.create('div');
+            wrapper.appendChild(tDiv);
+            wrapper.appendChild(textDiv);
+            wrapper.appendChild(bDiv);
+            dialog.containerNode.appendChild(wrapper);
+
+            var button = new dijit.form.Button({label:'Save'}, 
+                dojo.query('[name=save_button]', bDiv)[0]);
             button.onClick = function() { dialog.hide(); self.update(true); };
 
-            button = new dijit.form.Button({label:'Cancel'}, dojo.query('[name=cancel_button]', table)[0]);
+            button = new dijit.form.Button({label:'Cancel'}, 
+                dojo.query('[name=cancel_button]', bDiv)[0]);
             button.onClick = function() { dialog.hide(); };
 
             return dialog;
@@ -74,8 +134,7 @@ if(!dojo._hasResource["openils.widget.GridColumnPicker"]) {
 
         // builds the column-picker dialog table
         build : function() {
-            var  cells = this._selectableCellList();
-            var str = '';
+            this.refreshCells();
             var rows = dojo.query('tr', this.dialogTable);
 
             for(var i = 0; i < rows.length; i++) {
@@ -88,172 +147,248 @@ if(!dojo._hasResource["openils.widget.GridColumnPicker"]) {
             if(rows.length > 0)
                 lastChild = rows[rows.length-1];
 
-            for(var i = 0; i < cells.length; i++) {
+            for(var i = 0; i < this.cells.length; i++) {
                 // setting table.innerHTML breaks stuff, so do it the hard way
-                var cell = cells[i];
+                var cell = this.cells[i];
                 tr = document.createElement('tr');
                 tr.setAttribute('picker', 'picker');
+                td0 = document.createElement('td');
                 td1 = document.createElement('td');
                 td2 = document.createElement('td');
                 td3 = document.createElement('td');
 
                 ipt = document.createElement('input');
                 ipt.setAttribute('type', 'checkbox');
-                ipt.setAttribute('checked', 'checked');
-                ipt.setAttribute('ident', cell.field+''+cell.name);
                 ipt.setAttribute('name', 'selector');
 
                 ipt2 = document.createElement('input');
                 ipt2.setAttribute('type', 'checkbox');
-                ipt2.setAttribute('ident', cell.field+''+cell.name);
                 ipt2.setAttribute('name', 'width');
 
-                if(this.setting) {
-                    // set the UI based on the loaded settings
-                    if(this._arrayHas(this.setting.columns, cell.field)) {
-                        if(this._arrayHas(this.setting.auto, cell.field))
+                ipt3 = document.createElement('div');
+
+                if (cell.nonSelectable) {
+                    ipt.setAttribute('checked', 'checked');
+                    ipt.setAttribute('disabled', true);
+                    ipt2.setAttribute('disabled', true);
+
+                } else {
+                    if (cell._visible) {
+                        ipt.setAttribute('checked', 'checked');
+                        if (cell.width == 'auto') 
                             ipt2.setAttribute('checked', 'checked');
                     } else {
                         ipt.removeAttribute('checked');
                     }
                 }
 
-                td1.appendChild(document.createTextNode(cell.name));
-                td2.appendChild(ipt);
-                td3.appendChild(ipt2);
+                if (cell.field == '+selector') {
+                    // pick up the unescaped unicode checkmark char
+                    td0.innerHTML = cell.name;
+                } else {
+                    td0.appendChild(document.createTextNode(cell.name));
+                }
+                td1.appendChild(ipt);
+                td2.appendChild(ipt2);
+                td3.appendChild(ipt3);
+                tr.appendChild(td0);
                 tr.appendChild(td1);
                 tr.appendChild(td2);
                 tr.appendChild(td3);
+
                 if(lastChild)
                     this.dialogTable.insertBefore(tr, lastChild);
                 else
                     this.dialogTable.appendChild(tr);
+
+                if ( this.grid.canSort(i+1) ) { // column index is 1-based
+
+                    // must be added after its parent node is inserted into the DOM.
+                    var ns = new dijit.form.NumberSpinner(
+                        {   constraints : {places : 0}, 
+                            value : cell._sort || 0,
+                            style : 'width:4em',
+                            name : 'sort',
+                        }, ipt3
+                    );
+                }
             }
         },
 
         // update the grid based on the items selected in the picker dialog
         update : function(persist) {
-            var newCellList = [];
             var rows = dojo.query('[picker=picker]', this.dialogTable);
+            var _this = this;
+            var displayCells = [];
+            var sortUpdated = false;
+
+            for (var i = 0; i < rows.length; i++) {
+                var row = rows[i];
+                var selector = dojo.query('[name=selector]', row)[0];
+                var width = dojo.query('[name=width]', row)[0];
+                var sort = dojo.query('[name=sort]', row)[0];
+                var cell = this.cells[i]; // index should match dialog
+
+                if (sort && cell._sort != sort.value) {
+                    sortUpdated = true;
+                    cell._sort = sort.value;
+                }
 
-            for(var j = 0; j < this.baseCellList.length; j++) {
-                var cell = this.baseCellList[j];
-                if(cell.selectableColumn) {
-                    for(var i = 0; i < rows.length; i++) {
-                        var row = rows[i];
-                        var selector = dojo.query('[name=selector]', row)[0];
-                        var width = dojo.query('[name=width]', row)[0];
-                        if(selector.checked && selector.getAttribute('ident') == cell.field+''+cell.name) {
-                            if(width.checked) {
-                                cell.width = 'auto';
-                            } else {
-                                if(cell.width == 'auto')
-                                    delete cell.width;
-                            }
-                            newCellList.push(cell);
-                        }
+                if (selector.checked) {
+                    cell._visible = true;
+                    if (width.checked) {
+                        cell.width = 'auto';
+                    } else if(cell.width == 'auto') {
+                        delete cell.width;
                     }
-                } else { // if it's not selectable, always show it
-                    newCellList.push(cell); 
+                    displayCells.push(cell);
+
+                } else {
+                    cell._visible = false;
+                    delete cell.width;
                 }
             }
 
-            this.structure[0].cells[0] = newCellList;
+            if (sortUpdated && this.onSortChange) 
+                this.onSortChange(this.buildSortList());
+
+            this.structure[0].cells[0] = displayCells;
             this.grid.setStructure(this.structure);
             this.grid.update();
 
-            if(persist) this.persist();
+            if (persist) this.persist(true);
         },
 
-        _selectableCellList : function() {
-            var cellList = this.structure[0].cells[0];
-            var cells = [];
-            for(var i = 0; i < cellList.length; i++) {
-                var cell = cellList[i];
-                if(!cell.nonSelectable) cell.selectableColumn = true;
-                if(cell.selectableColumn) 
-                    cells.push({name:cell.name, field:cell.field}); 
-            }
-            return cells;
+        // extract cells that have sorting applied, order lowest to highest
+        buildSortList : function() {
+            var sortList = this.cells.filter(
+                function(cella) { return Number(cella._sort) }
+            ).sort( 
+                function(a, b) { 
+                    if (Math.abs(a._sort) < Math.abs(b._sort)) return -1; 
+                    return 1; 
+                }
+            );
+
+            return sortList.map(function(f){
+                var dir = f._sort < 0 ? 'desc' : 'asc';
+                return {field : f.field, direction : dir};
+            });
         },
 
         // save me as a user setting
-        persist : function() {
-            var cells = this.structure[0].cells[0];
+        persist : function(noRefresh) {
             var list = [];
             var autos = [];
-            for(var i = 0; i < cells.length; i++) {
-                var cell = cells[i];
-                if(cell.selectableColumn) {
+            if (!noRefresh) this.refreshCells();
+
+            for(var i = 0; i < this.cells.length; i++) {
+                var cell = this.cells[i];
+                if (cell._visible) {
                     list.push(cell.field);
                     if(cell.width == 'auto')
                         autos.push(cell.field);
-                }
+                } 
             }
+
             var setting = {};
-            setting[this.USER_PERSIST_SETTING+'.'+this.persistPrefix] = {'columns':list, 'auto':autos};
+            setting[this.persistKey] = {
+                'columns' : list, 
+                'auto' : autos,
+                'sort' : this.buildSortList().map(function(f){return f.field})
+            };
+
+            var _this = this;
             fieldmapper.standardRequest(
                 ['open-ils.actor', 'open-ils.actor.patron.settings.update'],
                 {   async: true,
                     params: [this.authtoken, null, setting],
                     oncomplete: function(r) {
                         var stat = openils.Util.readResponse(r);
+                    },
+                    onmethoderror : function() {},
+                    onerror : function() { 
+                        console.log("No user setting '" + _this.persistKey + "' configured.  Cannot persist") 
                     }
                 }
             );
         }, 
 
-        _arrayHas : function(arr, val) {
-            for(var i = 0; arr && i < arr.length; i++) {
-                if(arr[i] == val)
-                    return true;
-            }
-            return false;
-        },
-
-        _loadColsFromSetting : function(setting) {
+        loadColsFromSetting : function(setting) {
+            var _this = this;
             this.setting = setting;
-            var newCellList = [];
-            for(var j = 0; j < this.baseCellList.length; j++) {
-                var cell = this.baseCellList[j];
-                if(cell.selectableColumn) {
-                    if(this._arrayHas(setting.columns, cell.field)) {
-                        newCellList.push(cell);
-                        if(this._arrayHas(setting.auto, cell.field)) {
+            var displayCells = [];
+            
+            // new component, existing settings may not have this
+            if (!setting.sort) setting.sort = [];
+
+            dojo.forEach(setting.columns,
+                function(col) {
+                    var cell = _this.cells.filter(function(c){return c.field == col})[0];
+                    if (cell) {
+                        cell._visible = true;
+                        displayCells.push(cell);
+
+                        if(setting.auto.indexOf(cell.field) > -1) {
                             cell.width = 'auto';
                         } else {
                             if(cell.width == 'auto')
                                 delete cell.width;
                         }
+                        cell._sort = setting.sort.indexOf(cell.field) + 1;
+
+                    } else {
+                        console.log('Unknown setting column '+col+'.  Ignoring...');
                     }
-                }  else { // if it's not selectable, always show it
-                    newCellList.push(cell); 
                 }
-            }
+            );
+            
+            // any cells not in the setting must be marked as non-visible
+            dojo.forEach(this.cells, function(cell) { 
+                if (setting.columns.indexOf(cell.field) == -1) {
+                    cell._visible = false;
+                    cell._sort = 0;
+                }
+            });
 
-            this.build();
-            this.structure[0].cells[0] = newCellList;
+            this.structure[0].cells[0] = displayCells;
             this.grid.setStructure(this.structure);
             this.grid.update();
         },
 
         load : function() {
-            if(this.setting)
-                return this._loadColsFromSetting(this.setting);
-            var picker = this;
+            var _this = this;
+
+            // if load is called before the user has logged in,
+            // queue the loading up for after authentication.
+            if (!this.authtoken) {
+                var _this = this;
+                openils.Util.addOnLoad(function() {
+                    _this.authtoken = openils.User.authtoken;
+                    _this.load();
+                }); 
+                return;
+            }
+
+            if(this.setting) {
+                this.loadColsFromSetting(this.setting);
+                this._onLoad({sortFields : this.buildSortList()});
+                return;
+            }
+
             fieldmapper.standardRequest(
                 ['open-ils.actor', 'open-ils.actor.patron.settings.retrieve'],
                 {   async: true,
-                    params: [this.authtoken, null, this.USER_PERSIST_SETTING+'.'+this.persistPrefix],
+                    params: [this.authtoken, null, this.persistKey],
                     oncomplete: function(r) {
                         var set = openils.Util.readResponse(r);
                         if(set) {
-                            picker._loadColsFromSetting(set);
+                            _this.loadColsFromSetting(set);
                         } else {
-                            picker.build();
-                            picker.grid.setStructure(picker.structure);
-                            picker.grid.update();
+                            _this.grid.setStructure(_this.structure);
+                            _this.grid.update();
                         }
+                        _this._onLoad({sortFields : _this.buildSortList()});
                     }
                 }
             );
index 9ed23ce..df1c374 100644 (file)
@@ -31,6 +31,7 @@ if (!dojo._hasResource['openils.widget.PCrudFilterDialog']) {
      * org unit selector will not respect selected filters in this dijit, and
      * vice-versa.
      */
+
     dojo.provide('openils.widget.PCrudFilterDialog');
     dojo.require('openils.widget.AutoFieldWidget');
     dojo.require('dijit.form.FilteringSelect');
@@ -40,6 +41,8 @@ if (!dojo._hasResource['openils.widget.PCrudFilterDialog']) {
     dojo.require('openils.Util');
 
     dojo.requireLocalization("openils.widget", "PCrudFilterDialog");
+
+    /* XXX namespace pollution! arg! Fix this whole module sometime. */
     var localeStrings = dojo.i18n.getLocalization(
         "openils.widget", "PCrudFilterDialog"
     );
@@ -410,8 +413,8 @@ if (!dojo._hasResource['openils.widget.PCrudFilterDialog']) {
 
             for (var i = 0; i < param_count; i++) {
                 var widg = new openils.widget.AutoFieldWidget({
-                    "fmClass": this.filter_row_manager.fm_class,
-                    "fmField": this.selected_field,
+                    "fmClass": this.selected_field_fm_class,
+                    "fmField": this.selected_field_fm_field,
                     "parentNode": dojo.create("span", {}, this.value_slot),
                     "dijitArgs": {"scrollOnFocus": false}
                 });
@@ -450,6 +453,18 @@ if (!dojo._hasResource['openils.widget.PCrudFilterDialog']) {
             if (this.field_selector.item) {
                 this.selected_field = value;
                 this.selected_field_type = this.field_selector.item.type;
+
+                /* This is really about supporting flattenergrid, of which
+                 * we're in the superclass (in a sloppy sad way). From now
+                 * on I won't mix this kind of lazy object with Dojo modules. */
+                //console.log(dojo.toJson(this.field_selector.item));
+                this.selected_field_fm_field = this.field_selector.item.name;
+                this.selected_field_is_indirect =
+                    this.field_selector.item.indirect || false;
+                this.selected_field_fm_class =
+                    this.field_selector.item.fmClass ||
+                    this.filter_row_manager.fm_class;
+
                 this._adjust_operator_selector();
                 this._rebuild_value_widgets();
             }
@@ -458,7 +473,11 @@ if (!dojo._hasResource['openils.widget.PCrudFilterDialog']) {
         this.compile = function() {
             if (this.value_widgets) {
                 var values = this.value_widgets.map(
-                    function(widg) { return widg.getFormattedValue(); }
+                    function(widg) {
+                        return self.selected_field_is_indirect ?
+                            widg.widget.attr('displayedValue') :
+                            widg.getFormattedValue();
+                    }
                 );
 
                 if (!values.length) {
@@ -502,16 +521,52 @@ if (!dojo._hasResource['openils.widget.PCrudFilterDialog']) {
                 this.widgetCache = {};
             },
 
-            /* All we really do here is create a data store out of the fields
-             * from the IDL for our given class, place a few buttons at the
-             * bottom of the dialog, and hand off to PCrudFilterRowManager to
-             * do the actual work.
-             */
+            _buildButtons : function() {
+                var self = this;
 
-            startup : function() {
+                var button_holder = dojo.create(
+                    "div", {
+                        "className": "oils-pcrudfilterdialog-buttonholder"
+                    }, this.domNode
+                );
+
+                new dijit.form.Button(
+                    {
+                        "label": localeStrings.ADD_ROW,
+                        "scrollOnFocus": false, /* almost always better */
+                        "onClick": function() {
+                            self.filter_row_manager.add_row();
+                        }
+                    }, dojo.create("span", {}, button_holder)
+                );
+
+                new dijit.form.Button(
+                    {
+                        "label": localeStrings.APPLY,
+                        "scrollOnFocus": false,
+                        "onClick": function() {
+                            if (self.onApply)
+                                self.onApply(self.filter_row_manager.compile());
+                            self.hide();
+                        }
+                    }, dojo.create("span", {}, button_holder)
+                );
+
+                new dijit.form.Button(
+                    {
+                        "label": localeStrings.CANCEL,
+                        "scrollOnFocus": false,
+                        "onClick": function() {
+                            if (self.onCancel)
+                            self.onCancel();
+                            self.hide();
+                        }
+                    }, dojo.create("span", {}, button_holder)
+                );
+            },
+
+            _buildFieldStore : function() {
                 var self = this;
-                this.inherited(arguments);
-                this.initAutoEnv();
                 var realFieldList = this.sortedFieldList.filter(
                     function(item) { return !(item.virtual || item.nonIdl); }
                 );
@@ -549,51 +604,27 @@ if (!dojo._hasResource['openils.widget.PCrudFilterDialog']) {
                         )
                     }
                 });
+            },
 
-                this.filter_row_manager = new PCrudFilterRowManager(
-                    dojo.create("div", {}, this.domNode),
-                    this.fieldStore, this.fmClass
-                );
+            /* All we really do here is create a data store out of the fields
+             * from the IDL for our given class, place a few buttons at the
+             * bottom of the dialog, and hand off to PCrudFilterRowManager to
+             * do the actual work.
+             */
 
-                var button_holder = dojo.create(
-                    "div", {
-                        "className": "oils-pcrudfilterdialog-buttonholder"
-                    }, this.domNode
-                );
+            startup : function() {
+                var self = this;
+                this.inherited(arguments);
+                this.initAutoEnv();
 
-                new dijit.form.Button(
-                    {
-                        "label": localeStrings.ADD_ROW,
-                        "scrollOnFocus": false, /* almost always better */
-                        "onClick": function() {
-                            self.filter_row_manager.add_row();
-                        }
-                    }, dojo.create("span", {}, button_holder)
-                );
+                this._buildFieldStore();
 
-                new dijit.form.Button(
-                    {
-                        "label": localeStrings.APPLY,
-                        "scrollOnFocus": false,
-                        "onClick": function() {
-                            if (self.onApply)
-                                self.onApply(self.filter_row_manager.compile());
-                            self.hide();
-                        }
-                    }, dojo.create("span", {}, button_holder)
+                this.filter_row_manager = new PCrudFilterRowManager(
+                    dojo.create("div", {}, this.domNode),
+                    this.fieldStore, this.fmClass
                 );
 
-                new dijit.form.Button(
-                    {
-                        "label": localeStrings.CANCEL,
-                        "scrollOnFocus": false,
-                        "onClick": function() {
-                            if (self.onCancel)
-                            self.onCancel();
-                            self.hide();
-                        }
-                    }, dojo.create("span", {}, button_holder)
-                );
+                this._buildButtons();
             }
         }
     );
diff --git a/Open-ILS/web/js/dojo/openils/widget/_GridHelperColumns.js b/Open-ILS/web/js/dojo/openils/widget/_GridHelperColumns.js
new file mode 100644 (file)
index 0000000..1b004c1
--- /dev/null
@@ -0,0 +1,138 @@
+/* to be inherited by autogrid and similar */
+if (!dojo._hasResource["openils.widget._GridHelperColumns"]) {
+    dojo._hasResource["openils.widget._GridHelperColumns"] = true;
+
+    dojo.provide("openils.widget._GridHelperColumns");
+    dojo.declare(
+        "openils.widget._GridHelperColumns", null, {
+
+            "hideSelector": false,
+            "selectorWidth": 1.5,
+            "hideLineNumber": false,
+            "lineNumberWidth": "1.5",
+
+            "getSelectedRows": function() {
+                var rows = [];
+                dojo.forEach(
+                    dojo.query('[name=autogrid.selector]', this.domNode),
+                    function(input) {
+                        if(input.checked)
+                            rows.push(input.getAttribute('row'));
+                    }
+                );
+                return rows;
+            },
+
+            "getFirstSelectedRow": function() {
+                return this.getSelectedRows()[0];
+            },
+
+            "getSelectedItems": function() {
+                var items = [];
+                var self = this;
+                dojo.forEach(this.getSelectedRows(), function(idx) { items.push(self.getItem(idx)); });
+                return items;
+            },
+
+            "selectRow": function(rowIdx) {
+                var inputs = dojo.query('[name=autogrid.selector]', this.domNode);
+                for(var i = 0; i < inputs.length; i++) {
+                    if(inputs[i].getAttribute('row') == rowIdx) {
+                        if(!inputs[i].disabled)
+                            inputs[i].checked = true;
+                        break;
+                    }
+                }
+            },
+
+            "deSelectRow": function(rowIdx) {
+                var inputs = dojo.query('[name=autogrid.selector]', this.domNode);
+                for(var i = 0; i < inputs.length; i++) {
+                    if(inputs[i].getAttribute('row') == rowIdx) {
+                        inputs[i].checked = false;
+                        break;
+                    }
+                }
+            },
+
+            "toggleSelectAll": function() {
+                var selected = this.getSelectedRows();
+                for(var i = 0; i < this.rowCount; i++) {
+                    if(selected[0])
+                        this.deSelectRow(i);
+                    else
+                        this.selectRow(i);
+                }
+            },
+
+            "_formatRowSelectInput": function(rowIdx) {
+                if (rowIdx === null || rowIdx === undefined)
+                    return "";
+                var s = "<input type='checkbox' name='autogrid.selector' row='"
+                    + rowIdx + "'";
+                if (this.disableSelectorForRow &&
+                        this.disableSelectorForRow(rowIdx))
+                    s += " disabled='disabled'";
+                return s + "/>";
+            },
+
+            // style the cells in the line number column
+            "onStyleRow": function(row) {
+                if (!this.hideLineNumber) {
+                    var cellIdx = this.hideSelector ? 0 : 1;
+                    dojo.addClass(
+                        this.views.views[0].getCellNode(row.index, cellIdx),
+                        "autoGridLineNumber"
+                    );
+                }
+            },
+
+            /* Don't allow sorting on the selector column */
+            "canSort": function(rowIdx) {
+                if (rowIdx == 1 && !this.hideSelector)
+                    return false;
+                if (this.hideSelector && rowIdx == 1 && !this.hideLineNumber)
+                    return false;
+                if (!this.hideSelector && rowIdx == 2 && !this.hideLineNumber)
+                    return false;
+                return true;
+            },
+
+            "_startupGridHelperColumns": function() {
+                if (!this.hideLineNumber) {
+                    this.structure[0].cells[0].unshift({
+                        "field": "+lineno",
+                        "get": function(rowIdx, item) {
+                            if (item) return 1 + rowIdx;
+                        },
+                        "width": this.lineNumberWidth,
+                        "name": "#",
+                        "nonSelectable": false
+                    });
+                }
+                if (!this.hideSelector) {
+                    this.structure[0].cells[0].unshift({
+                        "field": "+selector",
+                        "formatter": dojo.hitch(
+                            this, function(rowIdx) {
+                                return this._formatRowSelectInput(rowIdx);
+                            }
+                        ),
+                        "get": function(rowIdx, item) {
+                            if (item) return rowIdx;
+                        },
+                        "width": this.selectorWidth,
+                        "name": "&#x2713",
+                        "nonSelectable": true
+                    });
+                    dojo.connect(
+                        this, "onHeaderCellClick", function(e) {
+                            if (e.cell.index == 0)
+                                this.toggleSelectAll();
+                        }
+                    );
+                }
+            }
+        }
+    );
+}
diff --git a/Open-ILS/xsl/FlatFielder2HTML.xsl b/Open-ILS/xsl/FlatFielder2HTML.xsl
new file mode 100644 (file)
index 0000000..c988ba8
--- /dev/null
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+  
+<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  version="1.0">
+  <xsl:output method="html" doctype-public="-//W3C/DTD HTML 4.01 Transitional//EN" doctype-system="http://www.w3.org/TR/html4/strict.dtd" />    
+
+  <xsl:template match="//FlatSearch">
+    <html>
+        <head>
+            <meta http-equiv="Content-Type" content="text/html" charset="utf-8"/>
+        </head>
+        <body>
+            <table>
+                <tbody>
+                    <xsl:apply-templates select="row[@ordinal='1']"/>
+                    <xsl:apply-templates select="row[not(@ordinal='1')]"/>
+                </tbody>
+            </table>
+        </body>
+    </html>
+  </xsl:template>
+
+  <xsl:template match="row[@ordinal='1']">
+    <tr>
+        <xsl:for-each select="column"><th><xsl:value-of select="@name"/></th></xsl:for-each>
+    <tr>
+    </tr>
+        <xsl:for-each select="column"><td><xsl:value-of select="."/></td></xsl:for-each>
+    </tr>
+  </xsl:template>
+
+   <xsl:template match="row">
+    <tr>
+        <xsl:for-each select="column"><td><xsl:value-of select="."/></td></xsl:for-each>
+    </tr>
+  </xsl:template>
+    
+</xsl:stylesheet>
diff --git a/docs/TechRef/Flattener/design.txt b/docs/TechRef/Flattener/design.txt
new file mode 100644 (file)
index 0000000..087b188
--- /dev/null
@@ -0,0 +1,124 @@
+Deep-data Flattening Service
+============================
+Mike Rylander
+with Lebbeous Fogle-Weekley
+
+[abstract]
+Purpose
+-------
+Evergreen supplies a broad API for accessing, processing and interacting with library data.  Because of the relatively complex nature of the underlying database schema, and the flexibility required by clients when, in the simplest case, performing CRUD operations, focus has been given to providing a nearly direct view of various data source.  When, however, the verbosity or complexity of full object access gets in the way of performant or straight-forward UIs, there has been a tendency to create one-off data simplification calls targetting specific use cases.
+
+A generalized API which accepts a simplified query structure and field mapping, calculates the required backend query, and flattens the hierarchical data returned for each top level row into a would facilitate the simplification of existing UIs and provide for new UIs based on simple, reusable components.
+
+Overview
+--------
+The existing, public open-ils.fielder server will be extended with two new OpenSRF methods, contained in a separate package so that they will be reusable in a private service which does not require authentication.
+
+These methods will be supported by code which takes simplifed cstore/pcrud search and order-by hashes and computes the full data structure required for the query.  The simplification will leverage the IDL to discover links between classes.
+
+Underlying the simplified search grammar will be a path-field mapping structure.  This will describe the layout of fields, how they are to collapse from fleshed objects, and how the shape of both the query and result data structures should be interpreted by and presented to the caller.
+
+Mapping Structure
+-----------------
+Implemented as a JSON object, each property name will represent a data element that can be displayed, filtered or sorted, and each property value will represent either a simple path (in which case it is usable for display, filtering or sorting), or an object providing the path and available uses.
+
+Example Map
+~~~~~~~~~~~
+Assuming a core class of acp:
+
+--------------------------------------------------------------------------------
+{
+    "barcode":          "barcode",
+    "circ_lib_name":    "circ_lib.name",
+    "circ_lib":         "circ_lib.shortname",
+    "call_number":      { "path": "call_number.label", "sort": true, "display": true },
+    "tcn":              { "path": "call_number.record.tcn_value", "filter": true, "sort": true }
+}
+--------------------------------------------------------------------------------
+
+'Yes I realize that this example ignores call number prefixes and suffixes, but it's just an example.'
+
+Based on this mapping structure simplified queries can be constructed by the caller, which will then be expanded in the flattening service to produce join and where clauses that can be used by open-ils.pcrud.
+
+Example Query
+~~~~~~~~~~~~~
+Assuming the above example map:
+
+-------------------------------------
+{   "tcn":   { ">": "100" },
+    "circ_lib": "BR1"
+}
+-------------------------------------
+
+This example would expand to a PCrud query based on the map provided above, containing not only the complex where clause, but a join tree and the necessary fleshing structure.
+
+
+Expanded PCrud Query
+~~~~~~~~~~~~~~~~~~~~
+
+---------------------------------------
+{
+    "+__circ_lib_aou": {"shortname":"BR1"},
+    "+__tcn_bre":{"tcn_value":{">":"100"}}
+}, {
+    "flesh_fields": {
+        "acp":["call_number", "circ_lib"]
+    },"flesh":1,
+    "join": {
+        "__circ_lib_name_aou": {
+            "fkey":"circ_lib",
+            "class":"aou",
+            "field":"id"
+        },
+        "__call_number_acn":{
+            "fkey":"call_number",
+            "class":"acn",
+            "field":"id"
+        },
+        "__tcn_acn":{
+            "fkey":"call_number",
+            "class":"acn",
+            "field":"id",
+            "join":{
+                "__tcn_bre":{
+                    "fkey":"record",
+                    "class":"bre",
+                    "field":"id"
+                }
+            }
+        },
+        "__circ_lib_aou":{
+            "fkey":"circ_lib",
+            "class":"aou",
+            "field":"id"
+        }
+    }
+}
+---------------------------------------
+
+
+API
+---
+
+OpenSRF Method name: open-ils.fielder.flattened_search
+
+Parameters:
+
+- Authentication token (as for pcrud)
+- IDL class
+ * e.g. "acp"
+- Path map hash
+ * e.g. { "barcode": "barcode", "circ_lib_name": "circ_lib.name", "circ_lib": "circ_lib.shortname", "call_number": { "path": "call_number.label", "sort": true, "display": true }, "id": "id", "tcn": { "path": "call_number.record.tcn_value", "filter": true, "sort": true } }
+- Simplified query hash
+ * e.g. {"tcn": {">": "100" }, "circ_lib": "BR1"}
+- Simplified sort/limit/offset hash
+ * e.g. { "sort":[{"circ_lib":"desc"},{"call_number":"asc"}],"limit":10 }
+ * or {"sort":{"call_number":"desc"}}
+ * or {"sort": "circ_lib"}
+ * or {"sort": ["circ_lib", {"checkin_time": "desc"}]}
+
+Returns:
+
+- stream (or array, for .atomic) of hashes having the shape described in the path map
+ * e.g.  { "call_number":"PR3533.B61994", "circ_lib_name":"Example Branch 1", "barcode":"23624564258", "id":7, "circ_lib":"BR1" }
+