1 import {Component, OnInit, Input, ViewChild,
2 Output, EventEmitter, TemplateRef} from '@angular/core';
3 import {NgForm} from '@angular/forms';
4 import {IdlService, IdlObject} from '@eg/core/idl.service';
5 import {Observable} from 'rxjs';
6 import {map} from 'rxjs/operators';
7 import {AuthService} from '@eg/core/auth.service';
8 import {PcrudService} from '@eg/core/pcrud.service';
9 import {DialogComponent} from '@eg/share/dialog/dialog.component';
10 import {ToastService} from '@eg/share/toast/toast.service';
11 import {StringComponent} from '@eg/share/string/string.component';
12 import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
13 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
14 import {FormatService} from '@eg/core/format.service';
15 import {TranslateComponent} from '@eg/share/translate/translate.component';
16 import {FmRecordEditorActionComponent} from './fm-editor-action.component';
17 import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
19 interface CustomFieldTemplate {
20 template: TemplateRef<any>;
22 // Allow the caller to pass in a free-form context blob to
23 // be addedto the caller's custom template context, along
24 // with our stock context.
25 context?: {[fields: string]: any};
28 export interface CustomFieldContext {
29 // Current create/edit/view record
32 // IDL field definition blob
35 // additional context values passed via CustomFieldTemplate
36 [fields: string]: any;
39 // Collection of extra options that may be applied to fields
40 // for controling non-default behaviour.
41 export interface FmFieldOptions {
43 // Render the field as a combobox using these values, regardless
44 // of the field's datatype.
45 customValues?: ComboboxEntry[];
47 // Provide / override the "selector" value for the linked class.
48 // This is the field the combobox will search for typeahead. If no
49 // field is defined, the "selector" field is used. If no "selector"
50 // field exists, the combobox will pre-load all linked values so
51 // the user can click to navigate.
52 linkedSearchField?: string;
54 // When true for combobox fields, pre-fetch the combobox data
55 // so the user can click or type to find values.
56 preloadLinkedValues?: boolean;
58 // Directly override the required state of the field.
59 // This only has an affect if the value is true.
62 // If this function is defined, the function will be called
63 // at render time to see if the field should be marked are required.
64 // This supersedes all other isRequired specifiers.
65 isRequiredOverride?: (field: string, record: IdlObject) => boolean;
67 // Directly apply the readonly status of the field.
68 // This only has an affect if the value is true.
71 // If this function is defined, the function will be called
72 // at render time to see if the field should be marked readonly.
73 // This supersedes all other isReadonly specifiers.
74 isReadonlyOverride?: (field: string, record: IdlObject) => boolean;
76 // Render the field using this custom template instead of chosing
77 // from the default set of form inputs.
78 customTemplate?: CustomFieldTemplate;
80 // help text to display via a popover
81 helpText?: StringComponent;
85 selector: 'eg-fm-record-editor',
86 templateUrl: './fm-editor.component.html',
87 /* align checkboxes when not using class="form-check" */
88 styles: ['input[type="checkbox"] {margin-left: 0px;}']
90 export class FmRecordEditorComponent
91 extends DialogComponent implements OnInit {
93 // IDL class hint (e.g. "aou")
94 @Input() idlClass: string;
96 // Show datetime fields in this particular timezone
97 timezone: string = this.format.wsOrgTimezone;
99 // Permissions extracted from the permacrud defs in the IDL
100 // for the current IDL class
101 modePerms: {[mode: string]: string};
103 // Collection of FmFieldOptions for specifying non-default
104 // behaviour for each field (by field name).
105 @Input() fieldOptions: {[fieldName: string]: FmFieldOptions} = {};
107 // This is used to set default values when making a new record
108 @Input() defaultNewRecord: IdlObject;
110 // list of fields that should not be displayed
111 @Input() hiddenFieldsList: string[] = [];
112 @Input() hiddenFields: string; // comma-separated string version
114 // list of fields that should always be read-only
115 @Input() readonlyFieldsList: string[] = [];
116 @Input() readonlyFields: string; // comma-separated string version
118 // list of required fields; this supplements what the IDL considers
120 @Input() requiredFieldsList: string[] = [];
121 @Input() requiredFields: string; // comma-separated string version
123 // list of timestamp fields that should display with a timepicker
124 @Input() datetimeFieldsList: string[] = [];
125 @Input() datetimeFields: string; // comma-separated string version
127 // list of org_unit fields where a default value may be applied by
128 // the org-select if no value is present.
129 @Input() orgDefaultAllowedList: string[] = [];
130 @Input() orgDefaultAllowed: string; // comma-separated string version
132 // IDL record display label. Defaults to the IDL label.
133 @Input() recordLabel: string;
135 // When true at the component level, pre-fetch the combobox data
136 // for all combobox fields. See also FmFieldOptions.
137 @Input() preloadLinkedValues: boolean;
139 // Display within a modal dialog window or inline in the page.
140 @Input() displayMode: 'dialog' | 'inline' = 'dialog';
142 // Hide the top 'Record Editor: ...' banner. Primarily useful
143 // for displayMode === 'inline'
144 @Input() hideBanner: boolean;
146 // do not close dialog on error saving record
147 @Input() remainOpenOnError: false;
149 // Emit the modified object when the save action completes.
150 @Output() recordSaved = new EventEmitter<IdlObject>();
152 // Emit the modified object when the save action completes.
153 @Output() recordDeleted = new EventEmitter<IdlObject>();
155 // Emit the original object when the save action is canceled.
156 @Output() recordCanceled = new EventEmitter<IdlObject>();
158 // Emit an error message when the save action fails.
159 @Output() recordError = new EventEmitter<string>();
161 @ViewChild('translator', { static: true }) private translator: TranslateComponent;
162 @ViewChild('successStr', { static: true }) successStr: StringComponent;
163 @ViewChild('failStr', { static: true }) failStr: StringComponent;
164 @ViewChild('confirmDel', { static: true }) confirmDel: ConfirmDialogComponent;
165 @ViewChild('fmEditForm', { static: false}) fmEditForm: NgForm;
167 // IDL info for the the selected IDL class
170 // Can we edit the primary key?
171 pkeyIsEditable = false;
173 // List of IDL field definitions. This is a subset of the full
174 // list of fields on the IDL, since some are hidden, virtual, etc.
177 // DOM id prefix to prevent id collisions.
180 // mode: 'create' for creating a new record,
181 // 'update' for editing an existing record
182 // 'view' for viewing an existing record without editing
183 @Input() mode: 'create' | 'update' | 'view' = 'create';
185 // custom function for munging the record before it gets saved;
186 // will get passed mode and the record itself
187 @Input() preSave: Function;
189 // recordId and record getters and setters.
190 // Note that setting the this.recordId to NULL does not clear the
191 // current value of this.record and vice versa. Only viable data
192 // is actionable. This allows the caller to use both @Input()'s
193 // without each clobbering the other.
195 // Record ID to view/update.
196 _recordId: any = null;
197 @Input() set recordId(id: any) {
199 if (id !== this._recordId) {
201 this._record = null; // force re-fetch
202 this.handleRecordChange();
205 this._recordId = null;
209 get recordId(): any {
210 return this._recordId;
213 // IDL record we are editing
214 _record: IdlObject = null;
215 @Input() set record(r: IdlObject) {
217 if (!this.idl.pkeyMatches(this.record, r)) {
219 this._recordId = null; // avoid mismatch
220 this.handleRecordChange();
227 get record(): IdlObject {
231 actions: FmRecordEditorActionComponent[] = [];
235 // Comma-separated list of field names defining the order in which
236 // fields should be rendered in the form. Any fields not represented
237 // will be rendered alphabetically by label after the named fields.
238 @Input() fieldOrder: string;
240 // When true, show a delete button and support delete operations.
241 @Input() showDelete: boolean;
244 private modal: NgbModal, // required for passing to parent
245 private idl: IdlService,
246 private auth: AuthService,
247 private toast: ToastService,
248 private format: FormatService,
249 private pcrud: PcrudService) {
253 // Avoid fetching data on init since that may lead to unnecessary
257 // In case the caller sets the value to null / undef.
258 if (!this.fieldOptions) { this.fieldOptions = {}; }
260 this.listifyInputs();
261 this.idlDef = this.idl.classes[this.idlClass];
262 this.recordLabel = this.idlDef.label;
264 // Add some randomness to the generated DOM IDs to ensure against clobbering
265 this.idPrefix = 'fm-editor-' + Math.floor(Math.random() * 100000);
267 if (this.isDialog()) {
268 this.onOpen$.subscribe(() => this.initRecord());
272 this.initDone = true;
275 // If the record ID changes after ngOnInit has been called
276 // and we're using displayMode=inline, force the data to
277 // resync in real time
278 handleRecordChange() {
279 if (this.initDone && !this.isDialog()) {
284 open(args?: NgbModalOptions): Observable<any> {
288 // ensure we don't hang on to our copy of the record
289 // if the user dismisses the dialog
290 args.beforeDismiss = () => {
291 this.record = undefined;
294 return super.open(args);
297 isDialog(): boolean {
298 return this.displayMode === 'dialog';
302 return this.fmEditForm ? this.fmEditForm.dirty : false;
305 // DEPRECATED: This is a duplicate of this.record = abc;
306 setRecord(record: IdlObject) {
307 console.warn('fm-editor:setRecord() is deprecated. ' +
308 'Use editor.record = abc or [record]="abc" instead');
309 this.record = record; // this calls the setter
312 // Translate comma-separated string versions of various inputs
314 private listifyInputs() {
315 if (this.hiddenFields) {
316 this.hiddenFieldsList = this.hiddenFields.split(/,/);
318 if (this.readonlyFields) {
319 this.readonlyFieldsList = this.readonlyFields.split(/,/);
321 if (this.requiredFields) {
322 this.requiredFieldsList = this.requiredFields.split(/,/);
324 if (this.datetimeFields) {
325 this.datetimeFieldsList = this.datetimeFields.split(/,/);
327 if (this.orgDefaultAllowed) {
328 this.orgDefaultAllowedList = this.orgDefaultAllowed.split(/,/);
332 private initRecord(): Promise<any> {
334 const pc = this.idlDef.permacrud || {};
336 view: pc.retrieve ? pc.retrieve.perms : [],
337 create: pc.create ? pc.create.perms : [],
338 update: pc.update ? pc.update.perms : [],
341 this.pkeyIsEditable = !('pkey_sequence' in this.idlDef);
343 if (this.mode === 'update' || this.mode === 'view') {
346 if (this.record && this.recordId === null) {
347 promise = Promise.resolve(this.record);
348 } else if (this.recordId) {
350 this.pcrud.retrieve(this.idlClass, this.recordId).toPromise();
352 // Not enough data yet to fetch anything
353 return Promise.resolve();
356 return promise.then(rec => {
359 return Promise.reject(`No '${this.idlClass}'
360 record found with id ${this.recordId}`);
363 // Set this._record (not this.record) to avoid loop in initRecord()
365 this.convertDatatypesToJs();
366 return this.getFieldList();
372 // Create a new record from the stub record provided by the
373 // caller or a new from-scratch record
375 // NOTE: Set this._record (not this.record) to avoid
376 // loop in initRecord()
377 if (this.defaultNewRecord) {
378 // Clone to avoid polluting the stub record
379 this._record = this.idl.clone(this.defaultNewRecord);
381 this._record = this.idl.create(this.idlClass);
384 this._recordId = null; // avoid future confusion
386 return this.getFieldList();
389 // Modifies the FM record in place, replacing IDL-compatible values
390 // with native JS values.
391 private convertDatatypesToJs() {
392 this.idlDef.fields.forEach(field => {
393 if (field.datatype === 'bool') {
394 if (this.record[field.name]() === 't') {
395 this.record[field.name](true);
396 } else if (this.record[field.name]() === 'f') {
397 this.record[field.name](false);
403 // Modifies the provided FM record in place, replacing JS values
404 // with IDL-compatible values.
405 convertDatatypesToIdl(rec: IdlObject) {
406 const fields = this.idlDef.fields.filter(f => !f.virtual);
408 fields.forEach(field => {
409 if (field.datatype === 'bool') {
410 if (rec[field.name]() === true) {
411 rec[field.name]('t');
412 // } else if (rec[field.name]() === false) {
413 } else { // TODO: some bools can be NULL
414 rec[field.name]('f');
416 } else if (field.datatype === 'org_unit') {
417 const org = rec[field.name]();
418 if (org && typeof org === 'object') {
419 rec[field.name](org.id());
425 private flattenLinkedValues(field: any, list: IdlObject[]): ComboboxEntry[] {
426 const class_ = field.class;
427 const fieldOptions = this.fieldOptions[field.name] || {};
428 const idField = this.idl.classes[class_].pkey;
430 const selector = fieldOptions.linkedSearchField
431 || this.idl.getClassSelector(class_) || idField;
433 return list.map(item => {
434 if (item !== undefined) {
435 return {id: item[idField](), label: item[selector]()};
440 private getFieldList(): Promise<any> {
442 const fields = this.idlDef.fields.filter(f =>
443 !f.virtual && !this.hiddenFieldsList.includes(f.name));
445 // Wait for all network calls to complete
447 fields.map(field => this.constructOneField(field))
450 const order = this.fieldOrder ? this.fieldOrder.split(/,/) : [];
451 this.fields = this.idl.sortIdlFields(fields, order);
455 private constructOneField(field: any): Promise<any> {
458 const fieldOptions = this.fieldOptions[field.name] || {};
460 if (this.mode === 'view') {
461 field.readOnly = true;
462 } else if (fieldOptions.isReadonlyOverride) {
464 !fieldOptions.isReadonlyOverride(field.name, this.record);
466 field.readOnly = fieldOptions.isReadonly === true
467 || this.readonlyFieldsList.includes(field.name);
470 if (fieldOptions.isRequiredOverride) {
471 field.isRequired = () => {
472 return fieldOptions.isRequiredOverride(field.name, this.record);
475 field.isRequired = () => {
476 return field.required
477 || fieldOptions.isRequired
478 || this.requiredFieldsList.includes(field.name);
482 if (fieldOptions.customValues) {
484 field.linkedValues = fieldOptions.customValues;
486 } else if (field.datatype === 'link' && field.readOnly) {
488 // no need to fetch all possible values for read-only fields
489 const idToFetch = this.record[field.name]();
493 // If the linked class defines a selector field, fetch the
494 // linked data so we can display the data within the selector
495 // field. Otherwise, avoid the network lookup and let the
496 // bare value (usually an ID) be displayed.
497 const selector = fieldOptions.linkedSearchField ||
498 this.idl.getClassSelector(field.class);
500 if (selector && selector !== field.name) {
501 promise = this.pcrud.retrieve(field.class, idToFetch)
502 .toPromise().then(list => {
504 this.flattenLinkedValues(field, Array(list));
507 // No selector, display the raw id/key value.
508 field.linkedValues = [{id: idToFetch, name: idToFetch}];
512 } else if (field.datatype === 'link') {
514 promise = this.wireUpCombobox(field);
516 } else if (field.datatype === 'timestamp') {
517 field.datetime = this.datetimeFieldsList.includes(field.name);
518 } else if (field.datatype === 'org_unit') {
519 field.orgDefaultAllowed =
520 this.orgDefaultAllowedList.includes(field.name);
523 if (fieldOptions.customTemplate) {
524 field.template = fieldOptions.customTemplate.template;
525 field.context = fieldOptions.customTemplate.context;
528 if (fieldOptions.helpText) {
529 field.helpText = fieldOptions.helpText;
530 field.helpText.current().then(help => field.helpTextValue = help);
533 return promise || Promise.resolve();
536 wireUpCombobox(field: any): Promise<any> {
538 const fieldOptions = this.fieldOptions[field.name] || {};
540 // globally preloading unless a field-specific value is set.
541 if (this.preloadLinkedValues) {
542 if (!('preloadLinkedValues' in fieldOptions)) {
543 fieldOptions.preloadLinkedValues = true;
547 const selector = fieldOptions.linkedSearchField ||
548 this.idl.getClassSelector(field.class);
550 if (!selector && !fieldOptions.preloadLinkedValues) {
551 // User probably expects an async data source, but we can't
552 // provide one without a selector. Warn the user.
553 console.warn(`Class ${field.class} has no selector.
554 Pre-fetching all rows for combobox`);
557 if (fieldOptions.preloadLinkedValues || !selector) {
558 return this.pcrud.retrieveAll(field.class, {}, {atomic : true})
559 .toPromise().then(list => {
561 this.flattenLinkedValues(field, list);
565 // If we have a selector, wire up for async data retrieval
566 field.linkedValuesSource =
567 (term: string): Observable<ComboboxEntry> => {
570 const orderBy = {order_by: {}};
571 const idField = this.idl.classes[field.class].pkey || 'id';
573 search[selector] = {'ilike': `%${term}%`};
574 orderBy.order_by[field.class] = selector;
576 return this.pcrud.search(field.class, search, orderBy)
577 .pipe(map(idlThing =>
578 // Map each object into a ComboboxEntry upon arrival
579 this.flattenLinkedValues(field, [idlThing])[0]
583 // Using an async data source, but a value is already set
584 // on the field. Fetch the linked object and add it to the
585 // combobox entry list so it will be avilable for display
586 // at dialog load time.
587 const linkVal = this.record[field.name]();
588 if (linkVal !== null && linkVal !== undefined) {
589 return this.pcrud.retrieve(field.class, linkVal).toPromise()
592 this.flattenLinkedValues(field, Array(idlThing));
596 // No linked value applied, nothing to pre-fetch.
597 return Promise.resolve();
600 // Returns a context object to be inserted into a custom
602 customTemplateFieldContext(fieldDef: any): CustomFieldContext {
603 return Object.assign(
604 { record : this.record,
605 field: fieldDef // from this.fields
606 }, fieldDef.context || {}
611 const recToSave = this.idl.clone(this.record);
613 this.preSave(this.mode, recToSave);
615 this.convertDatatypesToIdl(recToSave);
616 this.pcrud[this.mode]([recToSave]).toPromise().then(
618 this.recordSaved.emit(result);
619 if (this.fmEditForm) {
620 this.fmEditForm.form.markAsPristine();
622 this.successStr.current().then(msg => this.toast.success(msg));
623 if (this.isDialog()) { this.record = undefined; this.close(result); }
626 this.recordError.emit(error);
627 this.failStr.current().then(msg => this.toast.warning(msg));
628 if (this.isDialog() && !this.remainOpenOnError) { this.error(error); }
634 this.confirmDel.open().subscribe(confirmed => {
635 if (!confirmed) { return; }
636 const recToRemove = this.idl.clone(this.record);
637 this.pcrud.remove(recToRemove).toPromise().then(
639 this.recordDeleted.emit(result);
640 this.successStr.current().then(msg => this.toast.success(msg));
641 if (this.isDialog()) { this.close(result); }
644 this.recordError.emit(error);
645 this.failStr.current().then(msg => this.toast.warning(msg));
646 if (this.isDialog() && !this.remainOpenOnError) { this.error(error); }
653 this.recordCanceled.emit(this.record);
654 this.record = undefined;
659 this.record = undefined;
663 // Returns a string describing the type of input to display
664 // for a given field. This helps cut down on the if/else
665 // nesti-ness in the template. Each field will match
667 inputType(field: any): string {
669 if (field.template) {
673 if ( field.datatype === 'timestamp' && field.datetime ) {
674 return 'timestamp-timepicker';
677 // Some widgets handle readOnly for us.
678 if ( field.datatype === 'timestamp'
679 || field.datatype === 'org_unit'
680 || field.datatype === 'bool') {
681 return field.datatype;
684 if (field.readOnly) {
685 if (field.datatype === 'money') {
686 return 'readonly-money';
689 if (field.datatype === 'link' && field.class === 'au') {
690 return 'readonly-au';
693 if (field.datatype === 'link' || field.linkedValues) {
694 return 'readonly-list';
700 if (field.datatype === 'id' && !this.pkeyIsEditable) {
704 if ( field.datatype === 'int'
705 || field.datatype === 'float'
706 || field.datatype === 'money') {
707 return field.datatype;
710 if (field.datatype === 'link' || field.linkedValues) {
714 // datatype == text / interval / editable-pkey
718 openTranslator(field: string) {
719 this.translator.fieldName = field;
720 this.translator.idlObject = this.record;
722 this.translator.open().subscribe(
725 this.record[field](newValue);