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