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