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 // Optional comma-separated list of field names defining the order in which
48 // fields should be rendered in the fm-editor and grid.
49 @Input() fieldOrder: string;
51 // comma-separated list of fields to hide.
52 // This does not imply all other fields should be visible, only that
53 // the selected fields will be hidden.
54 @Input() hideGridFields: string;
56 // If an org unit field is specified, an org unit filter
57 // is added to the top of the page.
58 @Input() orgField: string;
60 // Disable the auto-matic org unit field filter
61 @Input() disableOrgFilter: boolean;
63 // Give the grid an option to undelete any deleted rows
64 @Input() enableUndelete: boolean;
66 // Include objects linking to org units which are ancestors
67 // of the selected org unit.
68 @Input() includeOrgAncestors: boolean;
70 // Ditto includeOrgAncestors, but descendants.
71 @Input() includeOrgDescendants: boolean;
73 // Optional grid persist key. This is the part of the key
75 @Input() persistKey: string;
77 // Optional path component to add to the generated grid persist key,
78 // formatted as (for example):
79 // 'eg.grid.admin.${persistKeyPfx}.config.billing_type'
80 @Input() persistKeyPfx: string;
82 // Optional comma-separated list of read-only fields
83 @Input() readonlyFields: string;
85 // Optional record label to use instead of the IDL label
86 @Input() recordLabel: string;
88 // optional flag to hide the Clear Filters action for gridFilters
89 @Input() hideClearFilters: boolean;
91 // optional list of org fields which are allowed a default if unset
92 @Input() orgDefaultAllowed: string;
94 // Optional template containing help/about text which will
95 // be added to the page, above the grid.
96 @Input() helpTemplate: TemplateRef<any>;
98 // Override field options for create/edit dialog
99 @Input() fieldOptions: {[field: string]: FmFieldOptions};
101 // Override default values for fm-editor
102 @Input() defaultNewRecord: IdlObject;
104 // Used as the first part of the routerLink path when creating
105 // links to related tables via configField's.
106 @Input() configLinkBasePath: string;
108 // Bonus fields to add to the grid by passing arbitrary templates,
109 // for example, a column created by callbacks based on data from
111 @Input() templateFields: TemplateField[];
113 @ViewChild('grid', { static: true }) grid: GridComponent;
114 @ViewChild('editDialog', { static: true }) editDialog: FmRecordEditorComponent;
115 @ViewChild('successString', { static: true }) successString: StringComponent;
116 @ViewChild('createString', { static: true }) createString: StringComponent;
117 @ViewChild('createErrString', { static: true }) createErrString: StringComponent;
118 @ViewChild('updateFailedString', { static: true }) updateFailedString: StringComponent;
119 @ViewChild('deleteFailedString', { static: true }) deleteFailedString: StringComponent;
120 @ViewChild('deleteSuccessString', { static: true }) deleteSuccessString: StringComponent;
121 @ViewChild('undeleteFailedString', { static: true }) undeleteFailedString: StringComponent;
122 @ViewChild('undeleteSuccessString', { static: true }) undeleteSuccessString: StringComponent;
123 @ViewChild('translator', { static: true }) translator: TranslateComponent;
127 configFields: any[]; // IDL field definitions
129 // True if any columns on the object support translations
130 translateRowIdx: number;
131 translateFieldIdx: number;
132 translatableFields: string[];
134 contextOrg: IdlObject;
135 searchOrgs: OrgFamily;
136 orgFieldLabel: string;
140 // Filters may be passed via URL query param.
141 // They are used to augment the grid data search query.
142 gridFilters: {[key: string]: string | number};
145 private route: ActivatedRoute,
146 private ngLocation: Location,
147 private format: FormatService,
148 public idl: IdlService,
149 private org: OrgService,
150 public auth: AuthService,
151 public pcrud: PcrudService,
152 private perm: PermService,
153 public toast: ToastService
155 this.translatableFields = [];
156 this.configFields = [];
159 applyOrgValues(orgId?: number) {
161 if (this.disableOrgFilter) {
162 this.orgField = null;
166 if (!this.orgField) {
167 // If no org unit field is specified, try to find one.
168 // If an object type has multiple org unit fields, the
169 // caller should specify one or disable org unit filter.
170 this.idlClassDef.fields.forEach(field => {
171 if (field['class'] === 'aou') {
172 this.orgField = field.name;
178 this.orgFieldLabel = this.idlClassDef.field_map[this.orgField].label;
179 this.contextOrg = this.org.get(orgId) || this.org.get(this.auth.user().ws_ou()) || this.org.root();
180 this.searchOrgs = {primaryOrgId: this.contextOrg.id()};
186 this.idlClassDef = this.idl.classes[this.idlClass];
187 this.pkeyField = this.idlClassDef.pkey || 'id';
189 this.translatableFields =
190 this.idlClassDef.fields.filter(f => f.i18n).map(f => f.name);
192 if (!this.persistKey) {
195 (this.persistKeyPfx ? this.persistKeyPfx + '.' : '') +
196 this.idlClassDef.table;
200 // Note the field filter could be based purely on fields
201 // which are links, but that leads to cases where links
202 // are created to tables which are too big and/or admin
203 // interfaces which are not otherwise used because they
204 // have custom UI's instead.
205 // this.idlClassDef.fields.filter(f => f.datatype === 'link');
207 this.idlClassDef.fields.filter(f => f.config_field);
209 // gridFilters are a JSON encoded string
210 const filters = this.route.snapshot.queryParamMap.get('gridFilters');
213 this.gridFilters = JSON.parse(filters);
215 console.error('Invalid grid filters provided: ', filters);
218 // Use the grid filters as the basis for our default
219 // new record (passed to fm-editor).
220 if (!this.defaultNewRecord) {
221 const rec = this.idl.create(this.idlClass);
222 Object.keys(this.gridFilters).forEach(field => {
223 // When filtering on the primary key of the current
224 // object type, avoid using it in the default new object.
225 if (rec[field] && this.pkeyField !== field) {
226 rec[field](this.gridFilters[field]);
229 this.defaultNewRecord = rec;
233 // Limit the view org selector to orgs where the user has
234 // permacrud-encoded view permissions.
235 const pc = this.idlClassDef.permacrud;
236 if (pc && pc.retrieve) {
237 this.viewPerms = pc.retrieve.perms;
240 const contextOrg = this.route.snapshot.queryParamMap.get('contextOrg');
241 this.checkCreatePerms();
242 this.applyOrgValues(Number(contextOrg));
244 // If the caller provides not data source, create a generic one.
245 if (!this.dataSource) {
246 this.initDataSource();
251 this.canCreate = false;
252 const pc = this.idlClassDef.permacrud || {};
253 const perms = pc.create ? pc.create.perms : [];
254 if (perms.length === 0) { return; }
256 this.perm.hasWorkPermAt(perms, true).then(permMap => {
257 Object.keys(permMap).forEach(key => {
258 if (permMap[key].length > 0) {
259 this.canCreate = true;
266 this.dataSource = new GridDataSource();
268 this.dataSource.getRows = (pager: Pager, sort: any[]) => {
269 const orderBy: any = {};
272 // Sort specified from grid
273 orderBy[this.idlClass] = sort[0].name + ' ' + sort[0].dir;
275 } else if (this.sortField) {
276 // Default sort field
277 orderBy[this.idlClass] = this.sortField;
281 offset: pager.offset,
286 if (!this.contextOrg && !this.gridFilters && !Object.keys(this.dataSource.filters).length) {
287 // No org filter -- fetch all rows
288 return this.pcrud.retrieveAll(
289 this.idlClass, searchOps, {fleshSelectors: true});
292 const search: any[] = new Array();
293 const orgFilter: any = {};
295 if (this.orgField && (this.searchOrgs || this.contextOrg)) {
296 orgFilter[this.orgField] =
297 this.searchOrgs.orgIds || [this.contextOrg.id()];
298 search.push(orgFilter);
301 Object.keys(this.dataSource.filters).forEach(key => {
302 Object.keys(this.dataSource.filters[key]).forEach(key2 => {
303 search.push(this.dataSource.filters[key][key2]);
307 // FIXME - do we want to remove this, which is used in several
308 // secondary admin pages, in favor of switching it to the built-in
310 if (this.gridFilters) {
311 // Lay the URL grid filters over our search object.
312 Object.keys(this.gridFilters).forEach(key => {
313 const urlProvidedFilters = {};
314 urlProvidedFilters[key] = this.gridFilters[key];
315 search.push(urlProvidedFilters);
319 return this.pcrud.search(
320 this.idlClass, search, searchOps, {fleshSelectors: true});
324 showEditDialog(idlThing: IdlObject): Promise<any> {
325 this.editDialog.mode = 'update';
326 this.editDialog.recordId = idlThing[this.pkeyField]();
327 return new Promise((resolve, reject) => {
328 this.editDialog.open({size: this.dialogSize}).subscribe(
330 this.successString.current()
331 .then(str => this.toast.success(str));
336 this.updateFailedString.current()
337 .then(str => this.toast.danger(str));
344 editSelected(idlThings: IdlObject[]) {
346 // Edit each IDL thing one at a time
347 const editOneThing = (thing: IdlObject) => {
348 if (!thing) { return; }
350 this.showEditDialog(thing).then(
351 () => editOneThing(idlThings.shift()));
354 editOneThing(idlThings.shift());
357 undeleteSelected(idlThings: IdlObject[]) {
358 idlThings.forEach(idlThing => idlThing.deleted(false));
359 this.pcrud.update(idlThings).subscribe(
361 this.undeleteSuccessString.current()
362 .then(str => this.toast.success(str));
365 this.undeleteFailedString.current()
366 .then(str => this.toast.danger(str));
368 () => this.grid.reload()
372 deleteSelected(idlThings: IdlObject[]) {
373 idlThings.forEach(idlThing => idlThing.isdeleted(true));
374 this.pcrud.autoApply(idlThings).subscribe(
376 this.deleteSuccessString.current()
377 .then(str => this.toast.success(str));
380 this.deleteFailedString.current()
381 .then(str => this.toast.danger(str));
383 () => this.grid.reload()
387 shouldDisableDelete(rows: IdlObject[]): boolean {
388 if (rows.length === 0) {
391 const deletedRows = rows.filter((row) => {
392 if (row.deleted && row.deleted() === 't') {
394 } else if (row.isdeleted) {
395 return row.isdeleted();
398 return deletedRows.length > 0;
402 shouldDisableUndelete(rows: IdlObject[]): boolean {
403 if (rows.length === 0) {
406 const deletedRows = rows.filter((row) => {
407 if (row.deleted && row.deleted() === 't') {
409 } else if (row.isdeleted) {
410 return row.isdeleted();
413 return deletedRows.length !== rows.length;
418 this.editDialog.mode = 'create';
419 // We reuse the same editor for all actions. Be sure
420 // create action does not try to modify an existing record.
421 this.editDialog.recordId = null;
422 this.editDialog.record = null;
423 this.editDialog.open({size: this.dialogSize}).subscribe(
425 this.createString.current()
426 .then(str => this.toast.success(str));
430 if (!rejection.dismissed) {
431 this.createErrString.current()
432 .then(str => this.toast.danger(str));
437 // Open the field translation dialog.
438 // Link the next/previous actions to cycle through each translatable
439 // field on each row.
441 this.translateRowIdx = 0;
442 this.translateFieldIdx = 0;
443 this.translator.fieldName = this.translatableFields[this.translateFieldIdx];
444 this.translator.idlObject = this.dataSource.data[this.translateRowIdx];
446 this.translator.nextString = () => {
448 if (this.translateFieldIdx < this.translatableFields.length - 1) {
449 this.translateFieldIdx++;
451 } else if (this.translateRowIdx < this.dataSource.data.length - 1) {
452 this.translateRowIdx++;
453 this.translateFieldIdx = 0;
456 this.translator.idlObject =
457 this.dataSource.data[this.translateRowIdx];
458 this.translator.fieldName =
459 this.translatableFields[this.translateFieldIdx];
462 this.translator.prevString = () => {
464 if (this.translateFieldIdx > 0) {
465 this.translateFieldIdx--;
467 } else if (this.translateRowIdx > 0) {
468 this.translateRowIdx--;
469 this.translateFieldIdx = 0;
472 this.translator.idlObject =
473 this.dataSource.data[this.translateRowIdx];
474 this.translator.fieldName =
475 this.translatableFields[this.translateFieldIdx];
478 this.translator.open({size: 'lg'});
481 // Construct a routerLink path for a configField.
482 configFieldRouteLink(row: any, col: GridColumn): string {
483 const cf = this.configFields.filter(field => field.name === col.name)[0];
484 const linkClass = this.idl.classes[cf['class']];
485 const pathParts = linkClass.table.split(/\./); // schema.tablename
486 return `${this.configLinkBasePath}/${pathParts[0]}/${pathParts[1]}`;
489 // Compiles a gridFilter value used when navigating to a linked
490 // class via configField. The filter ensures the linked page
491 // only shows rows which refer back to the object from which the
493 configFieldRouteParams(row: any, col: GridColumn): any {
494 const cf = this.configFields.filter(field => field.name === col.name)[0];
495 let value = this.configFieldLinkedValue(row, col);
497 // For certain has-a relationships, the linked object will be
498 // fleshed so its display (selector) value can be used.
499 // Extract the scalar value found at the remote target field.
500 if (value && typeof value === 'object') { value = value[cf.key](); }
502 const filter: any = {};
503 filter[cf.key] = value;
505 return {gridFilters : JSON.stringify(filter)};
508 // Returns the value on the local object for the field which
509 // refers to the remote object. This may be a scalar or a
510 // fleshed IDL object.
511 configFieldLinkedValue(row: any, col: GridColumn): any {
512 const cf = this.configFields.filter(field => field.name === col.name)[0];
513 const linkClass = this.idl.classes[cf['class']];
515 // cf.key is the name of the field on the linked object that matches
516 // the value on our local object.
517 // In as has_many relationship, the remote field has its own
518 // 'key' value which determines which field on the local object
519 // represents the other end of the relationship. This is
520 // typically, but not always the local pkey field.
523 cf.reltype === 'has_many' ?
524 (linkClass.field_map[cf.key].key || this.pkeyField) : cf.name;
526 return row[localField]();
529 // Returns a URL suitable for using as an href.
530 // We use an href to jump to the secondary admin page because
531 // routerLink within the same base component results in component
532 // reuse of a series of components which were not designed with
534 configFieldLinkUrl(row: any, col: GridColumn): string {
535 const path = this.configFieldRouteLink(row, col);
536 const filters = this.configFieldRouteParams(row, col);
537 const url = path + '?gridFilters=' +
538 encodeURIComponent(filters.gridFilters);
540 return this.ngLocation.prepareExternalUrl(url);
543 configLinkLabel(row: any, col: GridColumn): string {
544 const cf = this.configFields.filter(field => field.name === col.name)[0];
546 // Has-many links have no specific value to use for display
547 // so just use the column label.
548 if (cf.reltype === 'has_many') { return col.label; }
550 return this.format.transform({
551 value: row[col.name](),
552 idlClass: this.idlClass,
557 clearGridFiltersUrl(): string {
558 const parts = this.idlClassDef.table.split(/\./);
559 const url = this.configLinkBasePath + '/' + parts[0] + '/' + parts[1];
560 return this.ngLocation.prepareExternalUrl(url);
564 export interface TemplateField {
565 cellTemplate: TemplateRef<any>;