LP#1978839 Holds Pull List - Library Shortnames
[Evergreen.git] / Open-ILS / src / eg2 / src / app / staff / share / admin-page / admin-page.component.ts
1 import {Component, Input, OnInit, TemplateRef, ViewChild} from '@angular/core';
2 import {ActivatedRoute} from '@angular/router';
3 import {Location} from '@angular/common';
4 import {IdlService, IdlObject} from '@eg/core/idl.service';
5 import {FormatService} from '@eg/core/format.service';
6 import {GridDataSource, GridColumn} from '@eg/share/grid/grid';
7 import {GridComponent} from '@eg/share/grid/grid.component';
8 import {TranslateComponent} from '@eg/share/translate/translate.component';
9 import {ToastService} from '@eg/share/toast/toast.service';
10 import {Pager} from '@eg/share/util/pager';
11 import {PcrudService} from '@eg/core/pcrud.service';
12 import {OrgService} from '@eg/core/org.service';
13 import {PermService} from '@eg/core/perm.service';
14 import {AuthService} from '@eg/core/auth.service';
15 import {FmRecordEditorComponent, FmFieldOptions
16     } from '@eg/share/fm-editor/fm-editor.component';
17 import {StringComponent} from '@eg/share/string/string.component';
18 import {OrgFamily} from '@eg/share/org-family-select/org-family-select.component';
19
20 /**
21  * General purpose CRUD interface for IDL objects
22  *
23  * Object types using this component must be editable via PCRUD.
24  */
25
26 @Component({
27     selector: 'eg-admin-page',
28     templateUrl: './admin-page.component.html'
29 })
30
31 export class AdminPageComponent implements OnInit {
32
33     @Input() idlClass: string;
34
35     // Default sort field, used when no grid sorting is applied.
36     @Input() sortField: string;
37
38     // Data source may be provided by the caller.  This gives the caller
39     // complete control over the contents of the grid.  If no data source
40     // is provided, a generic one is create which is sufficient for data
41     // that requires no special handling, filtering, etc.
42     @Input() dataSource: GridDataSource;
43
44     // Size of create/edito dialog.  Uses large by default.
45     @Input() dialogSize: 'sm' | 'lg' = 'lg';
46
47     // Optional comma-separated list of field names defining the order in which
48     // fields should be rendered in the fm-editor and grid.
49     @Input() fieldOrder: string;
50
51     // comma-separated list of fields to hide.
52     // This does not imply all other fields should be visible, only that
53     // the selected fields will be hidden.
54     @Input() hideGridFields: string;
55
56     // If an org unit field is specified, an org unit filter
57     // is added to the top of the page.
58     @Input() orgField: string;
59
60     // Disable the auto-matic org unit field filter
61     @Input() disableOrgFilter: boolean;
62
63     // Include objects linking to org units which are ancestors
64     // of the selected org unit.
65     @Input() includeOrgAncestors: boolean;
66
67     // Ditto includeOrgAncestors, but descendants.
68     @Input() includeOrgDescendants: boolean;
69
70     // Optional grid persist key.  This is the part of the key
71     // following eg.grid.
72     @Input() persistKey: string;
73
74     // Optional path component to add to the generated grid persist key,
75     // formatted as (for example):
76     // 'eg.grid.admin.${persistKeyPfx}.config.billing_type'
77     @Input() persistKeyPfx: string;
78
79     // Optional comma-separated list of read-only fields
80     @Input() readonlyFields: string;
81
82     // Optional template containing help/about text which will
83     // be added to the page, above the grid.
84     @Input() helpTemplate: TemplateRef<any>;
85
86     // Override field options for create/edit dialog
87     @Input() fieldOptions: {[field: string]: FmFieldOptions};
88
89     // Override default values for fm-editor
90     @Input() defaultNewRecord: IdlObject;
91
92     // Used as the first part of the routerLink path when creating
93     // links to related tables via configField's.
94     @Input() configLinkBasePath: string;
95
96     // Bonus fields to add to the grid by passing arbitrary templates,
97     // for example, a column created by callbacks based on data from
98     // other columns
99     @Input() templateFields: TemplateField[];
100
101     @ViewChild('grid', { static: true }) grid: GridComponent;
102     @ViewChild('editDialog', { static: true }) editDialog: FmRecordEditorComponent;
103     @ViewChild('successString', { static: true }) successString: StringComponent;
104     @ViewChild('createString', { static: true }) createString: StringComponent;
105     @ViewChild('createErrString', { static: true }) createErrString: StringComponent;
106     @ViewChild('updateFailedString', { static: true }) updateFailedString: StringComponent;
107     @ViewChild('deleteFailedString', { static: true }) deleteFailedString: StringComponent;
108     @ViewChild('deleteSuccessString', { static: true }) deleteSuccessString: StringComponent;
109     @ViewChild('translator', { static: true }) translator: TranslateComponent;
110
111     idlClassDef: any;
112     pkeyField: string;
113     configFields: any[]; // IDL field definitions
114
115     // True if any columns on the object support translations
116     translateRowIdx: number;
117     translateFieldIdx: number;
118     translatableFields: string[];
119
120     contextOrg: IdlObject;
121     searchOrgs: OrgFamily;
122     orgFieldLabel: string;
123     viewPerms: string;
124     canCreate: boolean;
125
126     // Filters may be passed via URL query param.
127     // They are used to augment the grid data search query.
128     gridFilters: {[key: string]: string | number};
129
130     constructor(
131         private route: ActivatedRoute,
132         private ngLocation: Location,
133         private format: FormatService,
134         public idl: IdlService,
135         private org: OrgService,
136         public auth: AuthService,
137         public pcrud: PcrudService,
138         private perm: PermService,
139         public toast: ToastService
140     ) {
141         this.translatableFields = [];
142         this.configFields = [];
143     }
144
145     applyOrgValues(orgId?: number) {
146
147         if (this.disableOrgFilter) {
148             this.orgField = null;
149             return;
150         }
151
152         if (!this.orgField) {
153             // If no org unit field is specified, try to find one.
154             // If an object type has multiple org unit fields, the
155             // caller should specify one or disable org unit filter.
156             this.idlClassDef.fields.forEach(field => {
157                 if (field['class'] === 'aou') {
158                     this.orgField = field.name;
159                 }
160             });
161         }
162
163         if (this.orgField) {
164             this.orgFieldLabel = this.idlClassDef.field_map[this.orgField].label;
165             this.contextOrg = this.org.get(orgId) || this.org.get(this.auth.user().ws_ou()) || this.org.root();
166             this.searchOrgs = {primaryOrgId: this.contextOrg.id()};
167         }
168     }
169
170     ngOnInit() {
171
172         this.idlClassDef = this.idl.classes[this.idlClass];
173         this.pkeyField = this.idlClassDef.pkey || 'id';
174
175         this.translatableFields =
176             this.idlClassDef.fields.filter(f => f.i18n).map(f => f.name);
177
178         if (!this.persistKey) {
179             this.persistKey =
180                 'admin.' +
181                 (this.persistKeyPfx ? this.persistKeyPfx + '.' : '') +
182                 this.idlClassDef.table;
183         }
184
185
186         // Note the field filter could be based purely on fields
187         // which are links, but that leads to cases where links
188         // are created to tables which are too big and/or admin
189         // interfaces which are not otherwise used because they
190         // have custom UI's instead.
191         // this.idlClassDef.fields.filter(f => f.datatype === 'link');
192         this.configFields =
193             this.idlClassDef.fields.filter(f => f.config_field);
194
195         // gridFilters are a JSON encoded string
196         const filters = this.route.snapshot.queryParamMap.get('gridFilters');
197         if (filters) {
198             try {
199                 this.gridFilters = JSON.parse(filters);
200             } catch (E) {
201                 console.error('Invalid grid filters provided: ', filters);
202             }
203
204             // Use the grid filters as the basis for our default
205             // new record (passed to fm-editor).
206             if (!this.defaultNewRecord) {
207                 const rec = this.idl.create(this.idlClass);
208                 Object.keys(this.gridFilters).forEach(field => {
209                     // When filtering on the primary key of the current
210                     // object type, avoid using it in the default new object.
211                     if (rec[field] && this.pkeyField !== field) {
212                         rec[field](this.gridFilters[field]);
213                     }
214                 });
215                 this.defaultNewRecord = rec;
216             }
217         }
218
219         // Limit the view org selector to orgs where the user has
220         // permacrud-encoded view permissions.
221         const pc = this.idlClassDef.permacrud;
222         if (pc && pc.retrieve) {
223             this.viewPerms = pc.retrieve.perms;
224         }
225
226         const contextOrg = this.route.snapshot.queryParamMap.get('contextOrg');
227         this.checkCreatePerms();
228         this.applyOrgValues(Number(contextOrg));
229
230         // If the caller provides not data source, create a generic one.
231         if (!this.dataSource) {
232             this.initDataSource();
233         }
234     }
235
236     checkCreatePerms() {
237         this.canCreate = false;
238         const pc = this.idlClassDef.permacrud || {};
239         const perms = pc.create ? pc.create.perms : [];
240         if (perms.length === 0) { return; }
241
242         this.perm.hasWorkPermAt(perms, true).then(permMap => {
243             Object.keys(permMap).forEach(key => {
244                 if (permMap[key].length > 0) {
245                     this.canCreate = true;
246                 }
247             });
248         });
249     }
250
251     initDataSource() {
252         this.dataSource = new GridDataSource();
253
254         this.dataSource.getRows = (pager: Pager, sort: any[]) => {
255             const orderBy: any = {};
256
257             if (sort.length) {
258                 // Sort specified from grid
259                 orderBy[this.idlClass] = sort[0].name + ' ' + sort[0].dir;
260
261             } else if (this.sortField) {
262                 // Default sort field
263                 orderBy[this.idlClass] = this.sortField;
264             }
265
266             const searchOps = {
267                 offset: pager.offset,
268                 limit: pager.limit,
269                 order_by: orderBy
270             };
271
272             if (!this.contextOrg && !this.gridFilters && !Object.keys(this.dataSource.filters).length) {
273                 // No org filter -- fetch all rows
274                 return this.pcrud.retrieveAll(
275                     this.idlClass, searchOps, {fleshSelectors: true});
276             }
277
278             const search: any[] = new Array();
279             const orgFilter: any = {};
280
281             if (this.orgField && (this.searchOrgs || this.contextOrg)) {
282                 orgFilter[this.orgField] =
283                     this.searchOrgs.orgIds || [this.contextOrg.id()];
284                 search.push(orgFilter);
285             }
286
287             Object.keys(this.dataSource.filters).forEach(key => {
288                 Object.keys(this.dataSource.filters[key]).forEach(key2 => {
289                     search.push(this.dataSource.filters[key][key2]);
290                 });
291             });
292
293             // FIXME - do we want to remove this, which is used in several
294             // secondary admin pages, in favor of switching it to the built-in
295             // grid filtering?
296             if (this.gridFilters) {
297                 // Lay the URL grid filters over our search object.
298                 Object.keys(this.gridFilters).forEach(key => {
299                     const urlProvidedFilters = {};
300                     urlProvidedFilters[key] = this.gridFilters[key];
301                     search.push(urlProvidedFilters);
302                 });
303             }
304
305             return this.pcrud.search(
306                 this.idlClass, search, searchOps, {fleshSelectors: true});
307         };
308     }
309
310     showEditDialog(idlThing: IdlObject): Promise<any> {
311         this.editDialog.mode = 'update';
312         this.editDialog.recordId = idlThing[this.pkeyField]();
313         return new Promise((resolve, reject) => {
314             this.editDialog.open({size: this.dialogSize}).subscribe(
315                 result => {
316                     this.successString.current()
317                         .then(str => this.toast.success(str));
318                     this.grid.reload();
319                     resolve(result);
320                 },
321                 error => {
322                     this.updateFailedString.current()
323                         .then(str => this.toast.danger(str));
324                     reject(error);
325                 }
326             );
327         });
328     }
329
330     editSelected(idlThings: IdlObject[]) {
331
332         // Edit each IDL thing one at a time
333         const editOneThing = (thing: IdlObject) => {
334             if (!thing) { return; }
335
336             this.showEditDialog(thing).then(
337                 () => editOneThing(idlThings.shift()));
338         };
339
340         editOneThing(idlThings.shift());
341     }
342
343     deleteSelected(idlThings: IdlObject[]) {
344         idlThings.forEach(idlThing => idlThing.isdeleted(true));
345         this.pcrud.autoApply(idlThings).subscribe(
346             val => {
347                 this.deleteSuccessString.current()
348                     .then(str => this.toast.success(str));
349             },
350             err => {
351                 this.deleteFailedString.current()
352                     .then(str => this.toast.danger(str));
353             },
354             ()  => this.grid.reload()
355         );
356     }
357
358     createNew() {
359         this.editDialog.mode = 'create';
360         // We reuse the same editor for all actions.  Be sure
361         // create action does not try to modify an existing record.
362         this.editDialog.recordId = null;
363         this.editDialog.record = null;
364         this.editDialog.open({size: this.dialogSize}).subscribe(
365             ok => {
366                 this.createString.current()
367                     .then(str => this.toast.success(str));
368                 this.grid.reload();
369             },
370             rejection => {
371                 if (!rejection.dismissed) {
372                     this.createErrString.current()
373                         .then(str => this.toast.danger(str));
374                 }
375             }
376         );
377     }
378     // Open the field translation dialog.
379     // Link the next/previous actions to cycle through each translatable
380     // field on each row.
381     translate() {
382         this.translateRowIdx = 0;
383         this.translateFieldIdx = 0;
384         this.translator.fieldName = this.translatableFields[this.translateFieldIdx];
385         this.translator.idlObject = this.dataSource.data[this.translateRowIdx];
386
387         this.translator.nextString = () => {
388
389             if (this.translateFieldIdx < this.translatableFields.length - 1) {
390                 this.translateFieldIdx++;
391
392             } else if (this.translateRowIdx < this.dataSource.data.length - 1) {
393                 this.translateRowIdx++;
394                 this.translateFieldIdx = 0;
395             }
396
397             this.translator.idlObject =
398                 this.dataSource.data[this.translateRowIdx];
399             this.translator.fieldName =
400                 this.translatableFields[this.translateFieldIdx];
401         };
402
403         this.translator.prevString = () => {
404
405             if (this.translateFieldIdx > 0) {
406                 this.translateFieldIdx--;
407
408             } else if (this.translateRowIdx > 0) {
409                 this.translateRowIdx--;
410                 this.translateFieldIdx = 0;
411             }
412
413             this.translator.idlObject =
414                 this.dataSource.data[this.translateRowIdx];
415             this.translator.fieldName =
416                 this.translatableFields[this.translateFieldIdx];
417         };
418
419         this.translator.open({size: 'lg'});
420     }
421
422     // Construct a routerLink path for a configField.
423     configFieldRouteLink(row: any, col: GridColumn): string {
424         const cf = this.configFields.filter(field => field.name === col.name)[0];
425         const linkClass = this.idl.classes[cf['class']];
426         const pathParts = linkClass.table.split(/\./); // schema.tablename
427         return `${this.configLinkBasePath}/${pathParts[0]}/${pathParts[1]}`;
428     }
429
430     // Compiles a gridFilter value used when navigating to a linked
431     // class via configField.  The filter ensures the linked page
432     // only shows rows which refer back to the object from which the
433     // link was clicked.
434     configFieldRouteParams(row: any, col: GridColumn): any {
435         const cf = this.configFields.filter(field => field.name === col.name)[0];
436         let value = this.configFieldLinkedValue(row, col);
437
438         // For certain has-a relationships, the linked object will be
439         // fleshed so its display (selector) value can be used.
440         // Extract the scalar value found at the remote target field.
441         if (value && typeof value === 'object') { value = value[cf.key](); }
442
443         const filter: any = {};
444         filter[cf.key] = value;
445
446         return {gridFilters : JSON.stringify(filter)};
447     }
448
449     // Returns the value on the local object for the field which
450     // refers to the remote object.  This may be a scalar or a
451     // fleshed IDL object.
452     configFieldLinkedValue(row: any, col: GridColumn): any {
453         const cf = this.configFields.filter(field => field.name === col.name)[0];
454         const linkClass = this.idl.classes[cf['class']];
455
456         // cf.key is the name of the field on the linked object that matches
457         // the value on our local object.
458         // In as has_many relationship, the remote field has its own
459         // 'key' value which determines which field on the local object
460         // represents the other end of the relationship.  This is
461         // typically, but not always the local pkey field.
462
463         const localField =
464             cf.reltype === 'has_many' ?
465             (linkClass.field_map[cf.key].key || this.pkeyField) : cf.name;
466
467         return row[localField]();
468     }
469
470     // Returns a URL suitable for using as an href.
471     // We use an href to jump to the secondary admin page because
472     // routerLink within the same base component results in component
473     // reuse of a series of components which were not designed with
474     // reuse in mind.
475     configFieldLinkUrl(row: any, col: GridColumn): string {
476         const path = this.configFieldRouteLink(row, col);
477         const filters = this.configFieldRouteParams(row, col);
478         const url = path + '?gridFilters=' +
479             encodeURIComponent(filters.gridFilters);
480
481         return this.ngLocation.prepareExternalUrl(url);
482     }
483
484     configLinkLabel(row: any, col: GridColumn): string {
485         const cf = this.configFields.filter(field => field.name === col.name)[0];
486
487         // Has-many links have no specific value to use for display
488         // so just use the column label.
489         if (cf.reltype === 'has_many') { return col.label; }
490
491         return this.format.transform({
492             value: row[col.name](),
493             idlClass: this.idlClass,
494             idlField: col.name
495         });
496     }
497
498     clearGridFiltersUrl(): string {
499         const parts = this.idlClassDef.table.split(/\./);
500         const url = this.configLinkBasePath + '/' + parts[0] + '/' + parts[1];
501         return this.ngLocation.prepareExternalUrl(url);
502     }
503 }
504
505 export interface TemplateField {
506     cellTemplate: TemplateRef<any>;
507     name: string;
508 }
509