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