</div>
<div class="col-lg-7">
- <span *ngIf="field.template">
- <ng-container
- *ngTemplateOutlet="field.template; context:customTemplateFieldContext(field)">
+ <ng-container [ngSwitch]="inputType(field)">
+
+ <ng-container *ngSwitchCase="'template'">
+ <ng-container
+ *ngTemplateOutlet="field.template; context:customTemplateFieldContext(field)">
+ </ng-container>
</ng-container>
- </span>
- <span *ngIf="!field.template">
+ <ng-container *ngSwitchCase="'readonly'">
+ <span>{{record[field.name]()}}</span>
+ </ng-container>
- <span *ngIf="field.datatype == 'id' && !pkeyIsEditable">
- {{record[field.name]()}}
- </span>
-
- <ng-container *ngIf="field.datatype == 'id' && pkeyIsEditable">
- <ng-container *ngIf="field.readOnly">
- <span>{{record[field.name]()}}</span>
- </ng-container>
- <ng-container *ngIf="!field.readOnly">
- <input
- class="form-control"
- name="{{field.name}}"
- id="{{idPrefix}}-{{field.name}}"
- placeholder="{{field.label}}..."
- i18n-placeholder
- [required]="field.isRequired()"
- [ngModel]="record[field.name]()"
- (ngModelChange)="record[field.name]($event)"/>
- </ng-container>
+ <ng-container *ngSwitchCase="'readonly-money'">
+ <span>{{record[field.name]() | currency}}</span>
</ng-container>
-
- <ng-container
- *ngIf="field.datatype == 'text' || field.datatype == 'interval'">
- <ng-container *ngIf="field.readOnly">
- <span>{{record[field.name]()}}</span>
- </ng-container>
- <ng-container *ngIf="!field.readOnly">
- <input
- class="form-control"
- name="{{field.name}}"
- id="{{idPrefix}}-{{field.name}}"
- placeholder="{{field.label}}..."
- i18n-placeholder
- [required]="field.isRequired()"
- [ngModel]="record[field.name]()"
- (ngModelChange)="record[field.name]($event)"/>
+
+ <ng-container *ngSwitchCase="'readonly-list'">
+ <ng-container *ngIf="field.linkedValues">
+ <span>{{field.linkedValues[0].label}}</span>
</ng-container>
</ng-container>
- <!-- TODO: add support to eg-date-select for read-only view -->
- <span *ngIf="field.datatype == 'timestamp'">
+ <ng-container *ngSwitchCase="'timestamp'">
<eg-date-select
domId="{{idPrefix}}-{{field.name}}"
(onChangeAsIso)="record[field.name]($event)"
initialIso="{{record[field.name]()}}">
</eg-date-select>
- </span>
+ </ng-container>
- <ng-container *ngIf="field.datatype == 'int'">
- <ng-container *ngIf="field.readOnly">
- <span>{{record[field.name]()}}</span>
- </ng-container>
- <ng-container *ngIf="!field.readOnly">
+ <ng-container *ngSwitchCase="'org_unit'">
+ <eg-org-select
+ placeholder="{{field.label}}..."
+ i18n-placeholder
+ domId="{{idPrefix}}-{{field.name}}"
+ [limitPerms]="modePerms[mode]"
+ [readOnly]="field.readOnly"
+ [applyDefault]="field.orgDefaultAllowed"
+ [initialOrgId]="record[field.name]()"
+ (onChange)="record[field.name]($event)">
+ </eg-org-select>
+ </ng-container>
+
+ <ng-container *ngSwitchCase="'money'">
+ <input
+ class="form-control"
+ type="number" step="0.1"
+ name="{{field.name}}"
+ id="{{idPrefix}}-{{field.name}}"
+ placeholder="{{field.label}}..."
+ i18n-placeholder
+ [readonly]="field.readOnly"
+ [required]="field.isRequired()"
+ [ngModel]="record[field.name]()"
+ (ngModelChange)="record[field.name]($event)"/>
+ </ng-container>
+ <ng-container *ngSwitchCase="'int'">
<input
class="form-control"
type="number"
[required]="field.isRequired()"
[ngModel]="record[field.name]()"
(ngModelChange)="record[field.name]($event)"/>
- </ng-container>
</ng-container>
- <ng-container *ngIf="field.datatype == 'float'">
- <ng-container *ngIf="field.readOnly">
- <span>{{record[field.name]()}}</span>
- </ng-container>
- <ng-container *ngIf="!field.readOnly">
- <input
- class="form-control"
- type="number" step="0.1"
- name="{{field.name}}"
- id="{{idPrefix}}-{{field.name}}"
- placeholder="{{field.label}}..."
- i18n-placeholder
- [required]="field.isRequired()"
- [ngModel]="record[field.name]()"
- (ngModelChange)="record[field.name]($event)"/>
- </ng-container>
+ <ng-container *ngSwitchCase="'float'">
+ <input
+ class="form-control"
+ type="number" step="0.1"
+ name="{{field.name}}"
+ id="{{idPrefix}}-{{field.name}}"
+ placeholder="{{field.label}}..."
+ i18n-placeholder
+ [required]="field.isRequired()"
+ [ngModel]="record[field.name]()"
+ (ngModelChange)="record[field.name]($event)"/>
</ng-container>
-
- <ng-container *ngIf="field.datatype == 'money'">
- <ng-container *ngIf="field.readOnly">
- <span>{{record[field.name]() | currency}}</span>
- </ng-container>
- <ng-container *ngIf="!field.readOnly">
- <input
- class="form-control"
- type="number" step="0.1"
- name="{{field.name}}"
- id="{{idPrefix}}-{{field.name}}"
- placeholder="{{field.label}}..."
- i18n-placeholder
- [readonly]="field.readOnly"
- [required]="field.isRequired()"
- [ngModel]="record[field.name]()"
- (ngModelChange)="record[field.name]($event)"/>
- </ng-container>
+
+ <ng-container *ngSwitchCase="'text'">
+ <input
+ class="form-control"
+ id="{{idPrefix}}-{{field.name}}" name="{{field.name}}"
+ type="text"
+ placeholder="{{field.label}}..." i18n-placeholder
+ [required]="field.isRequired()"
+ [ngModel]="record[field.name]()"
+ (ngModelChange)="record[field.name]($event)"/>
</ng-container>
-
- <input *ngIf="field.datatype == 'bool'"
- class="form-check-input"
- type="checkbox"
- name="{{field.name}}"
- id="{{idPrefix}}-{{field.name}}"
- [disabled]="field.readOnly"
- [ngModel]="record[field.name]()"
- (ngModelChange)="record[field.name]($event)"/>
-
- <ng-container *ngIf="field.datatype == 'link'">
- <ng-container *ngIf="field.readOnly">
- <!-- in readOnly mode, if a value is present, it will
- live as the only item in the linkedValues array -->
- <ng-container *ngIf="(field.linkedValues != null) && (field.linkedValues.length)">
- <span>{{field.linkedValues[0].name}}</span>
- </ng-container>
- </ng-container>
- <ng-container *ngIf="!field.readOnly">
- <span [ngClass]="{nullable : !field.isRequired()}">
- <select
- class="form-control"
- name="{{field.name}}"
- id="{{idPrefix}}-{{field.name}}"
- [required]="field.isRequired()"
- [ngModel]="record[field.name]()"
- (ngModelChange)="record[field.name]($event)">
- <option *ngFor="let item of field.linkedValues"
- [value]="item.id">{{item.name}}</option>
- </select>
- </span>
- </ng-container>
+
+ <ng-container *ngSwitchCase="'bool'">
+ <input
+ class="form-check-input"
+ type="checkbox"
+ name="{{field.name}}"
+ id="{{idPrefix}}-{{field.name}}"
+ [disabled]="field.readOnly"
+ [ngModel]="record[field.name]()"
+ (ngModelChange)="record[field.name]($event)"/>
</ng-container>
- <eg-org-select *ngIf="field.datatype == 'org_unit'"
- placeholder="{{field.label}}..."
- i18n-placeholder
- domId="{{idPrefix}}-{{field.name}}"
- [limitPerms]="modePerms[mode]"
- [readOnly]="field.readOnly"
- [applyDefault]="field.orgDefaultAllowed"
- [initialOrgId]="record[field.name]()"
- (onChange)="record[field.name]($event)">
- </eg-org-select>
-
- </span>
+ <ng-container *ngSwitchCase="'list'">
+ <eg-combobox
+ id="{{idPrefix}}-{{field.name}}" name="{{field.name}}"
+ placeholder="{{field.label}}..." i18n-placeholder
+ [required]="field.isRequired()"
+ [entries]="field.linkedValues"
+ [asyncDataSource]="field.linkedValuesSource"
+ [startId]="record[field.name]()"
+ (onChange)="record[field.name]($event ? $event.id : null)">
+ </eg-combobox>
+ </ng-container>
+ </ng-container> <!-- switch -->
</div>
</div>
</form>
import {Component, OnInit, Input,
Output, EventEmitter, TemplateRef} from '@angular/core';
import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {Observable} from 'rxjs';
+import {map} from 'rxjs/operators';
import {AuthService} from '@eg/core/auth.service';
import {PcrudService} from '@eg/core/pcrud.service';
import {DialogComponent} from '@eg/share/dialog/dialog.component';
import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
interface CustomFieldTemplate {
template: TemplateRef<any>;
[fields: string]: any;
}
+// Collection of extra options that may be applied to fields
+// for controling non-default behaviour.
+export interface FmFieldOptions {
+
+ // Render the field as a combobox using these values, regardless
+ // of the field's datatype.
+ customValues?: {[field: string]: ComboboxEntry[]};
+
+ // Provide / override the "selector" value for the linked class.
+ // This is the field the combobox will search for typeahead. If no
+ // field is defined, the "selector" field is used. If no "selector"
+ // field exists, the combobox will pre-load all linked values so
+ // the user can click to navigate.
+ linkedSearchField?: string;
+
+ // When true for combobox fields, pre-fetch the combobox data
+ // so the user can click or type to find values.
+ preloadLinkedValues?: boolean;
+
+ // Directly override the required state of the field.
+ // This only has an affect if the value is true.
+ isRequired?: boolean;
+
+ // If this function is defined, the function will be called
+ // at render time to see if the field should be marked are required.
+ // This supersedes all other isRequired specifiers.
+ isRequiredOverride?: (field: string, record: IdlObject) => boolean;
+
+ // Directly apply the readonly status of the field.
+ // This only has an affect if the value is true.
+ isReadonly?: boolean;
+
+ // Render the field using this custom template instead of chosing
+ // from the default set of form inputs.
+ customTemplate?: CustomFieldTemplate;
+}
+
@Component({
selector: 'eg-fm-record-editor',
templateUrl: './fm-editor.component.html',
// 'view' for viewing an existing record without editing
mode: 'create' | 'update' | 'view' = 'create';
recId: any;
+
// IDL record we are editing
// TODO: allow this to be update in real time by the caller?
record: IdlObject;
// for the current IDL class
modePerms: {[mode: string]: string};
- @Input() customFieldTemplates:
- {[fieldName: string]: CustomFieldTemplate} = {};
+ // Collection of FmFieldOptions for specifying non-default
+ // behaviour for each field (by field name).
+ @Input() fieldOptions: {[fieldName: string]: FmFieldOptions} = {};
// list of fields that should not be displayed
@Input() hiddenFieldsList: string[] = [];
@Input() orgDefaultAllowedList: string[] = [];
@Input() orgDefaultAllowed: string; // comma-separated string version
- // hash, keyed by field name, of functions to invoke to check
- // whether a field is required. Each callback is passed the field
- // name and the record and should return a boolean value. This
- // supports cases where whether a field is required or not depends
- // on the current value of another field.
- @Input() isRequiredOverride:
- {[field: string]: (field: string, record: IdlObject) => boolean};
-
// IDL record display label. Defaults to the IDL label.
@Input() recordLabel: string;
});
}
+ // Returns the name of the field on a class (typically via a linked
+ // field) that acts as the selector value for display / search.
+ getClassSelector(class_: string): string {
+ if (class_) {
+ const linkedClass = this.idl.classes[class_];
+ return linkedClass.pkey ?
+ linkedClass.field_map[linkedClass.pkey].selector : null;
+ }
+ return null;
+ }
- private flattenLinkedValues(cls: string, list: IdlObject[]): any[] {
- const idField = this.idl.classes[cls].pkey;
- const selector =
- this.idl.classes[cls].field_map[idField].selector || idField;
+ private flattenLinkedValues(field: any, list: IdlObject[]): ComboboxEntry[] {
+ const class_ = field.class;
+ const fieldOptions = this.fieldOptions[field.name] || {};
+ const idField = this.idl.classes[class_].pkey;
+
+ const selector = fieldOptions.linkedSearchField
+ || this.getClassSelector(class_) || idField;
return list.map(item => {
- return {id: item[idField](), name: item[selector]()};
+ return {id: item[idField](), label: item[selector]()};
});
}
!f.virtual && !this.hiddenFieldsList.includes(f.name)
);
- const promises = [];
-
- this.fields.forEach(field => {
- field.readOnly = this.mode === 'view'
- || this.readonlyFieldsList.includes(field.name);
-
- if (this.isRequiredOverride &&
- field.name in this.isRequiredOverride) {
- field.isRequired = () => {
- return this.isRequiredOverride[field.name](field.name, this.record);
- };
- } else {
- field.isRequired = () => {
- return field.required ||
- this.requiredFieldsList.includes(field.name);
- };
- }
+ // Wait for all network calls to complete
+ return Promise.all(
+ this.fields.map(field => this.constructOneField(field)));
+ }
+
+ private constructOneField(field: any): Promise<any> {
+
+ let promise = null;
+ const fieldOptions = this.fieldOptions[field.name] || {};
+
+ field.readOnly = this.mode === 'view'
+ || fieldOptions.isReadonly === true
+ || this.readonlyFieldsList.includes(field.name);
+
+ if (fieldOptions.isRequiredOverride) {
+ field.isRequired = () => {
+ return fieldOptions.isRequiredOverride(field.name, this.record);
+ };
+ } else {
+ field.isRequired = () => {
+ return field.required
+ || fieldOptions.isRequired
+ || this.requiredFieldsList.includes(field.name);
+ };
+ }
+
+ if (fieldOptions.customValues) {
+
+ field.linkedValues = fieldOptions.customValues;
+
+ } else if (field.datatype === 'link' && field.readOnly) {
+
+ // no need to fetch all possible values for read-only fields
+ const idToFetch = this.record[field.name]();
- if (field.datatype === 'link' && field.readOnly) {
-
- // no need to fetch all possible values for read-only fields
- const idToFetch = this.record[field.name]();
-
- if (idToFetch) {
-
- // If the linked class defines a selector field, fetch the
- // linked data so we can display the data within the selector
- // field. Otherwise, avoid the network lookup and let the
- // bare value (usually an ID) be displayed.
- const selector =
- this.idl.getLinkSelector(this.idlClass, field.name);
-
- if (selector && selector !== field.name) {
- promises.push(
- this.pcrud.retrieve(field.class, this.record[field.name]())
- .toPromise().then(list => {
- field.linkedValues =
- this.flattenLinkedValues(field.class, Array(list));
- })
- );
- } else {
- // No selector, display the raw id/key value.
- field.linkedValues = [{id: idToFetch, name: idToFetch}];
- }
+ if (idToFetch) {
+
+ // If the linked class defines a selector field, fetch the
+ // linked data so we can display the data within the selector
+ // field. Otherwise, avoid the network lookup and let the
+ // bare value (usually an ID) be displayed.
+ const selector = fieldOptions.linkedSearchField ||
+ this.getClassSelector(field.class);
+
+ if (selector && selector !== field.name) {
+ promise = this.pcrud.retrieve(field.class, idToFetch)
+ .toPromise().then(list => {
+ field.linkedValues =
+ this.flattenLinkedValues(field, Array(list));
+ });
+ } else {
+ // No selector, display the raw id/key value.
+ field.linkedValues = [{id: idToFetch, name: idToFetch}];
}
- } else if (field.datatype === 'link') {
- promises.push(
- this.pcrud.retrieveAll(field.class, {}, {atomic : true})
- .toPromise().then(list => {
- field.linkedValues =
- this.flattenLinkedValues(field.class, list);
- })
- );
- } else if (field.datatype === 'org_unit') {
- field.orgDefaultAllowed =
- this.orgDefaultAllowedList.includes(field.name);
}
- if (this.customFieldTemplates[field.name]) {
- field.template = this.customFieldTemplates[field.name].template;
- field.context = this.customFieldTemplates[field.name].context;
- }
+ } else if (field.datatype === 'link') {
- });
+ promise = this.wireUpCombobox(field);
- // Wait for all network calls to complete
- return Promise.all(promises);
+ } else if (field.datatype === 'org_unit') {
+ field.orgDefaultAllowed =
+ this.orgDefaultAllowedList.includes(field.name);
+ }
+
+ if (fieldOptions.customTemplate) {
+ field.template = fieldOptions.customTemplate.template;
+ field.context = fieldOptions.customTemplate.context;
+ }
+
+ return promise || Promise.resolve();
+ }
+
+ wireUpCombobox(field: any): Promise<any> {
+
+ const fieldOptions = this.fieldOptions[field.name] || {};
+
+ const selector = fieldOptions.linkedSearchField ||
+ this.getClassSelector(field.class);
+
+ if (!selector && !fieldOptions.preloadLinkedValues) {
+ // User probably expects an async data source, but we can't
+ // provide one without a selector. Warn the user.
+ console.warn(`Class ${field.class} has no selector.
+ Pre-fetching all rows for combobox`);
+ }
+
+ if (fieldOptions.preloadLinkedValues || !selector) {
+ return this.pcrud.retrieveAll(field.class, {}, {atomic : true})
+ .toPromise().then(list => {
+ field.linkedValues =
+ this.flattenLinkedValues(field, list);
+ });
+ }
+
+ // If we have a selector, wire up for async data retrieval
+ field.linkedValuesSource =
+ (term: string): Observable<ComboboxEntry> => {
+
+ const search = {};
+ const orderBy = {order_by: {}};
+ const idField = this.idl.classes[field.class].pkey || 'id';
+
+ search[selector] = {'ilike': `%${term}%`};
+ orderBy.order_by[field.class] = selector;
+
+ return this.pcrud.search(field.class, search, orderBy)
+ .pipe(map(idlThing =>
+ // Map each object into a ComboboxEntry upon arrival
+ this.flattenLinkedValues(field, [idlThing])[0]
+ ));
+ };
+
+ // Using an async data source, but a value is already set
+ // on the field. Fetch the linked object and add it to the
+ // combobox entry list so it will be avilable for display
+ // at dialog load time.
+ const linkVal = this.record[field.name]();
+ if (linkVal !== null && linkVal !== undefined) {
+ return this.pcrud.retrieve(field.class, linkVal).toPromise()
+ .then(idlThing => {
+ field.linkedValues =
+ this.flattenLinkedValues(field, Array(idlThing));
+ });
+ }
+
+ // No linked value applied, nothing to pre-fetch.
+ return Promise.resolve();
}
// Returns a context object to be inserted into a custom
cancel() {
this.dismiss('canceled');
}
+
+ // Returns a string describing the type of input to display
+ // for a given field. This helps cut down on the if/else
+ // nesti-ness in the template. Each field will match
+ // exactly one type.
+ inputType(field: any): string {
+
+ if (field.template) {
+ return 'template';
+ }
+
+ // Some widgets handle readOnly for us.
+ if ( field.datatype === 'timestamp'
+ || field.datatype === 'org_unit'
+ || field.datatype === 'bool') {
+ return field.datatype;
+ }
+
+ if (field.readOnly) {
+ if (field.datatype === 'money') {
+ return 'readonly-money';
+ }
+
+ if (field.datatype === 'link' || field.linkedValues) {
+ return 'readonly-list';
+ }
+
+ return 'readonly';
+ }
+
+ if (field.datatype === 'id' && !this.pkeyIsEditable) {
+ return 'readonly';
+ }
+
+ if ( field.datatype === 'int'
+ || field.datatype === 'float'
+ || field.datatype === 'money') {
+ return field.datatype;
+ }
+
+ if (field.datatype === 'link' || field.linkedValues) {
+ return 'list';
+ }
+
+ // datatype == text / interval / editable-pkey
+ return 'text';
+ }
}