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';
21 * General purpose CRUD interface for IDL objects
23 * Object types using this component must be editable via PCRUD.
27 selector: 'eg-admin-page',
28 templateUrl: './admin-page.component.html'
31 export class AdminPageComponent implements OnInit {
33 @Input() idlClass: string;
35 // Default sort field, used when no grid sorting is applied.
36 @Input() sortField: string;
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;
44 // Size of create/edito dialog. Uses large by default.
45 @Input() dialogSize: 'sm' | 'lg' = 'lg';
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;
51 // Disable the auto-matic org unit field filter
52 @Input() disableOrgFilter: boolean;
54 // Include objects linking to org units which are ancestors
55 // of the selected org unit.
56 @Input() includeOrgAncestors: boolean;
58 // Ditto includeOrgAncestors, but descendants.
59 @Input() includeOrgDescendants: boolean;
61 // Optional grid persist key. This is the part of the key
63 @Input() persistKey: string;
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;
70 // Optional comma-separated list of read-only fields
71 @Input() readonlyFields: string;
73 // Optional template containing help/about text which will
74 // be added to the page, above the grid.
75 @Input() helpTemplate: TemplateRef<any>;
77 // Override field options for create/edit dialog
78 @Input() fieldOptions: {[field: string]: FmFieldOptions};
80 // Override default values for fm-editor
81 @Input() defaultNewRecord: IdlObject;
83 // Used as the first part of the routerLink path when creating
84 // links to related tables via configField's.
85 @Input() configLinkBasePath: string;
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;
99 configFields: any[]; // IDL field definitions
101 // True if any columns on the object support translations
102 translateRowIdx: number;
103 translateFieldIdx: number;
104 translatableFields: string[];
106 contextOrg: IdlObject;
107 searchOrgs: OrgFamily;
108 orgFieldLabel: string;
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};
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
127 this.translatableFields = [];
128 this.configFields = [];
131 applyOrgValues(orgId?: number) {
133 if (this.disableOrgFilter) {
134 this.orgField = null;
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;
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()};
158 this.idlClassDef = this.idl.classes[this.idlClass];
159 this.pkeyField = this.idlClassDef.pkey || 'id';
161 this.translatableFields =
162 this.idlClassDef.fields.filter(f => f.i18n).map(f => f.name);
164 if (!this.persistKey) {
167 (this.persistKeyPfx ? this.persistKeyPfx + '.' : '') +
168 this.idlClassDef.table;
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');
179 this.idlClassDef.fields.filter(f => f.config_field);
181 // gridFilters are a JSON encoded string
182 const filters = this.route.snapshot.queryParamMap.get('gridFilters');
185 this.gridFilters = JSON.parse(filters);
187 console.error('Invalid grid filters provided: ', filters);
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]);
201 this.defaultNewRecord = rec;
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;
212 const contextOrg = this.route.snapshot.queryParamMap.get('contextOrg');
213 this.checkCreatePerms();
214 this.applyOrgValues(Number(contextOrg));
216 // If the caller provides not data source, create a generic one.
217 if (!this.dataSource) {
218 this.initDataSource();
221 // TODO: pass the row activate handler via the grid markup
222 this.grid.onRowActivate.subscribe(
223 (idlThing: IdlObject) => this.showEditDialog(idlThing)
228 this.canCreate = false;
229 const pc = this.idlClassDef.permacrud || {};
230 const perms = pc.create ? pc.create.perms : [];
231 if (perms.length === 0) { return; }
233 this.perm.hasWorkPermAt(perms, true).then(permMap => {
234 Object.keys(permMap).forEach(key => {
235 if (permMap[key].length > 0) {
236 this.canCreate = true;
243 this.dataSource = new GridDataSource();
245 this.dataSource.getRows = (pager: Pager, sort: any[]) => {
246 const orderBy: any = {};
249 // Sort specified from grid
250 orderBy[this.idlClass] = sort[0].name + ' ' + sort[0].dir;
252 } else if (this.sortField) {
253 // Default sort field
254 orderBy[this.idlClass] = this.sortField;
258 offset: pager.offset,
263 if (!this.contextOrg && !this.gridFilters) {
264 // No org filter -- fetch all rows
265 return this.pcrud.retrieveAll(
266 this.idlClass, searchOps, {fleshSelectors: true});
269 const search: any = {};
272 search[this.orgField] =
273 this.searchOrgs.orgIds || [this.contextOrg.id()];
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];
283 return this.pcrud.search(
284 this.idlClass, search, searchOps, {fleshSelectors: true});
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(
294 this.successString.current()
295 .then(str => this.toast.success(str));
300 this.updateFailedString.current()
301 .then(str => this.toast.danger(str));
308 editSelected(idlThings: IdlObject[]) {
310 // Edit each IDL thing one at a time
311 const editOneThing = (thing: IdlObject) => {
312 if (!thing) { return; }
314 this.showEditDialog(thing).then(
315 () => editOneThing(idlThings.shift()));
318 editOneThing(idlThings.shift());
321 deleteSelected(idlThings: IdlObject[]) {
322 idlThings.forEach(idlThing => idlThing.isdeleted(true));
323 this.pcrud.autoApply(idlThings).subscribe(
325 console.debug('deleted: ' + val);
326 this.deleteSuccessString.current()
327 .then(str => this.toast.success(str));
330 this.deleteFailedString.current()
331 .then(str => this.toast.danger(str));
333 () => this.grid.reload()
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(
345 this.createString.current()
346 .then(str => this.toast.success(str));
350 if (!rejection.dismissed) {
351 this.createErrString.current()
352 .then(str => this.toast.danger(str));
357 // Open the field translation dialog.
358 // Link the next/previous actions to cycle through each translatable
359 // field on each row.
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];
366 this.translator.nextString = () => {
368 if (this.translateFieldIdx < this.translatableFields.length - 1) {
369 this.translateFieldIdx++;
371 } else if (this.translateRowIdx < this.dataSource.data.length - 1) {
372 this.translateRowIdx++;
373 this.translateFieldIdx = 0;
376 this.translator.idlObject =
377 this.dataSource.data[this.translateRowIdx];
378 this.translator.fieldName =
379 this.translatableFields[this.translateFieldIdx];
382 this.translator.prevString = () => {
384 if (this.translateFieldIdx > 0) {
385 this.translateFieldIdx--;
387 } else if (this.translateRowIdx > 0) {
388 this.translateRowIdx--;
389 this.translateFieldIdx = 0;
392 this.translator.idlObject =
393 this.dataSource.data[this.translateRowIdx];
394 this.translator.fieldName =
395 this.translatableFields[this.translateFieldIdx];
398 this.translator.open({size: 'lg'});
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]}`;
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
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);
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](); }
422 const filter: any = {};
423 filter[cf.key] = value;
425 return {gridFilters : JSON.stringify(filter)};
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']];
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.
443 cf.reltype === 'has_many' ?
444 (linkClass.field_map[cf.key].key || this.pkeyField) : cf.name;
446 return row[localField]();
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
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);
460 return this.ngLocation.prepareExternalUrl(url);
463 configLinkLabel(row: any, col: GridColumn): string {
464 const cf = this.configFields.filter(field => field.name === col.name)[0];
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; }
470 return this.format.transform({
471 value: row[col.name](),
472 idlClass: this.idlClass,
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);