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 // opens the record merge dialog
561 $scope.openRecordMergeDialog = function(records) {
563 templateUrl: './cat/bucket/record/t_merge_records',
566 windowClass: 'eg-wide-modal',
568 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
571 $scope.merge_profile = null;
572 $scope.lead = { marc_xml : null };
573 $scope.editing_inplace = false;
574 angular.forEach(records, function(rec) {
575 $scope.records.push({ id : rec.id });
577 $scope.ok = function() {
578 $uibModalInstance.close({
579 lead_id : $scope.lead_id,
580 records : $scope.records,
581 merge_profile : $scope.merge_profile,
585 $scope.cancel = function () { $uibModalInstance.dismiss() }
587 $scope.merge_marc = function() {
588 // need lead, at least one sub, and a merge profile
589 if (!$scope.lead_id) return;
590 if (!$scope.merge_profile) return;
592 if (!$scope.records.length) {
593 // if we got here, the last subordinate record
594 // was likely removed, so let's refresh the
595 // lead for the sake of a consistent display
596 egCore.pcrud.retrieve('bre', $scope.lead_id)
597 .then(function(rec) {
598 $scope.lead.marc_xml = rec.marc();
603 var recs = $scope.records.map(function(val) { return val.id; });
604 recs.unshift($scope.lead_id);
607 'open-ils.cat.merge.biblio.per_profile',
609 $scope.merge_profile,
611 ).then(function(merged) {
612 if (merged) $scope.lead.marc_xml = merged;
615 $scope.$watch('merge_profile', function(newVal, oldVal) {
616 if (newVal && newVal !== oldVal) {
621 $scope.use_as_lead = function(rec) {
622 if ($scope.lead_id) {
623 $scope.records.push({ id : $scope.lead_id });
625 $scope.lead_id = rec.id;
628 egCore.pcrud.retrieve('bre', $scope.lead_id)
629 .then(function(rec) {
630 $scope.lead.marc_xml = rec.marc();
634 $scope.drop = function(rec) {
635 angular.forEach($scope.records, function(val, i) {
636 if (rec == $scope.records[i]) {
637 $scope.records.splice(i, 1);
642 $scope.post_edit_inplace = function() {
643 $scope.editing_inplace = false;
645 $scope.edit_lead_inplace = function() {
646 $scope.editing_inplace = true;
648 $scope.edit_lead = function() {
649 var lead = { marc_xml : $scope.lead.marc_xml };
651 // passing the on-save callback this way is a
652 // hack - this invocation of the MARC editor doesn't
653 // need it, but for some reason using this stomps
654 // over the callback set by the other MARC editor
656 var callback = $scope.post_edit_inplace;
659 templateUrl: './cat/bucket/record/t_edit_lead_record',
663 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
664 $scope.focusMe = true;
666 $scope.dirty_flag = false;
667 $scope.ok = function() { $uibModalInstance.close() }
668 $scope.cancel = function () { $uibModalInstance.dismiss() }
669 $scope.on_save = callback;
671 }).result.then(function() {
672 $scope.lead.marc_xml = lead.marc_xml;
676 }).result.then(function (args) {
677 if (!args.lead_id) return;
678 if (!args.records.length) return;
680 function update_bib() {
681 if (args.merge_profile) {
682 return egCore.pcrud.retrieve('bre', args.lead_id)
683 .then(function(rec) {
684 rec.marc(args.lead.marc_xml);
685 rec.edit_date('now');
686 rec.editor(egCore.auth.user().id());
687 return egCore.pcrud.update(rec);
694 update_bib().then(function() {
697 'open-ils.cat.biblio.records.merge',
700 args.records.map(function(val) { return val.id; })
702 $window.open(egCore.env.basePath + 'cat/catalog/record/' + args.lead_id);
708 $scope.showRecords = function(records) {
709 // TODO: probably want to set a limit on the number of
710 // new tabs one could choose to open at once
711 angular.forEach(records, function(rec) {
712 var url = egCore.env.basePath +
713 'cat/catalog/record/' +
715 $timeout(function() { $window.open(url, '_blank') });
719 $scope.batchEdit = function() {
720 var url = egCore.env.basePath +
721 'cat/catalog/batchEdit/bucket/' + $scope.bucketId;
722 $timeout(function() { $window.open(url, '_blank') });
725 $scope.detachRecords = function(records) {
727 angular.forEach(records, function(rec) {
728 var item = bucketSvc.currentBucket.items().filter(
730 return (i.target_biblio_record_entry() == rec.id)
734 promises.push(bucketSvc.detachRecord(item[0].id()));
737 bucketSvc.bucketNeedsRefresh = true;
738 return $q.all(promises).then(drawBucket);
741 $scope.deleteRecordsFromCatalog = function(records) {
742 egConfirmDialog.open(
743 egCore.strings.CONFIRM_DELETE_RECORD_BUCKET_ITEMS_FROM_CATALOG,
746 ).result.then(function() {
748 angular.forEach(records, function(rec) {
749 promises.push(bucketSvc.deleteRecordFromCatalog(rec.id));
751 bucketSvc.bucketNeedsRefresh = true;
752 return $q.all(promises).then(function(results) {
753 var failures = results.filter(function(result) {
754 return egCore.evt.parse(result);
755 }).map(function(result) {
756 var evt = egCore.evt.parse(result);
758 return { recordId: evt.payload, desc: evt.desc };
761 if (failures.length) {
763 templateUrl: './cat/bucket/record/t_records_not_deleted',
766 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
767 $scope.failures = failures;
768 $scope.ok = function() { $uibModalInstance.close() }
769 $scope.cancel = function() { $uibModalInstance.dismiss() }
778 // fetch the bucket; on error show the not-allowed message
780 drawBucket()['catch'](function() { $scope.forbidden = true });