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 {TranslateComponent} from '@eg/staff/share/translate/translate.component';
14 import {FormatService} from '@eg/core/format.service';
16 interface CustomFieldTemplate {
17 template: TemplateRef<any>;
19 // Allow the caller to pass in a free-form context blob to
20 // be addedto the caller's custom template context, along
21 // with our stock context.
22 context?: {[fields: string]: any};
25 export interface CustomFieldContext {
26 // Current create/edit/view record
29 // IDL field definition blob
32 // additional context values passed via CustomFieldTemplate
33 [fields: string]: any;
36 // Collection of extra options that may be applied to fields
37 // for controling non-default behaviour.
38 export interface FmFieldOptions {
40 // Render the field as a combobox using these values, regardless
41 // of the field's datatype.
42 customValues?: ComboboxEntry[];
44 // Provide / override the "selector" value for the linked class.
45 // This is the field the combobox will search for typeahead. If no
46 // field is defined, the "selector" field is used. If no "selector"
47 // field exists, the combobox will pre-load all linked values so
48 // the user can click to navigate.
49 linkedSearchField?: string;
51 // When true for combobox fields, pre-fetch the combobox data
52 // so the user can click or type to find values.
53 preloadLinkedValues?: boolean;
55 // Directly override the required state of the field.
56 // This only has an affect if the value is true.
59 // If this function is defined, the function will be called
60 // at render time to see if the field should be marked are required.
61 // This supersedes all other isRequired specifiers.
62 isRequiredOverride?: (field: string, record: IdlObject) => boolean;
64 // Directly apply the readonly status of the field.
65 // This only has an affect if the value is true.
68 // Render the field using this custom template instead of chosing
69 // from the default set of form inputs.
70 customTemplate?: CustomFieldTemplate;
74 selector: 'eg-fm-record-editor',
75 templateUrl: './fm-editor.component.html',
76 /* align checkboxes when not using class="form-check" */
77 styles: ['input[type="checkbox"] {margin-left: 0px;}']
79 export class FmRecordEditorComponent
80 extends DialogComponent implements OnInit {
82 // IDL class hint (e.g. "aou")
83 @Input() idlClass: string;
87 // Show datetime fields in this particular timezone
88 timezone: string = this.format.wsOrgTimezone;
90 // IDL record we are editing
93 // Permissions extracted from the permacrud defs in the IDL
94 // for the current IDL class
95 modePerms: {[mode: string]: string};
97 // Collection of FmFieldOptions for specifying non-default
98 // behaviour for each field (by field name).
99 @Input() fieldOptions: {[fieldName: string]: FmFieldOptions} = {};
101 // list of fields that should not be displayed
102 @Input() hiddenFieldsList: string[] = [];
103 @Input() hiddenFields: string; // comma-separated string version
105 // list of fields that should always be read-only
106 @Input() readonlyFieldsList: string[] = [];
107 @Input() readonlyFields: string; // comma-separated string version
109 // list of required fields; this supplements what the IDL considers
111 @Input() requiredFieldsList: string[] = [];
112 @Input() requiredFields: string; // comma-separated string version
114 // list of timestamp fields that should display with a timepicker
115 @Input() datetimeFieldsList: string[] = [];
116 @Input() datetimeFields: string; // comma-separated string version
118 // list of org_unit fields where a default value may be applied by
119 // the org-select if no value is present.
120 @Input() orgDefaultAllowedList: string[] = [];
121 @Input() orgDefaultAllowed: string; // comma-separated string version
123 // IDL record display label. Defaults to the IDL label.
124 @Input() recordLabel: string;
126 // When true at the component level, pre-fetch the combobox data
127 // for all combobox fields. See also FmFieldOptions.
128 @Input() preloadLinkedValues: boolean;
130 // Display within a modal dialog window or inline in the page.
131 @Input() displayMode: 'dialog' | 'inline' = 'dialog';
133 // Emit the modified object when the save action completes.
134 @Output() onSave$ = new EventEmitter<IdlObject>();
136 // Emit the original object when the save action is canceled.
137 @Output() onCancel$ = new EventEmitter<IdlObject>();
139 // Emit an error message when the save action fails.
140 @Output() onError$ = new EventEmitter<string>();
142 @ViewChild('translator') private translator: TranslateComponent;
143 @ViewChild('successStr') successStr: StringComponent;
144 @ViewChild('failStr') failStr: StringComponent;
146 // IDL info for the the selected IDL class
149 // Can we edit the primary key?
150 pkeyIsEditable = false;
152 // List of IDL field definitions. This is a subset of the full
153 // list of fields on the IDL, since some are hidden, virtual, etc.
156 // DOM id prefix to prevent id collisions.
159 // mode: 'create' for creating a new record,
160 // 'update' for editing an existing record
161 // 'view' for viewing an existing record without editing
162 @Input() mode: 'create' | 'update' | 'view' = 'create';
164 // Record ID to view/update. Value is dynamic. Records are not
165 // fetched until .open() is called.
166 @Input() set recordId(id: any) {
167 if (id) { this.recId = id; }
170 // custom function for munging the record before it gets saved;
171 // will get passed mode and the record itself
172 @Input() preSave: Function;
175 private modal: NgbModal, // required for passing to parent
176 private idl: IdlService,
177 private auth: AuthService,
178 private toast: ToastService,
179 private format: FormatService,
180 private pcrud: PcrudService) {
184 // Avoid fetching data on init since that may lead to unnecessary
188 // In case the caller sets the value to null / undef.
189 if (!this.fieldOptions) { this.fieldOptions = {}; }
191 this.listifyInputs();
192 this.idlDef = this.idl.classes[this.idlClass];
193 this.recordLabel = this.idlDef.label;
195 // Add some randomness to the generated DOM IDs to ensure against clobbering
196 this.idPrefix = 'fm-editor-' + Math.floor(Math.random() * 100000);
198 if (this.isDialog()) {
199 this.onOpen$.subscribe(() => this.initRecord());
205 open(args?: NgbModalOptions): Observable<any> {
209 // ensure we don't hang on to our copy of the record
210 // if the user dismisses the dialog
211 args.beforeDismiss = () => {
212 this.record = undefined;
215 return super.open(args);
218 isDialog(): boolean {
219 return this.displayMode === 'dialog';
222 // Set the record value and clear the recId value to
223 // indicate the record is our current source of data.
224 setRecord(record: IdlObject) {
225 this.record = record;
229 // Translate comma-separated string versions of various inputs
231 private listifyInputs() {
232 if (this.hiddenFields) {
233 this.hiddenFieldsList = this.hiddenFields.split(/,/);
235 if (this.readonlyFields) {
236 this.readonlyFieldsList = this.readonlyFields.split(/,/);
238 if (this.requiredFields) {
239 this.requiredFieldsList = this.requiredFields.split(/,/);
241 if (this.datetimeFields) {
242 this.datetimeFieldsList = this.datetimeFields.split(/,/);
244 if (this.orgDefaultAllowed) {
245 this.orgDefaultAllowedList = this.orgDefaultAllowed.split(/,/);
249 private initRecord(): Promise<any> {
251 const pc = this.idlDef.permacrud || {};
253 view: pc.retrieve ? pc.retrieve.perms : [],
254 create: pc.create ? pc.create.perms : [],
255 update: pc.update ? pc.update.perms : [],
258 this.pkeyIsEditable = !('pkey_sequence' in this.idlDef);
260 if (this.mode === 'update' || this.mode === 'view') {
263 if (this.record && this.recId === null) {
264 promise = Promise.resolve(this.record);
267 this.pcrud.retrieve(this.idlClass, this.recId).toPromise();
270 return promise.then(rec => {
273 return Promise.reject(`No '${this.idlClass}'
274 record found with id ${this.recId}`);
278 this.convertDatatypesToJs();
279 return this.getFieldList();
285 // Create a new record from the stub record provided by the
286 // caller or a new from-scratch record
287 this.setRecord(this.record || this.idl.create(this.idlClass));
289 return this.getFieldList();
292 // Modifies the FM record in place, replacing IDL-compatible values
293 // with native JS values.
294 private convertDatatypesToJs() {
295 this.idlDef.fields.forEach(field => {
296 if (field.datatype === 'bool') {
297 if (this.record[field.name]() === 't') {
298 this.record[field.name](true);
299 } else if (this.record[field.name]() === 'f') {
300 this.record[field.name](false);
306 // Modifies the provided FM record in place, replacing JS values
307 // with IDL-compatible values.
308 convertDatatypesToIdl(rec: IdlObject) {
309 const fields = this.idlDef.fields;
310 fields.forEach(field => {
311 if (field.datatype === 'bool') {
312 if (rec[field.name]() === true) {
313 rec[field.name]('t');
314 // } else if (rec[field.name]() === false) {
315 } else { // TODO: some bools can be NULL
316 rec[field.name]('f');
318 } else if (field.datatype === 'org_unit') {
319 const org = rec[field.name]();
320 if (org && typeof org === 'object') {
321 rec[field.name](org.id());
327 // Returns the name of the field on a class (typically via a linked
328 // field) that acts as the selector value for display / search.
329 getClassSelector(class_: string): string {
331 const linkedClass = this.idl.classes[class_];
332 return linkedClass.pkey ?
333 linkedClass.field_map[linkedClass.pkey].selector : null;
338 private flattenLinkedValues(field: any, list: IdlObject[]): ComboboxEntry[] {
339 const class_ = field.class;
340 const fieldOptions = this.fieldOptions[field.name] || {};
341 const idField = this.idl.classes[class_].pkey;
343 const selector = fieldOptions.linkedSearchField
344 || this.getClassSelector(class_) || idField;
346 return list.map(item => {
347 return {id: item[idField](), label: item[selector]()};
351 private getFieldList(): Promise<any> {
353 this.fields = this.idlDef.fields.filter(f =>
354 !f.virtual && !this.hiddenFieldsList.includes(f.name)
357 // Wait for all network calls to complete
359 this.fields.map(field => this.constructOneField(field)));
362 private constructOneField(field: any): Promise<any> {
365 const fieldOptions = this.fieldOptions[field.name] || {};
367 field.readOnly = this.mode === 'view'
368 || fieldOptions.isReadonly === true
369 || this.readonlyFieldsList.includes(field.name);
371 if (fieldOptions.isRequiredOverride) {
372 field.isRequired = () => {
373 return fieldOptions.isRequiredOverride(field.name, this.record);
376 field.isRequired = () => {
377 return field.required
378 || fieldOptions.isRequired
379 || this.requiredFieldsList.includes(field.name);
383 if (fieldOptions.customValues) {
385 field.linkedValues = fieldOptions.customValues;
387 } else if (field.datatype === 'link' && field.readOnly) {
389 // no need to fetch all possible values for read-only fields
390 const idToFetch = this.record[field.name]();
394 // If the linked class defines a selector field, fetch the
395 // linked data so we can display the data within the selector
396 // field. Otherwise, avoid the network lookup and let the
397 // bare value (usually an ID) be displayed.
398 const selector = fieldOptions.linkedSearchField ||
399 this.getClassSelector(field.class);
401 if (selector && selector !== field.name) {
402 promise = this.pcrud.retrieve(field.class, idToFetch)
403 .toPromise().then(list => {
405 this.flattenLinkedValues(field, Array(list));
408 // No selector, display the raw id/key value.
409 field.linkedValues = [{id: idToFetch, name: idToFetch}];
413 } else if (field.datatype === 'link') {
415 promise = this.wireUpCombobox(field);
417 } else if (field.datatype === 'timestamp') {
418 field.datetime = this.datetimeFieldsList.includes(field.name);
419 } else if (field.datatype === 'org_unit') {
420 field.orgDefaultAllowed =
421 this.orgDefaultAllowedList.includes(field.name);
424 if (fieldOptions.customTemplate) {
425 field.template = fieldOptions.customTemplate.template;
426 field.context = fieldOptions.customTemplate.context;
429 return promise || Promise.resolve();
432 wireUpCombobox(field: any): Promise<any> {
434 const fieldOptions = this.fieldOptions[field.name] || {};
436 // globally preloading unless a field-specific value is set.
437 if (this.preloadLinkedValues) {
438 if (!('preloadLinkedValues' in fieldOptions)) {
439 fieldOptions.preloadLinkedValues = true;
443 const selector = fieldOptions.linkedSearchField ||
444 this.getClassSelector(field.class);
446 if (!selector && !fieldOptions.preloadLinkedValues) {
447 // User probably expects an async data source, but we can't
448 // provide one without a selector. Warn the user.
449 console.warn(`Class ${field.class} has no selector.
450 Pre-fetching all rows for combobox`);
453 if (fieldOptions.preloadLinkedValues || !selector) {
454 return this.pcrud.retrieveAll(field.class, {}, {atomic : true})
455 .toPromise().then(list => {
457 this.flattenLinkedValues(field, list);
461 // If we have a selector, wire up for async data retrieval
462 field.linkedValuesSource =
463 (term: string): Observable<ComboboxEntry> => {
466 const orderBy = {order_by: {}};
467 const idField = this.idl.classes[field.class].pkey || 'id';
469 search[selector] = {'ilike': `%${term}%`};
470 orderBy.order_by[field.class] = selector;
472 return this.pcrud.search(field.class, search, orderBy)
473 .pipe(map(idlThing =>
474 // Map each object into a ComboboxEntry upon arrival
475 this.flattenLinkedValues(field, [idlThing])[0]
479 // Using an async data source, but a value is already set
480 // on the field. Fetch the linked object and add it to the
481 // combobox entry list so it will be avilable for display
482 // at dialog load time.
483 const linkVal = this.record[field.name]();
484 if (linkVal !== null && linkVal !== undefined) {
485 return this.pcrud.retrieve(field.class, linkVal).toPromise()
488 this.flattenLinkedValues(field, Array(idlThing));
492 // No linked value applied, nothing to pre-fetch.
493 return Promise.resolve();
496 // Returns a context object to be inserted into a custom
498 customTemplateFieldContext(fieldDef: any): CustomFieldContext {
499 return Object.assign(
500 { record : this.record,
501 field: fieldDef // from this.fields
502 }, fieldDef.context || {}
507 const recToSave = this.idl.clone(this.record);
509 this.preSave(this.mode, recToSave);
511 this.convertDatatypesToIdl(recToSave);
512 this.pcrud[this.mode]([recToSave]).toPromise().then(
514 this.onSave$.emit(result);
515 this.successStr.current().then(msg => this.toast.success(msg));
516 if (this.isDialog()) { this.record = undefined; this.close(result); }
519 this.onError$.emit(error);
520 this.failStr.current().then(msg => this.toast.warning(msg));
521 if (this.isDialog()) { this.error(error); }
527 this.onCancel$.emit(this.record);
528 this.record = undefined;
533 this.record = undefined;
537 // Returns a string describing the type of input to display
538 // for a given field. This helps cut down on the if/else
539 // nesti-ness in the template. Each field will match
541 inputType(field: any): string {
543 if (field.template) {
547 if ( field.datatype === 'timestamp' && field.datetime ) {
548 return 'timestamp-timepicker';
551 // Some widgets handle readOnly for us.
552 if ( field.datatype === 'timestamp'
553 || field.datatype === 'org_unit'
554 || field.datatype === 'bool') {
555 return field.datatype;
558 if (field.readOnly) {
559 if (field.datatype === 'money') {
560 return 'readonly-money';
563 if (field.datatype === 'link' && field.class === 'au') {
564 return 'readonly-au';
567 if (field.datatype === 'link' || field.linkedValues) {
568 return 'readonly-list';
574 if (field.datatype === 'id' && !this.pkeyIsEditable) {
578 if ( field.datatype === 'int'
579 || field.datatype === 'float'
580 || field.datatype === 'money') {
581 return field.datatype;
584 if (field.datatype === 'link' || field.linkedValues) {
588 // datatype == text / interval / editable-pkey
592 openTranslator(field: string) {
593 this.translator.fieldName = field;
594 this.translator.idlObject = this.record;
596 this.translator.open().subscribe(
599 this.record[field](newValue);