LP#1845706 (follow-up): Fix callback
[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 {IdlService, IdlObject} from '@eg/core/idl.service';
4 import {GridDataSource} from '@eg/share/grid/grid';
5 import {GridComponent} from '@eg/share/grid/grid.component';
6 import {TranslateComponent} from '@eg/share/translate/translate.component';
7 import {ToastService} from '@eg/share/toast/toast.service';
8 import {Pager} from '@eg/share/util/pager';
9 import {PcrudService} from '@eg/core/pcrud.service';
10 import {OrgService} from '@eg/core/org.service';
11 import {PermService} from '@eg/core/perm.service';
12 import {AuthService} from '@eg/core/auth.service';
13 import {FmRecordEditorComponent, FmFieldOptions
14     } from '@eg/share/fm-editor/fm-editor.component';
15 import {StringComponent} from '@eg/share/string/string.component';
16 import {OrgFamily} from '@eg/share/org-family-select/org-family-select.component';
17
18 /**
19  * General purpose CRUD interface for IDL objects
20  *
21  * Object types using this component must be editable via PCRUD.
22  */
23
24 @Component({
25     selector: 'eg-admin-page',
26     templateUrl: './admin-page.component.html'
27 })
28
29 export class AdminPageComponent implements OnInit {
30
31     @Input() idlClass: string;
32
33     // Default sort field, used when no grid sorting is applied.
34     @Input() sortField: string;
35
36     // Data source may be provided by the caller.  This gives the caller
37     // complete control over the contents of the grid.  If no data source
38     // is provided, a generic one is create which is sufficient for data
39     // that requires no special handling, filtering, etc.
40     @Input() dataSource: GridDataSource;
41
42     // Size of create/edito dialog.  Uses large by default.
43     @Input() dialogSize: 'sm' | 'lg' = 'lg';
44
45     // If an org unit field is specified, an org unit filter
46     // is added to the top of the page.
47     @Input() orgField: string;
48
49     // Disable the auto-matic org unit field filter
50     @Input() disableOrgFilter: boolean;
51
52     // Include objects linking to org units which are ancestors
53     // of the selected org unit.
54     @Input() includeOrgAncestors: boolean;
55
56     // Ditto includeOrgAncestors, but descendants.
57     @Input() includeOrgDescendants: boolean;
58
59     // Optional grid persist key.  This is the part of the key
60     // following eg.grid.
61     @Input() persistKey: string;
62
63     // Optional path component to add to the generated grid persist key,
64     // formatted as (for example):
65     // 'eg.grid.admin.${persistKeyPfx}.config.billing_type'
66     @Input() persistKeyPfx: string;
67
68     // Optional comma-separated list of read-only fields
69     @Input() readonlyFields: string;
70
71     // Optional template containing help/about text which will
72     // be added to the page, above the grid.
73     @Input() helpTemplate: TemplateRef<any>;
74
75     // Override field options for create/edit dialog
76     @Input() fieldOptions: {[field: string]: FmFieldOptions};
77
78     @ViewChild('grid', { static: true }) grid: GridComponent;
79     @ViewChild('editDialog', { static: true }) editDialog: FmRecordEditorComponent;
80     @ViewChild('successString', { static: true }) successString: StringComponent;
81     @ViewChild('createString', { static: true }) createString: StringComponent;
82     @ViewChild('createErrString', { static: true }) createErrString: StringComponent;
83     @ViewChild('updateFailedString', { static: true }) updateFailedString: StringComponent;
84     @ViewChild('deleteFailedString', { static: true }) deleteFailedString: StringComponent;
85     @ViewChild('deleteSuccessString', { static: true }) deleteSuccessString: StringComponent;
86     @ViewChild('translator', { static: true }) translator: TranslateComponent;
87
88     idlClassDef: any;
89     pkeyField: string;
90
91     // True if any columns on the object support translations
92     translateRowIdx: number;
93     translateFieldIdx: number;
94     translatableFields: string[];
95
96     contextOrg: IdlObject;
97     searchOrgs: OrgFamily;
98     orgFieldLabel: string;
99     viewPerms: string;
100     canCreate: boolean;
101
102     // Filters may be passed via URL query param.
103     // They are used to augment the grid data search query.
104     gridFilters: {[key: string]: string | number};
105
106     constructor(
107         private route: ActivatedRoute,
108         public idl: IdlService,
109         private org: OrgService,
110         public auth: AuthService,
111         public pcrud: PcrudService,
112         private perm: PermService,
113         public toast: ToastService
114     ) {
115         this.translatableFields = [];
116     }
117
118     applyOrgValues(orgId?: number) {
119
120         if (this.disableOrgFilter) {
121             this.orgField = null;
122             return;
123         }
124
125         if (!this.orgField) {
126             // If no org unit field is specified, try to find one.
127             // If an object type has multiple org unit fields, the
128             // caller should specify one or disable org unit filter.
129             this.idlClassDef.fields.forEach(field => {
130                 if (field['class'] === 'aou') {
131                     this.orgField = field.name;
132                 }
133             });
134         }
135
136         if (this.orgField) {
137             this.orgFieldLabel = this.idlClassDef.field_map[this.orgField].label;
138             this.contextOrg = this.org.get(orgId) || this.org.root();
139             this.searchOrgs = {primaryOrgId: this.contextOrg.id()};
140         }
141     }
142
143     ngOnInit() {
144         this.idlClassDef = this.idl.classes[this.idlClass];
145         this.pkeyField = this.idlClassDef.pkey || 'id';
146
147         this.translatableFields =
148             this.idlClassDef.fields.filter(f => f.i18n).map(f => f.name);
149
150         if (!this.persistKey) {
151             this.persistKey =
152                 'admin.' +
153                 (this.persistKeyPfx ? this.persistKeyPfx + '.' : '') +
154                 this.idlClassDef.table;
155         }
156
157         // gridFilters are a JSON encoded string
158         const filters = this.route.snapshot.queryParamMap.get('gridFilters');
159         if (filters) {
160             try {
161                 this.gridFilters = JSON.parse(filters);
162             } catch (E) {
163                 console.error('Invalid grid filters provided: ', filters);
164             }
165         }
166
167         // Limit the view org selector to orgs where the user has
168         // permacrud-encoded view permissions.
169         const pc = this.idlClassDef.permacrud;
170         if (pc && pc.retrieve) {
171             this.viewPerms = pc.retrieve.perms;
172         }
173
174         const contextOrg = this.route.snapshot.queryParamMap.get('contextOrg');
175         this.checkCreatePerms();
176         this.applyOrgValues(Number(contextOrg));
177
178         // If the caller provides not data source, create a generic one.
179         if (!this.dataSource) {
180             this.initDataSource();
181         }
182
183         // TODO: pass the row activate handler via the grid markup
184         this.grid.onRowActivate.subscribe(
185             (idlThing: IdlObject) => this.showEditDialog(idlThing)
186         );
187     }
188
189     checkCreatePerms() {
190         this.canCreate = false;
191         const pc = this.idlClassDef.permacrud || {};
192         const perms = pc.create ? pc.create.perms : [];
193         if (perms.length === 0) { return; }
194
195         this.perm.hasWorkPermAt(perms, true).then(permMap => {
196             Object.keys(permMap).forEach(key => {
197                 if (permMap[key].length > 0) {
198                     this.canCreate = true;
199                 }
200             });
201         });
202     }
203
204     initDataSource() {
205         this.dataSource = new GridDataSource();
206
207         this.dataSource.getRows = (pager: Pager, sort: any[]) => {
208             const orderBy: any = {};
209
210             if (sort.length) {
211                 // Sort specified from grid
212                 orderBy[this.idlClass] = sort[0].name + ' ' + sort[0].dir;
213
214             } else if (this.sortField) {
215                 // Default sort field
216                 orderBy[this.idlClass] = this.sortField;
217             }
218
219             const searchOps = {
220                 offset: pager.offset,
221                 limit: pager.limit,
222                 order_by: orderBy
223             };
224
225             if (!this.contextOrg && !this.gridFilters) {
226                 // No org filter -- fetch all rows
227                 return this.pcrud.retrieveAll(
228                     this.idlClass, searchOps, {fleshSelectors: true});
229             }
230
231             const search: any = {};
232
233             search[this.orgField] = this.searchOrgs.orgIds || [this.contextOrg.id()];
234
235             if (this.gridFilters) {
236                 // Lay the URL grid filters over our search object.
237                 Object.keys(this.gridFilters).forEach(key => {
238                     search[key] = this.gridFilters[key];
239                 });
240             }
241
242             return this.pcrud.search(
243                 this.idlClass, search, searchOps, {fleshSelectors: true});
244         };
245     }
246
247     showEditDialog(idlThing: IdlObject): Promise<any> {
248         this.editDialog.mode = 'update';
249         this.editDialog.recordId = idlThing[this.pkeyField]();
250         return new Promise((resolve, reject) => {
251             this.editDialog.open({size: this.dialogSize}).subscribe(
252                 result => {
253                     this.successString.current()
254                         .then(str => this.toast.success(str));
255                     this.grid.reload();
256                     resolve(result);
257                 },
258                 error => {
259                     this.updateFailedString.current()
260                         .then(str => this.toast.danger(str));
261                     reject(error);
262                 }
263             );
264         });
265     }
266
267     editSelected(idlThings: IdlObject[]) {
268
269         // Edit each IDL thing one at a time
270         const editOneThing = (thing: IdlObject) => {
271             if (!thing) { return; }
272
273             this.showEditDialog(thing).then(
274                 () => editOneThing(idlThings.shift()));
275         };
276
277         editOneThing(idlThings.shift());
278     }
279
280     deleteSelected(idlThings: IdlObject[]) {
281         idlThings.forEach(idlThing => idlThing.isdeleted(true));
282         this.pcrud.autoApply(idlThings).subscribe(
283             val => {
284                 console.debug('deleted: ' + val);
285                 this.deleteSuccessString.current()
286                     .then(str => this.toast.success(str));
287             },
288             err => {
289                 this.deleteFailedString.current()
290                     .then(str => this.toast.danger(str));
291             },
292             ()  => this.grid.reload()
293         );
294     }
295
296     createNew() {
297         this.editDialog.mode = 'create';
298         // We reuse the same editor for all actions.  Be sure
299         // create action does not try to modify an existing record.
300         this.editDialog.recordId = null;
301         this.editDialog.record = null;
302         this.editDialog.open({size: this.dialogSize}).subscribe(
303             ok => {
304                 this.createString.current()
305                     .then(str => this.toast.success(str));
306                 this.grid.reload();
307             },
308             rejection => {
309                 if (!rejection.dismissed) {
310                     this.createErrString.current()
311                         .then(str => this.toast.danger(str));
312                 }
313             }
314         );
315     }
316     // Open the field translation dialog.
317     // Link the next/previous actions to cycle through each translatable
318     // field on each row.
319     translate() {
320         this.translateRowIdx = 0;
321         this.translateFieldIdx = 0;
322         this.translator.fieldName = this.translatableFields[this.translateFieldIdx];
323         this.translator.idlObject = this.dataSource.data[this.translateRowIdx];
324
325         this.translator.nextString = () => {
326
327             if (this.translateFieldIdx < this.translatableFields.length - 1) {
328                 this.translateFieldIdx++;
329
330             } else if (this.translateRowIdx < this.dataSource.data.length - 1) {
331                 this.translateRowIdx++;
332                 this.translateFieldIdx = 0;
333             }
334
335             this.translator.idlObject =
336                 this.dataSource.data[this.translateRowIdx];
337             this.translator.fieldName =
338                 this.translatableFields[this.translateFieldIdx];
339         };
340
341         this.translator.prevString = () => {
342
343             if (this.translateFieldIdx > 0) {
344                 this.translateFieldIdx--;
345
346             } else if (this.translateRowIdx > 0) {
347                 this.translateRowIdx--;
348                 this.translateFieldIdx = 0;
349             }
350
351             this.translator.idlObject =
352                 this.dataSource.data[this.translateRowIdx];
353             this.translator.fieldName =
354                 this.translatableFields[this.translateFieldIdx];
355         };
356
357         this.translator.open({size: 'lg'});
358     }
359 }
360
361