1 import {Component, OnInit, Input, ViewChild,
2 Output, EventEmitter, TemplateRef} from '@angular/core';
3 import {IdlService, IdlObject} from '@eg/core/idl.service';
4 import {Observable} from 'rxjs';
5 import {map} from 'rxjs/operators';
6 import {AuthService} from '@eg/core/auth.service';
7 import {PcrudService} from '@eg/core/pcrud.service';
8 import {DialogComponent} from '@eg/share/dialog/dialog.component';
9 import {ToastService} from '@eg/share/toast/toast.service';
10 import {StringComponent} from '@eg/share/string/string.component';
11 import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
12 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
13 import {FormatService} from '@eg/core/format.service';
14 import {TranslateComponent} from '@eg/share/translate/translate.component';
15 import {FmRecordEditorActionComponent} from './fm-editor-action.component';
16 import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
18 interface CustomFieldTemplate {
19 template: TemplateRef<any>;
21 // Allow the caller to pass in a free-form context blob to
22 // be addedto the caller's custom template context, along
23 // with our stock context.
24 context?: {[fields: string]: any};
27 export interface CustomFieldContext {
28 // Current create/edit/view record
31 // IDL field definition blob
34 // additional context values passed via CustomFieldTemplate
35 [fields: string]: any;
38 // Collection of extra options that may be applied to fields
39 // for controling non-default behaviour.
40 export interface FmFieldOptions {
42 // Render the field as a combobox using these values, regardless
43 // of the field's datatype.
44 customValues?: ComboboxEntry[];
46 // Provide / override the "selector" value for the linked class.
47 // This is the field the combobox will search for typeahead. If no
48 // field is defined, the "selector" field is used. If no "selector"
49 // field exists, the combobox will pre-load all linked values so
50 // the user can click to navigate.
51 linkedSearchField?: string;
53 // When true for combobox fields, pre-fetch the combobox data
54 // so the user can click or type to find values.
55 preloadLinkedValues?: boolean;
57 // Directly override the required state of the field.
58 // This only has an affect if the value is true.
61 // If this function is defined, the function will be called
62 // at render time to see if the field should be marked are required.
63 // This supersedes all other isRequired specifiers.
64 isRequiredOverride?: (field: string, record: IdlObject) => boolean;
66 // Directly apply the readonly status of the field.
67 // This only has an affect if the value is true.
70 // Render the field using this custom template instead of chosing
71 // from the default set of form inputs.
72 customTemplate?: CustomFieldTemplate;
76 selector: 'eg-fm-record-editor',
77 templateUrl: './fm-editor.component.html',
78 /* align checkboxes when not using class="form-check" */
79 styles: ['input[type="checkbox"] {margin-left: 0px;}']
81 export class FmRecordEditorComponent
82 extends DialogComponent implements OnInit {
84 // IDL class hint (e.g. "aou")
85 @Input() idlClass: string;
87 // Show datetime fields in this particular timezone
88 timezone: string = this.format.wsOrgTimezone;
90 // Permissions extracted from the permacrud defs in the IDL
91 // for the current IDL class
92 modePerms: {[mode: string]: string};
94 // Collection of FmFieldOptions for specifying non-default
95 // behaviour for each field (by field name).
96 @Input() fieldOptions: {[fieldName: string]: FmFieldOptions} = {};
98 // list of fields that should not be displayed
99 @Input() hiddenFieldsList: string[] = [];
100 @Input() hiddenFields: string; // comma-separated string version
102 // list of fields that should always be read-only
103 @Input() readonlyFieldsList: string[] = [];
104 @Input() readonlyFields: string; // comma-separated string version
106 // list of required fields; this supplements what the IDL considers
108 @Input() requiredFieldsList: string[] = [];
109 @Input() requiredFields: string; // comma-separated string version
111 // list of timestamp fields that should display with a timepicker
112 @Input() datetimeFieldsList: string[] = [];
113 @Input() datetimeFields: string; // comma-separated string version
115 // list of org_unit fields where a default value may be applied by
116 // the org-select if no value is present.
117 @Input() orgDefaultAllowedList: string[] = [];
118 @Input() orgDefaultAllowed: string; // comma-separated string version
120 // IDL record display label. Defaults to the IDL label.
121 @Input() recordLabel: string;
123 // When true at the component level, pre-fetch the combobox data
124 // for all combobox fields. See also FmFieldOptions.
125 @Input() preloadLinkedValues: boolean;
127 // Display within a modal dialog window or inline in the page.
128 @Input() displayMode: 'dialog' | 'inline' = 'dialog';
130 // Hide the top 'Record Editor: ...' banner. Primarily useful
131 // for displayMode === 'inline'
132 @Input() hideBanner: boolean;
134 // Emit the modified object when the save action completes.
135 @Output() recordSaved = new EventEmitter<IdlObject>();
137 // Emit the modified object when the save action completes.
138 @Output() recordDeleted = new EventEmitter<IdlObject>();
140 // Emit the original object when the save action is canceled.
141 @Output() recordCanceled = new EventEmitter<IdlObject>();
143 // Emit an error message when the save action fails.
144 @Output() recordError = new EventEmitter<string>();
146 @ViewChild('translator') private translator: TranslateComponent;
147 @ViewChild('successStr') successStr: StringComponent;
148 @ViewChild('failStr') failStr: StringComponent;
149 @ViewChild('confirmDel') confirmDel: ConfirmDialogComponent;
151 // IDL info for the the selected IDL class
154 // Can we edit the primary key?
155 pkeyIsEditable = false;
157 // List of IDL field definitions. This is a subset of the full
158 // list of fields on the IDL, since some are hidden, virtual, etc.
161 // DOM id prefix to prevent id collisions.
164 // mode: 'create' for creating a new record,
165 // 'update' for editing an existing record
166 // 'view' for viewing an existing record without editing
167 @Input() mode: 'create' | 'update' | 'view' = 'create';
169 // custom function for munging the record before it gets saved;
170 // will get passed mode and the record itself
171 @Input() preSave: Function;
173 // recordId and record getters and setters.
174 // Note that setting the this.recordId to NULL does not clear the
175 // current value of this.record and vice versa. Only viable data
176 // is actionable. This allows the caller to use both @Input()'s
177 // without each clobbering the other.
179 // Record ID to view/update.
180 _recordId: any = null;
181 @Input() set recordId(id: any) {
183 if (id !== this._recordId) {
185 this._record = null; // force re-fetch
186 this.handleRecordChange();
189 this._recordId = null;
193 get recordId(): any {
194 return this._recordId;
197 // IDL record we are editing
198 _record: IdlObject = null;
199 @Input() set record(r: IdlObject) {
201 if (!this.idl.pkeyMatches(this.record, r)) {
203 this._recordId = null; // avoid mismatch
204 this.handleRecordChange();
211 get record(): IdlObject {
215 actions: FmRecordEditorActionComponent[] = [];
219 // Comma-separated list of field names defining the order in which
220 // fields should be rendered in the form. Any fields not represented
221 // will be rendered alphabetically by label after the named fields.
222 @Input() fieldOrder: string;
224 // When true, show a delete button and support delete operations.
225 @Input() showDelete: boolean;
228 private modal: NgbModal, // required for passing to parent
229 private idl: IdlService,
230 private auth: AuthService,
231 private toast: ToastService,
232 private format: FormatService,
233 private pcrud: PcrudService) {
237 // Avoid fetching data on init since that may lead to unnecessary
241 // In case the caller sets the value to null / undef.
242 if (!this.fieldOptions) { this.fieldOptions = {}; }
244 this.listifyInputs();
245 this.idlDef = this.idl.classes[this.idlClass];
246 this.recordLabel = this.idlDef.label;
248 // Add some randomness to the generated DOM IDs to ensure against clobbering
249 this.idPrefix = 'fm-editor-' + Math.floor(Math.random() * 100000);
251 if (this.isDialog()) {
252 this.onOpen$.subscribe(() => this.initRecord());
256 this.initDone = true;
259 // If the record ID changes after ngOnInit has been called
260 // and we're using displayMode=inline, force the data to
261 // resync in real time
262 handleRecordChange() {
263 if (this.initDone && !this.isDialog()) {
268 open(args?: NgbModalOptions): Observable<any> {
272 // ensure we don't hang on to our copy of the record
273 // if the user dismisses the dialog
274 args.beforeDismiss = () => {
275 this.record = undefined;
278 return super.open(args);
281 isDialog(): boolean {
282 return this.displayMode === 'dialog';
285 // DEPRECATED: This is a duplicate of this.record = abc;
286 setRecord(record: IdlObject) {
287 console.warn('fm-editor:setRecord() is deprecated. ' +
288 'Use editor.record = abc or [record]="abc" instead');
289 this.record = record; // this calls the setter
292 // Translate comma-separated string versions of various inputs
294 private listifyInputs() {
295 if (this.hiddenFields) {
296 this.hiddenFieldsList = this.hiddenFields.split(/,/);
298 if (this.readonlyFields) {
299 this.readonlyFieldsList = this.readonlyFields.split(/,/);
301 if (this.requiredFields) {
302 this.requiredFieldsList = this.requiredFields.split(/,/);
304 if (this.datetimeFields) {
305 this.datetimeFieldsList = this.datetimeFields.split(/,/);
307 if (this.orgDefaultAllowed) {
308 this.orgDefaultAllowedList = this.orgDefaultAllowed.split(/,/);
312 private initRecord(): Promise<any> {
314 const pc = this.idlDef.permacrud || {};
316 view: pc.retrieve ? pc.retrieve.perms : [],
317 create: pc.create ? pc.create.perms : [],
318 update: pc.update ? pc.update.perms : [],
321 this.pkeyIsEditable = !('pkey_sequence' in this.idlDef);
323 if (this.mode === 'update' || this.mode === 'view') {
326 if (this.record && this.recordId === null) {
327 promise = Promise.resolve(this.record);
328 } else if (this.recordId) {
330 this.pcrud.retrieve(this.idlClass, this.recordId).toPromise();
332 // Not enough data yet to fetch anything
333 return Promise.resolve();
336 return promise.then(rec => {
339 return Promise.reject(`No '${this.idlClass}'
340 record found with id ${this.recordId}`);
343 // Set this._record (not this.record) to avoid loop in initRecord()
345 this.convertDatatypesToJs();
346 return this.getFieldList();
352 // Create a new record from the stub record provided by the
353 // caller or a new from-scratch record
354 // Set this._record (not this.record) to avoid loop in initRecord()
355 this._record = this.record || this.idl.create(this.idlClass);
356 this._recordId = null; // avoid future confusion
358 return this.getFieldList();
361 // Modifies the FM record in place, replacing IDL-compatible values
362 // with native JS values.
363 private convertDatatypesToJs() {
364 this.idlDef.fields.forEach(field => {
365 if (field.datatype === 'bool') {
366 if (this.record[field.name]() === 't') {
367 this.record[field.name](true);
368 } else if (this.record[field.name]() === 'f') {
369 this.record[field.name](false);
375 // Modifies the provided FM record in place, replacing JS values
376 // with IDL-compatible values.
377 convertDatatypesToIdl(rec: IdlObject) {
378 const fields = this.idlDef.fields.filter(f => !f.virtual);
380 fields.forEach(field => {
381 if (field.datatype === 'bool') {
382 if (rec[field.name]() === true) {
383 rec[field.name]('t');
384 // } else if (rec[field.name]() === false) {
385 } else { // TODO: some bools can be NULL
386 rec[field.name]('f');
388 } else if (field.datatype === 'org_unit') {
389 const org = rec[field.name]();
390 if (org && typeof org === 'object') {
391 rec[field.name](org.id());
397 // Returns the name of the field on a class (typically via a linked
398 // field) that acts as the selector value for display / search.
399 getClassSelector(class_: string): string {
401 const linkedClass = this.idl.classes[class_];
402 return linkedClass.pkey ?
403 linkedClass.field_map[linkedClass.pkey].selector : null;
408 private flattenLinkedValues(field: any, list: IdlObject[]): ComboboxEntry[] {
409 const class_ = field.class;
410 const fieldOptions = this.fieldOptions[field.name] || {};
411 const idField = this.idl.classes[class_].pkey;
413 const selector = fieldOptions.linkedSearchField
414 || this.getClassSelector(class_) || idField;
416 return list.map(item => {
417 return {id: item[idField](), label: item[selector]()};
421 private getFieldList(): Promise<any> {
423 const fields = this.idlDef.fields.filter(f =>
424 !f.virtual && !this.hiddenFieldsList.includes(f.name));
426 // Wait for all network calls to complete
428 fields.map(field => this.constructOneField(field))
432 if (!this.fieldOrder) {
433 this.fields = fields.sort((a, b) => a.label < b.label ? -1 : 1);
438 const ordered = this.fieldOrder.split(/,/);
440 ordered.forEach(name => {
441 const f1 = fields.filter(f2 => f2.name === name)[0];
442 if (f1) { newList.push(f1); }
445 // Sort remaining fields by label
446 const remainder = fields.filter(f => !ordered.includes(f.name));
447 remainder.sort((a, b) => a.label < b.label ? -1 : 1);
448 newList = newList.concat(remainder);
450 this.fields = newList;
454 private constructOneField(field: any): Promise<any> {
457 const fieldOptions = this.fieldOptions[field.name] || {};
459 field.readOnly = this.mode === 'view'
460 || fieldOptions.isReadonly === true
461 || this.readonlyFieldsList.includes(field.name);
463 if (fieldOptions.isRequiredOverride) {
464 field.isRequired = () => {
465 return fieldOptions.isRequiredOverride(field.name, this.record);
468 field.isRequired = () => {
469 return field.required
470 || fieldOptions.isRequired
471 || this.requiredFieldsList.includes(field.name);
475 if (fieldOptions.customValues) {
477 field.linkedValues = fieldOptions.customValues;
479 } else if (field.datatype === 'link' && field.readOnly) {
481 // no need to fetch all possible values for read-only fields
482 const idToFetch = this.record[field.name]();
486 // If the linked class defines a selector field, fetch the
487 // linked data so we can display the data within the selector
488 // field. Otherwise, avoid the network lookup and let the
489 // bare value (usually an ID) be displayed.
490 const selector = fieldOptions.linkedSearchField ||
491 this.getClassSelector(field.class);
493 if (selector && selector !== field.name) {
494 promise = this.pcrud.retrieve(field.class, idToFetch)
495 .toPromise().then(list => {
497 this.flattenLinkedValues(field, Array(list));
500 // No selector, display the raw id/key value.
501 field.linkedValues = [{id: idToFetch, name: idToFetch}];
505 } else if (field.datatype === 'link') {
507 promise = this.wireUpCombobox(field);
509 } else if (field.datatype === 'timestamp') {
510 field.datetime = this.datetimeFieldsList.includes(field.name);
511 } else if (field.datatype === 'org_unit') {
512 field.orgDefaultAllowed =
513 this.orgDefaultAllowedList.includes(field.name);
516 if (fieldOptions.customTemplate) {
517 field.template = fieldOptions.customTemplate.template;
518 field.context = fieldOptions.customTemplate.context;
521 return promise || Promise.resolve();
524 wireUpCombobox(field: any): Promise<any> {
526 const fieldOptions = this.fieldOptions[field.name] || {};
528 // globally preloading unless a field-specific value is set.
529 if (this.preloadLinkedValues) {
530 if (!('preloadLinkedValues' in fieldOptions)) {
531 fieldOptions.preloadLinkedValues = true;
535 const selector = fieldOptions.linkedSearchField ||
536 this.getClassSelector(field.class);
538 if (!selector && !fieldOptions.preloadLinkedValues) {
539 // User probably expects an async data source, but we can't
540 // provide one without a selector. Warn the user.
541 console.warn(`Class ${field.class} has no selector.
542 Pre-fetching all rows for combobox`);
545 if (fieldOptions.preloadLinkedValues || !selector) {
546 return this.pcrud.retrieveAll(field.class, {}, {atomic : true})
547 .toPromise().then(list => {
549 this.flattenLinkedValues(field, list);
553 // If we have a selector, wire up for async data retrieval
554 field.linkedValuesSource =
555 (term: string): Observable<ComboboxEntry> => {
558 const orderBy = {order_by: {}};
559 const idField = this.idl.classes[field.class].pkey || 'id';
561 search[selector] = {'ilike': `%${term}%`};
562 orderBy.order_by[field.class] = selector;
564 return this.pcrud.search(field.class, search, orderBy)
565 .pipe(map(idlThing =>
566 // Map each object into a ComboboxEntry upon arrival
567 this.flattenLinkedValues(field, [idlThing])[0]
571 // Using an async data source, but a value is already set
572 // on the field. Fetch the linked object and add it to the
573 // combobox entry list so it will be avilable for display
574 // at dialog load time.
575 const linkVal = this.record[field.name]();
576 if (linkVal !== null && linkVal !== undefined) {
577 return this.pcrud.retrieve(field.class, linkVal).toPromise()
580 this.flattenLinkedValues(field, Array(idlThing));
584 // No linked value applied, nothing to pre-fetch.
585 return Promise.resolve();
588 // Returns a context object to be inserted into a custom
590 customTemplateFieldContext(fieldDef: any): CustomFieldContext {
591 return Object.assign(
592 { record : this.record,
593 field: fieldDef // from this.fields
594 }, fieldDef.context || {}
599 const recToSave = this.idl.clone(this.record);
601 this.preSave(this.mode, recToSave);
603 this.convertDatatypesToIdl(recToSave);
604 this.pcrud[this.mode]([recToSave]).toPromise().then(
606 this.recordSaved.emit(result);
607 this.successStr.current().then(msg => this.toast.success(msg));
608 if (this.isDialog()) { this.record = undefined; this.close(result); }
611 this.recordError.emit(error);
612 this.failStr.current().then(msg => this.toast.warning(msg));
613 if (this.isDialog()) { this.error(error); }
619 this.confirmDel.open().subscribe(confirmed => {
620 if (!confirmed) { return; }
621 const recToRemove = this.idl.clone(this.record);
622 this.pcrud.remove(recToRemove).toPromise().then(
624 this.recordDeleted.emit(result);
625 this.successStr.current().then(msg => this.toast.success(msg));
626 if (this.isDialog()) { this.close(result); }
629 this.recordError.emit(error);
630 this.failStr.current().then(msg => this.toast.warning(msg));
631 if (this.isDialog()) { this.error(error); }
638 this.recordCanceled.emit(this.record);
639 this.record = undefined;
644 this.record = undefined;
648 // Returns a string describing the type of input to display
649 // for a given field. This helps cut down on the if/else
650 // nesti-ness in the template. Each field will match
652 inputType(field: any): string {
654 if (field.template) {
658 if ( field.datatype === 'timestamp' && field.datetime ) {
659 return 'timestamp-timepicker';
662 // Some widgets handle readOnly for us.
663 if ( field.datatype === 'timestamp'
664 || field.datatype === 'org_unit'
665 || field.datatype === 'bool') {
666 return field.datatype;
669 if (field.readOnly) {
670 if (field.datatype === 'money') {
671 return 'readonly-money';
674 if (field.datatype === 'link' && field.class === 'au') {
675 return 'readonly-au';
678 if (field.datatype === 'link' || field.linkedValues) {
679 return 'readonly-list';
685 if (field.datatype === 'id' && !this.pkeyIsEditable) {
689 if ( field.datatype === 'int'
690 || field.datatype === 'float'
691 || field.datatype === 'money') {
692 return field.datatype;
695 if (field.datatype === 'link' || field.linkedValues) {
699 // datatype == text / interval / editable-pkey
703 openTranslator(field: string) {
704 this.translator.fieldName = field;
705 this.translator.idlObject = this.record;
707 this.translator.open().subscribe(
710 this.record[field](newValue);