1 import {Component, OnInit, Input,
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 {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
10 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
12 interface CustomFieldTemplate {
13 template: TemplateRef<any>;
15 // Allow the caller to pass in a free-form context blob to
16 // be addedto the caller's custom template context, along
17 // with our stock context.
18 context?: {[fields: string]: any};
21 interface CustomFieldContext {
22 // Current create/edit/view record
25 // IDL field definition blob
28 // additional context values passed via CustomFieldTemplate
29 [fields: string]: any;
32 // Collection of extra options that may be applied to fields
33 // for controling non-default behaviour.
34 export interface FmFieldOptions {
36 // Render the field as a combobox using these values, regardless
37 // of the field's datatype.
38 customValues?: {[field: string]: ComboboxEntry[]};
40 // Provide / override the "selector" value for the linked class.
41 // This is the field the combobox will search for typeahead. If no
42 // field is defined, the "selector" field is used. If no "selector"
43 // field exists, the combobox will pre-load all linked values so
44 // the user can click to navigate.
45 linkedSearchField?: string;
47 // When true for combobox fields, pre-fetch the combobox data
48 // so the user can click or type to find values.
49 preloadLinkedValues?: boolean;
51 // Directly override the required state of the field.
52 // This only has an affect if the value is true.
55 // If this function is defined, the function will be called
56 // at render time to see if the field should be marked are required.
57 // This supersedes all other isRequired specifiers.
58 isRequiredOverride?: (field: string, record: IdlObject) => boolean;
60 // Directly apply the readonly status of the field.
61 // This only has an affect if the value is true.
64 // Render the field using this custom template instead of chosing
65 // from the default set of form inputs.
66 customTemplate?: CustomFieldTemplate;
70 selector: 'eg-fm-record-editor',
71 templateUrl: './fm-editor.component.html',
72 /* align checkboxes when not using class="form-check" */
73 styles: ['input[type="checkbox"] {margin-left: 0px;}']
75 export class FmRecordEditorComponent
76 extends DialogComponent implements OnInit {
78 // IDL class hint (e.g. "aou")
79 @Input() idlClass: string;
81 // mode: 'create' for creating a new record,
82 // 'update' for editing an existing record
83 // 'view' for viewing an existing record without editing
84 mode: 'create' | 'update' | 'view' = 'create';
87 // IDL record we are editing
88 // TODO: allow this to be update in real time by the caller?
91 // Permissions extracted from the permacrud defs in the IDL
92 // for the current IDL class
93 modePerms: {[mode: string]: string};
95 // Collection of FmFieldOptions for specifying non-default
96 // behaviour for each field (by field name).
97 @Input() fieldOptions: {[fieldName: string]: FmFieldOptions} = {};
99 // list of fields that should not be displayed
100 @Input() hiddenFieldsList: string[] = [];
101 @Input() hiddenFields: string; // comma-separated string version
103 // list of fields that should always be read-only
104 @Input() readonlyFieldsList: string[] = [];
105 @Input() readonlyFields: string; // comma-separated string version
107 // list of required fields; this supplements what the IDL considers
109 @Input() requiredFieldsList: string[] = [];
110 @Input() requiredFields: string; // comma-separated string version
112 // list of org_unit fields where a default value may be applied by
113 // the org-select if no value is present.
114 @Input() orgDefaultAllowedList: string[] = [];
115 @Input() orgDefaultAllowed: string; // comma-separated string version
117 // IDL record display label. Defaults to the IDL label.
118 @Input() recordLabel: string;
120 // When true at the component level, pre-fetch the combobox data
121 // for all combobox fields. See also FmFieldOptions.
122 @Input() preloadLinkedValues: boolean;
124 // Emit the modified object when the save action completes.
125 @Output() onSave$ = new EventEmitter<IdlObject>();
127 // Emit the original object when the save action is canceled.
128 @Output() onCancel$ = new EventEmitter<IdlObject>();
130 // Emit an error message when the save action fails.
131 @Output() onError$ = new EventEmitter<string>();
133 // IDL info for the the selected IDL class
136 // Can we edit the primary key?
137 pkeyIsEditable = false;
139 // List of IDL field definitions. This is a subset of the full
140 // list of fields on the IDL, since some are hidden, virtual, etc.
143 // DOM id prefix to prevent id collisions.
146 @Input() editMode(mode: 'create' | 'update' | 'view') {
150 // Record ID to view/update. Value is dynamic. Records are not
151 // fetched until .open() is called.
152 @Input() set recordId(id: any) {
153 if (id) { this.recId = id; }
157 private modal: NgbModal, // required for passing to parent
158 private idl: IdlService,
159 private auth: AuthService,
160 private pcrud: PcrudService) {
164 // Avoid fetching data on init since that may lead to unnecessary
167 this.listifyInputs();
168 this.idlDef = this.idl.classes[this.idlClass];
169 this.recordLabel = this.idlDef.label;
171 // Add some randomness to the generated DOM IDs to ensure against clobbering
172 this.idPrefix = 'fm-editor-' + Math.floor(Math.random() * 100000);
175 // Opening dialog, fetch data.
176 open(options?: NgbModalOptions): Promise<any> {
177 return this.initRecord().then(
178 ok => super.open(options),
179 err => console.warn(`Error fetching FM data: ${err}`)
183 // Translate comma-separated string versions of various inputs
185 private listifyInputs() {
186 if (this.hiddenFields) {
187 this.hiddenFieldsList = this.hiddenFields.split(/,/);
189 if (this.readonlyFields) {
190 this.readonlyFieldsList = this.readonlyFields.split(/,/);
192 if (this.requiredFields) {
193 this.requiredFieldsList = this.requiredFields.split(/,/);
195 if (this.orgDefaultAllowed) {
196 this.orgDefaultAllowedList = this.orgDefaultAllowed.split(/,/);
200 private initRecord(): Promise<any> {
202 const pc = this.idlDef.permacrud || {};
204 view: pc.retrieve ? pc.retrieve.perms : [],
205 create: pc.create ? pc.create.perms : [],
206 update: pc.update ? pc.update.perms : [],
209 if (this.mode === 'update' || this.mode === 'view') {
210 return this.pcrud.retrieve(this.idlClass, this.recId)
211 .toPromise().then(rec => {
214 return Promise.reject(`No '${this.idlClass}'
215 record found with id ${this.recId}`);
219 this.convertDatatypesToJs();
220 return this.getFieldList();
224 // create a new record from scratch or from a stub record
225 // provided by the caller.
226 this.pkeyIsEditable = !('pkey_sequence' in this.idlDef);
228 this.record = this.idl.create(this.idlClass);
230 return this.getFieldList();
233 // Modifies the FM record in place, replacing IDL-compatible values
234 // with native JS values.
235 private convertDatatypesToJs() {
236 this.idlDef.fields.forEach(field => {
237 if (field.datatype === 'bool') {
238 if (this.record[field.name]() === 't') {
239 this.record[field.name](true);
240 } else if (this.record[field.name]() === 'f') {
241 this.record[field.name](false);
247 // Modifies the provided FM record in place, replacing JS values
248 // with IDL-compatible values.
249 convertDatatypesToIdl(rec: IdlObject) {
250 const fields = this.idlDef.fields;
251 fields.forEach(field => {
252 if (field.datatype === 'bool') {
253 if (rec[field.name]() === true) {
254 rec[field.name]('t');
255 // } else if (rec[field.name]() === false) {
256 } else { // TODO: some bools can be NULL
257 rec[field.name]('f');
259 } else if (field.datatype === 'org_unit') {
260 const org = rec[field.name]();
261 if (org && typeof org === 'object') {
262 rec[field.name](org.id());
268 // Returns the name of the field on a class (typically via a linked
269 // field) that acts as the selector value for display / search.
270 getClassSelector(class_: string): string {
272 const linkedClass = this.idl.classes[class_];
273 return linkedClass.pkey ?
274 linkedClass.field_map[linkedClass.pkey].selector : null;
279 private flattenLinkedValues(field: any, list: IdlObject[]): ComboboxEntry[] {
280 const class_ = field.class;
281 const fieldOptions = this.fieldOptions[field.name] || {};
282 const idField = this.idl.classes[class_].pkey;
284 const selector = fieldOptions.linkedSearchField
285 || this.getClassSelector(class_) || idField;
287 return list.map(item => {
288 return {id: item[idField](), label: item[selector]()};
292 private getFieldList(): Promise<any> {
294 this.fields = this.idlDef.fields.filter(f =>
295 !f.virtual && !this.hiddenFieldsList.includes(f.name)
298 // Wait for all network calls to complete
300 this.fields.map(field => this.constructOneField(field)));
303 private constructOneField(field: any): Promise<any> {
306 const fieldOptions = this.fieldOptions[field.name] || {};
308 field.readOnly = this.mode === 'view'
309 || fieldOptions.isReadonly === true
310 || this.readonlyFieldsList.includes(field.name);
312 if (fieldOptions.isRequiredOverride) {
313 field.isRequired = () => {
314 return fieldOptions.isRequiredOverride(field.name, this.record);
317 field.isRequired = () => {
318 return field.required
319 || fieldOptions.isRequired
320 || this.requiredFieldsList.includes(field.name);
324 if (fieldOptions.customValues) {
326 field.linkedValues = fieldOptions.customValues;
328 } else if (field.datatype === 'link' && field.readOnly) {
330 // no need to fetch all possible values for read-only fields
331 const idToFetch = this.record[field.name]();
335 // If the linked class defines a selector field, fetch the
336 // linked data so we can display the data within the selector
337 // field. Otherwise, avoid the network lookup and let the
338 // bare value (usually an ID) be displayed.
339 const selector = fieldOptions.linkedSearchField ||
340 this.getClassSelector(field.class);
342 if (selector && selector !== field.name) {
343 promise = this.pcrud.retrieve(field.class, idToFetch)
344 .toPromise().then(list => {
346 this.flattenLinkedValues(field, Array(list));
349 // No selector, display the raw id/key value.
350 field.linkedValues = [{id: idToFetch, name: idToFetch}];
354 } else if (field.datatype === 'link') {
356 promise = this.wireUpCombobox(field);
358 } else if (field.datatype === 'org_unit') {
359 field.orgDefaultAllowed =
360 this.orgDefaultAllowedList.includes(field.name);
363 if (fieldOptions.customTemplate) {
364 field.template = fieldOptions.customTemplate.template;
365 field.context = fieldOptions.customTemplate.context;
368 return promise || Promise.resolve();
371 wireUpCombobox(field: any): Promise<any> {
373 const fieldOptions = this.fieldOptions[field.name] || {};
375 // globally preloading unless a field-specific value is set.
376 if (this.preloadLinkedValues) {
377 if (!('preloadLinkedValues' in fieldOptions)) {
378 fieldOptions.preloadLinkedValues = true;
382 const selector = fieldOptions.linkedSearchField ||
383 this.getClassSelector(field.class);
385 if (!selector && !fieldOptions.preloadLinkedValues) {
386 // User probably expects an async data source, but we can't
387 // provide one without a selector. Warn the user.
388 console.warn(`Class ${field.class} has no selector.
389 Pre-fetching all rows for combobox`);
392 if (fieldOptions.preloadLinkedValues || !selector) {
393 return this.pcrud.retrieveAll(field.class, {}, {atomic : true})
394 .toPromise().then(list => {
396 this.flattenLinkedValues(field, list);
400 // If we have a selector, wire up for async data retrieval
401 field.linkedValuesSource =
402 (term: string): Observable<ComboboxEntry> => {
405 const orderBy = {order_by: {}};
406 const idField = this.idl.classes[field.class].pkey || 'id';
408 search[selector] = {'ilike': `%${term}%`};
409 orderBy.order_by[field.class] = selector;
411 return this.pcrud.search(field.class, search, orderBy)
412 .pipe(map(idlThing =>
413 // Map each object into a ComboboxEntry upon arrival
414 this.flattenLinkedValues(field, [idlThing])[0]
418 // Using an async data source, but a value is already set
419 // on the field. Fetch the linked object and add it to the
420 // combobox entry list so it will be avilable for display
421 // at dialog load time.
422 const linkVal = this.record[field.name]();
423 if (linkVal !== null && linkVal !== undefined) {
424 return this.pcrud.retrieve(field.class, linkVal).toPromise()
427 this.flattenLinkedValues(field, Array(idlThing));
431 // No linked value applied, nothing to pre-fetch.
432 return Promise.resolve();
435 // Returns a context object to be inserted into a custom
437 customTemplateFieldContext(fieldDef: any): CustomFieldContext {
438 return Object.assign(
439 { record : this.record,
440 field: fieldDef // from this.fields
441 }, fieldDef.context || {}
446 const recToSave = this.idl.clone(this.record);
447 this.convertDatatypesToIdl(recToSave);
448 this.pcrud[this.mode]([recToSave]).toPromise().then(
449 result => this.close(result),
450 error => this.dismiss(error)
455 this.dismiss('canceled');
458 // Returns a string describing the type of input to display
459 // for a given field. This helps cut down on the if/else
460 // nesti-ness in the template. Each field will match
462 inputType(field: any): string {
464 if (field.template) {
468 // Some widgets handle readOnly for us.
469 if ( field.datatype === 'timestamp'
470 || field.datatype === 'org_unit'
471 || field.datatype === 'bool') {
472 return field.datatype;
475 if (field.readOnly) {
476 if (field.datatype === 'money') {
477 return 'readonly-money';
480 if (field.datatype === 'link' || field.linkedValues) {
481 return 'readonly-list';
487 if (field.datatype === 'id' && !this.pkeyIsEditable) {
491 if ( field.datatype === 'int'
492 || field.datatype === 'float'
493 || field.datatype === 'money') {
494 return field.datatype;
497 if (field.datatype === 'link' || field.linkedValues) {
501 // datatype == text / interval / editable-pkey