import { IComponentController } from 'angular' import _ from 'lodash' import { Component, Inject, Input, Output } from '../decorators' /** A single entry in a metadata schema. */ export interface MetadataDefinition { description?: string endpoint?: string enum?: (string | { id: string, label: string }) [] format: 'color' | 'date-time' | 'dropdown' | 'password' | 'lookup' | 'multiline' | 'tag' | 'unit-list' | null isArray?: boolean isGenAIPopulated?: boolean label: string lookupCallback?: (field: string, term: string) => ng.IPromise name: string objectDefinition?: MetadataDefinition[] placeholder?: string readOnly?: boolean required: boolean type: 'boolean' | 'date' | 'datetime' | 'int' | 'object' | 'string' } /** A match returned by api query against a lookup field. */ export interface MetadataFieldRecord { id: string name: string [key: string]: any } @Component({ selector: 'mflyMetadataForm', template: require('./metadata-form.html') }) export default class MetadataFormController implements IComponentController { /** Whether the first field in the form should be auto-focused. */ @Input() autoFocus: boolean /** Whether the component should show toggles for bulk editing. */ @Input() isBulkEdit: boolean /** Whether the entire form should be disabled. */ @Input() isDisabled: boolean /** The actual key/value pairs to display in the controls. */ @Input() metadata: { [key: string]: any } /** Called when any field value changes. Not implemented for DTZ picker. */ @Output() onChange?: (change: { [key: string]: any }) => void /** The metadata schema describing what type of controls to render in the form. */ @Input() schema: MetadataDefinition[] /** Key/boolean pairs indicating whether a field should be enabled for bulk editing. */ bulkEnabled: { [key: string]: boolean } /** Collection of validation forms; each field in the metadata form has one. */ forms: { [key: string]: ng.IFormController } /** Stores ng-model values for lookup fields */ lookupName: { [key: string]: any } = {} /** Indicates which lookup fields are currently loading */ lookupLoading: { [key: string]: boolean } = {} // Translations addAnotherText: string changeText: string leaveUnchangedText: string noText: string yesText: string constructor( @Inject('$element') private $element: ng.IAugmentedJQuery, @Inject('$http') private $http: ng.IHttpService, @Inject('$timeout') private $timeout: ng.ITimeoutService, @Inject('translateFactory') private translateFactory ) {} $onChanges(changes: { metadata: ng.IChangesObject> }) { // Clear radio buttons in bulk form when metadata values are reset to empty object if (!this.isBulkEdit) { return } if (!changes.metadata) { return } if (changes.metadata.currentValue === changes.metadata.previousValue) { return } if (_.keys(changes.metadata.currentValue).length === 0) { this.bulkEnabled = {} } } $onInit() { this.bulkEnabled = {} this.addAnotherText = this.translateFactory.instant('JSUI.ADD_ANOTHER') this.changeText = this.translateFactory.instant('JSUI.CHANGE') this.leaveUnchangedText = this.translateFactory.instant('JSUI.LEAVE_UNCHANGED') this.noText = this.translateFactory.instant('JSUI.NO') this.yesText = this.translateFactory.instant('JSUI.YES') for (const field of this.schema) { if (field.type === 'date' && typeof this.metadata[field.name] === 'string') { this.metadata[field.name] = new Date(this.metadata[field.name]) } } } addArrayMetadata(property: MetadataDefinition) { const blankValue = (property.type === 'object') ? {} : '' // Adding a new row to a blank array-of-objects field causes an ng-repeat // "dupes in repeater" error because we just get two objects with the same // $$hashKey. Just preventing this from working for now, we can solve it in // a more robust way later if needed. if (!this.metadata[property.name] && property.type === 'object') { return } if (!this.metadata[property.name]) { this.metadata[property.name] = [blankValue] } this.metadata[property.name].push(blankValue) const lastIndex = this.metadata[property.name].length - 1 if (property.type !== 'object' && property.format !== 'tag') { this.setFocusInField(property.name, lastIndex) } if (this.onChange) { return this.onChange({ change: { [property.name]: this.metadata[property.name] }}) } } addTagFromAutoComplete(value: { [key: string]: unknown }, origin: string) { const clearAutoComplete = () => this.$element.find(`#${origin}`).val('') // We strip the Angular-generated $$hashKey here and below to make sure we // can compare tags to the metadata field value (the hash key changes constantly). const tagToAdd = _.omit(value, '$$hashKey') if (this.metadata[origin] === undefined || this.metadata[origin] === null) { this.metadata[origin] = [tagToAdd] clearAutoComplete() return } if (!!this.metadata[origin].find(existing => _.isEqual(_.omit(existing, '$$hashKey'), tagToAdd))) { clearAutoComplete() return } this.metadata[origin].push(tagToAdd) clearAutoComplete() } /** * Passes changes from the child components up to the parent and handles * internal transforms for the array and object models. */ change(value: any) { const fieldName = Object.keys(value)[0] const fieldValue = _.get(value, fieldName) const schemaField = _.find(this.schema, ['name', fieldName]) if (!schemaField) { return } // Initialize the array field if we're starting with an undefined/null array. if (schemaField.isArray && _.isNil(this.metadata[fieldName]) && schemaField.format !== 'tag' && schemaField.format !== 'lookup') { this.metadata[fieldName] = [fieldValue] } // If it's a first entry, set focus for simple arrays since we swapped out // the input field. But don't do this for object arrays because we don't // really know what field would be natural to put the focus in. if (schemaField.isArray && schemaField.type !== 'object' && schemaField.format !== 'tag' && this.metadata[fieldName].length === 1) { this.setFocusInField(fieldName, 0) } // If we're removing the last character from the last item of an array field, // flip its value to null. We want null instead of undefined in this case // because this allows us to explicitly remove values when bulk editing. if (schemaField.isArray && fieldValue === '' && schemaField.format !== 'tag' && this.metadata[fieldName].length === 1) { this.metadata[fieldName] = null } // Pass up the whole array for array type fields. if (schemaField.isArray && this.onChange) { return this.onChange({ change: { [fieldName]: this.metadata[fieldName] }}) } // For object fields, pass up an object containing the changed key/value. // Note: The property is passed differently in the template for objects. if (schemaField.type === 'object' && this.onChange) { return this.onChange({ change: value }) } // Otherwise, just pass up the actual value. if (this.onChange) { return this.onChange({ change: { [fieldName]: fieldValue }}) } } changeBulkEditBool(name: string, value: boolean) { this.metadata[name] = value this.forms[name].$setDirty() } enableField(name: string) { if (!this.bulkEnabled[name]) { this.metadata[name] = null this.bulkEnabled[name] = true } } disableField(name: string) { this.metadata[name] = undefined this.bulkEnabled[name] = false } getFieldForAttribute(schemaField: MetadataDefinition): string { // Keywords fields are technically array fields, but they don't have indexed inputs. // Same for lookup fields. if (schemaField.isArray && schemaField.format === 'tag') { return schemaField.name } if (schemaField.isArray && schemaField.format === 'lookup') { return schemaField.name } // For plain array fields, return the label of the first input. // Array controls are identified by their index. if (schemaField.isArray && schemaField.type !== 'object') { return `${schemaField.name}-0` } // For array-of-object fields, return the label of the first input. // These controls are identified by property name _and_ index. if (schemaField.isArray && schemaField.type === 'object') { if (!schemaField.objectDefinition?.length) { throw new Error(`Object type schema field, but no object definition`) } return `${schemaField.name}-${schemaField.objectDefinition[0].name}-0` } // For plain object fields, return the label of the first input. // Object controls are identified by their property names. if (schemaField.type === 'object' && schemaField.format !== 'lookup') { if (!schemaField.objectDefinition?.length) { throw new Error(`Object type schema field, but no object definition`) } return `${schemaField.name}-${schemaField.objectDefinition[0].name}` } return schemaField.name } inputClick(name: string) { if (this.isBulkEdit) { this.enableField(name) } } initLookup(property: MetadataDefinition, value: string) { if (value) { this.lookupFieldValue(property, value).then((response) => { if (response && response.length === 1) { this.lookupName[property.name] = response[0].name } }) } } lookupFieldValue(property: MetadataDefinition, term?: string) { const url = `${property.endpoint}` const config = { params: { field: property.name, term }} this.lookupLoading[property.name] = true return this.$http.get(url, config) .then(response => { this.lookupLoading[property.name] = false return response.data }) } /** Used for tag lookups to pass to auto-complete-match-renderer. */ tagLookupFieldValue(input: string, origin: string) { const field = this.schema.find(f => f.name === origin) if (!field) { throw new Error(`No schema field matching the origin ${origin} (passed from autocomplete-match-renderer).`) } this.lookupLoading[field.name] = true if (field.lookupCallback) { return field.lookupCallback(field.name, input) .then(response => { this.lookupLoading[field.name] = false return response }) } throw new Error(`Trying to fetch matches for field ${field.name} but it has no endpoint or lookupCallback in the schema.`) } removeArrayMetadata(property: MetadataDefinition, index: number) { _.pullAt(this.metadata[property.name], index) if (this.metadata[property.name].length === 0) { this.metadata[property.name] = null } if (!this.metadata[property.name] && property.type !== 'object' && property.format !== 'tag') { this.setFocusInField(property.name, 0) } this.forms[property.name].$setDirty() this.forms[property.name].$setPristine() if (this.onChange) { return this.onChange({ change: { [property.name]: this.metadata[property.name] }}) } } selectLookupRecord(name: string, record: MetadataFieldRecord) { this.metadata[name] = record.id } // Timeout 0 doesn't seem to be fast enough to reliably set focus on the new input. private setFocusInField(propertyName: string, index: number) { this.$timeout(() => { this.$element.find(`#${propertyName}-${index}`)[0]?.focus() }, 50) } }