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?|mailto|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',
314 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
315 $scope.focusMe = true;
316 $scope.ok = function(args) { $uibModalInstance.close(args) }
317 $scope.cancel = function () { $uibModalInstance.dismiss() }
319 }).result.then(function (args) {
320 if (!args || !args.name) return;
321 bucketSvc.createBucket(args.name, args.desc).then(
324 bucketSvc.viewList = [];
325 bucketSvc.allBuckets = []; // reset
326 bucketSvc.currentBucket = null;
328 '/cat/bucket/record/' + $scope.tab + '/' + id);
334 $scope.openEditBucketDialog = function() {
336 templateUrl: './cat/bucket/share/t_bucket_edit',
339 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
340 $scope.focusMe = true;
342 name : bucketSvc.currentBucket.name(),
343 desc : bucketSvc.currentBucket.description(),
344 pub : bucketSvc.currentBucket.pub() == 't'
346 $scope.ok = function(args) {
348 $scope.actionPending = true;
349 args.pub = args.pub ? 't' : 'f';
350 // close the dialog after edit has completed
351 bucketSvc.editBucket(args).then(
352 function() { $uibModalInstance.close() });
354 $scope.cancel = function () { $uibModalInstance.dismiss() }
360 // opens the delete confirmation and deletes the current
361 // bucket if the user confirms.
362 $scope.openDeleteBucketDialog = function() {
364 templateUrl: './cat/bucket/share/t_bucket_delete',
367 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
368 $scope.bucket = function() { return bucketSvc.currentBucket }
369 $scope.ok = function() { $uibModalInstance.close() }
370 $scope.cancel = function() { $uibModalInstance.dismiss() }
372 }).result.then(function () {
373 bucketSvc.deleteBucket(bucketSvc.currentBucket.id())
375 bucketSvc.allBuckets = [];
376 $location.path('/cat/bucket/record/view');
381 // retrieves the requested bucket by ID
382 $scope.openSharedBucketDialog = function() {
384 templateUrl: './cat/bucket/share/t_load_shared',
387 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
388 $scope.focusMe = true;
389 $scope.ok = function(args) {
390 if (args && args.id) {
391 $uibModalInstance.close(args.id)
394 $scope.cancel = function() { $uibModalInstance.dismiss() }
396 }).result.then(function(id) {
397 // RecordBucketCtrl $scope is not inherited by the
398 // modal, so we need to call loadBucket from the
400 $scope.loadBucket(id);
404 // allows user to create a carousel from the selected bucket
405 $scope.openCreateCarouselDialog = function() {
406 if (!bucketSvc.currentBucket || !bucketSvc.currentBucket.id()) {
410 templateUrl: './cat/bucket/record/t_create_carousel',
413 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
414 $scope.focusMe = true;
415 $scope.ok = function(args) {
416 if (args && args.name) {
417 return egCore.net.request(
419 'open-ils.actor.carousel.create.from_bucket',
420 egCore.auth.token(), args.name, bucketSvc.currentBucket.id()
421 ).then(function(carouselId) { $uibModalInstance.close(carouselId) });
424 $scope.cancel = function() { $uibModalInstance.dismiss() }
426 }).result.then(function(carouselId) {
427 // bouncing outside of AngularJS
428 $window.location.href = '/eg2/en-US/staff/admin/local/container/carousel';
432 // opens the record export dialog
433 $scope.openExportBucketDialog = function() {
435 templateUrl: './cat/bucket/record/t_bucket_export',
438 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
439 $scope.args = {format : 'XML', encoding : 'UTF-8'}; // defaults
440 $scope.ok = function(args) { $uibModalInstance.close(args) }
441 $scope.cancel = function() { $uibModalInstance.dismiss() }
443 }).result.then(function (args) {
445 args.containerid = bucketSvc.currentBucket.id();
447 var url = '/exporter?containerid=' + args.containerid +
448 '&format=' + args.format + '&encoding=' + args.encoding;
450 if (args.holdings) url += '&holdings=1';
452 // TODO: improve auth cookie handling so this isn't necessary.
453 // today the cookie path is too specific (/eg/staff) for non-staff
454 // UIs to access it. See services/auth.js
455 url += '&ses=' + egCore.auth.token();
457 $timeout(function() { $window.open(url) });
462 .controller('SearchCtrl',
463 ['$scope','$routeParams','egCore','bucketSvc',
464 function($scope, $routeParams, egCore , bucketSvc) {
466 $scope.setTab('search');
467 $scope.focusMe = true;
468 var idQueryHash = {};
470 function generateQuery() {
471 if (bucketSvc.queryRecords.length)
472 return {id : bucketSvc.queryRecords};
477 $scope.gridControls = {
478 setQuery : function() {return generateQuery()},
479 setSort : function() {return ['id']}
482 // add selected items directly to the pending list
483 $scope.addToPending = function(recs) {
484 angular.forEach(recs, function(rec) {
485 if (bucketSvc.pendingList.filter( // remove dupes
486 function(r) {return r.id == rec.id}).length) return;
487 bucketSvc.pendingList.push(rec);
491 $scope.search = function() {
492 $scope.searchList = [];
493 $scope.searchInProgress = true;
494 bucketSvc.queryRecords = [];
498 'open-ils.search.biblio.multiclass.query.staff', {
500 }, bucketSvc.queryString, true
501 ).then(function(resp) {
502 bucketSvc.queryRecords =
503 resp.ids.map(function(id){return id[0]});
504 $scope.gridControls.setQuery(generateQuery());
505 })['finally'](function() {
506 $scope.searchInProgress = false;
510 if ($routeParams.id &&
511 (!bucketSvc.currentBucket ||
512 bucketSvc.currentBucket.id() != $routeParams.id)) {
513 // user has accessed this page cold with a bucket ID.
514 // fetch the bucket for display, then set the totalCount
515 // (also for display), but avoid fully fetching the bucket,
516 // since it's premature, in this UI.
517 bucketSvc.fetchBucket($routeParams.id);
521 .controller('PendingCtrl',
522 ['$scope','$routeParams','bucketSvc','egGridDataProvider',
523 function($scope, $routeParams, bucketSvc , egGridDataProvider) {
524 $scope.setTab('pending');
526 var provider = egGridDataProvider.instance({});
527 provider.get = function(offset, count) {
528 return provider.arrayNotifier(
529 bucketSvc.pendingList, offset, count);
531 $scope.gridDataProvider = provider;
533 $scope.resetPendingList = function() {
534 bucketSvc.pendingList = [];
535 $scope.gridDataProvider.refresh();
539 if ($routeParams.id &&
540 (!bucketSvc.currentBucket ||
541 bucketSvc.currentBucket.id() != $routeParams.id)) {
542 // user has accessed this page cold with a bucket ID.
543 // fetch the bucket for display, then set the totalCount
544 // (also for display), but avoid fully fetching the bucket,
545 // since it's premature, in this UI.
546 bucketSvc.fetchBucket($routeParams.id);
550 .controller('ViewCtrl',
551 ['$scope','$q','$routeParams','bucketSvc','egCore','$window',
552 '$timeout','egConfirmDialog','$uibModal','egHolds',
553 function($scope, $q , $routeParams, bucketSvc, egCore, $window,
554 $timeout, egConfirmDialog, $uibModal, egHolds) {
556 $scope.setTab('view');
557 $scope.bucketId = $routeParams.id;
560 $scope.gridControls = {
561 setQuery : function(q) {
567 function drawBucket() {
568 return bucketSvc.fetchBucket($scope.bucketId).then(
570 var ids = bucket.items().map(
571 function(i){return i.target_biblio_record_entry()}
574 $scope.gridControls.setQuery({id : ids});
576 $scope.gridControls.setQuery({});
582 // runs the transfer title holds action
583 $scope.transfer_holds_to_marked = function(records) {
584 var bib_ids = records.map(function(val) { return val.id; })
585 egHolds.transfer_all_bib_holds_to_marked_title(bib_ids);
588 // Refresh and update a single bib record.
589 // Returns a promise.
590 function updateOneRecord(recId, marcXml) {
592 return egCore.net.request(
594 'open-ils.cat.biblio.record.xml.update',
595 egCore.auth.token(), recId, marcXml
596 ).then(function(result) {
597 var evt = egCore.evt.parse(result);
600 return $q.reject(evt);
602 return result; // bib record
607 // opens the record merge dialog
608 $scope.openRecordMergeDialog = function(records) {
610 templateUrl: './cat/bucket/record/t_merge_records',
613 windowClass: 'eg-wide-modal',
615 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
618 $scope.merge_profile = null;
619 $scope.lead = { marc_xml : null };
620 $scope.editing_inplace = false;
621 $scope.showHoldings = false;
622 angular.forEach(records, function(rec) {
623 $scope.records.push({ id : rec.id });
625 $scope.ok = function() {
626 $uibModalInstance.close({
627 lead_id : $scope.lead_id,
628 records : $scope.records,
629 merge_profile : $scope.merge_profile,
633 $scope.cancel = function () { $uibModalInstance.dismiss() }
635 $scope.merge_marc = function() {
636 // need lead, at least one sub, and a merge profile
637 if (!$scope.lead_id) return;
638 if (!$scope.merge_profile) return;
640 if (!$scope.records.length) {
641 // if we got here, the last subordinate record
642 // was likely removed, so let's refresh the
643 // lead for the sake of a consistent display
644 egCore.pcrud.retrieve('bre', $scope.lead_id)
645 .then(function(rec) {
646 $scope.lead.marc_xml = rec.marc();
651 var recs = $scope.records.map(function(val) { return val.id; });
652 recs.unshift($scope.lead_id);
655 'open-ils.cat.merge.biblio.per_profile',
657 $scope.merge_profile,
659 ).then(function(merged) {
660 if (merged) $scope.lead.marc_xml = merged;
663 $scope.$watch('merge_profile', function(newVal, oldVal) {
664 if (newVal && newVal !== oldVal) {
669 $scope.use_as_lead = function(rec) {
670 if ($scope.lead_id) {
671 $scope.records.push({ id : $scope.lead_id });
673 $scope.lead_id = rec.id;
676 egCore.pcrud.retrieve('bre', $scope.lead_id)
677 .then(function(rec) {
678 $scope.lead.marc_xml = rec.marc();
682 $scope.drop = function(rec) {
683 angular.forEach($scope.records, function(val, i) {
684 if (rec == $scope.records[i]) {
685 $scope.records.splice(i, 1);
690 $scope.post_edit_inplace = function() {
691 $scope.editing_inplace = false;
692 updateOneRecord($scope.lead_id, $scope.lead.marc_xml);
695 $scope.cancel_edit_lead_inplace = function() {
696 $scope.editing_inplace = false;
697 $scope.lead.marc_xml = $scope.lead.orig_marc_xml;
700 $scope.edit_lead_inplace = function() {
701 $scope.editing_inplace = true;
702 let lead = { orig_marc_xml : $scope.lead.marc_xml };
705 $scope.edit_lead = function() {
706 var lead = { marc_xml : $scope.lead.marc_xml };
707 var parentScope = $scope;
710 templateUrl: './cat/bucket/record/t_edit_lead_record',
714 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
715 $scope.focusMe = true;
717 $scope.dirty_flag = false;
718 $scope.ok = function() { $uibModalInstance.close() }
719 $scope.cancel = function () { $uibModalInstance.dismiss() }
720 $scope.on_save = function() {
721 parentScope.lead.marc_xml = $scope.lead.marc_xml;
722 parentScope.post_edit_inplace();
725 }).result.then(function() {
726 $scope.lead.marc_xml = lead.marc_xml;
730 }).result.then(function (args) {
731 if (!args.lead_id) return;
732 if (!args.records.length) return;
734 function update_bib() {
735 if (args.merge_profile) {
736 return updateOneRecord(args.lead_id, args.lead.marc_xml);
742 update_bib().then(function() {
745 'open-ils.cat.biblio.records.merge',
748 args.records.map(function(val) { return val.id; })
750 $window.open('/eg2/staff/catalog/record/' + args.lead_id);
756 $scope.showRecords = function(records) {
757 // TODO: probably want to set a limit on the number of
758 // new tabs one could choose to open at once
759 angular.forEach(records, function(rec) {
760 var url = '/eg2/staff/catalog/record/' + rec.id;
761 $timeout(function() { $window.open(url, '_blank') });
765 $scope.batchEdit = function() {
766 var url = egCore.env.basePath +
767 'cat/catalog/batchEdit/bucket/' + $scope.bucketId;
768 $timeout(function() { $window.open(url, '_blank') });
771 $scope.detachRecords = function(records) {
773 angular.forEach(records, function(rec) {
774 var item = bucketSvc.currentBucket.items().filter(
776 return (i.target_biblio_record_entry() == rec.id)
780 promises.push(bucketSvc.detachRecord(item[0].id()));
783 bucketSvc.bucketNeedsRefresh = true;
784 return $q.all(promises).then(drawBucket);
787 $scope.moveToPending = function(records) {
788 angular.forEach(records, function(rec) {
789 if (bucketSvc.pendingList.filter( // remove dupes
790 function(r) {return r.id == rec.id}).length) return;
791 bucketSvc.pendingList.push(rec);
793 $scope.detachRecords(records);
796 $scope.deleteRecordsFromCatalog = function(records) {
797 egConfirmDialog.open(
798 egCore.strings.CONFIRM_DELETE_RECORD_BUCKET_ITEMS_FROM_CATALOG,
801 ).result.then(function() {
803 angular.forEach(records, function(rec) {
804 promises.push(bucketSvc.deleteRecordFromCatalog(rec.id));
806 bucketSvc.bucketNeedsRefresh = true;
807 return $q.all(promises).then(function(results) {
808 var failures = results.filter(function(result) {
809 return egCore.evt.parse(result);
810 }).map(function(result) {
811 var evt = egCore.evt.parse(result);
813 return { recordId: evt.payload, desc: evt.desc };
816 if (failures.length) {
818 templateUrl: './cat/bucket/record/t_records_not_deleted',
821 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
822 $scope.failures = failures;
823 $scope.ok = function() { $uibModalInstance.close() }
824 $scope.cancel = function() { $uibModalInstance.dismiss() }
833 $scope.need_multiple_selected = function() {
834 var items = $scope.gridControls.selectedItems();
835 if (items.length > 1) return false;
839 // fetch the bucket; on error show the not-allowed message
841 drawBucket()['catch'](function() { $scope.forbidden = true });