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