2 * Catalog Record Buckets
6 * add-all actions only add visible/fetched items.
7 * remove all from bucket UI leaves busted pagination
8 * -- apply a refresh after item removal?
9 * problems with bucket view fetching by record ID instead of bucket item:
10 * -- dupe bibs always sort to the bottom
11 * -- dupe bibs result in more records displayed per page than requested
12 * -- item 'pos' ordering is not honored on initial load.
15 angular.module('egCatRecordBuckets',
16 ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'egGridMod', 'egMarcMod', 'egHoldingsMod'])
18 .config(function($routeProvider, $locationProvider, $compileProvider) {
19 $locationProvider.html5Mode(true);
20 $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
22 var resolver = {delay : function(egStartup) {return egStartup.go()}};
24 $routeProvider.when('/cat/bucket/record/search/:id', {
25 templateUrl: './cat/bucket/record/t_search',
26 controller: 'SearchCtrl',
30 $routeProvider.when('/cat/bucket/record/search', {
31 templateUrl: './cat/bucket/record/t_search',
32 controller: 'SearchCtrl',
36 $routeProvider.when('/cat/bucket/record/pending/:id', {
37 templateUrl: './cat/bucket/record/t_pending',
38 controller: 'PendingCtrl',
42 $routeProvider.when('/cat/bucket/record/pending', {
43 templateUrl: './cat/bucket/record/t_pending',
44 controller: 'PendingCtrl',
48 $routeProvider.when('/cat/bucket/record/view/:id', {
49 templateUrl: './cat/bucket/record/t_view',
50 controller: 'ViewCtrl',
54 $routeProvider.when('/cat/bucket/record/view', {
55 templateUrl: './cat/bucket/record/t_view',
56 controller: 'ViewCtrl',
60 // default page / bucket view
61 $routeProvider.otherwise({redirectTo : '/cat/bucket/record/view'});
65 * bucketSvc allows us to communicate between the search,
66 * pending, and view controllers. It also allows us to cache
67 * data for each so that data reloads are not needed on every
68 * tab click (i.e. route persistence).
70 .factory('bucketSvc', ['$q','egCore', function($q, egCore) {
73 allBuckets : [], // un-fleshed user buckets
74 queryString : '', // last run query
75 queryRecords : [], // last run query results
76 currentBucket : null, // currently viewed bucket
78 // per-page list collections
83 // fetches all staff/biblio buckets for the authenticated user
84 // this function may only be called after startup.
85 fetchUserBuckets : function(force) {
86 if (this.allBuckets.length && !force) return;
88 return egCore.net.request(
90 'open-ils.actor.container.retrieve_by_class.authoritative',
91 egCore.auth.token(), egCore.auth.user().id(),
92 'biblio', ['staff_client', 'vandelay_queue']
93 ).then(function(buckets) { self.allBuckets = buckets });
96 createBucket : function(name, desc) {
97 var deferred = $q.defer();
98 var bucket = new egCore.idl.cbreb();
99 bucket.owner(egCore.auth.user().id());
101 bucket.description(desc || '');
102 bucket.btype('staff_client');
106 'open-ils.actor.container.create',
107 egCore.auth.token(), 'biblio', bucket
108 ).then(function(resp) {
110 if (typeof resp == 'object') {
111 console.error('bucket create error: ' + js2JSON(resp));
114 deferred.resolve(resp);
119 return deferred.promise;
122 // edit the current bucket. since we edit the
123 // local object, there's no need to re-fetch.
124 editBucket : function(args) {
125 var bucket = service.currentBucket;
126 bucket.name(args.name);
127 bucket.description(args.desc);
128 bucket.pub(args.pub);
129 return egCore.net.request(
131 'open-ils.actor.container.update',
132 egCore.auth.token(), 'biblio', bucket
137 // returns 1 if full refresh is needed
138 // returns 2 if list refresh only is needed
139 service.bucketRefreshLevel = function(id) {
140 if (!service.currentBucket) return 1;
141 if (service.bucketNeedsRefresh) {
142 service.bucketNeedsRefresh = false;
143 service.currentBucket = null;
146 if (service.currentBucket.id() != id) return 1;
150 // returns a promise, resolved with bucket, rejected if bucket is
152 service.fetchBucket = function(id) {
153 var refresh = service.bucketRefreshLevel(id);
154 if (refresh == 2) return $q.when(service.currentBucket);
156 var deferred = $q.defer();
160 'open-ils.actor.container.flesh.authoritative',
161 egCore.auth.token(), 'biblio', id
162 ).then(function(bucket) {
163 var evt = egCore.evt.parse(bucket);
166 deferred.reject(evt);
169 egCore.pcrud.retrieve(
170 'au', bucket.owner(),
171 {flesh : 1, flesh_fields : {au : ["card"]}}
172 ).then(function(patron) {
173 // On the off chance no barcode is present (it's not
174 // required) use the patron username as the identifier.
175 bucket._owner_ident = patron.card() ?
176 patron.card().barcode() : patron.usrname();
177 bucket._owner_name = patron.family_name();
178 bucket._owner_ou = egCore.org.get(patron.home_ou()).shortname();
181 service.currentBucket = bucket;
182 deferred.resolve(bucket);
185 return deferred.promise;
188 // deletes a single container item from a bucket by container item ID.
189 // promise is rejected on failure
190 service.detachRecord = function(itemId) {
191 var deferred = $q.defer();
194 'open-ils.actor.container.item.delete',
195 egCore.auth.token(), 'biblio', itemId
196 ).then(function(resp) {
197 var evt = egCore.evt.parse(resp);
200 deferred.reject(evt);
203 console.log('detached bucket item ' + itemId);
204 deferred.resolve(resp);
207 return deferred.promise;
210 service.deleteRecordFromCatalog = function(recordId) {
211 var deferred = $q.defer();
215 'open-ils.cat.biblio.record_entry.delete',
216 egCore.auth.token(), recordId
217 ).then(function(resp) {
218 // rather than rejecting the promise in the
219 // case of a failure, we'll let the caller
220 // look for errors -- doing this because AngularJS
221 // does not have a native $q.allSettled() yet.
222 deferred.resolve(resp);
225 return deferred.promise;
228 // delete bucket by ID.
229 // resolved w/ response on successful delete,
230 // rejected otherwise.
231 service.deleteBucket = function(id) {
232 var deferred = $q.defer();
235 'open-ils.actor.container.full_delete',
236 egCore.auth.token(), 'biblio', id
237 ).then(function(resp) {
238 var evt = egCore.evt.parse(resp);
241 deferred.reject(evt);
244 deferred.resolve(resp);
246 return deferred.promise;
253 * Top-level controller.
254 * Hosts functions needed by all controllers.
256 .controller('RecordBucketCtrl',
257 ['$scope','$location','$q','$timeout','$uibModal',
258 '$window','egCore','bucketSvc',
259 function($scope, $location, $q, $timeout, $uibModal,
260 $window, egCore, bucketSvc) {
262 $scope.bucketSvc = bucketSvc;
263 $scope.bucket = function() { return bucketSvc.currentBucket }
265 // tabs: search, pending, view
266 $scope.setTab = function(tab) {
269 // for bucket selector; must be called after route resolve
270 bucketSvc.fetchUserBuckets();
273 $scope.loadBucketFromMenu = function(item, bucket) {
274 if (bucket) return $scope.loadBucket(bucket.id());
277 $scope.loadBucket = function(id) {
279 '/cat/bucket/record/' +
280 $scope.tab + '/' + encodeURIComponent(id));
283 $scope.addToBucket = function(recs) {
284 if (recs.length == 0) return;
285 bucketSvc.bucketNeedsRefresh = true;
287 angular.forEach(recs,
289 var item = new egCore.idl.cbrebi();
290 item.bucket(bucketSvc.currentBucket.id());
291 item.target_biblio_record_entry(rec.id);
294 'open-ils.actor.container.item.create',
295 egCore.auth.token(), 'biblio', item
296 ).then(function(resp) {
298 // HACK: add the IDs of the added items so that the size
299 // of the view list will grow (and update any UI looking at
300 // the list size). The data stored is inconsistent, but since
301 // we are forcing a bucket refresh on the next rendering of
302 // the view pane, the list will be repaired.
303 bucketSvc.currentBucket.items().push(resp);
309 $scope.openCreateBucketDialog = function() {
311 templateUrl: './cat/bucket/share/t_bucket_create',
313 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
314 $scope.focusMe = true;
315 $scope.ok = function(args) { $uibModalInstance.close(args) }
316 $scope.cancel = function () { $uibModalInstance.dismiss() }
318 }).result.then(function (args) {
319 if (!args || !args.name) return;
320 bucketSvc.createBucket(args.name, args.desc).then(
323 bucketSvc.viewList = [];
324 bucketSvc.allBuckets = []; // reset
325 bucketSvc.currentBucket = null;
327 '/cat/bucket/record/' + $scope.tab + '/' + id);
333 $scope.openEditBucketDialog = function() {
335 templateUrl: './cat/bucket/share/t_bucket_edit',
337 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
338 $scope.focusMe = true;
340 name : bucketSvc.currentBucket.name(),
341 desc : bucketSvc.currentBucket.description(),
342 pub : bucketSvc.currentBucket.pub() == 't'
344 $scope.ok = function(args) {
346 $scope.actionPending = true;
347 args.pub = args.pub ? 't' : 'f';
348 // close the dialog after edit has completed
349 bucketSvc.editBucket(args).then(
350 function() { $uibModalInstance.close() });
352 $scope.cancel = function () { $uibModalInstance.dismiss() }
358 // opens the delete confirmation and deletes the current
359 // bucket if the user confirms.
360 $scope.openDeleteBucketDialog = function() {
362 templateUrl: './cat/bucket/share/t_bucket_delete',
364 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
365 $scope.bucket = function() { return bucketSvc.currentBucket }
366 $scope.ok = function() { $uibModalInstance.close() }
367 $scope.cancel = function() { $uibModalInstance.dismiss() }
369 }).result.then(function () {
370 bucketSvc.deleteBucket(bucketSvc.currentBucket.id())
372 bucketSvc.allBuckets = [];
373 $location.path('/cat/bucket/record/view');
378 // retrieves the requested bucket by ID
379 $scope.openSharedBucketDialog = function() {
381 templateUrl: './cat/bucket/share/t_load_shared',
383 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
384 $scope.focusMe = true;
385 $scope.ok = function(args) {
386 if (args && args.id) {
387 $uibModalInstance.close(args.id)
390 $scope.cancel = function() { $uibModalInstance.dismiss() }
392 }).result.then(function(id) {
393 // RecordBucketCtrl $scope is not inherited by the
394 // modal, so we need to call loadBucket from the
396 $scope.loadBucket(id);
400 // opens the record export dialog
401 $scope.openExportBucketDialog = function() {
403 templateUrl: './cat/bucket/record/t_bucket_export',
405 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
406 $scope.args = {format : 'XML', encoding : 'UTF-8'}; // defaults
407 $scope.ok = function(args) { $uibModalInstance.close(args) }
408 $scope.cancel = function() { $uibModalInstance.dismiss() }
410 }).result.then(function (args) {
412 args.containerid = bucketSvc.currentBucket.id();
414 var url = '/exporter?containerid=' + args.containerid +
415 '&format=' + args.format + '&encoding=' + args.encoding;
417 if (args.holdings) url += '&holdings=1';
419 // TODO: improve auth cookie handling so this isn't necessary.
420 // today the cookie path is too specific (/eg/staff) for non-staff
421 // UIs to access it. See services/auth.js
422 url += '&ses=' + egCore.auth.token();
424 $timeout(function() { $window.open(url) });
429 .controller('SearchCtrl',
430 ['$scope','$routeParams','egCore','bucketSvc',
431 function($scope, $routeParams, egCore , bucketSvc) {
433 $scope.setTab('search');
434 $scope.focusMe = true;
435 var idQueryHash = {};
437 function generateQuery() {
438 if (bucketSvc.queryRecords.length)
439 return {id : bucketSvc.queryRecords};
444 $scope.gridControls = {
445 setQuery : function() {return generateQuery()},
446 setSort : function() {return ['id']}
449 // add selected items directly to the pending list
450 $scope.addToPending = function(recs) {
451 angular.forEach(recs, function(rec) {
452 if (bucketSvc.pendingList.filter( // remove dupes
453 function(r) {return r.id == rec.id}).length) return;
454 bucketSvc.pendingList.push(rec);
458 $scope.search = function() {
459 $scope.searchList = [];
460 $scope.searchInProgress = true;
461 bucketSvc.queryRecords = [];
465 'open-ils.search.biblio.multiclass.query.staff', {
467 }, bucketSvc.queryString, true
468 ).then(function(resp) {
469 bucketSvc.queryRecords =
470 resp.ids.map(function(id){return id[0]});
471 $scope.gridControls.setQuery(generateQuery());
472 })['finally'](function() {
473 $scope.searchInProgress = false;
477 if ($routeParams.id &&
478 (!bucketSvc.currentBucket ||
479 bucketSvc.currentBucket.id() != $routeParams.id)) {
480 // user has accessed this page cold with a bucket ID.
481 // fetch the bucket for display, then set the totalCount
482 // (also for display), but avoid fully fetching the bucket,
483 // since it's premature, in this UI.
484 bucketSvc.fetchBucket($routeParams.id);
488 .controller('PendingCtrl',
489 ['$scope','$routeParams','bucketSvc','egGridDataProvider',
490 function($scope, $routeParams, bucketSvc , egGridDataProvider) {
491 $scope.setTab('pending');
493 var provider = egGridDataProvider.instance({});
494 provider.get = function(offset, count) {
495 return provider.arrayNotifier(
496 bucketSvc.pendingList, offset, count);
498 $scope.gridDataProvider = provider;
500 $scope.resetPendingList = function() {
501 bucketSvc.pendingList = [];
502 $scope.gridDataProvider.refresh();
506 if ($routeParams.id &&
507 (!bucketSvc.currentBucket ||
508 bucketSvc.currentBucket.id() != $routeParams.id)) {
509 // user has accessed this page cold with a bucket ID.
510 // fetch the bucket for display, then set the totalCount
511 // (also for display), but avoid fully fetching the bucket,
512 // since it's premature, in this UI.
513 bucketSvc.fetchBucket($routeParams.id);
517 .controller('ViewCtrl',
518 ['$scope','$q','$routeParams','bucketSvc','egCore','$window',
519 '$timeout','egConfirmDialog','$uibModal','egHolds',
520 function($scope, $q , $routeParams, bucketSvc, egCore, $window,
521 $timeout, egConfirmDialog, $uibModal, egHolds) {
523 $scope.setTab('view');
524 $scope.bucketId = $routeParams.id;
527 $scope.gridControls = {
528 setQuery : function(q) {
534 function drawBucket() {
535 return bucketSvc.fetchBucket($scope.bucketId).then(
537 var ids = bucket.items().map(
538 function(i){return i.target_biblio_record_entry()}
541 $scope.gridControls.setQuery({id : ids});
543 $scope.gridControls.setQuery({});
549 // runs the transfer title holds action
550 $scope.transfer_holds_to_marked = function(records) {
551 var bib_ids = records.map(function(val) { return val.id; })
552 egHolds.transfer_all_bib_holds_to_marked_title(bib_ids);
555 // opens the record merge dialog
556 $scope.openRecordMergeDialog = function(records) {
558 templateUrl: './cat/bucket/record/t_merge_records',
560 windowClass: 'eg-wide-modal',
562 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
565 $scope.merge_profile = null;
566 $scope.lead = { marc_xml : null };
567 $scope.editing_inplace = false;
568 angular.forEach(records, function(rec) {
569 $scope.records.push({ id : rec.id });
571 $scope.ok = function() {
572 $uibModalInstance.close({
573 lead_id : $scope.lead_id,
574 records : $scope.records,
575 merge_profile : $scope.merge_profile,
579 $scope.cancel = function () { $uibModalInstance.dismiss() }
581 $scope.merge_marc = function() {
582 // need lead, at least one sub, and a merge profile
583 if (!$scope.lead_id) return;
584 if (!$scope.merge_profile) return;
586 if (!$scope.records.length) {
587 // if we got here, the last subordinate record
588 // was likely removed, so let's refresh the
589 // lead for the sake of a consistent display
590 egCore.pcrud.retrieve('bre', $scope.lead_id)
591 .then(function(rec) {
592 $scope.lead.marc_xml = rec.marc();
597 var recs = $scope.records.map(function(val) { return val.id; });
598 recs.unshift($scope.lead_id);
601 'open-ils.cat.merge.biblio.per_profile',
603 $scope.merge_profile,
605 ).then(function(merged) {
606 if (merged) $scope.lead.marc_xml = merged;
609 $scope.$watch('merge_profile', function(newVal, oldVal) {
610 if (newVal && newVal !== oldVal) {
615 $scope.use_as_lead = function(rec) {
616 if ($scope.lead_id) {
617 $scope.records.push({ id : $scope.lead_id });
619 $scope.lead_id = rec.id;
622 egCore.pcrud.retrieve('bre', $scope.lead_id)
623 .then(function(rec) {
624 $scope.lead.marc_xml = rec.marc();
628 $scope.drop = function(rec) {
629 angular.forEach($scope.records, function(val, i) {
630 if (rec == $scope.records[i]) {
631 $scope.records.splice(i, 1);
636 $scope.post_edit_inplace = function() {
637 $scope.editing_inplace = false;
639 $scope.edit_lead_inplace = function() {
640 $scope.editing_inplace = true;
642 $scope.edit_lead = function() {
643 var lead = { marc_xml : $scope.lead.marc_xml };
645 // passing the on-save callback this way is a
646 // hack - this invocation of the MARC editor doesn't
647 // need it, but for some reason using this stomps
648 // over the callback set by the other MARC editor
650 var callback = $scope.post_edit_inplace;
653 templateUrl: './cat/bucket/record/t_edit_lead_record',
656 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
657 $scope.focusMe = true;
659 $scope.dirty_flag = false;
660 $scope.ok = function() { $uibModalInstance.close() }
661 $scope.cancel = function () { $uibModalInstance.dismiss() }
662 $scope.on_save = callback;
664 }).result.then(function() {
665 $scope.lead.marc_xml = lead.marc_xml;
669 }).result.then(function (args) {
670 if (!args.lead_id) return;
671 if (!args.records.length) return;
673 function update_bib() {
674 if (args.merge_profile) {
675 return egCore.pcrud.retrieve('bre', args.lead_id)
676 .then(function(rec) {
677 rec.marc(args.lead.marc_xml);
678 rec.edit_date('now');
679 rec.editor(egCore.auth.user().id());
680 return egCore.pcrud.update(rec);
687 update_bib().then(function() {
690 'open-ils.cat.biblio.records.merge',
693 args.records.map(function(val) { return val.id; })
695 $window.location.href =
696 egCore.env.basePath + 'cat/catalog/record/' + args.lead_id;
702 $scope.showRecords = function(records) {
703 // TODO: probably want to set a limit on the number of
704 // new tabs one could choose to open at once
705 angular.forEach(records, function(rec) {
706 var url = egCore.env.basePath +
707 'cat/catalog/record/' +
709 $timeout(function() { $window.open(url, '_blank') });
713 $scope.batchEdit = function() {
714 var url = egCore.env.basePath +
715 'cat/catalog/batchEdit/bucket/' + $scope.bucketId;
716 $timeout(function() { $window.open(url, '_blank') });
719 $scope.detachRecords = function(records) {
721 angular.forEach(records, function(rec) {
722 var item = bucketSvc.currentBucket.items().filter(
724 return (i.target_biblio_record_entry() == rec.id)
728 promises.push(bucketSvc.detachRecord(item[0].id()));
731 bucketSvc.bucketNeedsRefresh = true;
732 return $q.all(promises).then(drawBucket);
735 $scope.deleteRecordsFromCatalog = function(records) {
736 egConfirmDialog.open(
737 egCore.strings.CONFIRM_DELETE_RECORD_BUCKET_ITEMS_FROM_CATALOG,
740 ).result.then(function() {
742 angular.forEach(records, function(rec) {
743 promises.push(bucketSvc.deleteRecordFromCatalog(rec.id));
745 bucketSvc.bucketNeedsRefresh = true;
746 return $q.all(promises).then(function(results) {
747 var failures = results.filter(function(result) {
748 return egCore.evt.parse(result);
749 }).map(function(result) {
750 var evt = egCore.evt.parse(result);
752 return { recordId: evt.payload, desc: evt.desc };
755 if (failures.length) {
757 templateUrl: './cat/bucket/record/t_records_not_deleted',
759 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
760 $scope.failures = failures;
761 $scope.ok = function() { $uibModalInstance.close() }
762 $scope.cancel = function() { $uibModalInstance.dismiss() }
771 // fetch the bucket; on error show the not-allowed message
773 drawBucket()['catch'](function() { $scope.forbidden = true });