@@ -, +, @@ - Add API endpoint for ILL request creation - UI for creation of ILL batches, including: - Ability to paste in list of identifier - Auto metadata enrichment for each identifier, using installed third party service API plugins - Auto retrieval of identifier availability, using installed third party service API plugins - Auto creation of local requests within batch - UI for management of existing ILL batches, including adding requests to, and removing requests from, batches - Additional UI to allow users to navigate from requests to their enclosing batches and vice-versa --- Koha/Illrequest.pm | 3 - Koha/REST/V1/Illrequests.pm | 107 +- admin/columns_settings.yml | 2 + api/v1/swagger/definitions/illrequest.yaml | 144 +++ api/v1/swagger/definitions/illrequests.yaml | 5 + api/v1/swagger/paths/illrequests.yaml | 52 + api/v1/swagger/swagger.yaml | 4 + ill/ill-requests.pl | 103 +- .../prog/css/src/staff-global.scss | 106 ++ .../en/includes/ill-batch-modal-strings.inc | 36 + .../prog/en/includes/ill-batch-modal.inc | 92 ++ .../en/includes/ill-batch-table-strings.inc | 9 + .../prog/en/includes/ill-batch.inc | 20 + .../prog/en/includes/ill-list-table.inc | 1 + .../prog/en/includes/ill-toolbar.inc | 19 +- .../prog/en/modules/ill/ill-requests.tt | 50 +- .../intranet-tmpl/prog/js/ill-batch-modal.js | 993 ++++++++++++++++++ .../intranet-tmpl/prog/js/ill-batch-table.js | 205 ++++ koha-tmpl/intranet-tmpl/prog/js/ill-batch.js | 49 + .../intranet-tmpl/prog/js/ill-list-table.js | 17 +- t/db_dependent/api/v1/illrequests.t | 86 +- 21 files changed, 2078 insertions(+), 25 deletions(-) create mode 100644 api/v1/swagger/definitions/illrequest.yaml create mode 100644 api/v1/swagger/definitions/illrequests.yaml create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-modal-strings.inc create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-modal.inc create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-table-strings.inc create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch.inc create mode 100644 koha-tmpl/intranet-tmpl/prog/js/ill-batch-modal.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/ill-batch-table.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/ill-batch.js --- a/Koha/Illrequest.pm +++ a/Koha/Illrequest.pm @@ -156,9 +156,6 @@ sub batch { my ( $self ) = @_; return Koha::Illbatches->find($self->_result->batch_id); -# return Koha::Illbatch->_new_from_dbic( -# scalar $self->_result->batch_id -# ); } =head3 statusalias --- a/Koha/REST/V1/Illrequests.pm +++ a/Koha/REST/V1/Illrequests.pm @@ -22,6 +22,7 @@ use Mojo::Base 'Mojolicious::Controller'; use C4::Context; use Koha::Illrequests; use Koha::Illrequestattributes; +use Koha::Illbatches; use Koha::Libraries; use Koha::Patrons; use Koha::Libraries; @@ -43,8 +44,11 @@ sub list { my $c = shift->openapi->valid_input or return; my $args = $c->req->params->to_hash // {}; - my $output = []; - my @format_dates = ( 'placed', 'updated', 'completed' ); + + # Get the pipe-separated string of hidden ILL statuses + my $hidden_statuses_string = C4::Context->preference('ILLHiddenRequestStatuses') // q{}; + # Turn into arrayref + my $hidden_statuses = [ split /\|/, $hidden_statuses_string ]; # Create a hash where all keys are embedded values # Enables easy checking @@ -55,11 +59,6 @@ sub list { delete $args->{embed}; } - # Get the pipe-separated string of hidden ILL statuses - my $hidden_statuses_string = C4::Context->preference('ILLHiddenRequestStatuses') // q{}; - # Turn into arrayref - my $hidden_statuses = [ split /\|/, $hidden_statuses_string ]; - # Get all requests # If necessary, restrict the resultset my @requests = Koha::Illrequests->search({ @@ -74,6 +73,73 @@ sub list { : () })->as_list; + my $output = _form_request(\@requests, \%embed); + + return $c->render( status => 200, openapi => $output ); +} + +=head3 add + +Adds a new ILL request + +=cut + +sub add { + my $c = shift->openapi->valid_input or return; + + my $body = $c->validation->param('body'); + + return try { + my $request = Koha::Illrequest->new->load_backend( $body->{backend} ); + + my $create_api = $request->_backend->capabilities('create_api'); + + if (!$create_api) { + return $c->render( + status => 405, + openapi => { + errors => [ 'This backend does not allow request creation via API' ] + } + ); + } + + my $create_result = &{$create_api}($body, $request); + my $new_id = $create_result->illrequest_id; + + my @new_req = Koha::Illrequests->search({ + illrequest_id => $new_id + })->as_list; + + my $output = _form_request(\@new_req, { + metadata => 1, + patron => 1, + library => 1, + status_alias => 1, + comments => 1, + requested_partners => 1 + }); + + return $c->render( + status => 201, + openapi => $output->[0] + ); + } catch { + return $c->render( + status => 500, + openapi => { error => 'Unable to create request' } + ) + }; +} + +sub _form_request { + my ($requests_hash, $embed_hash) = @_; + + my @requests = @{$requests_hash}; + my %embed = %{$embed_hash}; + + my $output = []; + my @format_dates = ( 'placed', 'updated', 'completed' ); + my $fetch_backends = {}; foreach my $request (@requests) { $fetch_backends->{ $request->backend } ||= @@ -83,17 +149,19 @@ sub list { # Pre-load the backend object to avoid useless backend lookup/loads @requests = map { $_->_backend( $fetch_backends->{ $_->backend } ); $_ } @requests; - # Identify patrons & branches that + # Identify additional stuff that # we're going to need and get them my $to_fetch = { patrons => {}, branches => {}, - capabilities => {} + capabilities => {}, + batches => {} }; foreach my $req (@requests) { $to_fetch->{patrons}->{$req->borrowernumber} = 1 if $embed{patron}; $to_fetch->{branches}->{$req->branchcode} = 1 if $embed{library}; $to_fetch->{capabilities}->{$req->backend} = 1 if $embed{capabilities}; + $to_fetch->{batches}->{$req->batch_id} = 1 if $req->batch_id; } # Fetch the patrons we need @@ -130,7 +198,17 @@ sub list { } } - # Now we've got all associated users and branches, + # Fetch the batches we need + my $batch_arr = []; + my @batch_ids = keys %{$to_fetch->{batches}}; + if (scalar @batch_ids > 0) { + my $where = { + id => { -in => \@batch_ids } + }; + $batch_arr = Koha::Illbatches->search($where)->unblessed; + } + + # Now we've got all associated stuff # we can augment the request objects my @output = (); foreach my $req(@requests) { @@ -166,6 +244,12 @@ sub list { last; } } + foreach my $b(@{$batch_arr}) { + if ($b->{id} eq $req->batch_id) { + $to_push->{batch} = $b; + last; + } + } if ($embed{metadata}) { my $metadata = Koha::Illrequestattributes->search( { illrequest_id => $req->illrequest_id }, @@ -191,8 +275,7 @@ sub list { } push @output, $to_push; } - - return $c->render( status => 200, openapi => \@output ); + return \@output; } 1; --- a/admin/columns_settings.yml +++ a/admin/columns_settings.yml @@ -802,6 +802,8 @@ modules: columns: - columnname: illrequest_id + - + columnname: batch - columnname: metadata_author - --- a/api/v1/swagger/definitions/illrequest.yaml +++ a/api/v1/swagger/definitions/illrequest.yaml @@ -0,0 +1,144 @@ +--- +type: object +properties: + accessurl: + type: + - string + - "null" + description: A URL for accessing the resource + backend: + type: string + description: Name of the request's ILL supplier + batch_id: + type: + - string + - "null" + description: The ID of the batch the request belongs to + batch: + type: + - object + - "null" + description: The batch the request belongs to + biblio_id: + type: + - string + - "null" + description: The ID of the biblio created from the requested item + borrowernumber: + type: string + description: Borrower number of the patron of the ILL request + branchcode: + type: string + description: The branch code associated with the request + capabilities: + type: + - object + - "null" + description: A list of the valid actions that can be carried out on this request + comments: + type: + - string + - "null" + description: The number of comments this request has associated with it + completed: + type: + - string + - "null" + description: Is this request completed + cost: + type: + - string + - "null" + description: The recorded cost for this request + id_prefix: + type: + - string + - "null" + description: A prefix that should be prepended to the ID of this request during display + illrequest_id: + type: + - string + - "null" + description: The ID of this request + library: + type: + - object + - "null" + description: The library object associated with this request + medium: + type: + - string + - "null" + description: The material type associated with this request + metadata: + type: object + description: Metadata that formed this request + notesopac: + type: + - string + - "null" + description: Notes that have been entered for display in the OPAC + notesstaff: + type: + - string + - "null" + description: Notes that have been entered for display in the staff interface + orderid: + type: + - string + - "null" + description: The supplier's ID for the request + patron: + type: + - object + - "null" + description: The patron associated with this request + placed: + type: + - string + - "null" + description: The timestamp the request was placed + placed_formatted: + type: + - string + - "null" + description: The timestamp the request was placed (formatted for display) + completed_formatted: + type: + - string + - "null" + description: The timestamp the request was completed (formatted for display) + price_paid: + type: + - string + - "null" + description: The amount ultimately paid for the request + replied: + type: + - string + - "null" + description: N/A (Not in use) + requested_partners: + type: + - string + - "null" + description: The email addresses of partners this request been requested from + status: + type: string + description: The request's current status + status_alias: + type: + - string + - "null" + description: The ID of a user defined status for this request + updated: + type: + - string + - "null" + description: The timestamp the request was last updated + updated_formatted: + type: + - string + - "null" + description: The timestamp the request was last updated (formatted for display) +additionalProperties: false --- a/api/v1/swagger/definitions/illrequests.yaml +++ a/api/v1/swagger/definitions/illrequests.yaml @@ -0,0 +1,5 @@ +--- +type: array +items: + $ref: "illrequest.yaml" +additionalProperties: false --- a/api/v1/swagger/paths/illrequests.yaml +++ a/api/v1/swagger/paths/illrequests.yaml @@ -108,6 +108,8 @@ responses: "200": description: A list of ILL requests + schema: + $ref: "../swagger.yaml#/definitions/illrequests" "401": description: Authentication required schema: @@ -134,3 +136,53 @@ x-koha-authorization: permissions: ill: "1" + post: + x-mojo-to: Illrequests#add + operationId: addIllrequest + tags: + - illrequests + summary: Add ILL request + parameters: + - name: body + in: body + description: A JSON object containing informations about the new request + required: true + schema: + $ref: "../swagger.yaml#/definitions/illrequest" + produces: + - application/json + responses: + "201": + description: Request added + schema: + $ref: "../swagger.yaml#/definitions/illrequest" + "400": + description: Bad request + schema: + $ref: "../swagger.yaml#/definitions/error" + "401": + description: Authentication required + schema: + $ref: "../swagger.yaml#/definitions/error" + "403": + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + "409": + description: Conflict in creating resource + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: | + Internal server error. Possible `error_code` attribute values: + + * `internal_server_error` + schema: + $ref: "../swagger.yaml#/definitions/error" + "503": + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" + x-koha-authorization: + permissions: + ill: "1" --- a/api/v1/swagger/swagger.yaml +++ a/api/v1/swagger/swagger.yaml @@ -52,6 +52,10 @@ definitions: $ref: ./definitions/illbatch.yaml illbatches: $ref: ./definitions/illbatches.yaml + illrequest: + $ref: ./definitions/illrequest.yaml + illrequests: + $ref: ./definitions/illrequests.yaml import_batch_profile: $ref: ./definitions/import_batch_profile.yaml import_batch_profiles: --- a/ill/ill-requests.pl +++ a/ill/ill-requests.pl @@ -27,9 +27,11 @@ use Koha::Notice::Templates; use Koha::AuthorisedValues; use Koha::Illcomment; use Koha::Illrequests; +use Koha::Illbatches; use Koha::Illrequest::Availability; use Koha::Libraries; use Koha::Token; +use Koha::Plugins; use Try::Tiny qw( catch try ); use URI::Escape qw( uri_escape_utf8 ); @@ -65,10 +67,27 @@ my $has_branch = $cfg->has_branch; my $backends_available = ( scalar @{$backends} > 0 ); $template->param( backends_available => $backends_available, - has_branch => $has_branch + has_branch => $has_branch, + have_batch => have_batch_backends($backends) ); if ( $backends_available ) { + # Establish what metadata enrichment plugins we have available + my $enrichment_services = get_metadata_enrichment(); + if (scalar @{$enrichment_services} > 0) { + $template->param( + metadata_enrichment_services => encode_json($enrichment_services) + ); + } + # Establish whether we have any availability services that can provide availability + # for the batch identifier types we support + my $batch_availability_services = get_ill_availability($enrichment_services); + if (scalar @{$batch_availability_services} > 0) { + $template->param( + batch_availability_services => encode_json($batch_availability_services) + ); + } + if ( $op eq 'illview' ) { # View the details of an ILL my $request = Koha::Illrequests->find($params->{illrequest_id}); @@ -147,8 +166,8 @@ if ( $backends_available ) { } else { my $backend_result = $request->backend_create($params); $template->param( - whole => $backend_result, - request => $request + whole => $backend_result, + request => $request ); handle_commit_maybe($backend_result, $request); } @@ -215,6 +234,9 @@ if ( $backends_available ) { # We simulate the API for backend requests for uniformity. # So, init: my $request = Koha::Illrequests->find($params->{illrequest_id}); + my $batches = Koha::Illbatches->search(undef, { + order_by => { -asc => 'name' } + }); if ( !$params->{stage} ) { my $backend_result = { error => 0, @@ -227,13 +249,15 @@ if ( $backends_available ) { }; $template->param( whole => $backend_result, - request => $request + request => $request, + batches => $batches ); } else { # Commit: # Save the changes $request->borrowernumber($params->{borrowernumber}); $request->biblio_id($params->{biblio_id}); + $request->batch_id($params->{batch_id}); $request->branchcode($params->{branchcode}); $request->price_paid($params->{price_paid}); $request->notesopac($params->{notesopac}); @@ -365,7 +389,7 @@ if ( $backends_available ) { } elsif ( $op eq 'illlist') { # If we receive a pre-filter, make it available to the template - my $possible_filters = ['borrowernumber']; + my $possible_filters = ['borrowernumber', 'batch_id']; my $active_filters = {}; foreach my $filter(@{$possible_filters}) { if ($params->{$filter}) { @@ -384,6 +408,17 @@ if ( $backends_available ) { $template->param( prefilters => join("&", @tpl_arr) ); + + if ($active_filters->{batch_id}) { + my $batch_id = $active_filters->{batch_id}; + if ($batch_id) { + my $batch = Koha::Illbatches->find($batch_id); + $template->param( + batch => $batch + ); + } + } + } elsif ( $op eq "save_comment" ) { die "Wrong CSRF token" unless Koha::Token->new->check_csrf({ session_id => scalar $cgi->cookie('CGISESSID'), @@ -418,6 +453,9 @@ if ( $backends_available ) { scalar $params->{illrequest_id} . $append ); exit; + } elsif ( $op eq "batch_list" ) { + } elsif ( $op eq "batch_create" ) { + # Batch create } else { my $request = Koha::Illrequests->find($params->{illrequest_id}); my $backend_result = $request->custom_capability($op, $params); @@ -475,3 +513,58 @@ sub redirect_to_list { print $cgi->redirect('/cgi-bin/koha/ill/ill-requests.pl'); exit; } + +# Do any of the available backends provide batch requesting +sub have_batch_backends { + my ( $backends ) = @_; + + my @have_batch = (); + + foreach my $backend(@{$backends}) { + my $can_batch = can_batch($backend); + if ($can_batch) { + push @have_batch, $backend; + } + } + return \@have_batch; +} + +# Does a given backend provide batch requests +sub can_batch { + my ( $backend ) = @_; + my $request = Koha::Illrequest->new->load_backend( $backend ); + return $request->_backend_capability( 'provides_batch_requests' ); +} + +# Get available metadata enrichment plugins +sub get_metadata_enrichment { + my @candidates = Koha::Plugins->new()->GetPlugins({ + method => 'provides_api' + }); + my @services = (); + foreach my $plugin(@candidates) { + my $supported = $plugin->provides_api(); + if ($supported->{type} eq 'search') { + push @services, $supported; + } + } + return \@services; +} + +# Get ILL availability plugins that can help us with the batch identifier types +# we support +sub get_ill_availability { + my ( $services ) = @_; + + my $id_types = {}; + foreach my $service(@{$services}) { + foreach my $id_supported(keys %{$service->{identifiers_supported}}) { + $id_types->{$id_supported} = 1; + } + } + + my $availability = Koha::Illrequest::Availability->new($id_types); + return $availability->get_services({ + ui_context => 'staff' + }); +} --- a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss +++ a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss @@ -3769,6 +3769,112 @@ input.renew { } #interlibraryloans { + + .ill-toolbar { + display: flex; + } + + #ill-batch { + flex-grow: 1; + display: flex; + justify-content: flex-end; + gap: 5px; + } + + #ill-batch-requests { + .action-buttons { + display: flex; + gap: 5px; + justify-content: center; + } + } + + #ill-batch-modal { + .modal-footer { + display: flex; + & > * { + flex: 1; + } + #lhs { + text-align: left; + } + } + #create-progress { + margin-top: 17px; + } + .fetch-failed { + background-color: rgba(255,0,0,0.1); + & > * { + background-color: inherit; + } + } + .progress { + margin-bottom: 0; + margin-top: 17px; + } + #create-requests { + display: flex; + justify-content: flex-end; + } + .action-column { + text-align: center; + & > * { + margin-left: 5px; + } + & > *:first-child { + margin-left: 0; + } + } + .metadata-row:not(:first-child) { + margin-top: 0.5em; + } + .metadata-label { + font-weight: 600; + } + .more-less { + text-align: right; + margin: 2px 0; + } + + } + + #batch-form { + legend { + margin-bottom: 2em; + } + textarea { + width: 100%; + min-height: 100px; + padding: 5px; + resize: vertical; + } + #new-batch-form { + display: flex; + gap: 20px; + } + li#process-button { + display: flex; + justify-content: flex-end; + } + #textarea-metadata { + padding: 0 15px; + display: flex; + justify-content: space-between; + } + #textarea-errors { + display: flex; + flex-direction: column; + gap: 10px; + padding: 20px 15px 10px + } + .batch-modal-actions { + text-align: center; + } + fieldset { + border: 2px solid #b9d8d9; + } + } + #dataPreviewLabel { margin: .3em 0; } --- a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-modal-strings.inc +++ a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-modal-strings.inc @@ -0,0 +1,36 @@ + + + --- a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-modal.inc +++ a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-modal.inc @@ -0,0 +1,92 @@ + + --- a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-table-strings.inc +++ a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch-table-strings.inc @@ -0,0 +1,9 @@ + + + --- a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch.inc +++ a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-batch.inc @@ -0,0 +1,20 @@ +[% IF query_type == "batch_list" %] +
+ + + + + + + + + + + + + +
Batch IDNameNumber of requestsPatronBranch
+
+[% ELSIF query_type == "batch_create" %] + +[% END %] --- a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-list-table.inc +++ a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-list-table.inc @@ -7,6 +7,7 @@ Request ID + Batch Author Title Article title --- a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-toolbar.inc +++ a/koha-tmpl/intranet-tmpl/prog/en/includes/ill-toolbar.inc @@ -1,6 +1,6 @@ [% USE Koha %] [% IF Koha.Preference('ILLModule ') && CAN_user_ill %] -
+
[% IF backends_available %] [% IF backends.size > 1 %] [% END %] --- a/koha-tmpl/intranet-tmpl/prog/en/modules/ill/ill-requests.tt +++ a/koha-tmpl/intranet-tmpl/prog/en/modules/ill/ill-requests.tt @@ -120,6 +120,7 @@
ILL module configuration problem. Take a look at the about page
[% ELSE %] [% INCLUDE 'ill-toolbar.inc' %] + [% INCLUDE 'ill-batch-modal.inc' %] [% IF whole.error %]

Error performing operation

@@ -433,6 +434,23 @@ [% END %] + [% IF batches.count > 0 %] +
  • + + +
  • + [% END %]
  • [% request.updated | $KohaDates with_hours => 1 %] @@ -647,6 +665,14 @@ [% END %] [% END %]
  • + [% IF request.batch > 0 %] +
  • + Batch: + + [% request.batch.name | html %] + +
  • + [% END %]
  • Last updated: [% request.updated | $KohaDates with_hours => 1 %] @@ -783,7 +809,12 @@ [% ELSIF query_type == 'illlist' %] -

    View ILL requests

    +

    + View ILL requests + [% IF batch %] + for batch "[% batch.name | html %]" + [% END %] +

    Details for all requests

    [% INCLUDE 'ill-list-table.inc' %] @@ -823,6 +854,8 @@ [% INCLUDE 'ill-availability-table.inc' service=service %] [% END %]
    + [% ELSIF query_type == 'batch_list' || query_type == 'batch_create' %] + [% INCLUDE 'ill-batch.inc' %] [% ELSE %] [% PROCESS $whole.template %] @@ -840,6 +873,16 @@ [% INCLUDE 'columns_settings.inc' %] [% INCLUDE 'calendar.inc' %] [% INCLUDE 'select2.inc' %] + [% IF metadata_enrichment_services %] + + [% END %] + [% IF batch_availability_services %] + + [% END %] [% INCLUDE 'ill-list-table-strings.inc' %] + [% INCLUDE 'ill-batch-table-strings.inc' %] + [% INCLUDE 'ill-batch-modal-strings.inc' %] [% Asset.js("js/ill-list-table.js") | $raw %] + [% Asset.js("js/ill-batch.js") | $raw %] + [% Asset.js("js/ill-batch-table.js") | $raw %] + [% Asset.js("js/ill-batch-modal.js") | $raw %] [% IF (query_type == 'availability' || query_type == 'generic_confirm') && Koha.Preference('ILLCheckAvailability') %] [% Asset.js("js/ill-availability.js") | $raw %] [% END %] --- a/koha-tmpl/intranet-tmpl/prog/js/ill-batch-modal.js +++ a/koha-tmpl/intranet-tmpl/prog/js/ill-batch-modal.js @@ -0,0 +1,993 @@ +(function () { + window.addEventListener('load', onload); + + // Delay between API requests + var debounceDelay = 1000; + + // Elements we work frequently with + var textarea = document.getElementById("identifiers_input"); + var nameInput = document.getElementById("name"); + var cardnumberInput = document.getElementById("cardnumber"); + var branchcodeSelect = document.getElementById("branchcode"); + var processButton = document.getElementById("process_button"); + var createButton = document.getElementById("button_create_batch"); + var finishButton = document.getElementById("button_finish"); + var batchItemsDisplay = document.getElementById("add_batch_items"); + var createProgressTotal = document.getElementById("processed_total"); + var createProgressCount = document.getElementById("processed_count"); + var createProgressFailed = document.getElementById("processed_failed"); + var createProgressBar = document.getElementById("processed_progress_bar"); + var identifierTable = document.getElementById('identifier-table'); + var createRequestsButton = document.getElementById('create-requests-button'); + + + // We need a data structure keyed on identifier type, which tells us how to parse that + // identifier type and what services can get its metadata. We receive an array of + // available services + var supportedIdentifiers = {}; + metadata_enrichment_services.forEach(function (service) { + // Iterate the identifiers that this service supports + Object.keys(service.identifiers_supported).forEach(function (idType) { + if (!supportedIdentifiers[idType]) { + supportedIdentifiers[idType] = []; + } + supportedIdentifiers[idType].push(service); + }); + }); + + // An object for when we're creating a new batch + var emptyBatch = { + name: '', + backend: null, + cardnumber: '', + branchcode: '' + }; + + // The object that holds the batch we're working with + // It's a proxy so we can update portions of the UI + // upon changes + var batch = new Proxy( + { data: {} }, + { + get: function (obj, prop) { + return obj[prop]; + }, + set: function (obj, prop, value) { + obj[prop] = value; + manageBatchItemsDisplay(); + updateBatchInputs(); + setFinishButton(); + disableCardnumberInput(); + displayPatronName(); + } + } + ); + + // The object that holds the contents of the table + // It's a proxy so we can make it automatically redraw the + // table upon changes + var tableContent = new Proxy( + { data: [] }, + { + get: function (obj, prop) { + return obj[prop]; + }, + set: function (obj, prop, value) { + obj[prop] = value; + updateTable(); + updateRowCount(); + updateProcessTotals(); + checkAvailability(); + } + } + ); + + var progressTotals = new Proxy( + { + data: {} + }, + { + get: function (obj, prop) { + return obj[prop]; + }, + set: function (obj, prop, value) { + obj[prop] = value; + showCreateRequestsButton(); + } + } + ); + + // Keep track of submission API calls that are in progress + // so we don't duplicate them + var submissionSent = {}; + + // Keep track of availability API calls that are in progress + // so we don't duplicate them + var availabilitySent = {}; + + // The datatable + var table; + var tableEl = document.getElementById('identifier-table'); + + // The element that potentially holds the ID of the batch + // we're working with + var idEl = document.getElementById('ill-batch-details'); + var batchId = null; + var backend = null; + + function onload() { + $('#ill-batch-modal').on('shown.bs.modal', function () { + init(); + patronAutocomplete(); + batchInputsEventListeners(); + createButtonEventListener(); + createRequestsButtonEventListener(); + moreLessEventListener(); + removeRowEventListener(); + }); + $('#ill-batch-modal').on('hidden.bs.modal', function () { + // Reset our state when we close the modal + delete idEl.dataset.batchId; + delete idEl.dataset.backend; + batchId = null; + tableEl.style.display = 'none'; + tableContent.data = []; + progressTotals.data = { + total: 0, + count: 0, + failed: 0 + }; + textarea.value = ''; + batch.data = {}; + // Remove event listeners we created + removeEventListeners(); + }); + }; + + function init() { + batchId = idEl.dataset.batchId; + backend = idEl.dataset.backend; + emptyBatch.backend = backend; + progressTotals.data = { + total: 0, + count: 0, + failed: 0 + }; + if (batchId) { + fetchBatch(); + setModalHeading(true); + } else { + batch.data = emptyBatch; + setModalHeading(); + } + finishButtonEventListener(); + processButtonEventListener(); + identifierTextareaEventListener(); + displaySupportedIdentifiers(); + createButtonEventListener(); + updateRowCount(); + }; + + function initPostCreate() { + disableCreateButton(); + }; + + function setFinishButton() { + if (batch.data.patron) { + finishButton.removeAttribute('disabled'); + } + }; + + function setModalHeading(isUpdate) { + var heading = document.getElementById('ill-batch-modal-label'); + heading.textContent = isUpdate ? ill_batch_update : ill_batch_add; + } + + // Identify items that have metadata and therefore can have a local request + // created, and do so + function requestRequestable() { + createRequestsButton.setAttribute('disabled', true); + var toCheck = tableContent.data; + toCheck.forEach(function (row) { + if ( + !row.requestId && + Object.keys(row.metadata).length > 0 && + !submissionSent[row.value] + ) { + submissionSent[row.value] = 1; + makeLocalSubmission(row.value, row.metadata); + } + }); + }; + + // Identify items that can have their availability checked, and do it + function checkAvailability() { + // Only proceed if we've got services that can check availability + if (!batch_availability_services || batch_availability_services.length === 0) return; + var toCheck = tableContent.data; + toCheck.forEach(function (row) { + if ( + !row.url && + Object.keys(row.metadata).length > 0 && + !availabilitySent[row.value] + ) { + availabilitySent[row.value] = 1; + getAvailability(row.value, row.metadata); + } + }); + }; + + // Check availability services for immediate availability, if found, + // create a link in the table linking to the item + function getAvailability(identifier, metadata) { + // Prep the metadata for passing to the availability plugins + var prepped = encodeURIComponent(base64EncodeUnicode(JSON.stringify(metadata))); + for (i = 0; i < batch_availability_services.length; i++) { + var service = batch_availability_services[i]; + window.doApiRequest( + service.endpoint + prepped + ) + .then(function (response) { + return response.json(); + }) + .then(function (data) { + if (data.results.search_results && data.results.search_results.length > 0) { + var result = data.results.search_results[0]; + tableContent.data = tableContent.data.map(function (row) { + if (row.value === identifier) { + row.url = result.url; + row.availabilitySupplier = service.name; + } + return row; + }); + } + }); + } + }; + + // Help btoa with > 8 bit strings + // Shamelessly grabbed from: https://www.base64encoder.io/javascript/ + function base64EncodeUnicode(str) { + // First we escape the string using encodeURIComponent to get the UTF-8 encoding of the characters, + // then we convert the percent encodings into raw bytes, and finally feed it to btoa() function. + utf8Bytes = encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function(match, p1) { + return String.fromCharCode('0x' + p1); + }); + + return btoa(utf8Bytes); + }; + + // Create a local submission and update our local state + // upon success + function makeLocalSubmission(identifier, metadata) { + var payload = { + batch_id: batchId, + backend: batch.data.backend, + borrowernumber: batch.data.patron.borrowernumber, + branchcode: batch.data.branchcode, + metadata: metadata + }; + window.doCreateSubmission(payload) + .then(function (response) { + return response.json(); + }) + .then(function (data) { + tableContent.data = tableContent.data.map(function (row) { + if (row.value === identifier) { + row.requestId = data.illrequest_id; + } + return row; + }); + }) + .catch(function () { + window.handleApiError(ill_batch_api_request_fail); + }); + }; + + function updateProcessTotals() { + var init = { + total: 0, + count: 0, + failed: 0 + }; + progressTotals.data = init; + var toUpdate = progressTotals.data; + tableContent.data.forEach(function (row) { + toUpdate.total++; + if (Object.keys(row.metadata).length > 0 || row.failed.length > 0) { + toUpdate.count++; + } + if (Object.keys(row.failed).length > 0) { + toUpdate.failed++; + } + }); + createProgressTotal.innerHTML = toUpdate.total; + createProgressCount.innerHTML = toUpdate.count; + createProgressFailed.innerHTML = toUpdate.failed; + var percentDone = Math.ceil((toUpdate.count / toUpdate.total) * 100); + createProgressBar.setAttribute('aria-valuenow', percentDone); + createProgressBar.innerHTML = percentDone + '%'; + createProgressBar.style.width = percentDone + '%'; + progressTotals.data = toUpdate; + }; + + function displayPatronName() { + var span = document.getElementById('patron_link'); + if (batch.data.patron) { + var link = createPatronLink(); + span.appendChild(link); + } else { + if (span.children.length > 0) { + span.removeChild(span.firstChild); + } + } + }; + + function removeEventListeners() { + textarea.removeEventListener('paste', processButtonState); + textarea.removeEventListener('keyup', processButtonState); + processButton.removeEventListener('click', processIdentifiers); + nameInput.removeEventListener('keyup', createButtonState); + cardnumberInput.removeEventListener('keyup', createButtonState); + branchcodeSelect.removeEventListener('change', createButtonState); + createButton.removeEventListener('click', createBatch); + identifierTable.removeEventListener('click', toggleMetadata); + identifierTable.removeEventListener('click', removeRow); + createRequestsButton.remove('click', requestRequestable); + }; + + function finishButtonEventListener() { + finishButton.addEventListener('click', doFinish); + }; + + function identifierTextareaEventListener() { + textarea.addEventListener('paste', textareaUpdate); + textarea.addEventListener('keyup', textareaUpdate); + }; + + function processButtonEventListener() { + processButton.addEventListener('click', processIdentifiers); + }; + + function createRequestsButtonEventListener() { + createRequestsButton.addEventListener('click', requestRequestable); + }; + + function createButtonEventListener() { + createButton.addEventListener('click', createBatch); + }; + + function batchInputsEventListeners() { + nameInput.addEventListener('keyup', createButtonState); + cardnumberInput.addEventListener('keyup', createButtonState); + branchcodeSelect.addEventListener('change', createButtonState); + }; + + function moreLessEventListener() { + identifierTable.addEventListener('click', toggleMetadata); + }; + + function removeRowEventListener() { + identifierTable.addEventListener('click', removeRow); + }; + + function textareaUpdate() { + processButtonState(); + updateRowCount(); + }; + + function processButtonState() { + if (textarea.value.length > 0) { + processButton.removeAttribute('disabled'); + } else { + processButton.setAttribute('disabled', 1); + } + }; + + function disableCardnumberInput() { + if (batch.data.patron) { + cardnumberInput.setAttribute('disabled', true); + } else { + cardnumberInput.removeAttribute('disabled'); + } + }; + + function createButtonState() { + if ( + nameInput.value.length > 0 && + cardnumberInput.value.length > 0 && + branchcodeSelect.selectedOptions.length === 1 + ) { + createButton.removeAttribute('disabled'); + createButton.setAttribute('display', 'inline-block'); + } else { + createButton.setAttribute('disabled', 1); + createButton.setAttribute('display', 'none'); + } + }; + + function doFinish() { + updateBatch() + .then(function () { + $('#ill-batch-modal').modal({ show: false }); + location.href = '/cgi-bin/koha/ill/ill-requests.pl?batch_id=' + batch.data.id; + }); + }; + + // Get the batch + function fetchBatch() { + window.doBatchApiRequest("/" + batchId) + .then(function (response) { + return response.json(); + }) + .then(function (jsoned) { + batch.data = { + id: jsoned.id, + name: jsoned.name, + backend: jsoned.backend, + cardnumber: jsoned.cardnumber, + branchcode: jsoned.branchcode + } + return jsoned; + }) + .then(function (data) { + batch.data = data; + }) + .catch(function () { + window.handleApiError(ill_batch_api_fail); + }); + + }; + + function createBatch() { + var selectedBranchcode = branchcodeSelect.selectedOptions[0].value; + return doBatchApiRequest('', { + method: 'POST', + headers: { + 'Content-type': 'application/json' + }, + body: JSON.stringify({ + name: nameInput.value, + backend: backend, + cardnumber: cardnumberInput.value, + branchcode: selectedBranchcode + }) + }) + .then(function (response) { + return response.json(); + }) + .then(function (body) { + batchId = body.id; + batch.data = { + id: body.id, + name: body.name, + backend: body.backend, + cardnumber: body.patron.cardnumber, + branchcode: body.branchcode, + patron: body.patron + }; + initPostCreate(); + }) + .catch(function () { + handleApiError(ill_batch_create_api_fail); + }); + }; + + function updateBatch() { + var selectedBranchcode = branchcodeSelect.selectedOptions[0].value; + return doBatchApiRequest('/' + batch.data.id, { + method: 'PUT', + headers: { + 'Content-type': 'application/json' + }, + body: JSON.stringify({ + name: nameInput.value, + backend: batch.data.backend, + cardnumber: batch.data.patron.cardnumber, + branchcode: selectedBranchcode + }) + }) + .catch(function () { + handleApiError(ill_batch_update_api_fail); + }); + }; + + function displaySupportedIdentifiers() { + var names = Object.keys(supportedIdentifiers).map(function (identifier) { + return window['ill_batch_' + identifier]; + }); + var displayEl = document.getElementById('supported_identifiers'); + displayEl.textContent = names.length > 0 ? names.join(', ') : ill_batch_none; + } + + function updateRowCount() { + var textEl = document.getElementById('row_count_value'); + var val = textarea.value.trim(); + var cnt = 0; + if (val.length > 0) { + cnt = val.split(/\n/).length; + } + textEl.textContent = cnt; + } + + function showProgress() { + var el = document.getElementById('create-progress'); + el.style.display = 'block'; + } + + function showCreateRequestsButton() { + var data = progressTotals.data; + var el = document.getElementById('create-requests'); + el.style.display = (data.total > 0 && data.count === data.total) ? 'flex' : 'none'; + } + + async function processIdentifiers() { + var content = textarea.value; + hideErrors(); + if (content.length === 0) return; + + disableProcessButton(); + var label = document.getElementById('progress-label').firstChild; + label.innerHTML = ill_batch_retrieving_metadata; + showProgress(); + + // Errors encountered when processing + var processErrors = {}; + + // Prepare the content, including trimming each row + var contentArr = content.split(/\n/); + var trimmed = contentArr.map(function (row) { + return row.trim(); + }); + + var parsed = []; + + trimmed.forEach(function (identifier) { + var match = identifyIdentifier(identifier); + // If this identifier is not identifiable or + // looks like more than one type, we can't be sure + // what it is + if (match.length != 1) { + parsed.push({ + type: 'unknown', + value: identifier + }); + } else { + parsed.push(match[0]); + } + }); + + var unknownIdentifiers = parsed + .filter(function (parse) { + if (parse.type == 'unknown') { + return parse; + } + }) + .map(function (filtered) { + return filtered.value; + }); + + if (unknownIdentifiers.length > 0) { + processErrors.badidentifiers = { + element: 'badids', + values: unknownIdentifiers.join(', ') + }; + }; + + // Deduping + var deduped = []; + var dupes = {}; + parsed.forEach(function (row) { + var value = row.value; + var alreadyInDeduped = deduped.filter(function (d) { + return d.value === value; + }); + if (alreadyInDeduped.length > 0 && !dupes[value]) { + dupes[value] = 1; + } else if (alreadyInDeduped.length === 0) { + row.metadata = {}; + row.failed = {}; + row.requestId = null; + deduped.push(row); + } + }); + // Update duplicate error if dupes were found + if (Object.keys(dupes).length > 0) { + processErrors.duplicates = { + element: 'dupelist', + values: Object.keys(dupes).join(', ') + }; + } + + // Display any errors + displayErrors(processErrors); + + // Now build and display the table + if (!table) { + buildTable(); + } + + // We may be appending new values to an existing table, + // in which case, ensure we don't create duplicates + var tabIdentifiers = tableContent.data.map(function (tabId) { + return tabId.value; + }); + var notInTable = deduped.filter(function (ded) { + if (!tabIdentifiers.includes(ded.value)) { + return ded; + } + }); + if (notInTable.length > 0) { + tableContent.data = tableContent.data.concat(notInTable); + } + + // Populate metadata for those records that need it + var newData = tableContent.data; + for (var i = 0; i < tableContent.data.length; i++) { + var row = tableContent.data[i]; + // Skip rows that don't need populating + if ( + Object.keys(tableContent.data[i].metadata).length > 0 || + Object.keys(tableContent.data[i].failed).length > 0 + ) continue; + var identifier = { type: row.type, value: row.value }; + try { + var populated = await populateMetadata(identifier); + row.metadata = populated.results.result || {}; + } catch (e) { + row.failed = ill_populate_failed; + } + newData[i] = row; + tableContent.data = newData; + } + } + + function disableProcessButton() { + processButton.setAttribute('disabled', true); + } + + function disableCreateButton() { + createButton.setAttribute('disabled', true); + } + + function disableRemoveRowButtons() { + var buttons = document.getElementsByClassName('remove-row'); + for (var button of buttons) { + button.setAttribute('disabled', true); + } + } + + async function populateMetadata(identifier) { + // All services that support this identifier type + var services = supportedIdentifiers[identifier.type]; + // Check each service and use the first results we get, if any + for (var i = 0; i < services.length; i++) { + var service = services[i]; + var endpoint = '/api/v1/contrib/' + service.api_namespace + service.search_endpoint + '?' + identifier.type + '=' + identifier.value; + var metadata = await getMetadata(endpoint); + if (metadata.errors.length === 0) { + var parsed = await parseMetadata(metadata, service); + if (parsed.errors.length > 0) { + throw Error(metadata.errors.map(function (error) { + return error.message; + }).join(', ')); + } + return parsed; + } + } + }; + + async function getMetadata(endpoint) { + var response = await debounce(doApiRequest)(endpoint); + return response.json(); + }; + + async function parseMetadata(metadata, service) { + var endpoint = '/api/v1/contrib/' + service.api_namespace + service.ill_parse_endpoint; + var response = await doApiRequest(endpoint, { + method: 'POST', + headers: { + 'Content-type': 'application/json' + }, + body: JSON.stringify(metadata) + }); + return response.json(); + } + + // A render function for identifier type + function createIdentifierType(data) { + return window['ill_batch_' + data]; + }; + + // Get an item's title + function getTitle(meta) { + if (meta.article_title && meta.article_title.length > 0) { + return { + prop: 'article_title', + value: meta.article_title + }; + } else if (meta.title && meta.title.length > 0) { + return { + prop: 'title', + value: meta.title + }; + } + }; + + // Create a metadata row + function createMetadataRow(data, meta, prop) { + if (!meta[prop]) return; + + var div = document.createElement('div'); + div.classList.add('metadata-row'); + var label = document.createElement('span'); + label.classList.add('metadata-label'); + label.innerText = ill_batch_metadata[prop] + ': '; + + // Add a link to the availability URL if appropriate + var value; + if (!data.url) { + value = document.createElement('span'); + } else { + value = document.createElement('a'); + value.setAttribute('href', data.url); + value.setAttribute('target', '_blank'); + value.setAttribute('title', ill_batch_available_via + ' ' + data.availabilitySupplier); + } + value.classList.add('metadata-value'); + value.innerText = meta[prop]; + div.appendChild(label); + div.appendChild(value); + + return div; + } + + // A render function for displaying metadata + function createMetadata(x, y, data) { + // If the fetch failed + if (data.failed.length > 0) { + return data.failed; + } + + // If we've not yet got any metadata back + if (Object.keys(data.metadata).length === 0) { + return ill_populate_waiting; + } + + var core = ['doi', 'pmid', 'issn', 'title', 'year', 'issue', 'pages', 'publisher', 'article_title', 'article_author', 'volume']; + var meta = data.metadata; + + var container = document.createElement('div'); + container.classList.add('metadata-container'); + + // Create the title row + var title = getTitle(meta); + if (title) { + // Remove the title element from the props + // we're about to iterate + core = core.filter(function (i) { + return i !== title.prop; + }); + var titleRow = createMetadataRow(data, meta, title.prop); + container.appendChild(titleRow); + } + + var remainder = document.createElement('div'); + remainder.classList.add('metadata-remainder'); + remainder.style.display = 'none'; + // Create the remaining rows + core.sort().forEach(function (prop) { + var div = createMetadataRow(data, meta, prop); + if (div) { + remainder.appendChild(div); + } + }); + container.appendChild(remainder); + + // Add a more/less toggle + var firstField = container.firstChild; + var moreLess = document.createElement('div'); + moreLess.classList.add('more-less'); + var moreLessLink = document.createElement('a'); + moreLessLink.setAttribute('href', '#'); + moreLessLink.classList.add('more-less-link'); + moreLessLink.innerText = ' [' + ill_batch_metadata_more + ']'; + moreLess.appendChild(moreLessLink); + firstField.appendChild(moreLess); + + return container.outerHTML; + }; + + function removeRow(ev) { + if (ev.target.className.includes('remove-row')) { + if (!confirm(ill_batch_item_remove)) return; + // Find the parent row + var ancestor = ev.target.closest('tr'); + var identifier = ancestor.querySelector('.identifier').innerText; + tableContent.data = tableContent.data.filter(function (row) { + return row.value !== identifier; + }); + } + } + + function toggleMetadata(ev) { + if (ev.target.className === 'more-less-link') { + // Find the element we need to show + var ancestor = ev.target.closest('.metadata-container'); + var meta = ancestor.querySelector('.metadata-remainder'); + + // Display or hide based on its current state + var display = window.getComputedStyle(meta).display; + + meta.style.display = display === 'block' ? 'none' : 'block'; + + // Update the More / Less text + ev.target.innerText = ' [ ' + (display === 'none' ? ill_batch_metadata_less : ill_batch_metadata_more) + ' ]'; + } + } + + // A render function for the link to a request ID + function createRequestId(x, y, data) { + return data.requestId || '-'; + } + + function buildTable(identifiers) { + table = KohaTable('identifier-table', { + processing: true, + deferRender: true, + ordering: false, + paging: false, + searching: false, + autoWidth: false, + columns: [ + { + data: 'type', + width: '13%', + render: createIdentifierType + }, + { + data: 'value', + width: '25%', + className: 'identifier' + }, + { + data: 'metadata', + render: createMetadata + }, + { + data: 'requestId', + width: '13%', + render: createRequestId + }, + { + width: '18%', + render: createActions, + className: 'action-column' + } + ], + createdRow: function (row, data) { + if (data.failed.length > 0) { + row.classList.add('fetch-failed'); + } + } + }); + } + + function createActions(x, y, data) { + return ''; + } + + // Redraw the table + function updateTable() { + if (!table) return; + tableEl.style.display = tableContent.data.length > 0 ? 'table' : 'none'; + tableEl.style.width = '100%'; + table.api() + .clear() + .rows.add(tableContent.data) + .draw(); + }; + + function identifyIdentifier(identifier) { + var matches = []; + + // Iterate our available services to see if any can identify this identifier + Object.keys(supportedIdentifiers).forEach(function (identifierType) { + // Since all the services supporting this identifier type should use the same + // regex to identify it, we can just use the first + var service = supportedIdentifiers[identifierType][0]; + var regex = new RegExp(service.identifiers_supported[identifierType].regex); + var match = identifier.match(regex); + if (match && match.groups && match.groups.identifier) { + matches.push({ + type: identifierType, + value: match.groups.identifier + }); + } + }); + return matches; + } + + function displayErrors(errors) { + var keys = Object.keys(errors); + if (keys.length > 0) { + keys.forEach(function (key) { + var el = document.getElementById(errors[key].element); + el.textContent = errors[key].values; + el.style.display = 'inline'; + var container = document.getElementById(key); + container.style.display = 'block'; + }); + var el = document.getElementById('textarea-errors'); + el.style.display = 'flex'; + } + } + + function hideErrors() { + var dupelist = document.getElementById('dupelist'); + var badids = document.getElementById('badids'); + dupelist.textContent = ''; + dupelist.parentElement.style.display = 'none'; + badids.textContent = ''; + badids.parentElement.style.display = 'none'; + var tae = document.getElementById('textarea-errors'); + tae.style.display = 'none'; + } + + function manageBatchItemsDisplay() { + batchItemsDisplay.style.display = batch.data.id ? 'block' : 'none' + }; + + function updateBatchInputs() { + nameInput.value = batch.data.name || ''; + cardnumberInput.value = batch.data.cardnumber || ''; + branchcodeSelect.value = batch.data.branchcode || ''; + } + + function debounce(func) { + var timeout; + return function (...args) { + return new Promise(function (resolve) { + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(function () { + return resolve(func(...args)); + }, debounceDelay); + }); + } + } + + function patronAutocomplete() { + // Add autocomplete for patron selection + $('#batch-form #cardnumber').autocomplete({ + appendTo: '#batch-form', + source: "/cgi-bin/koha/circ/ysearch.pl", + minLength: 3, + select: function (event, ui) { + var field = ui.item.cardnumber; + $('#batch-form #cardnumber').val(field) + return false; + } + }) + .data("ui-autocomplete")._renderItem = function (ul, item) { + return $("
  • ") + .data("ui-autocomplete-item", item) + .append("" + item.surname + ", " + item.firstname + " (" + item.cardnumber + ") " + item.address + " " + item.city + " " + item.zipcode + " " + item.country + "") + .appendTo(ul); + }; + }; + + function createPatronLink() { + if (!batch.data.patron) return; + var patron = batch.data.patron; + var a = document.createElement('a'); + var href = '/cgi-bin/koha/members/moremember.pl?borrowernumber=' + patron.borrowernumber; + var text = patron.surname + ' (' + patron.cardnumber + ')'; + a.setAttribute('title', ill_borrower_details); + a.setAttribute('href', href); + a.textContent = text; + return a; + }; + +})(); --- a/koha-tmpl/intranet-tmpl/prog/js/ill-batch-table.js +++ a/koha-tmpl/intranet-tmpl/prog/js/ill-batch-table.js @@ -0,0 +1,205 @@ +(function () { + var table; + var batchesProxy; + + window.addEventListener('load', onload); + + function onload() { + // Only proceed if appropriate + if (!document.getElementById('ill-batch-requests')) return; + + // A proxy that will give us some element of reactivity to + // changes in our list of batches + batchesProxy = new Proxy( + { data: [] }, + { + get: function (obj, prop) { + return obj[prop]; + }, + set: function (obj, prop, value) { + obj[prop] = value; + updateTable(); + } + } + ); + + // Initialise the Datatable, binding it to our proxy object + table = initTable(); + + // Do the initial data population + window.doBatchApiRequest() + .then(function (response) { + return response.json(); + }) + .then(function (data) { + batchesProxy.data = data; + }); + + // Clean up any event listeners we added + window.addEventListener('beforeunload', removeEventListeners); + }; + + // Initialise the Datatable + var initTable = function () { + return KohaTable("ill-batch-requests", { + data: batchesProxy.data, + columns: [ + { + data: 'id', + width: '10%' + }, + { + data: 'name', + render: createName, + width: '30%' + }, + { + data: 'requests_count', + width: '10%' + }, + { + data: 'patron', + render: createPatronLink, + width: '20%' + }, + { + data: 'branch', + render: createBranch, + width: '20%' + }, + { + render: createActions, + width: '10%', + orderable: false + } + ], + processing: true, + deferRender: true, + drawCallback: addEventListeners + }); + } + + // A render function for branch name + var createBranch = function (data) { + return data.branchname; + } + + // A render function for batch name + var createName = function (x, y, data) { + var a = document.createElement('a'); + a.setAttribute('href', '/cgi-bin/koha/ill/ill-requests.pl?batch_id=' + data.id); + a.setAttribute('title', data.name); + a.textContent = data.name; + return a.outerHTML; + }; + + // A render function for our patron link + var createPatronLink = function (data) { + var link = document.createElement('a'); + link.setAttribute('title', ill_batch_borrower_details); + link.setAttribute('href', '/cgi-bin/koha/members/moremember.pl?borrowernumber=' + data.borrowernumber); + var displayText = [data.firstname, data.surname].join(' ') + ' ( ' + data.cardnumber + ' )'; + link.appendChild(document.createTextNode(displayText)); + + return link.outerHTML; + }; + + // A render function for our row action buttons + var createActions = function (data, type, row) { + var div = document.createElement('div'); + div.setAttribute('class', 'action-buttons'); + + var editButton = document.createElement('button'); + editButton.setAttribute('type', 'button'); + editButton.setAttribute('class', 'editButton btn btn-xs btn-default'); + editButton.setAttribute('data-batch-id', row.id); + editButton.appendChild(document.createTextNode(ill_batch_edit)); + + var deleteButton = document.createElement('button'); + deleteButton.setAttribute('type', 'button'); + deleteButton.setAttribute('class', 'deleteButton btn btn-xs btn-danger'); + deleteButton.setAttribute('data-batch-id', row.id); + deleteButton.appendChild(document.createTextNode(ill_batch_delete)); + + div.appendChild(editButton); + div.appendChild(deleteButton); + + return div.outerHTML; + }; + + // Add event listeners to our row action buttons + var addEventListeners = function () { + var del = document.querySelectorAll('.deleteButton'); + del.forEach(function (el) { + el.addEventListener('click', handleDeleteClick); + }); + + var edit = document.querySelectorAll('.editButton'); + edit.forEach(function (elEdit) { + elEdit.addEventListener('click', handleEditClick); + }); + }; + + // Remove all added event listeners + var removeEventListeners = function () { + var del = document.querySelectorAll('.deleteButton'); + del.forEach(function (el) { + el.removeEventListener('click', handleDeleteClick); + }); + window.removeEventListener('load', onload); + window.removeEventListener('beforeunload', removeEventListeners); + }; + + // Handle "Delete" clicks + var handleDeleteClick = function(e) { + var el = e.srcElement; + if (confirm(ill_batch_confirm_delete)) { + deleteBatch(el); + } + }; + + // Handle "Edit" clicks + var handleEditClick = function(e) { + var el = e.srcElement; + var id = el.dataset.batchId; + window.openBatchModal(id); + }; + + // Delete a batch + // - Make the API call + // - Handle errors + // - Update our proxy data + var deleteBatch = function (el) { + var id = el.dataset.batchId; + doBatchApiRequest( + '/' + id, + { method: 'DELETE' } + ) + .then(function (response) { + if (!response.ok) { + window.handleApiError(ill_batch_delete_fail); + } else { + removeBatch(el.dataset.batchId); + } + }) + .catch(function (response) { + window.handleApiError(ill_batch_delete_fail); + }) + }; + + // Remove a batch from our proxy data + var removeBatch = function(id) { + batchesProxy.data = batchesProxy.data.filter(function (batch) { + return batch.id != id; + }); + }; + + // Redraw the table + var updateTable = function () { + table.api() + .clear() + .rows.add(batchesProxy.data) + .draw(); + }; + +})(); --- a/koha-tmpl/intranet-tmpl/prog/js/ill-batch.js +++ a/koha-tmpl/intranet-tmpl/prog/js/ill-batch.js @@ -0,0 +1,49 @@ +(function () { + // Enable the modal to be opened from anywhere + // If we're working with an existing batch, set the ID so the + // modal can access it + window.openBatchModal = function (id, backend) { + var idEl = document.getElementById('ill-batch-details'); + idEl.dataset.backend = backend; + if (id) { + idEl.dataset.batchId = id; + } + $('#ill-batch-modal').modal({ show: true }); + }; + + // Make a batch API call, returning the resulting promise + window.doBatchApiRequest = function (url, options) { + var batchListApi = '/api/v1/illbatches'; + var fullUrl = batchListApi + (url ? url : ''); + return doApiRequest(fullUrl, options); + }; + + // Make a "create local ILL submission" call + window.doCreateSubmission = function (body, options) { + options = Object.assign( + options || {}, + { + headers: { + 'Content-Type': 'application/json' + }, + method: 'POST', + body: JSON.stringify(body) + } + ); + return doApiRequest( + '/api/v1/illrequests', + options + ) + } + + // Make an API call, returning the resulting promise + window.doApiRequest = function (url, options) { + return fetch(url, options); + }; + + // Display an API error + window.handleApiError = function (error) { + alert(error); + }; + +})(); --- a/koha-tmpl/intranet-tmpl/prog/js/ill-list-table.js +++ a/koha-tmpl/intranet-tmpl/prog/js/ill-list-table.js @@ -221,6 +221,12 @@ $(document).ready(function() { return row.id_prefix + row.illrequest_id; }; + // Render function for batch + var createBatch = function (data, type, row) { + if (!row.batch) return; + return '' + row.batch.name + ''; + }; + // Render function for type var createType = function(data, type, row) { if (!row.hasOwnProperty('metadata_Type') || !row.metadata_Type) { @@ -291,6 +297,10 @@ $(document).ready(function() { func: createActionLink, skipSanitize: true }, + batch: { + func: createBatch, + skipSanitize: true + }, illrequest_id: { func: createRequestId }, @@ -383,7 +393,10 @@ $(document).ready(function() { ( // ILL list requests page window.location.href.match(/ill\/ill-requests\.pl/) && - window.location.search.length == 0 + ( + window.location.search.length == 0 || + /borrowernumber|batch_id/.test(window.location.search) + ) ) || // Patron profile page window.location.href.match(/members\/ill-requests\.pl/) @@ -391,7 +404,7 @@ $(document).ready(function() { var ajax = $.ajax( '/api/v1/illrequests?embed=metadata,patron,capabilities,library,status_alias,comments,requested_partners' + filterParam - ).done(function() { + ).done(function () { var data = JSON.parse(ajax.responseText); // Make a copy, we'll be removing columns next and need // to be able to refer to data that has been removed --- a/t/db_dependent/api/v1/illrequests.t +++ a/t/db_dependent/api/v1/illrequests.t @@ -17,7 +17,7 @@ use Modern::Perl; -use Test::More tests => 1; +use Test::More tests => 2; use Test::MockModule; use Test::MockObject; @@ -173,6 +173,90 @@ subtest 'list() tests' => sub { $schema->storage->txn_rollback; }; +subtest 'add() tests' => sub { + + plan tests => 2; + + $schema->storage->txn_begin; + + # create an authorized user + my $patron = $builder->build_object({ + class => 'Koha::Patrons', + value => { flags => 2 ** 22 } # 22 => ill + }); + my $password = 'thePassword123'; + $patron->set_password({ password => $password, skip_validation => 1 }); + my $userid = $patron->userid; + + my $library = $builder->build_object( { class => 'Koha::Libraries' } ); + + # Create an ILL request + my $illrequest = $builder->build_object( + { + class => 'Koha::Illrequests', + value => { + backend => 'Mock', + branchcode => $library->branchcode, + borrowernumber => $patron->borrowernumber, + status => 'STATUS1', + } + } + ); + + # Mock ILLBackend (as object) + my $backend = Test::MockObject->new; + $backend->set_isa('Koha::Illbackends::Mock'); + $backend->set_always('name', 'Mock'); + $backend->set_always('capabilities', sub { + return $illrequest; + } ); + $backend->mock( + 'metadata', + sub { + my ( $self, $rq ) = @_; + return { + ID => $rq->illrequest_id, + Title => $rq->patron->borrowernumber + } + } + ); + $backend->mock( + 'status_graph', sub {}, + ); + + # Mock Koha::Illrequest::load_backend (to load Mocked Backend) + my $illreqmodule = Test::MockModule->new('Koha::Illrequest'); + $illreqmodule->mock( 'load_backend', + sub { my $self = shift; $self->{_my_backend} = $backend; return $self } + ); + + $schema->storage->txn_begin; + + Koha::Illrequests->search->delete; + + my $body = { + backend => 'Mock', + borrowernumber => $patron->borrowernumber, + branchcode => $library->branchcode, + metadata => { + article_author => "Jessop, E. G.", + article_title => "Sleep", + issn => "0957-4832", + issue => "2", + pages => "89-90", + publisher => "OXFORD UNIVERSITY PRESS", + title => "Journal of public health medicine.", + year => "2001" + } + }; + + ## Authorized user test + $t->post_ok( "//$userid:$password@/api/v1/illrequests" => json => $body) + ->status_is(201); + + $schema->storage->txn_rollback; +}; + sub add_formatted { my $req = shift; my @format_dates = ( 'placed', 'updated', 'completed' ); --