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