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 {NgbModal} from '@ng-bootstrap/ng-bootstrap';
10 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
11 import {TranslateComponent} from '@eg/staff/share/translate/translate.component';
14 interface CustomFieldTemplate {
15 template: TemplateRef<any>;
17 // Allow the caller to pass in a free-form context blob to
18 // be addedto the caller's custom template context, along
19 // with our stock context.
20 context?: {[fields: string]: any};
23 interface CustomFieldContext {
24 // Current create/edit/view record
27 // IDL field definition blob
30 // additional context values passed via CustomFieldTemplate
31 [fields: string]: any;
34 // Collection of extra options that may be applied to fields
35 // for controling non-default behaviour.
36 export interface FmFieldOptions {
38 // Render the field as a combobox using these values, regardless
39 // of the field's datatype.
40 customValues?: {[field: string]: ComboboxEntry[]};
42 // Provide / override the "selector" value for the linked class.
43 // This is the field the combobox will search for typeahead. If no
44 // field is defined, the "selector" field is used. If no "selector"
45 // field exists, the combobox will pre-load all linked values so
46 // the user can click to navigate.
47 linkedSearchField?: string;
49 // When true for combobox fields, pre-fetch the combobox data
50 // so the user can click or type to find values.
51 preloadLinkedValues?: boolean;
53 // Directly override the required state of the field.
54 // This only has an affect if the value is true.
57 // If this function is defined, the function will be called
58 // at render time to see if the field should be marked are required.
59 // This supersedes all other isRequired specifiers.
60 isRequiredOverride?: (field: string, record: IdlObject) => boolean;
62 // Directly apply the readonly status of the field.
63 // This only has an affect if the value is true.
66 // Render the field using this custom template instead of chosing
67 // from the default set of form inputs.
68 customTemplate?: CustomFieldTemplate;
72 selector: 'eg-fm-record-editor',
73 templateUrl: './fm-editor.component.html',
74 /* align checkboxes when not using class="form-check" */
75 styles: ['input[type="checkbox"] {margin-left: 0px;}']
77 export class FmRecordEditorComponent
78 extends DialogComponent implements OnInit {
80 // IDL class hint (e.g. "aou")
81 @Input() idlClass: string;
83 // mode: 'create' for creating a new record,
84 // 'update' for editing an existing record
85 // 'view' for viewing an existing record without editing
86 mode: 'create' | 'update' | 'view' = 'create';
89 // IDL record we are editing
92 // Permissions extracted from the permacrud defs in the IDL
93 // for the current IDL class
94 modePerms: {[mode: string]: string};
96 // Collection of FmFieldOptions for specifying non-default
97 // behaviour for each field (by field name).
98 @Input() fieldOptions: {[fieldName: string]: FmFieldOptions} = {};
100 // list of fields that should not be displayed
101 @Input() hiddenFieldsList: string[] = [];
102 @Input() hiddenFields: string; // comma-separated string version
104 // list of fields that should always be read-only
105 @Input() readonlyFieldsList: string[] = [];
106 @Input() readonlyFields: string; // comma-separated string version
108 // list of required fields; this supplements what the IDL considers
110 @Input() requiredFieldsList: string[] = [];
111 @Input() requiredFields: string; // comma-separated string version
113 // list of org_unit fields where a default value may be applied by
114 // the org-select if no value is present.
115 @Input() orgDefaultAllowedList: string[] = [];
116 @Input() orgDefaultAllowed: string; // comma-separated string version
118 // IDL record display label. Defaults to the IDL label.
119 @Input() recordLabel: string;
121 // When true at the component level, pre-fetch the combobox data
122 // for all combobox fields. See also FmFieldOptions.
123 @Input() preloadLinkedValues: boolean;
125 // Emit the modified object when the save action completes.
126 @Output() onSave$ = new EventEmitter<IdlObject>();
128 // Emit the original object when the save action is canceled.
129 @Output() onCancel$ = new EventEmitter<IdlObject>();
131 // Emit an error message when the save action fails.
132 @Output() onError$ = new EventEmitter<string>();
134 @ViewChild('translator') private translator: TranslateComponent;
136 // IDL info for the the selected IDL class
139 // Can we edit the primary key?
140 pkeyIsEditable = false;
142 // List of IDL field definitions. This is a subset of the full
143 // list of fields on the IDL, since some are hidden, virtual, etc.
146 // DOM id prefix to prevent id collisions.
149 @Input() editMode(mode: 'create' | 'update' | 'view') {
153 // Record ID to view/update. Value is dynamic. Records are not
154 // fetched until .open() is called.
155 @Input() set recordId(id: any) {
156 if (id) { this.recId = id; }
160 private modal: NgbModal, // required for passing to parent
161 private idl: IdlService,
162 private auth: AuthService,
163 private pcrud: PcrudService) {
167 // Avoid fetching data on init since that may lead to unnecessary
170 this.listifyInputs();
171 this.idlDef = this.idl.classes[this.idlClass];
172 this.recordLabel = this.idlDef.label;
174 // Add some randomness to the generated DOM IDs to ensure against clobbering
175 this.idPrefix = 'fm-editor-' + Math.floor(Math.random() * 100000);
177 this.onOpen$.subscribe(() => this.initRecord());
180 // Set the record value and clear the recId value to
181 // indicate the record is our current source of data.
182 setRecord(record: IdlObject) {
183 this.record = record;
187 // Translate comma-separated string versions of various inputs
189 private listifyInputs() {
190 if (this.hiddenFields) {
191 this.hiddenFieldsList = this.hiddenFields.split(/,/);
193 if (this.readonlyFields) {
194 this.readonlyFieldsList = this.readonlyFields.split(/,/);
196 if (this.requiredFields) {
197 this.requiredFieldsList = this.requiredFields.split(/,/);
199 if (this.orgDefaultAllowed) {
200 this.orgDefaultAllowedList = this.orgDefaultAllowed.split(/,/);
204 private initRecord(): Promise<any> {
206 const pc = this.idlDef.permacrud || {};
208 view: pc.retrieve ? pc.retrieve.perms : [],
209 create: pc.create ? pc.create.perms : [],
210 update: pc.update ? pc.update.perms : [],
213 this.pkeyIsEditable = !('pkey_sequence' in this.idlDef);
215 if (this.mode === 'update' || this.mode === 'view') {
218 if (this.record && this.recId === null) {
219 promise = Promise.resolve(this.record);
222 this.pcrud.retrieve(this.idlClass, this.recId).toPromise();
225 return promise.then(rec => {
228 return Promise.reject(`No '${this.idlClass}'
229 record found with id ${this.recId}`);
233 this.convertDatatypesToJs();
234 return this.getFieldList();
240 // Create a new record from the stub record provided by the
241 // caller or a new from-scratch record
242 this.setRecord(this.record || this.idl.create(this.idlClass));
244 return this.getFieldList();
247 // Modifies the FM record in place, replacing IDL-compatible values
248 // with native JS values.
249 private convertDatatypesToJs() {
250 this.idlDef.fields.forEach(field => {
251 if (field.datatype === 'bool') {
252 if (this.record[field.name]() === 't') {
253 this.record[field.name](true);
254 } else if (this.record[field.name]() === 'f') {
255 this.record[field.name](false);
261 // Modifies the provided FM record in place, replacing JS values
262 // with IDL-compatible values.
263 convertDatatypesToIdl(rec: IdlObject) {
264 const fields = this.idlDef.fields;
265 fields.forEach(field => {
266 if (field.datatype === 'bool') {
267 if (rec[field.name]() === true) {
268 rec[field.name]('t');
269 // } else if (rec[field.name]() === false) {
270 } else { // TODO: some bools can be NULL
271 rec[field.name]('f');
273 } else if (field.datatype === 'org_unit') {
274 const org = rec[field.name]();
275 if (org && typeof org === 'object') {
276 rec[field.name](org.id());
282 // Returns the name of the field on a class (typically via a linked
283 // field) that acts as the selector value for display / search.
284 getClassSelector(class_: string): string {
286 const linkedClass = this.idl.classes[class_];
287 return linkedClass.pkey ?
288 linkedClass.field_map[linkedClass.pkey].selector : null;
293 private flattenLinkedValues(field: any, list: IdlObject[]): ComboboxEntry[] {
294 const class_ = field.class;
295 const fieldOptions = this.fieldOptions[field.name] || {};
296 const idField = this.idl.classes[class_].pkey;
298 const selector = fieldOptions.linkedSearchField
299 || this.getClassSelector(class_) || idField;
301 return list.map(item => {
302 return {id: item[idField](), label: item[selector]()};
306 private getFieldList(): Promise<any> {
308 this.fields = this.idlDef.fields.filter(f =>
309 !f.virtual && !this.hiddenFieldsList.includes(f.name)
312 // Wait for all network calls to complete
314 this.fields.map(field => this.constructOneField(field)));
317 private constructOneField(field: any): Promise<any> {
320 const fieldOptions = this.fieldOptions[field.name] || {};
322 field.readOnly = this.mode === 'view'
323 || fieldOptions.isReadonly === true
324 || this.readonlyFieldsList.includes(field.name);
326 if (fieldOptions.isRequiredOverride) {
327 field.isRequired = () => {
328 return fieldOptions.isRequiredOverride(field.name, this.record);
331 field.isRequired = () => {
332 return field.required
333 || fieldOptions.isRequired
334 || this.requiredFieldsList.includes(field.name);
338 if (fieldOptions.customValues) {
340 field.linkedValues = fieldOptions.customValues;
342 } else if (field.datatype === 'link' && field.readOnly) {
344 // no need to fetch all possible values for read-only fields
345 const idToFetch = this.record[field.name]();
349 // If the linked class defines a selector field, fetch the
350 // linked data so we can display the data within the selector
351 // field. Otherwise, avoid the network lookup and let the
352 // bare value (usually an ID) be displayed.
353 const selector = fieldOptions.linkedSearchField ||
354 this.getClassSelector(field.class);
356 if (selector && selector !== field.name) {
357 promise = this.pcrud.retrieve(field.class, idToFetch)
358 .toPromise().then(list => {
360 this.flattenLinkedValues(field, Array(list));
363 // No selector, display the raw id/key value.
364 field.linkedValues = [{id: idToFetch, name: idToFetch}];
368 } else if (field.datatype === 'link') {
370 promise = this.wireUpCombobox(field);
372 } else if (field.datatype === 'org_unit') {
373 field.orgDefaultAllowed =
374 this.orgDefaultAllowedList.includes(field.name);
377 if (fieldOptions.customTemplate) {
378 field.template = fieldOptions.customTemplate.template;
379 field.context = fieldOptions.customTemplate.context;
382 return promise || Promise.resolve();
385 wireUpCombobox(field: any): Promise<any> {
387 const fieldOptions = this.fieldOptions[field.name] || {};
389 // globally preloading unless a field-specific value is set.
390 if (this.preloadLinkedValues) {
391 if (!('preloadLinkedValues' in fieldOptions)) {
392 fieldOptions.preloadLinkedValues = true;
396 const selector = fieldOptions.linkedSearchField ||
397 this.getClassSelector(field.class);
399 if (!selector && !fieldOptions.preloadLinkedValues) {
400 // User probably expects an async data source, but we can't
401 // provide one without a selector. Warn the user.
402 console.warn(`Class ${field.class} has no selector.
403 Pre-fetching all rows for combobox`);
406 if (fieldOptions.preloadLinkedValues || !selector) {
407 return this.pcrud.retrieveAll(field.class, {}, {atomic : true})
408 .toPromise().then(list => {
410 this.flattenLinkedValues(field, list);
414 // If we have a selector, wire up for async data retrieval
415 field.linkedValuesSource =
416 (term: string): Observable<ComboboxEntry> => {
419 const orderBy = {order_by: {}};
420 const idField = this.idl.classes[field.class].pkey || 'id';
422 search[selector] = {'ilike': `%${term}%`};
423 orderBy.order_by[field.class] = selector;
425 return this.pcrud.search(field.class, search, orderBy)
426 .pipe(map(idlThing =>
427 // Map each object into a ComboboxEntry upon arrival
428 this.flattenLinkedValues(field, [idlThing])[0]
432 // Using an async data source, but a value is already set
433 // on the field. Fetch the linked object and add it to the
434 // combobox entry list so it will be avilable for display
435 // at dialog load time.
436 const linkVal = this.record[field.name]();
437 if (linkVal !== null && linkVal !== undefined) {
438 return this.pcrud.retrieve(field.class, linkVal).toPromise()
441 this.flattenLinkedValues(field, Array(idlThing));
445 // No linked value applied, nothing to pre-fetch.
446 return Promise.resolve();
449 // Returns a context object to be inserted into a custom
451 customTemplateFieldContext(fieldDef: any): CustomFieldContext {
452 return Object.assign(
453 { record : this.record,
454 field: fieldDef // from this.fields
455 }, fieldDef.context || {}
460 const recToSave = this.idl.clone(this.record);
461 this.convertDatatypesToIdl(recToSave);
462 this.pcrud[this.mode]([recToSave]).toPromise().then(
463 result => this.close(result),
464 error => this.error(error)
472 // Returns a string describing the type of input to display
473 // for a given field. This helps cut down on the if/else
474 // nesti-ness in the template. Each field will match
476 inputType(field: any): string {
478 if (field.template) {
482 // Some widgets handle readOnly for us.
483 if ( field.datatype === 'timestamp'
484 || field.datatype === 'org_unit'
485 || field.datatype === 'bool') {
486 return field.datatype;
489 if (field.readOnly) {
490 if (field.datatype === 'money') {
491 return 'readonly-money';
494 if (field.datatype === 'link' || field.linkedValues) {
495 return 'readonly-list';
501 if (field.datatype === 'id' && !this.pkeyIsEditable) {
505 if ( field.datatype === 'int'
506 || field.datatype === 'float'
507 || field.datatype === 'money') {
508 return field.datatype;
511 if (field.datatype === 'link' || field.linkedValues) {
515 // datatype == text / interval / editable-pkey
519 openTranslator(field: string) {
520 this.translator.fieldName = field;
521 this.translator.idlObject = this.record;
523 // TODO: will need to change once LP1823041 is merged
524 this.translator.open().then(
527 this.record[field](newValue);
530 () => {} // avoid console error