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 // opens the record export dialog
405 $scope.openExportBucketDialog = function() {
407 templateUrl: './cat/bucket/record/t_bucket_export',
410 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
411 $scope.args = {format : 'XML', encoding : 'UTF-8'}; // defaults
412 $scope.ok = function(args) { $uibModalInstance.close(args) }
413 $scope.cancel = function() { $uibModalInstance.dismiss() }
415 }).result.then(function (args) {
417 args.containerid = bucketSvc.currentBucket.id();
419 var url = '/exporter?containerid=' + args.containerid +
420 '&format=' + args.format + '&encoding=' + args.encoding;
422 if (args.holdings) url += '&holdings=1';
424 // TODO: improve auth cookie handling so this isn't necessary.
425 // today the cookie path is too specific (/eg/staff) for non-staff
426 // UIs to access it. See services/auth.js
427 url += '&ses=' + egCore.auth.token();
429 $timeout(function() { $window.open(url) });
434 .controller('SearchCtrl',
435 ['$scope','$routeParams','egCore','bucketSvc',
436 function($scope, $routeParams, egCore , bucketSvc) {
438 $scope.setTab('search');
439 $scope.focusMe = true;
440 var idQueryHash = {};
442 function generateQuery() {
443 if (bucketSvc.queryRecords.length)
444 return {id : bucketSvc.queryRecords};
449 $scope.gridControls = {
450 setQuery : function() {return generateQuery()},
451 setSort : function() {return ['id']}
454 // add selected items directly to the pending list
455 $scope.addToPending = function(recs) {
456 angular.forEach(recs, function(rec) {
457 if (bucketSvc.pendingList.filter( // remove dupes
458 function(r) {return r.id == rec.id}).length) return;
459 bucketSvc.pendingList.push(rec);
463 $scope.search = function() {
464 $scope.searchList = [];
465 $scope.searchInProgress = true;
466 bucketSvc.queryRecords = [];
470 'open-ils.search.biblio.multiclass.query.staff', {
472 }, bucketSvc.queryString, true
473 ).then(function(resp) {
474 bucketSvc.queryRecords =
475 resp.ids.map(function(id){return id[0]});
476 $scope.gridControls.setQuery(generateQuery());
477 })['finally'](function() {
478 $scope.searchInProgress = false;
482 if ($routeParams.id &&
483 (!bucketSvc.currentBucket ||
484 bucketSvc.currentBucket.id() != $routeParams.id)) {
485 // user has accessed this page cold with a bucket ID.
486 // fetch the bucket for display, then set the totalCount
487 // (also for display), but avoid fully fetching the bucket,
488 // since it's premature, in this UI.
489 bucketSvc.fetchBucket($routeParams.id);
493 .controller('PendingCtrl',
494 ['$scope','$routeParams','bucketSvc','egGridDataProvider',
495 function($scope, $routeParams, bucketSvc , egGridDataProvider) {
496 $scope.setTab('pending');
498 var provider = egGridDataProvider.instance({});
499 provider.get = function(offset, count) {
500 return provider.arrayNotifier(
501 bucketSvc.pendingList, offset, count);
503 $scope.gridDataProvider = provider;
505 $scope.resetPendingList = function() {
506 bucketSvc.pendingList = [];
507 $scope.gridDataProvider.refresh();
511 if ($routeParams.id &&
512 (!bucketSvc.currentBucket ||
513 bucketSvc.currentBucket.id() != $routeParams.id)) {
514 // user has accessed this page cold with a bucket ID.
515 // fetch the bucket for display, then set the totalCount
516 // (also for display), but avoid fully fetching the bucket,
517 // since it's premature, in this UI.
518 bucketSvc.fetchBucket($routeParams.id);
522 .controller('ViewCtrl',
523 ['$scope','$q','$routeParams','bucketSvc','egCore','$window',
524 '$timeout','egConfirmDialog','$uibModal','egHolds',
525 function($scope, $q , $routeParams, bucketSvc, egCore, $window,
526 $timeout, egConfirmDialog, $uibModal, egHolds) {
528 $scope.setTab('view');
529 $scope.bucketId = $routeParams.id;
532 $scope.gridControls = {
533 setQuery : function(q) {
539 function drawBucket() {
540 return bucketSvc.fetchBucket($scope.bucketId).then(
542 var ids = bucket.items().map(
543 function(i){return i.target_biblio_record_entry()}
546 $scope.gridControls.setQuery({id : ids});
548 $scope.gridControls.setQuery({});
554 // runs the transfer title holds action
555 $scope.transfer_holds_to_marked = function(records) {
556 var bib_ids = records.map(function(val) { return val.id; })
557 egHolds.transfer_all_bib_holds_to_marked_title(bib_ids);
560 // Refresh and update a single bib record.
561 // Returns a promise.
562 function updateOneRecord(recId, marcXml) {
564 return egCore.net.request(
566 'open-ils.cat.biblio.record.xml.update',
567 egCore.auth.token(), recId, marcXml
568 ).then(function(result) {
569 var evt = egCore.evt.parse(result);
572 return $q.reject(evt);
574 return result; // bib record
579 // opens the record merge dialog
580 $scope.openRecordMergeDialog = function(records) {
582 templateUrl: './cat/bucket/record/t_merge_records',
585 windowClass: 'eg-wide-modal',
587 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
590 $scope.merge_profile = null;
591 $scope.lead = { marc_xml : null };
592 $scope.editing_inplace = false;
593 $scope.showHoldings = false;
594 angular.forEach(records, function(rec) {
595 $scope.records.push({ id : rec.id });
597 $scope.ok = function() {
598 $uibModalInstance.close({
599 lead_id : $scope.lead_id,
600 records : $scope.records,
601 merge_profile : $scope.merge_profile,
605 $scope.cancel = function () { $uibModalInstance.dismiss() }
607 $scope.merge_marc = function() {
608 // need lead, at least one sub, and a merge profile
609 if (!$scope.lead_id) return;
610 if (!$scope.merge_profile) return;
612 if (!$scope.records.length) {
613 // if we got here, the last subordinate record
614 // was likely removed, so let's refresh the
615 // lead for the sake of a consistent display
616 egCore.pcrud.retrieve('bre', $scope.lead_id)
617 .then(function(rec) {
618 $scope.lead.marc_xml = rec.marc();
623 var recs = $scope.records.map(function(val) { return val.id; });
624 recs.unshift($scope.lead_id);
627 'open-ils.cat.merge.biblio.per_profile',
629 $scope.merge_profile,
631 ).then(function(merged) {
632 if (merged) $scope.lead.marc_xml = merged;
635 $scope.$watch('merge_profile', function(newVal, oldVal) {
636 if (newVal && newVal !== oldVal) {
641 $scope.use_as_lead = function(rec) {
642 if ($scope.lead_id) {
643 $scope.records.push({ id : $scope.lead_id });
645 $scope.lead_id = rec.id;
648 egCore.pcrud.retrieve('bre', $scope.lead_id)
649 .then(function(rec) {
650 $scope.lead.marc_xml = rec.marc();
654 $scope.drop = function(rec) {
655 angular.forEach($scope.records, function(val, i) {
656 if (rec == $scope.records[i]) {
657 $scope.records.splice(i, 1);
662 $scope.post_edit_inplace = function() {
663 $scope.editing_inplace = false;
664 updateOneRecord($scope.lead_id, $scope.lead.marc_xml);
667 $scope.cancel_edit_lead_inplace = function() {
668 $scope.editing_inplace = false;
669 $scope.lead.marc_xml = $scope.lead.orig_marc_xml;
672 $scope.edit_lead_inplace = function() {
673 $scope.editing_inplace = true;
674 let lead = { orig_marc_xml : $scope.lead.marc_xml };
677 $scope.edit_lead = function() {
678 var lead = { marc_xml : $scope.lead.marc_xml };
679 var parentScope = $scope;
682 templateUrl: './cat/bucket/record/t_edit_lead_record',
686 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
687 $scope.focusMe = true;
689 $scope.dirty_flag = false;
690 $scope.ok = function() { $uibModalInstance.close() }
691 $scope.cancel = function () { $uibModalInstance.dismiss() }
692 $scope.on_save = function() {
693 parentScope.lead.marc_xml = $scope.lead.marc_xml;
694 parentScope.post_edit_inplace();
697 }).result.then(function() {
698 $scope.lead.marc_xml = lead.marc_xml;
702 }).result.then(function (args) {
703 if (!args.lead_id) return;
704 if (!args.records.length) return;
706 function update_bib() {
707 if (args.merge_profile) {
708 return updateOneRecord(args.lead_id, args.lead.marc_xml);
714 update_bib().then(function() {
717 'open-ils.cat.biblio.records.merge',
720 args.records.map(function(val) { return val.id; })
722 $window.open(egCore.env.basePath + 'cat/catalog/record/' + args.lead_id);
728 $scope.showRecords = function(records) {
729 // TODO: probably want to set a limit on the number of
730 // new tabs one could choose to open at once
731 angular.forEach(records, function(rec) {
732 var url = egCore.env.basePath +
733 'cat/catalog/record/' +
735 $timeout(function() { $window.open(url, '_blank') });
739 $scope.batchEdit = function() {
740 var url = egCore.env.basePath +
741 'cat/catalog/batchEdit/bucket/' + $scope.bucketId;
742 $timeout(function() { $window.open(url, '_blank') });
745 $scope.detachRecords = function(records) {
747 angular.forEach(records, function(rec) {
748 var item = bucketSvc.currentBucket.items().filter(
750 return (i.target_biblio_record_entry() == rec.id)
754 promises.push(bucketSvc.detachRecord(item[0].id()));
757 bucketSvc.bucketNeedsRefresh = true;
758 return $q.all(promises).then(drawBucket);
761 $scope.moveToPending = function(records) {
762 angular.forEach(records, function(rec) {
763 if (bucketSvc.pendingList.filter( // remove dupes
764 function(r) {return r.id == rec.id}).length) return;
765 bucketSvc.pendingList.push(rec);
767 $scope.detachRecords(records);
770 $scope.deleteRecordsFromCatalog = function(records) {
771 egConfirmDialog.open(
772 egCore.strings.CONFIRM_DELETE_RECORD_BUCKET_ITEMS_FROM_CATALOG,
775 ).result.then(function() {
777 angular.forEach(records, function(rec) {
778 promises.push(bucketSvc.deleteRecordFromCatalog(rec.id));
780 bucketSvc.bucketNeedsRefresh = true;
781 return $q.all(promises).then(function(results) {
782 var failures = results.filter(function(result) {
783 return egCore.evt.parse(result);
784 }).map(function(result) {
785 var evt = egCore.evt.parse(result);
787 return { recordId: evt.payload, desc: evt.desc };
790 if (failures.length) {
792 templateUrl: './cat/bucket/record/t_records_not_deleted',
795 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
796 $scope.failures = failures;
797 $scope.ok = function() { $uibModalInstance.close() }
798 $scope.cancel = function() { $uibModalInstance.dismiss() }
807 $scope.need_multiple_selected = function() {
808 var items = $scope.gridControls.selectedItems();
809 if (items.length > 1) return false;
813 // fetch the bucket; on error show the not-allowed message
815 drawBucket()['catch'](function() { $scope.forbidden = true });