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';
23 * General purpose CRUD interface for IDL objects
25 * Object types using this component must be editable via PCRUD.
29 selector: 'eg-admin-page',
30 templateUrl: './admin-page.component.html'
33 export class AdminPageComponent implements OnInit {
35 @Input() idlClass: string;
37 // Default sort field, used when no grid sorting is applied.
38 @Input() sortField: string;
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;
46 // Size of create/edito dialog. Uses large by default.
47 @Input() dialogSize: 'sm' | 'lg' = 'lg';
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;
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;
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;
62 // Disable the auto-matic org unit field filter
63 @Input() disableOrgFilter: boolean;
65 // Give the grid an option to undelete any deleted rows
66 @Input() enableUndelete: boolean;
68 // Remove the ability to delete rows
69 @Input() disableDelete: boolean;
71 // Optional: Replace the default deletion confirmation text with this
72 @Input() deleteConfirmation: string;
74 // Remove the ability to edit rows
75 @Input() disableEdit: boolean;
77 // Include objects linking to org units which are ancestors
78 // of the selected org unit.
79 @Input() includeOrgAncestors: boolean;
81 // Ditto includeOrgAncestors, but descendants.
82 @Input() includeOrgDescendants: boolean;
84 // Optional grid persist key. This is the part of the key
86 @Input() persistKey: string;
88 // If present, will be applied to the org selector for the grid
89 @Input() contextOrgSelectorPersistKey: string;
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;
96 // Optional comma-separated list of read-only fields
97 @Input() readonlyFields: string;
99 // Optional record label to use instead of the IDL label
100 @Input() recordLabel: string;
102 // optional flag to hide the Clear Filters action for gridFilters
103 @Input() hideClearFilters: boolean;
105 // optional list of org fields which are allowed a default if unset
106 @Input() orgDefaultAllowed: string;
108 // list of org fields to receive the context org as their default for new records
109 @Input() orgFieldsDefaultingToContextOrg: string;
111 // Optional template containing help/about text which will
112 // be added to the page, above the grid.
113 @Input() helpTemplate: TemplateRef<any>;
115 // Override field options for create/edit dialog
116 @Input() fieldOptions: {[field: string]: FmFieldOptions};
118 // Add default filters to the grid
119 @Input() initialFilterValues: {[field: string]: string};
121 // Override default values for fm-editor
122 @Input() defaultNewRecord: IdlObject;
124 // Used as the first part of the routerLink path when creating
125 // links to related tables via configField's.
126 @Input() configLinkBasePath: string;
128 // Bonus fields to add to the grid by passing arbitrary templates,
129 // for example, a column created by callbacks based on data from
131 @Input() templateFields: TemplateField[];
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;
149 configFields: any[]; // IDL field definitions
151 // True if any columns on the object support translations
152 translateRowIdx: number;
153 translateFieldIdx: number;
154 translatableFields: string[];
156 contextOrg: IdlObject;
157 searchOrgs: OrgFamily;
158 orgFieldLabel: string;
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};
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
177 this.translatableFields = [];
178 this.configFields = [];
181 applyOrgValues(orgId?: number) {
183 if (this.disableOrgFilter) {
184 this.orgField = null;
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;
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()};
206 contextOrgChanged(orgEvent: any) {
208 this.setDefaultNewRecordOrgFieldDefaults( orgEvent['primaryOrgId'] );
213 this.idlClassDef = this.idl.classes[this.idlClass];
214 this.pkeyField = this.idlClassDef.pkey || 'id';
216 this.translatableFields =
217 this.idlClassDef.fields.filter(f => f.i18n).map(f => f.name);
219 if (!this.persistKey) {
222 (this.persistKeyPfx ? this.persistKeyPfx + '.' : '') +
223 this.idlClassDef.table;
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');
234 this.idlClassDef.fields.filter(f => f.config_field);
236 // gridFilters are a JSON encoded string
237 const filters = this.route.snapshot.queryParamMap.get('gridFilters');
240 this.gridFilters = JSON.parse(filters);
242 console.error('Invalid grid filters provided: ', filters);
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]);
256 this.defaultNewRecord = rec;
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;
267 const contextOrg = this.route.snapshot.queryParamMap.get('contextOrg');
268 this.checkCreatePerms();
269 this.applyOrgValues(Number(contextOrg));
271 this.setDefaultNewRecordOrgFieldDefaults( Number(contextOrg) );
273 // If the caller provides not data source, create a generic one.
274 if (!this.dataSource) {
275 this.initDataSource();
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);
285 this.orgFieldsDefaultingToContextOrg.split(/,/).forEach( field => {
286 if (this.defaultNewRecord[field] && this.pkeyField !== field) {
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 );
297 this.canCreate = false;
298 const pc = this.idlClassDef.permacrud || {};
299 const perms = pc.create ? pc.create.perms : [];
300 if (perms.length === 0) { return; }
302 this.perm.hasWorkPermAt(perms, true).then(permMap => {
303 Object.keys(permMap).forEach(key => {
304 if (permMap[key].length > 0) {
305 this.canCreate = true;
312 this.dataSource = new GridDataSource();
314 this.dataSource.getRows = (pager: Pager, sort: any[]) => {
315 const orderBy: any = {};
318 // Sort specified from grid
319 orderBy[this.idlClass] = sort[0].name + ' ' + sort[0].dir;
321 } else if (this.sortField) {
322 // Default sort field
323 orderBy[this.idlClass] = this.sortField;
327 offset: pager.offset,
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});
338 const search: any[] = new Array();
339 const orgFilter: any = {};
341 if (this.orgField && (this.searchOrgs || this.contextOrg)) {
342 orgFilter[this.orgField] =
343 this.searchOrgs.orgIds || [this.contextOrg.id()];
344 search.push(orgFilter);
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]);
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
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);
365 return this.pcrud.search(
366 this.idlClass, search, searchOps, {fleshSelectors: true});
370 showEditDialog(idlThing: IdlObject): Promise<any> {
371 if (this.disableEdit) {
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(
379 this.successString.current()
380 .then(str => this.toast.success(str));
384 (error: unknown) => {
385 this.updateFailedString.current()
386 .then(str => this.toast.danger(str));
393 editSelected(idlThings: IdlObject[]) {
395 // Edit each IDL thing one at a time
396 const editOneThing = (thing: IdlObject) => {
397 if (!thing) { return; }
399 this.showEditDialog(thing).then(
400 () => editOneThing(idlThings.shift()));
403 editOneThing(idlThings.shift());
406 undeleteSelected(idlThings: IdlObject[]) {
407 idlThings.forEach(idlThing => idlThing.deleted(false));
408 this.pcrud.update(idlThings).subscribe(
410 this.undeleteSuccessString.current()
411 .then(str => this.toast.success(str));
414 this.undeleteFailedString.current()
415 .then(str => this.toast.danger(str));
417 () => this.grid.reload()
421 deleteSelected(idlThings: IdlObject[]) {
422 this.deleteConfirmDialog.open().subscribe(confirmed => {
424 this.doDelete(idlThings);
429 doDelete(idlThings: IdlObject[]){
430 idlThings.forEach(idlThing => idlThing.isdeleted(true));
431 this.pcrud.autoApply(idlThings).subscribe(
433 this.deleteSuccessString.current()
434 .then(str => this.toast.success(str));
437 this.deleteFailedString.current()
438 .then(str => this.toast.danger(str));
440 () => this.grid.reload()
444 shouldDisableDelete(rows: IdlObject[]): boolean {
445 if (rows.length === 0) {
448 const deletedRows = rows.filter((row) => {
449 if (row.deleted && row.deleted() === 't') {
451 } else if (row.isdeleted) {
452 return row.isdeleted();
455 return deletedRows.length > 0;
459 shouldDisableUndelete(rows: IdlObject[]): boolean {
460 if (rows.length === 0) {
463 const deletedRows = rows.filter((row) => {
464 if (row.deleted && row.deleted() === 't') {
466 } else if (row.isdeleted) {
467 return row.isdeleted();
470 return deletedRows.length !== rows.length;
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(
482 this.createString.current()
483 .then(str => this.toast.success(str));
486 (rejection: any) => {
487 if (!rejection.dismissed) {
488 this.createErrString.current()
489 .then(str => this.toast.danger(str));
494 // Open the field translation dialog.
495 // Link the next/previous actions to cycle through each translatable
496 // field on each row.
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];
503 this.translator.nextString = () => {
505 if (this.translateFieldIdx < this.translatableFields.length - 1) {
506 this.translateFieldIdx++;
508 } else if (this.translateRowIdx < this.dataSource.data.length - 1) {
509 this.translateRowIdx++;
510 this.translateFieldIdx = 0;
513 this.translator.idlObject =
514 this.dataSource.data[this.translateRowIdx];
515 this.translator.fieldName =
516 this.translatableFields[this.translateFieldIdx];
519 this.translator.prevString = () => {
521 if (this.translateFieldIdx > 0) {
522 this.translateFieldIdx--;
524 } else if (this.translateRowIdx > 0) {
525 this.translateRowIdx--;
526 this.translateFieldIdx = 0;
529 this.translator.idlObject =
530 this.dataSource.data[this.translateRowIdx];
531 this.translator.fieldName =
532 this.translatableFields[this.translateFieldIdx];
535 this.translator.open({size: 'lg'});
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]}`;
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
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);
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](); }
559 const filter: any = {};
560 filter[cf.key] = value;
562 return {gridFilters : JSON.stringify(filter)};
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']];
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.
580 cf.reltype === 'has_many' ?
581 (linkClass.field_map[cf.key].key || this.pkeyField) : cf.name;
583 return row[localField]();
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
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);
597 return this.ngLocation.prepareExternalUrl(url);
600 configLinkLabel(row: any, col: GridColumn): string {
601 const cf = this.configFields.filter(field => field.name === col.name)[0];
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; }
607 return this.format.transform({
608 value: row[col.name](),
609 idlClass: this.idlClass,
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);
620 hasNoHistory(): boolean {
621 return history.length === 0;
630 export interface TemplateField {
631 cellTemplate: TemplateRef<any>;