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(
171 ).then(function(patron) {
172 bucket._owner_name = patron.usrname();
173 bucket._owner_ou = egCore.org.get(patron.home_ou()).shortname();
175 service.currentBucket = bucket;
176 deferred.resolve(bucket);
179 return deferred.promise;
182 // deletes a single container item from a bucket by container item ID.
183 // promise is rejected on failure
184 service.detachRecord = function(itemId) {
185 var deferred = $q.defer();
188 'open-ils.actor.container.item.delete',
189 egCore.auth.token(), 'biblio', itemId
190 ).then(function(resp) {
191 var evt = egCore.evt.parse(resp);
194 deferred.reject(evt);
197 console.log('detached bucket item ' + itemId);
198 deferred.resolve(resp);
201 return deferred.promise;
204 service.deleteRecordFromCatalog = function(recordId) {
205 var deferred = $q.defer();
209 'open-ils.cat.biblio.record_entry.delete',
210 egCore.auth.token(), recordId
211 ).then(function(resp) {
212 // rather than rejecting the promise in the
213 // case of a failure, we'll let the caller
214 // look for errors -- doing this because AngularJS
215 // does not have a native $q.allSettled() yet.
216 deferred.resolve(resp);
219 return deferred.promise;
222 // delete bucket by ID.
223 // resolved w/ response on successful delete,
224 // rejected otherwise.
225 service.deleteBucket = function(id) {
226 var deferred = $q.defer();
229 'open-ils.actor.container.full_delete',
230 egCore.auth.token(), 'biblio', id
231 ).then(function(resp) {
232 var evt = egCore.evt.parse(resp);
235 deferred.reject(evt);
238 deferred.resolve(resp);
240 return deferred.promise;
247 * Top-level controller.
248 * Hosts functions needed by all controllers.
250 .controller('RecordBucketCtrl',
251 ['$scope','$location','$q','$timeout','$uibModal',
252 '$window','egCore','bucketSvc',
253 function($scope, $location, $q, $timeout, $uibModal,
254 $window, egCore, bucketSvc) {
256 $scope.bucketSvc = bucketSvc;
257 $scope.bucket = function() { return bucketSvc.currentBucket }
259 // tabs: search, pending, view
260 $scope.setTab = function(tab) {
263 // for bucket selector; must be called after route resolve
264 bucketSvc.fetchUserBuckets();
267 $scope.loadBucketFromMenu = function(item, bucket) {
268 if (bucket) return $scope.loadBucket(bucket.id());
271 $scope.loadBucket = function(id) {
273 '/cat/bucket/record/' +
274 $scope.tab + '/' + encodeURIComponent(id));
277 $scope.addToBucket = function(recs) {
278 if (recs.length == 0) return;
279 bucketSvc.bucketNeedsRefresh = true;
281 angular.forEach(recs,
283 var item = new egCore.idl.cbrebi();
284 item.bucket(bucketSvc.currentBucket.id());
285 item.target_biblio_record_entry(rec.id);
288 'open-ils.actor.container.item.create',
289 egCore.auth.token(), 'biblio', item
290 ).then(function(resp) {
292 // HACK: add the IDs of the added items so that the size
293 // of the view list will grow (and update any UI looking at
294 // the list size). The data stored is inconsistent, but since
295 // we are forcing a bucket refresh on the next rendering of
296 // the view pane, the list will be repaired.
297 bucketSvc.currentBucket.items().push(resp);
303 $scope.openCreateBucketDialog = function() {
305 templateUrl: './cat/bucket/share/t_bucket_create',
307 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
308 $scope.focusMe = true;
309 $scope.ok = function(args) { $uibModalInstance.close(args) }
310 $scope.cancel = function () { $uibModalInstance.dismiss() }
312 }).result.then(function (args) {
313 if (!args || !args.name) return;
314 bucketSvc.createBucket(args.name, args.desc).then(
317 bucketSvc.viewList = [];
318 bucketSvc.allBuckets = []; // reset
319 bucketSvc.currentBucket = null;
321 '/cat/bucket/record/' + $scope.tab + '/' + id);
327 $scope.openEditBucketDialog = function() {
329 templateUrl: './cat/bucket/share/t_bucket_edit',
331 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
332 $scope.focusMe = true;
334 name : bucketSvc.currentBucket.name(),
335 desc : bucketSvc.currentBucket.description(),
336 pub : bucketSvc.currentBucket.pub() == 't'
338 $scope.ok = function(args) {
340 $scope.actionPending = true;
341 args.pub = args.pub ? 't' : 'f';
342 // close the dialog after edit has completed
343 bucketSvc.editBucket(args).then(
344 function() { $uibModalInstance.close() });
346 $scope.cancel = function () { $uibModalInstance.dismiss() }
352 // opens the delete confirmation and deletes the current
353 // bucket if the user confirms.
354 $scope.openDeleteBucketDialog = function() {
356 templateUrl: './cat/bucket/share/t_bucket_delete',
358 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
359 $scope.bucket = function() { return bucketSvc.currentBucket }
360 $scope.ok = function() { $uibModalInstance.close() }
361 $scope.cancel = function() { $uibModalInstance.dismiss() }
363 }).result.then(function () {
364 bucketSvc.deleteBucket(bucketSvc.currentBucket.id())
366 bucketSvc.allBuckets = [];
367 $location.path('/cat/bucket/record/view');
372 // retrieves the requested bucket by ID
373 $scope.openSharedBucketDialog = function() {
375 templateUrl: './cat/bucket/share/t_load_shared',
377 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
378 $scope.focusMe = true;
379 $scope.ok = function(args) {
380 if (args && args.id) {
381 $uibModalInstance.close(args.id)
384 $scope.cancel = function() { $uibModalInstance.dismiss() }
386 }).result.then(function(id) {
387 // RecordBucketCtrl $scope is not inherited by the
388 // modal, so we need to call loadBucket from the
390 $scope.loadBucket(id);
394 // opens the record export dialog
395 $scope.openExportBucketDialog = function() {
397 templateUrl: './cat/bucket/record/t_bucket_export',
399 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
400 $scope.args = {format : 'XML', encoding : 'UTF-8'}; // defaults
401 $scope.ok = function(args) { $uibModalInstance.close(args) }
402 $scope.cancel = function() { $uibModalInstance.dismiss() }
404 }).result.then(function (args) {
406 args.containerid = bucketSvc.currentBucket.id();
408 var url = '/exporter?containerid=' + args.containerid +
409 '&format=' + args.format + '&encoding=' + args.encoding;
411 if (args.holdings) url += '&holdings=1';
413 // TODO: improve auth cookie handling so this isn't necessary.
414 // today the cookie path is too specific (/eg/staff) for non-staff
415 // UIs to access it. See services/auth.js
416 url += '&ses=' + egCore.auth.token();
418 $timeout(function() { $window.open(url) });
423 .controller('SearchCtrl',
424 ['$scope','$routeParams','egCore','bucketSvc',
425 function($scope, $routeParams, egCore , bucketSvc) {
427 $scope.setTab('search');
428 $scope.focusMe = true;
429 var idQueryHash = {};
431 function generateQuery() {
432 if (bucketSvc.queryRecords.length)
433 return {id : bucketSvc.queryRecords};
438 $scope.gridControls = {
439 setQuery : function() {return generateQuery()},
440 setSort : function() {return ['id']}
443 // add selected items directly to the pending list
444 $scope.addToPending = function(recs) {
445 angular.forEach(recs, function(rec) {
446 if (bucketSvc.pendingList.filter( // remove dupes
447 function(r) {return r.id == rec.id}).length) return;
448 bucketSvc.pendingList.push(rec);
452 $scope.search = function() {
453 $scope.searchList = [];
454 $scope.searchInProgress = true;
455 bucketSvc.queryRecords = [];
459 'open-ils.search.biblio.multiclass.query.staff', {
461 }, bucketSvc.queryString, true
462 ).then(function(resp) {
463 bucketSvc.queryRecords =
464 resp.ids.map(function(id){return id[0]});
465 $scope.gridControls.setQuery(generateQuery());
466 })['finally'](function() {
467 $scope.searchInProgress = false;
471 if ($routeParams.id &&
472 (!bucketSvc.currentBucket ||
473 bucketSvc.currentBucket.id() != $routeParams.id)) {
474 // user has accessed this page cold with a bucket ID.
475 // fetch the bucket for display, then set the totalCount
476 // (also for display), but avoid fully fetching the bucket,
477 // since it's premature, in this UI.
478 bucketSvc.fetchBucket($routeParams.id);
482 .controller('PendingCtrl',
483 ['$scope','$routeParams','bucketSvc','egGridDataProvider',
484 function($scope, $routeParams, bucketSvc , egGridDataProvider) {
485 $scope.setTab('pending');
487 var provider = egGridDataProvider.instance({});
488 provider.get = function(offset, count) {
489 return provider.arrayNotifier(
490 bucketSvc.pendingList, offset, count);
492 $scope.gridDataProvider = provider;
494 $scope.resetPendingList = function() {
495 bucketSvc.pendingList = [];
496 $scope.gridDataProvider.refresh();
500 if ($routeParams.id &&
501 (!bucketSvc.currentBucket ||
502 bucketSvc.currentBucket.id() != $routeParams.id)) {
503 // user has accessed this page cold with a bucket ID.
504 // fetch the bucket for display, then set the totalCount
505 // (also for display), but avoid fully fetching the bucket,
506 // since it's premature, in this UI.
507 bucketSvc.fetchBucket($routeParams.id);
511 .controller('ViewCtrl',
512 ['$scope','$q','$routeParams','bucketSvc','egCore','$window',
513 '$timeout','egConfirmDialog','$uibModal','egHolds',
514 function($scope, $q , $routeParams, bucketSvc, egCore, $window,
515 $timeout, egConfirmDialog, $uibModal, egHolds) {
517 $scope.setTab('view');
518 $scope.bucketId = $routeParams.id;
521 $scope.gridControls = {
522 setQuery : function(q) {
528 function drawBucket() {
529 return bucketSvc.fetchBucket($scope.bucketId).then(
531 var ids = bucket.items().map(
532 function(i){return i.target_biblio_record_entry()}
535 $scope.gridControls.setQuery({id : ids});
537 $scope.gridControls.setQuery({});
543 // runs the transfer title holds action
544 $scope.transfer_holds_to_marked = function(records) {
545 var bib_ids = records.map(function(val) { return val.id; })
546 egHolds.transfer_all_bib_holds_to_marked_title(bib_ids);
549 // opens the record merge dialog
550 $scope.openRecordMergeDialog = function(records) {
552 templateUrl: './cat/bucket/record/t_merge_records',
554 windowClass: 'eg-wide-modal',
556 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
559 $scope.merge_profile = null;
560 $scope.lead = { marc_xml : null };
561 $scope.editing_inplace = false;
562 angular.forEach(records, function(rec) {
563 $scope.records.push({ id : rec.id });
565 $scope.ok = function() {
566 $uibModalInstance.close({
567 lead_id : $scope.lead_id,
568 records : $scope.records,
569 merge_profile : $scope.merge_profile,
573 $scope.cancel = function () { $uibModalInstance.dismiss() }
575 $scope.merge_marc = function() {
576 // need lead, at least one sub, and a merge profile
577 if (!$scope.lead_id) return;
578 if (!$scope.merge_profile) return;
580 if (!$scope.records.length) {
581 // if we got here, the last subordinate record
582 // was likely removed, so let's refresh the
583 // lead for the sake of a consistent display
584 egCore.pcrud.retrieve('bre', $scope.lead_id)
585 .then(function(rec) {
586 $scope.lead.marc_xml = rec.marc();
591 var recs = $scope.records.map(function(val) { return val.id; });
592 recs.unshift($scope.lead_id);
595 'open-ils.cat.merge.biblio.per_profile',
597 $scope.merge_profile,
599 ).then(function(merged) {
600 if (merged) $scope.lead.marc_xml = merged;
603 $scope.$watch('merge_profile', function(newVal, oldVal) {
604 if (newVal && newVal !== oldVal) {
609 $scope.use_as_lead = function(rec) {
610 if ($scope.lead_id) {
611 $scope.records.push({ id : $scope.lead_id });
613 $scope.lead_id = rec.id;
616 egCore.pcrud.retrieve('bre', $scope.lead_id)
617 .then(function(rec) {
618 $scope.lead.marc_xml = rec.marc();
622 $scope.drop = function(rec) {
623 angular.forEach($scope.records, function(val, i) {
624 if (rec == $scope.records[i]) {
625 $scope.records.splice(i, 1);
630 $scope.post_edit_inplace = function() {
631 $scope.editing_inplace = false;
633 $scope.edit_lead_inplace = function() {
634 $scope.editing_inplace = true;
636 $scope.edit_lead = function() {
637 var lead = { marc_xml : $scope.lead.marc_xml };
639 // passing the on-save callback this way is a
640 // hack - this invocation of the MARC editor doesn't
641 // need it, but for some reason using this stomps
642 // over the callback set by the other MARC editor
644 var callback = $scope.post_edit_inplace;
647 templateUrl: './cat/bucket/record/t_edit_lead_record',
650 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
651 $scope.focusMe = true;
653 $scope.dirty_flag = false;
654 $scope.ok = function() { $uibModalInstance.close() }
655 $scope.cancel = function () { $uibModalInstance.dismiss() }
656 $scope.on_save = callback;
658 }).result.then(function() {
659 $scope.lead.marc_xml = lead.marc_xml;
663 }).result.then(function (args) {
664 if (!args.lead_id) return;
665 if (!args.records.length) return;
667 function update_bib() {
668 if (args.merge_profile) {
669 return egCore.pcrud.retrieve('bre', args.lead_id)
670 .then(function(rec) {
671 rec.marc(args.lead.marc_xml);
672 rec.edit_date('now');
673 rec.editor(egCore.auth.user().id());
674 return egCore.pcrud.update(rec);
681 update_bib().then(function() {
684 'open-ils.cat.biblio.records.merge',
687 args.records.map(function(val) { return val.id; })
689 $window.location.href =
690 egCore.env.basePath + 'cat/catalog/record/' + args.lead_id;
696 $scope.showRecords = function(records) {
697 // TODO: probably want to set a limit on the number of
698 // new tabs one could choose to open at once
699 angular.forEach(records, function(rec) {
700 var url = egCore.env.basePath +
701 'cat/catalog/record/' +
703 $timeout(function() { $window.open(url, '_blank') });
707 $scope.batchEdit = function() {
708 var url = egCore.env.basePath +
709 'cat/catalog/batchEdit/bucket/' + $scope.bucketId;
710 $timeout(function() { $window.open(url, '_blank') });
713 $scope.detachRecords = function(records) {
715 angular.forEach(records, function(rec) {
716 var item = bucketSvc.currentBucket.items().filter(
718 return (i.target_biblio_record_entry() == rec.id)
722 promises.push(bucketSvc.detachRecord(item[0].id()));
725 bucketSvc.bucketNeedsRefresh = true;
726 return $q.all(promises).then(drawBucket);
729 $scope.deleteRecordsFromCatalog = function(records) {
730 egConfirmDialog.open(
731 egCore.strings.CONFIRM_DELETE_RECORD_BUCKET_ITEMS_FROM_CATALOG,
734 ).result.then(function() {
736 angular.forEach(records, function(rec) {
737 promises.push(bucketSvc.deleteRecordFromCatalog(rec.id));
739 bucketSvc.bucketNeedsRefresh = true;
740 return $q.all(promises).then(function(results) {
741 var failures = results.filter(function(result) {
742 return egCore.evt.parse(result);
743 }).map(function(result) {
744 var evt = egCore.evt.parse(result);
746 return { recordId: evt.payload, desc: evt.desc };
749 if (failures.length) {
751 templateUrl: './cat/bucket/record/t_records_not_deleted',
753 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
754 $scope.failures = failures;
755 $scope.ok = function() { $uibModalInstance.close() }
756 $scope.cancel = function() { $uibModalInstance.dismiss() }
765 // fetch the bucket; on error show the not-allowed message
767 drawBucket()['catch'](function() { $scope.forbidden = true });