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 service.currentBucket = bucket;
170 deferred.resolve(bucket);
173 return deferred.promise;
176 // deletes a single container item from a bucket by container item ID.
177 // promise is rejected on failure
178 service.detachRecord = function(itemId) {
179 var deferred = $q.defer();
182 'open-ils.actor.container.item.delete',
183 egCore.auth.token(), 'biblio', itemId
184 ).then(function(resp) {
185 var evt = egCore.evt.parse(resp);
188 deferred.reject(evt);
191 console.log('detached bucket item ' + itemId);
192 deferred.resolve(resp);
195 return deferred.promise;
198 service.deleteRecordFromCatalog = function(recordId) {
199 var deferred = $q.defer();
203 'open-ils.cat.biblio.record_entry.delete',
204 egCore.auth.token(), recordId
205 ).then(function(resp) {
206 // rather than rejecting the promise in the
207 // case of a failure, we'll let the caller
208 // look for errors -- doing this because AngularJS
209 // does not have a native $q.allSettled() yet.
210 deferred.resolve(resp);
213 return deferred.promise;
216 // delete bucket by ID.
217 // resolved w/ response on successful delete,
218 // rejected otherwise.
219 service.deleteBucket = function(id) {
220 var deferred = $q.defer();
223 'open-ils.actor.container.full_delete',
224 egCore.auth.token(), 'biblio', id
225 ).then(function(resp) {
226 var evt = egCore.evt.parse(resp);
229 deferred.reject(evt);
232 deferred.resolve(resp);
234 return deferred.promise;
241 * Top-level controller.
242 * Hosts functions needed by all controllers.
244 .controller('RecordBucketCtrl',
245 ['$scope','$location','$q','$timeout','$uibModal',
246 '$window','egCore','bucketSvc',
247 function($scope, $location, $q, $timeout, $uibModal,
248 $window, egCore, bucketSvc) {
250 $scope.bucketSvc = bucketSvc;
251 $scope.bucket = function() { return bucketSvc.currentBucket }
253 // tabs: search, pending, view
254 $scope.setTab = function(tab) {
257 // for bucket selector; must be called after route resolve
258 bucketSvc.fetchUserBuckets();
261 $scope.loadBucketFromMenu = function(item, bucket) {
262 if (bucket) return $scope.loadBucket(bucket.id());
265 $scope.loadBucket = function(id) {
267 '/cat/bucket/record/' +
268 $scope.tab + '/' + encodeURIComponent(id));
271 $scope.addToBucket = function(recs) {
272 if (recs.length == 0) return;
273 bucketSvc.bucketNeedsRefresh = true;
275 angular.forEach(recs,
277 var item = new egCore.idl.cbrebi();
278 item.bucket(bucketSvc.currentBucket.id());
279 item.target_biblio_record_entry(rec.id);
282 'open-ils.actor.container.item.create',
283 egCore.auth.token(), 'biblio', item
284 ).then(function(resp) {
286 // HACK: add the IDs of the added items so that the size
287 // of the view list will grow (and update any UI looking at
288 // the list size). The data stored is inconsistent, but since
289 // we are forcing a bucket refresh on the next rendering of
290 // the view pane, the list will be repaired.
291 bucketSvc.currentBucket.items().push(resp);
297 $scope.openCreateBucketDialog = function() {
299 templateUrl: './cat/bucket/record/t_bucket_create',
301 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
302 $scope.focusMe = true;
303 $scope.ok = function(args) { $uibModalInstance.close(args) }
304 $scope.cancel = function () { $uibModalInstance.dismiss() }
306 }).result.then(function (args) {
307 if (!args || !args.name) return;
308 bucketSvc.createBucket(args.name, args.desc).then(
311 bucketSvc.viewList = [];
312 bucketSvc.allBuckets = []; // reset
313 bucketSvc.currentBucket = null;
315 '/cat/bucket/record/' + $scope.tab + '/' + id);
321 $scope.openEditBucketDialog = function() {
323 templateUrl: './cat/bucket/record/t_bucket_edit',
325 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
326 $scope.focusMe = true;
328 name : bucketSvc.currentBucket.name(),
329 desc : bucketSvc.currentBucket.description(),
330 pub : bucketSvc.currentBucket.pub() == 't'
332 $scope.ok = function(args) {
334 $scope.actionPending = true;
335 args.pub = args.pub ? 't' : 'f';
336 // close the dialog after edit has completed
337 bucketSvc.editBucket(args).then(
338 function() { $uibModalInstance.close() });
340 $scope.cancel = function () { $uibModalInstance.dismiss() }
346 // opens the delete confirmation and deletes the current
347 // bucket if the user confirms.
348 $scope.openDeleteBucketDialog = function() {
350 templateUrl: './cat/bucket/record/t_bucket_delete',
352 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
353 $scope.bucket = function() { return bucketSvc.currentBucket }
354 $scope.ok = function() { $uibModalInstance.close() }
355 $scope.cancel = function() { $uibModalInstance.dismiss() }
357 }).result.then(function () {
358 bucketSvc.deleteBucket(bucketSvc.currentBucket.id())
360 bucketSvc.allBuckets = [];
361 $location.path('/cat/bucket/record/view');
366 // retrieves the requested bucket by ID
367 $scope.openSharedBucketDialog = function() {
369 templateUrl: './cat/bucket/record/t_load_shared',
371 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
372 $scope.focusMe = true;
373 $scope.ok = function(args) {
374 if (args && args.id) {
375 $uibModalInstance.close(args.id)
378 $scope.cancel = function() { $uibModalInstance.dismiss() }
380 }).result.then(function(id) {
381 // RecordBucketCtrl $scope is not inherited by the
382 // modal, so we need to call loadBucket from the
384 $scope.loadBucket(id);
388 // opens the record export dialog
389 $scope.openExportBucketDialog = function() {
391 templateUrl: './cat/bucket/record/t_bucket_export',
393 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
394 $scope.args = {format : 'XML', encoding : 'UTF-8'}; // defaults
395 $scope.ok = function(args) { $uibModalInstance.close(args) }
396 $scope.cancel = function() { $uibModalInstance.dismiss() }
398 }).result.then(function (args) {
400 args.containerid = bucketSvc.currentBucket.id();
402 var url = '/exporter?containerid=' + args.containerid +
403 '&format=' + args.format + '&encoding=' + args.encoding;
405 if (args.holdings) url += '&holdings=1';
407 // TODO: improve auth cookie handling so this isn't necessary.
408 // today the cookie path is too specific (/eg/staff) for non-staff
409 // UIs to access it. See services/auth.js
410 url += '&ses=' + egCore.auth.token();
412 $timeout(function() { $window.open(url) });
417 .controller('SearchCtrl',
418 ['$scope','$routeParams','egCore','bucketSvc',
419 function($scope, $routeParams, egCore , bucketSvc) {
421 $scope.setTab('search');
422 $scope.focusMe = true;
423 var idQueryHash = {};
425 function generateQuery() {
426 if (bucketSvc.queryRecords.length)
427 return {id : bucketSvc.queryRecords};
432 $scope.gridControls = {
433 setQuery : function() {return generateQuery()},
434 setSort : function() {return ['id']}
437 // add selected items directly to the pending list
438 $scope.addToPending = function(recs) {
439 angular.forEach(recs, function(rec) {
440 if (bucketSvc.pendingList.filter( // remove dupes
441 function(r) {return r.id == rec.id}).length) return;
442 bucketSvc.pendingList.push(rec);
446 $scope.search = function() {
447 $scope.searchList = [];
448 $scope.searchInProgress = true;
449 bucketSvc.queryRecords = [];
453 'open-ils.search.biblio.multiclass.query.staff', {
455 }, bucketSvc.queryString, true
456 ).then(function(resp) {
457 bucketSvc.queryRecords =
458 resp.ids.map(function(id){return id[0]});
459 $scope.gridControls.setQuery(generateQuery());
460 })['finally'](function() {
461 $scope.searchInProgress = false;
465 if ($routeParams.id &&
466 (!bucketSvc.currentBucket ||
467 bucketSvc.currentBucket.id() != $routeParams.id)) {
468 // user has accessed this page cold with a bucket ID.
469 // fetch the bucket for display, then set the totalCount
470 // (also for display), but avoid fully fetching the bucket,
471 // since it's premature, in this UI.
472 bucketSvc.fetchBucket($routeParams.id);
476 .controller('PendingCtrl',
477 ['$scope','$routeParams','bucketSvc','egGridDataProvider',
478 function($scope, $routeParams, bucketSvc , egGridDataProvider) {
479 $scope.setTab('pending');
481 var provider = egGridDataProvider.instance({});
482 provider.get = function(offset, count) {
483 return provider.arrayNotifier(
484 bucketSvc.pendingList, offset, count);
486 $scope.gridDataProvider = provider;
488 $scope.resetPendingList = function() {
489 bucketSvc.pendingList = [];
490 $scope.gridDataProvider.refresh();
494 if ($routeParams.id &&
495 (!bucketSvc.currentBucket ||
496 bucketSvc.currentBucket.id() != $routeParams.id)) {
497 // user has accessed this page cold with a bucket ID.
498 // fetch the bucket for display, then set the totalCount
499 // (also for display), but avoid fully fetching the bucket,
500 // since it's premature, in this UI.
501 bucketSvc.fetchBucket($routeParams.id);
505 .controller('ViewCtrl',
506 ['$scope','$q','$routeParams','bucketSvc','egCore','$window',
507 '$timeout','egConfirmDialog','$uibModal','egHolds',
508 function($scope, $q , $routeParams, bucketSvc, egCore, $window,
509 $timeout, egConfirmDialog, $uibModal, egHolds) {
511 $scope.setTab('view');
512 $scope.bucketId = $routeParams.id;
515 $scope.gridControls = {
516 setQuery : function(q) {
522 function drawBucket() {
523 return bucketSvc.fetchBucket($scope.bucketId).then(
525 var ids = bucket.items().map(
526 function(i){return i.target_biblio_record_entry()}
529 $scope.gridControls.setQuery({id : ids});
531 $scope.gridControls.setQuery({});
537 // runs the transfer title holds action
538 $scope.transfer_holds_to_marked = function(records) {
539 var bib_ids = records.map(function(val) { return val.id; })
540 egHolds.transfer_all_bib_holds_to_marked_title(bib_ids);
543 // opens the record merge dialog
544 $scope.openRecordMergeDialog = function(records) {
546 templateUrl: './cat/bucket/record/t_merge_records',
548 windowClass: 'eg-wide-modal',
550 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
553 $scope.editing_inplace = false;
554 angular.forEach(records, function(rec) {
555 $scope.records.push({ id : rec.id });
557 $scope.ok = function() {
558 $uibModalInstance.close({
559 lead_id : $scope.lead_id,
560 records : $scope.records
563 $scope.cancel = function () { $uibModalInstance.dismiss() }
564 $scope.use_as_lead = function(rec) {
565 if ($scope.lead_id) {
566 $scope.records.push({ id : $scope.lead_id });
568 $scope.lead_id = rec.id;
571 $scope.drop = function(rec) {
572 angular.forEach($scope.records, function(val, i) {
573 if (rec == $scope.records[i]) {
574 $scope.records.splice(i, 1);
578 $scope.post_edit_inplace = function() {
579 $scope.editing_inplace = false;
581 $scope.edit_lead_inplace = function() {
582 $scope.editing_inplace = true;
584 $scope.edit_lead = function() {
585 var lead_id = $scope.lead_id;
587 templateUrl: './cat/bucket/record/t_edit_lead_record',
590 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
591 $scope.focusMe = true;
592 $scope.record_id = lead_id;
593 $scope.dirty_flag = false;
594 $scope.ok = function() { $uibModalInstance.close() }
595 $scope.cancel = function () { $uibModalInstance.dismiss() }
597 }).result.then(function() {
598 // TODO: need a way to force a refresh of the egRecordBreaker, as
599 // the record ID does not change
603 }).result.then(function (args) {
604 if (!args.lead_id) return;
605 if (!args.records.length) return;
608 'open-ils.cat.biblio.records.merge',
611 args.records.map(function(val) { return val.id; })
613 $window.location.href =
614 egCore.env.basePath + 'cat/catalog/record/' + args.lead_id;
619 $scope.showRecords = function(records) {
620 // TODO: probably want to set a limit on the number of
621 // new tabs one could choose to open at once
622 angular.forEach(records, function(rec) {
623 var url = egCore.env.basePath +
624 'cat/catalog/record/' +
626 $timeout(function() { $window.open(url, '_blank') });
630 $scope.batchEdit = function() {
631 var url = egCore.env.basePath +
632 'cat/catalog/batchEdit/bucket/' + $scope.bucketId;
633 $timeout(function() { $window.open(url, '_blank') });
636 $scope.detachRecords = function(records) {
638 angular.forEach(records, function(rec) {
639 var item = bucketSvc.currentBucket.items().filter(
641 return (i.target_biblio_record_entry() == rec.id)
645 promises.push(bucketSvc.detachRecord(item[0].id()));
648 bucketSvc.bucketNeedsRefresh = true;
649 return $q.all(promises).then(drawBucket);
652 $scope.deleteRecordsFromCatalog = function(records) {
653 egConfirmDialog.open(
654 egCore.strings.CONFIRM_DELETE_RECORD_BUCKET_ITEMS_FROM_CATALOG,
657 ).result.then(function() {
659 angular.forEach(records, function(rec) {
660 promises.push(bucketSvc.deleteRecordFromCatalog(rec.id));
662 bucketSvc.bucketNeedsRefresh = true;
663 return $q.all(promises).then(function(results) {
664 var failures = results.filter(function(result) {
665 return egCore.evt.parse(result);
666 }).map(function(result) {
667 var evt = egCore.evt.parse(result);
669 return { recordId: evt.payload, desc: evt.desc };
672 if (failures.length) {
674 templateUrl: './cat/bucket/record/t_records_not_deleted',
676 ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
677 $scope.failures = failures;
678 $scope.ok = function() { $uibModalInstance.close() }
679 $scope.cancel = function() { $uibModalInstance.dismiss() }
688 // fetch the bucket; on error show the not-allowed message
690 drawBucket()['catch'](function() { $scope.forbidden = true });