diff --git a/AppBuilder/core b/AppBuilder/core index 9c645df2..5741dfb9 160000 --- a/AppBuilder/core +++ b/AppBuilder/core @@ -1 +1 @@ -Subproject commit 9c645df2f06fb745e3ea0e43e7fd3764da9cd82b +Subproject commit 5741dfb93935e18ebf8da81cc4a5921127f2ceb5 diff --git a/AppBuilder/platform/plugins/included/index.js b/AppBuilder/platform/plugins/included/index.js index da87f45b..8bb5909a 100644 --- a/AppBuilder/platform/plugins/included/index.js +++ b/AppBuilder/platform/plugins/included/index.js @@ -1,10 +1,12 @@ +import viewForm from "./view_form/FNAbviewform.js"; import viewList from "./view_list/FNAbviewlist.js"; import viewTab from "./view_tab/FNAbviewtab.js"; +import viewDetail from "./view_detail/FNAbviewdetail.js"; import viewText from "./view_text/FNAbviewtext.js"; import viewImage from "./view_image/FNAbviewimage.js"; import viewDataSelect from "./view_data-select/FNAbviewdataselect.js"; -const AllPlugins = [viewTab, viewList, viewText, viewImage, viewDataSelect]; +const AllPlugins = [viewTab, viewList, viewDetail, viewForm, viewText, viewImage, viewDataSelect]; export default { load: (AB) => { diff --git a/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetail.js b/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetail.js new file mode 100644 index 00000000..6824168d --- /dev/null +++ b/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetail.js @@ -0,0 +1,138 @@ +import FNAbviewdetailComponent from "./FNAbviewdetailComponent.js"; + +// Detail view plugin: replaces the original ABViewDetail / ABViewDetailCore. +// All logic from both Core and platform is contained in this file. +export default function FNAbviewdetail({ + ABViewContainer, + ABViewComponentPlugin, +}) { + const ABViewDetailComponent = FNAbviewdetailComponent({ + ABViewComponentPlugin, + }); + + const ABViewDetailDefaults = { + key: "detail", + icon: "file-text-o", + labelKey: "Detail(plugin)", + }; + + const ABViewDetailPropertyComponentDefaults = { + dataviewID: null, + showLabel: true, + labelPosition: "left", + labelWidth: 120, + height: 0, + }; + + return class ABViewDetailPlugin extends ABViewContainer { + /** + * @param {obj} values key=>value hash of ABView values + * @param {ABApplication} application the application object this view is under + * @param {ABView} parent the ABView this view is a child of. (can be null) + */ + constructor(values, application, parent, defaultValues) { + super( + values, + application, + parent, + defaultValues ?? ABViewDetailDefaults + ); + } + + static getPluginType() { + return "view"; + } + + static getPluginKey() { + return this.common().key; + } + + static common() { + return ABViewDetailDefaults; + } + + static defaultValues() { + return ABViewDetailPropertyComponentDefaults; + } + + /** + * @method fromValues() + * Initialize this object with the given set of values. + * @param {obj} values + */ + fromValues(values) { + super.fromValues(values); + + this.settings.labelPosition = + this.settings.labelPosition || + ABViewDetailPropertyComponentDefaults.labelPosition; + + this.settings.showLabel = JSON.parse( + this.settings.showLabel != null + ? this.settings.showLabel + : ABViewDetailPropertyComponentDefaults.showLabel + ); + + this.settings.labelWidth = parseInt( + this.settings.labelWidth || + ABViewDetailPropertyComponentDefaults.labelWidth + ); + this.settings.height = parseInt( + this.settings.height ?? + ABViewDetailPropertyComponentDefaults.height + ); + } + + /** + * @method componentList + * Return the list of components available on this view to display in the editor. + */ + componentList() { + const viewsToAllow = ["label", "text"]; + const allComponents = this.application.viewAll(); + return allComponents.filter((c) => + viewsToAllow.includes(c.common().key) + ); + } + + addFieldToDetail(field, yPosition) { + if (field == null) return; + + const newView = field + .detailComponent() + .newInstance(this.application, this); + if (newView == null) return; + + newView.settings = newView.settings ?? {}; + newView.settings.fieldId = field.id; + newView.settings.labelWidth = + this.settings.labelWidth || + ABViewDetailPropertyComponentDefaults.labelWidth; + newView.settings.alias = field.alias; + newView.position.y = yPosition; + + this._views.push(newView); + return newView; + } + + /** + * @method component() + * Return a UI component based upon this view. + * @return {obj} UI component + */ + component() { + return new ABViewDetailComponent(this); + } + + warningsEval() { + super.warningsEval(); + + const DC = this.datacollection; + if (!DC) { + this.warningsMessage( + `can't resolve it's datacollection[${this.settings.dataviewID}]` + ); + } + } + }; +} diff --git a/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetailComponent.js b/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetailComponent.js new file mode 100644 index 00000000..ca24a0b9 --- /dev/null +++ b/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetailComponent.js @@ -0,0 +1,51 @@ +export default function FNAbviewdetailComponent({ + /*AB,*/ + ABViewComponentPlugin, +}) { + return class ABAbviewdetailComponent extends ABViewComponentPlugin { + constructor(baseView, idBase, ids) { + super( + baseView, + idBase || `ABViewDetail_${baseView.id}`, + Object.assign({ detail: "" }, ids) + ); + } + + ui() { + const settings = this.settings; + const _uiDetail = { + id: this.ids.detail, + view: "dataview", + type: { + width: 1000, + height: 30, + }, + template: (item) => { + if (!item) return ""; + return JSON.stringify(item); + }, + }; + + // set height or autoHeight + if (settings.height !== 0) _uiDetail.height = settings.height; + else _uiDetail.autoHeight = true; + + const _ui = super.ui([_uiDetail]); + + delete _ui.type; + + return _ui; + } + + async init(AB) { + await super.init(AB); + + const dc = this.datacollection; + + if (!dc) return; + + // bind dc to component + dc.bind($$(this.ids.detail)); + } + }; +} diff --git a/AppBuilder/platform/plugins/included/view_form/FNAbviewform.js b/AppBuilder/platform/plugins/included/view_form/FNAbviewform.js new file mode 100644 index 00000000..729e31a6 --- /dev/null +++ b/AppBuilder/platform/plugins/included/view_form/FNAbviewform.js @@ -0,0 +1,845 @@ +import FNAbviewformComponent from "./FNAbviewformComponent.js"; + + +// FNAbviewform Web +// A web side import for an ABView. +// +export default function FNAbviewform({ + /*AB,*/ + ABViewPlugin, + ABViewComponentPlugin, + ABViewContainer +}) { + const ABAbviewformComponent = FNAbviewformComponent({ ABViewComponentPlugin }); + + const ABRecordRule = require("../../../../rules/ABViewRuleListFormRecordRules"); + const ABSubmitRule = require("../../../../rules/ABViewRuleListFormSubmitRules"); + + const ABViewFormDefaults = { + key: "form", // unique key identifier for this ABViewForm + icon: "list-alt", // icon reference: (without 'fa-' ) + labelKey: "Form(plugin)", // {string} the multilingual label key for the class label + }; + + const ABViewFormPropertyComponentDefaults = { + dataviewID: null, + showLabel: true, + labelPosition: "left", + labelWidth: 120, + height: 200, + clearOnLoad: false, + clearOnSave: false, + displayRules: [], + editForm: "none", // The url pointer of ABViewForm + + // [{ + // action: {string}, + // when: [ + // { + // fieldId: {UUID}, + // comparer: {string}, + // value: {string} + // } + // ], + // values: [ + // { + // fieldId: {UUID}, + // value: {object} + // } + // ] + // }] + recordRules: [], + + // [{ + // action: {string}, + // when: [ + // { + // fieldId: {UUID}, + // comparer: {string}, + // value: {string} + // } + // ], + // value: {string} + // }] + submitRules: [], + }; + + class ABViewFormCore extends ABViewContainer { + constructor(values, application, parent, defaultValues) { + super(values, application, parent, defaultValues || ABViewFormDefaults); + this.isForm = true; + } + + static common() { + return ABViewFormDefaults; + } + + static defaultValues() { + return ABViewFormPropertyComponentDefaults; + } + + /// + /// Instance Methods + /// + + /** + * @method fromValues() + * + * initialze this object with the given set of values. + * @param {obj} values + */ + fromValues(values) { + super.fromValues(values); + + this.settings.labelPosition = + this.settings.labelPosition || + ABViewFormPropertyComponentDefaults.labelPosition; + + // convert from "0" => true/false + this.settings.showLabel = JSON.parse( + this.settings.showLabel != null + ? this.settings.showLabel + : ABViewFormPropertyComponentDefaults.showLabel + ); + this.settings.clearOnLoad = JSON.parse( + this.settings.clearOnLoad != null + ? this.settings.clearOnLoad + : ABViewFormPropertyComponentDefaults.clearOnLoad + ); + this.settings.clearOnSave = JSON.parse( + this.settings.clearOnSave != null + ? this.settings.clearOnSave + : ABViewFormPropertyComponentDefaults.clearOnSave + ); + + // convert from "0" => 0 + this.settings.labelWidth = parseInt( + this.settings.labelWidth == null + ? ABViewFormPropertyComponentDefaults.labelWidth + : this.settings.labelWidth + ); + this.settings.height = parseInt( + this.settings.height == null + ? ABViewFormPropertyComponentDefaults.height + : this.settings.height + ); + } + + // Use this function in kanban + objectLoad(object) { + this._currentObject = object; + } + + /** + * @method componentList + * return the list of components available on this view to display in the editor. + */ + componentList() { + var viewsToAllow = ["label", "layout", "button", "text"], + allComponents = this.application.viewAll(); + + return allComponents.filter((c) => { + return viewsToAllow.indexOf(c.common().key) > -1; + }); + } + + /** + * @method fieldComponents() + * + * return an array of all the ABViewFormField children + * + * @param {fn} filter a filter fn to return a set of ABViewFormField that this fn + * returns true for. + * @return {array} array of ABViewFormField + */ + fieldComponents(filter) { + const flattenComponents = (views) => { + let components = []; + + views.forEach((v) => { + if (v == null) return; + + components.push(v); + + if (v._views?.length) { + components = components.concat(flattenComponents(v._views)); + } + }); + + return components; + }; + + if (this._views?.length) { + const allComponents = flattenComponents(this._views); + + if (filter == null) { + filter = (comp) => comp?.key?.startsWith("form"); + } + + return allComponents.filter(filter); + } else { + return []; + } + } + + addFieldToForm(field, yPosition) { + if (field == null) return; + + var fieldComponent = field.formComponent(); + if (fieldComponent == null) return; + + var newView = fieldComponent.newInstance(this.application, this); + if (newView == null) return; + + // set settings to component + newView.settings = newView.settings || {}; + newView.settings.fieldId = field.id; + // TODO : Default settings + + if (yPosition != null) newView.position.y = yPosition; + + // add a new component + this._views.push(newView); + + return newView; + } + + get RecordRule() { + let object = this.datacollection.datasource; + + if (this._recordRule == null) { + this._recordRule = new ABRecordRule(); + } + + this._recordRule.formLoad(this); + this._recordRule.fromSettings(this.settings.recordRules); + this._recordRule.objectLoad(object); + + return this._recordRule; + } + + doRecordRulesPre(rowData) { + return this.RecordRule.processPre({ data: rowData, form: this }); + } + + doRecordRules(rowData) { + // validate for record rules + if (rowData) { + let object = this.datacollection.datasource; + let ruleValidator = object.isValidData(rowData); + let isUpdatedDataValid = ruleValidator.pass(); + if (!isUpdatedDataValid) { + console.error("Updated data is invalid.", { rowData: rowData }); + return Promise.reject(new Error("Updated data is invalid.")); + } + } + + return this.RecordRule.process({ data: rowData, form: this }); + } + + doSubmitRules(rowData) { + var object = this.datacollection.datasource; + + var SubmitRules = new ABSubmitRule(); + SubmitRules.formLoad(this); + SubmitRules.fromSettings(this.settings.submitRules); + SubmitRules.objectLoad(object); + + return SubmitRules.process({ data: rowData, form: this }); + } + }; + + // const L = (...params) => AB.Multilingual.label(...params); + + // const ABRecordRule = require("../../rules/ABViewRuleListFormRecordRules"); + // const ABSubmitRule = require("../../rules/ABViewRuleListFormSubmitRules"); + + // let PopupRecordRule = null; + // let PopupSubmitRule = null; + + // const ABViewFormPropertyComponentDefaults = ABViewFormCore.defaultValues(); + + return class ABViewForm extends ABViewFormCore { + + static getPluginType() { + return "view"; + } + + /** + * @method getPluginKey + * return the plugin key for this view. + * @return {string} plugin key + */ + static getPluginKey() { + return this.common().key; + } + + static common() { + return ABViewFormDefaults; + } + + /** + * @method component() + * return a UI component based upon this view. + * @return {obj} UI component + */ + component(parentId) { + return new ABAbviewformComponent(this, parentId); + } + + + constructor(values, application, parent, defaultValues) { + super(values, application, parent, defaultValues); + + this._callbacks = { + onBeforeSaveData: () => true, + }; + } + + superComponent() { + if (this._superComponent == null) + this._superComponent = super.component(); + + return this._superComponent; + } + + + + refreshDefaultButton(ids) { + // If default button is not exists, then skip this + const ButtonClass = this.application.ViewManager?.viewClass?.("button"); + if (!ButtonClass) return; + + let defaultButton = this.views( + (v) => v?.key === "button" && v?.settings?.isDefault + )[0]; + + if (!defaultButton) { + defaultButton = ButtonClass.newInstance(this.application, this); + defaultButton.settings = defaultButton.settings || {}; + defaultButton.settings.isDefault = true; + } else { + this._views = this._views.filter((v) => v.id !== defaultButton.id); + } + + const yList = this._views.map((v) => (v.position?.y || 0) + 1); + const posY = yList.length ? Math.max(...yList) : 0; + + defaultButton.position = defaultButton.position || {}; + defaultButton.position.y = posY; + + this._views.push(defaultButton); + + return defaultButton; + } + + /** + * @method getFormValues + * + * @param {webix form} formView + * @param {ABObject} obj + * @param {ABDatacollection} dc + * @param {ABDatacollection} dcLink [optional] + */ + getFormValues(formView, obj, dc, dcLink) { + // get the fields that are on this form + const visibleFields = ["id"]; // we always want the id so we can udpate records + formView.getValues(function (obj) { + visibleFields.push(obj.config.name); + }); + + // only get data passed from form + const allVals = formView.getValues(); + const formVals = {}; + visibleFields.forEach((val) => { + formVals[val] = allVals[val]; + }); + + // get custom values + this.fieldComponents( + (comp) => + comp?.key === "formcustom" || + comp?.key === "formconnect" || + comp?.key === "formdatepicker" || + comp?.key === "formselectmultiple" || + (comp?.key === "formjson" && comp?.settings?.type === "filter") + ).forEach((f) => { + const vComponent = this.viewComponents[f.id]; + if (vComponent == null) return; + + const field = f.field(); + if (field) { + const getValue = vComponent.getValue ?? vComponent.logic.getValue; + if (getValue) + formVals[field.columnName] = getValue.call(vComponent, formVals); + } + }); + + // remove connected fields if they were not on the form and they are present in the formVals because it is a datacollection + obj.connectFields().forEach((f) => { + if ( + visibleFields.indexOf(f.columnName) == -1 && + formVals[f.columnName] + ) { + delete formVals[f.columnName]; + delete formVals[f.relationName()]; + } + }); + + // clear undefined values or empty arrays + for (const prop in formVals) { + if (formVals[prop] == null || formVals[prop].length == 0) + formVals[prop] = ""; + } + + // Add parent's data collection cursor when a connect field does not show + let linkValues; + + if (dcLink) { + linkValues = dcLink.getCursor(); + } + + if (linkValues) { + const objectLink = dcLink.datasource; + + const connectFields = obj.connectFields(); + connectFields.forEach((f) => { + const formFieldCom = this.fieldComponents( + (fComp) => fComp?.field?.()?.id === f?.id + ); + + if ( + objectLink.id == f.settings.linkObject && + formFieldCom.length < 1 && // check field does not show + formVals[f.columnName] === undefined + ) { + const linkColName = f.indexField + ? f.indexField.columnName + : objectLink.PK(); + + formVals[f.columnName] = {}; + formVals[f.columnName][linkColName] = + linkValues[linkColName] ?? linkValues.id; + } + }); + } + + // NOTE: need to pull data of current cursor to calculate Calculate & Formula fields + // .formVals variable does not include data that does not display in the Form widget + const cursorFormVals = Object.assign(dc.getCursor() ?? {}, formVals); + + // Set value of calculate or formula fields to use in record rule + obj.fields((f) => f.key == "calculate" || f.key == "formula").forEach( + (f) => { + if (formVals[f.columnName] == null) { + let reCalculate = true; + + // WORKAROUND: If "Formula" field will have Filter conditions, + // Then it is not able to re-calculate on client side + // because relational data is not full data so FilterComplex will not have data to check + if (f.key == "formula" && f.settings?.where?.rules?.length > 0) { + reCalculate = false; + } + + formVals[f.columnName] = f.format(cursorFormVals, reCalculate); + } + } + ); + + if (allVals.translations?.length > 0) + formVals.translations = allVals.translations; + + // give the Object a final chance to review the data being handled. + obj.formCleanValues(formVals); + + return formVals; + } + + /** + * @method validateData + * + * @param {webix form} formView + * @param {ABObject} object + * @param {object} formVals + * + * @return {boolean} isValid + */ + validateData($formView, object, formVals) { + let list = ""; + + // validate required fields + const requiredFields = this.fieldComponents( + (fComp) => + fComp?.field?.().settings?.required == true || + fComp?.settings?.required == true + ).map((fComp) => fComp.field()); + + // validate data + const validator = object.isValidData(formVals); + let isValid = validator.pass(); + + // $$($formView).validate(); + $formView.validate(); + /** + * helper function to fix the webix ui after adding an validation error + * message. + * @param {string} col - field.columnName + */ + const fixInvalidMessageUI = (col) => { + const $forminput = $formView.elements[col]; + if (!$forminput) return; + // Y position + const height = $forminput.$height; + if (height < 56) { + $forminput.define("height", 60); + $forminput.resize(); + } + + // X position + const domInvalidMessage = $forminput.$view.getElementsByClassName( + "webix_inp_bottom_label" + )[0]; + if (!domInvalidMessage?.style["margin-left"]) { + domInvalidMessage.style.marginLeft = `${this.settings.labelWidth ?? + ABViewFormPropertyComponentDefaults.labelWidth + }px`; + } + }; + + // Display required messages + requiredFields.forEach((f) => { + if (!f) return; + + const fieldVal = formVals[f.columnName]; + if (fieldVal == "" || fieldVal == null || fieldVal.length < 1) { + $formView.markInvalid(f.columnName, this.AB.Multilingual.label("This is a required field.")); + list += `
  • ${this.AB.Multilingual.label("Missing Required Field")} ${f.columnName}
  • `; + isValid = false; + + // Fix position of invalid message + fixInvalidMessageUI(f.columnName); + } + }); + + // if data is invalid + if (!isValid) { + const saveButton = $formView.queryView({ + view: "button", + type: "form", + }); + + // error message + if (validator?.errors?.length) { + validator.errors.forEach((err) => { + $formView.markInvalid(err.name, err.message); + list += `
  • ${err.name}: ${err.message}
  • `; + fixInvalidMessageUI(err.name); + }); + + saveButton?.disable(); + } else { + saveButton?.enable(); + } + } + if (list) { + webix.alert({ + type: "alert-error", + title: this.AB.Multilingual.label("Problems Saving"), + width: 400, + text: ``, + }); + } + + return isValid; + } + + /** + * @method recordRulesReady() + * This returns a Promise that gets resolved when all record rules report + * that they are ready. + * @return {Promise} + */ + async recordRulesReady() { + return this.RecordRule.rulesReady(); + } + + /** + * @method saveData + * save data in to database + * @param $formView - webix's form element + * + * @return {Promise} + */ + async saveData($formView) { + // call .onBeforeSaveData event + // if this function returns false, then it will not go on. + if (!this._callbacks?.onBeforeSaveData?.()) return; + + $formView.clearValidation(); + + // get ABDatacollection + const dv = this.datacollection; + if (dv == null) return; + + // get ABObject + const obj = dv.datasource; + if (obj == null) return; + + // show progress icon + $formView.showProgress?.({ type: "icon" }); + + // get update data + const formVals = this.getFormValues( + $formView, + obj, + dv, + dv.datacollectionLink + ); + + // form ready function + const formReady = (newFormVals) => { + // clear cursor after saving. + if (dv) { + if (this.settings.clearOnSave) { + dv.setCursor(null); + $formView.clear(); + } else { + if (newFormVals && newFormVals.id) dv.setCursor(newFormVals.id); + } + } + + $formView.hideProgress?.(); + + // if there was saved data pass it up to the onSaveData callback + // if (newFormVals) this._logic.callbacks.onSaveData(newFormVals); + if (newFormVals) this.emit("saved", newFormVals); // Q? is this the right upgrade? + }; + + const formError = (err) => { + const $saveButton = $formView.queryView({ + view: "button", + type: "form", + }); + + // mark error + if (err) { + if (err.invalidAttributes) { + for (const attr in err.invalidAttributes) { + let invalidAttrs = err.invalidAttributes[attr]; + if (invalidAttrs && invalidAttrs[0]) + invalidAttrs = invalidAttrs[0]; + + $formView.markInvalid(attr, invalidAttrs.message); + } + } else if (err.sqlMessage) { + webix.message({ + text: err.sqlMessage, + type: "error", + }); + } else { + webix.message({ + text: this.AB.Multilingual.label("System could not save your data"), + type: "error", + }); + this.AB.notify.developer(err, { + message: "Could not save your data", + view: this.toObj(), + }); + } + } + + $saveButton?.enable(); + + $formView?.hideProgress?.(); + }; + + // Load data of DCs that use in record rules + await this.loadDcDataOfRecordRules(); + + // wait for our Record Rules to be ready before we continue. + await this.recordRulesReady(); + + // update value from the record rule (pre-update) + this.doRecordRulesPre(formVals); + + // validate data + if (!this.validateData($formView, obj, formVals)) { + // console.warn("Data is invalid."); + $formView.hideProgress?.(); + return; + } + let newFormVals; + try { + newFormVals = await this.submitValues(formVals); + } catch (err) { + formError(err.data); + return; + } + // {obj} + // The fully populated values returned back from service call + // We use this in our post processing Rules + + /* + // OLD CODE: + try { + await this.doRecordRules(newFormVals); + // make sure any updates from RecordRules get passed along here. + this.doSubmitRules(newFormVals); + formReady(newFormVals); + return newFormVals; + } catch (err) { + this.AB.notify.developer(err, { + message: "Error processing Record Rules.", + view: this.toObj(), + newFormVals: newFormVals, + }); + // Question: how do we respond to an error? + // ?? just keep going ?? + this.doSubmitRules(newFormVals); + formReady(newFormVals); + return; + } + */ + + try { + await this.doRecordRules(newFormVals); + } catch (err) { + this.AB.notify.developer(err, { + message: "Error processing Record Rules.", + view: this.toObj(), + newFormVals: newFormVals, + }); + } + + // make sure any updates from RecordRules get passed along here. + try { + this.doSubmitRules(newFormVals); + } catch (errs) { + this.AB.notify.developer(errs, { + message: "Error processing Submit Rules.", + view: this.toObj(), + newFormVals: newFormVals, + }); + } + + formReady(newFormVals); + return newFormVals; + } + + focusOnFirst() { + let topPosition = 0; + let topPositionId = ""; + this.views().forEach((item) => { + if (item.key == "textbox" || item.key == "numberbox") { + if (item.position.y == topPosition) { + // topPosition = item.position.y; + topPositionId = item.id; + } + } + }); + let childComponent = this.viewComponents[topPositionId]; + if (childComponent && $$(childComponent.ui.id)) { + $$(childComponent.ui.id).focus(); + } + } + + async loadDcDataOfRecordRules() { + const tasks = []; + + (this.settings?.recordRules ?? []).forEach((rule) => { + (rule?.actionSettings?.valueRules?.fieldOperations ?? []).forEach( + (op) => { + if (op.valueType !== "exist") return; + + const pullDataDC = this.AB.datacollectionByID(op.value); + + if ( + pullDataDC?.dataStatus === + pullDataDC.dataStatusFlag.notInitial + ) + tasks.push(pullDataDC.loadData()); + } + ); + }); + + await Promise.all(tasks); + + return true; + } + + get viewComponents() { + const superComponent = this.superComponent(); + return superComponent.viewComponents; + } + + warningsEval() { + super.warningsEval(); + + let DC = this.datacollection; + if (!DC) { + this.warningsMessage( + `can't resolve it's datacollection[${this.settings.dataviewID}]` + ); + } + + if (this.settings.recordRules) { + // TODO: scan recordRules for warnings + } + + if (this.settings.submitRules) { + // TODO: scan submitRules for warnings. + } + } + + async submitValues(formVals) { + // get ABModel + const model = this.datacollection.model; + if (model == null) return; + + // is this an update or create? + if (formVals.id) { + return await model.update(formVals.id, formVals); + } else { + return await model.create(formVals); + } + } + + /** + * @method deleteData + * delete data in to database + * @param $formView - webix's form element + * + * @return {Promise} + */ + async deleteData($formView) { + // get ABDatacollection + const dc = this.datacollection; + if (dc == null) return; + + // get ABObject + const obj = dc.datasource; + if (obj == null) return; + + // get ABModel + const model = dc.model; + if (model == null) return; + + // get update data + const formVals = $formView.getValues(); + + if (formVals?.id) { + const result = await model.delete(formVals.id); + + // clear form + if (result) { + dc.setCursor(null); + $formView.clear(); + } + + return result; + } + } + }; + +} + diff --git a/AppBuilder/platform/plugins/included/view_form/FNAbviewformComponent.js b/AppBuilder/platform/plugins/included/view_form/FNAbviewformComponent.js new file mode 100644 index 00000000..509656c1 --- /dev/null +++ b/AppBuilder/platform/plugins/included/view_form/FNAbviewformComponent.js @@ -0,0 +1,591 @@ +import ABViewFormItem from "../../../views/ABViewFormItem.js"; +import ABViewFormCustom from "../../../views/ABViewFormCustom.js"; +import ABViewFormTextbox from "../../../views/ABViewFormTextbox.js"; +import ABViewFormJson from "../../../views/ABViewFormJson.js"; + +export default function FNAbviewformComponent({ + /*AB,*/ + ABViewComponentPlugin, +}) { + const fieldValidations = []; + + return class ABAbviewformComponent extends ABViewComponentPlugin { + + + constructor(baseView, idBase, ids) { + super( + baseView, + idBase || `ABViewForm_${baseView.id}`, + Object.assign( + { + form: "", + + layout: "", + filterComplex: "", + }, + ids + ) + ); + + this.timerId = null; + this._showed = false; + } + + ui() { + const baseView = this.view; + const superComponent = baseView.superComponent(); + const rows = superComponent.ui().rows ?? []; + const fieldValidationsHolder = this.uiValidationHolder(); + const _ui = super.ui([ + { + id: this.ids.form, + view: "form", + abid: baseView.id, + rows: rows.concat(fieldValidationsHolder), + }, + ]); + + delete _ui.type; + + return _ui; + } + + uiValidationHolder() { + const result = [ + { + hidden: true, + rows: [], + }, + ]; + + // NOTE: this._currentObject can be set in the KanBan Side Panel + const baseView = this.view; + const object = this.datacollection?.datasource ?? baseView._currentObject; + + if (!object) return result; + + const validationUI = []; + const existsFields = baseView.fieldComponents(); + + object + // Pull fields that have validation rules + .fields((f) => f?.settings?.validationRules) + .forEach((f) => { + const view = existsFields.find( + (com) => f.id === com.settings.fieldId + ); + if (!view) return; + + // parse the rules because they were stored as a string + // check if rules are still a string...if so lets parse them + if (typeof f.settings.validationRules === "string") { + f.settings.validationRules = JSON.parse( + f.settings.validationRules + ); + } + + // there could be more than one so lets loop through and build the UI + f.settings.validationRules.forEach((rule, indx) => { + const Filter = this.AB.filterComplexNew( + `${f.columnName}_${indx}` + ); + // add the new ui to an array so we can add them all at the same time + if (typeof Filter.ui === "function") { + validationUI.push(Filter.ui()); + } else { + // Legacy v1 method: + validationUI.push(Filter.ui); + } + + // store the filter's info so we can assign values and settings after the ui is rendered + fieldValidations.push({ + filter: Filter, + view: Filter.ids.querybuilder, + columnName: f.columnName, + validationRules: rule.rules, + invalidMessage: rule.invalidMessage, + }); + }); + }); + + result.rows = validationUI; + + return result; + } + + async init(AB, accessLevel, options = {}) { + await super.init(AB); + + this.view.superComponent().init(AB, accessLevel, options); + + this.initCallbacks(options); + this.initEvents(); + this.initValidationRules(); + + const abWebix = this.AB.Webix; + const $form = $$(this.ids.form); + + if ($form) { + abWebix.extend($form, abWebix.ProgressBar); + } + + if (accessLevel < 2) $form.disable(); + } + + initCallbacks(options = {}) { + // ? We need to determine from these options whether to clear on load? + if (options?.clearOnLoad) { + // does this need to be a function? + this.view.settings.clearOnLoad = options.clearOnLoad(); + } + // Q: Should we use emit the event instead ? + const baseView = this.view; + + if (options.onBeforeSaveData) + baseView._callbacks.onBeforeSaveData = options.onBeforeSaveData; + else baseView._callbacks.onBeforeSaveData = () => true; + } + + initEvents() { + // bind a data collection to form component + const dc = this.datacollection; + + if (!dc) return; + + // listen DC events + ["changeCursor", "cursorStale"].forEach((key) => { + this.eventAdd({ + emitter: dc, + eventName: key, + listener: (rowData) => { + const baseView = this.view; + const linkViaOneConnection = baseView.fieldComponents( + (comp) => comp instanceof ABViewFormConnect + ); + // clear previous xxx->one selections and add new from + // cursor change + linkViaOneConnection.forEach((f) => { + const field = f.field(); + if ( + field?.settings?.linkViaType == "one" && + field?.linkViaOneValues + ) { + delete field.linkViaOneValues; + const relationVals = + rowData?.[field.relationName()] ?? + rowData?.[field.columnName]; + if (relationVals) { + if (Array.isArray(relationVals)) { + const valArray = []; + relationVals.forEach((v) => { + valArray.push( + field.getRelationValue(v, { forUpdate: true }) + ); + }); + field.linkViaOneValues = valArray.join(","); + } else { + field.linkViaOneValues = field.getRelationValue( + relationVals, + { forUpdate: true } + ); + } + } + } + }); + + this.displayData(rowData); + }, + }); + }); + + const ids = this.ids; + + this.eventAdd({ + emitter: dc, + eventName: "initializingData", + listener: () => { + const $form = $$(ids.form); + + if ($form) { + $form.disable(); + + $form.showProgress?.({ type: "icon" }); + } + }, + }); + + this.eventAdd({ + emitter: dc, + eventName: "initializedData", + listener: () => { + const $form = $$(ids.form); + + if ($form) { + $form.enable(); + + $form.hideProgress?.(); + } + }, + }); + + // I think this case is currently handled by the DC.[changeCursor, cursorStale] + // events: + // this.eventAdd({ + // emitter: dc, + // eventName: "ab.datacollection.update", + // listener: (msg, data) => { + // if (!data?.objectId) return; + + // const object = dc.datasource; + + // if (!object) return; + + // if ( + // object.id === data.objectId || + // object.fields((f) => f.settings.linkObject === data.objectId) + // .length > 0 + // ) { + // const currData = dc.getCursor(); + + // if (currData) this.displayData(currData); + // } + // }, + // }); + + // bind the cursor event of the parent DC + const linkDv = dc.datacollectionLink; + + if (linkDv) + // update the value of link field when data of the parent dc is changed + ["changeCursor", "cursorStale"].forEach((key) => { + this.eventAdd({ + emitter: linkDv, + eventName: key, + listener: (rowData) => { + this.displayParentData(rowData); + }, + }); + }); + } + + initValidationRules() { + const dc = this.datacollection; + + if (!dc) return; + + if (!fieldValidations.length) return; + + // we need to store the rules for use later so lets build a container array + const complexValidations = []; + + fieldValidations.forEach((f) => { + // init each ui to have the properties (app and fields) of the object we are editing + f.filter.applicationLoad?.(dc.datasource.application); // depreciated. + f.filter.fieldsLoad(dc.datasource.fields()); + // now we can set the value because the fields are properly initialized + f.filter.setValue(f.validationRules); + + // if there are validation rules present we need to store them in a lookup hash + // so multiple rules can be stored on a single field + if (!Array.isArray(complexValidations[f.columnName])) + complexValidations[f.columnName] = []; + + // now we can push the rules into the hash + // what happens if $$(f.view) isn't present? + if ($$(f.view)) { + complexValidations[f.columnName].push({ + filters: $$(f.view).getFilterHelper(), + // values: $$(ids.form).getValues(), + invalidMessage: f.invalidMessage, + }); + } + }); + + const ids = this.ids; + + // use the lookup to build the validation rules + Object.keys(complexValidations).forEach((key) => { + // get our field that has validation rules + const formField = $$(ids.form).queryView({ + name: key, + }); + + if (!formField) return; + + // store the rules in a data param to be used later + formField.$view.complexValidations = complexValidations[key]; + // define validation rules + formField.define("validate", function (nval, oval, field) { + // get field now that we are validating + const fieldValidating = $$(ids.form)?.queryView({ + name: field, + }); + if (!fieldValidating) return true; + + // default valid is true + let isValid = true; + + // check each rule that was stored previously on the element + fieldValidating.$view.complexValidations.forEach((filter) => { + const object = dc.datasource; + const data = this.getValues(); + + // convert rowData from { colName : data } to { id : data } + const newData = {}; + + (object.fields() || []).forEach((field) => { + newData[field.id] = data[field.columnName]; + }); + + // for the case of "this_object" conditions: + if (data.uuid) newData["this_object"] = data.uuid; + + // use helper funtion to check if valid + const ruleValid = filter.filters(newData); + + // if invalid we need to tell the field + if (!ruleValid) { + isValid = false; + // we also need to define an error message + fieldValidating.define( + "invalidMessage", + filter.invalidMessage + ); + } + }); + + return isValid; + }); + + formField.refresh(); + }); + } + + async onShow(data) { + this.saveButton?.disable(); + + this._showed = true; + + const baseView = this.view; + + // call .onShow in the base component + const superComponent = baseView.superComponent(); + await superComponent.onShow(); + + const $form = $$(this.ids.form); + const dc = this.datacollection; + + if (dc) { + // clear current cursor on load + // if (this.settings.clearOnLoad || _logic.callbacks.clearOnLoad() ) { + const settings = this.settings; + + if (settings.clearOnLoad) { + dc.setCursor(null); + } + + // pull data of current cursor + // await dc.waitReady(); + const rowData = dc.getCursor(); + + if ($form) dc.bind($form); + + // do this for the initial form display so we can see defaults + await this.displayData(rowData); + } + // show blank data in the form + else await this.displayData(data ?? {}); + + //Focus on first focusable component + this.focusOnFirst(); + + if ($form) $form.adjust(); + + // Load data of DCs that are use in record rules here + // no need to wait until they are done. (Let the save button enable) + // It will be re-check again when saving. + baseView.loadDcDataOfRecordRules(); + + this.saveButton?.enable(); + } + + async displayData(rowData) { + // If setTimeout is already scheduled, no need to do anything + if (this.timerId) return; + this.timerId = true; + await new Promise((resolve) => setTimeout(resolve, 80)); + + const baseView = this.view; + const customFields = baseView.fieldComponents( + (comp) => + comp instanceof ABViewFormCustom || + // rich text + (comp instanceof ABViewFormTextbox && + comp.settings.type === "rich") || + (comp instanceof ABViewFormJson && comp.settings.type === "filter") + ); + + const normalFields = baseView.fieldComponents( + (comp) => + comp instanceof ABViewFormItem && + !(comp instanceof ABViewFormCustom) + ); + + // Set default values + if (!rowData) { + customFields.forEach((f) => { + const field = f.field(); + if (!field) return; + + const comp = baseView.viewComponents[f.id]; + if (!comp) return; + + // var colName = field.columnName; + if (this._showed) comp?.onShow?.(); + + // set value to each components + const defaultRowData = {}; + + field.defaultValue(defaultRowData); + field.setValue($$(comp.ids.formItem), defaultRowData); + + comp?.refresh?.(defaultRowData); + }); + + normalFields.forEach((f) => { + if (f.key === "button") return; + + const field = f.field(); + if (!field) return; + + const comp = baseView.viewComponents[f.id]; + if (!comp) return; + + const colName = field.columnName; + + // set value to each components + const values = {}; + + field.defaultValue(values); + $$(comp.ids.formItem)?.setValue(values[colName] ?? ""); + }); + + // select parent data to default value + const dc = this.datacollection; + const linkDv = dc.datacollectionLink; + + if (linkDv) { + const parentData = linkDv.getCursor(); + + this.displayParentData(parentData); + } + } + + // Populate value to custom fields + else { + customFields.forEach((f) => { + const comp = baseView.viewComponents[f.id]; + if (!comp) return; + + if (this._showed) comp?.onShow?.(); + + // set value to each components + f?.field()?.setValue($$(comp.ids.formItem), rowData); + + comp?.refresh?.(rowData); + }); + + normalFields.forEach((f) => { + if (f.key === "button") return; + + const field = f.field(); + if (!field) return; + + const comp = baseView.viewComponents[f.id]; + if (!comp) return; + // + if (f.key === "datepicker") { + // Not sure why, but the local format isn't applied correctly + // without a timeout here + setTimeout(() => { + field.setValue($$(comp.ids.formItem), rowData); + }, 200); + return; + } + + field.setValue($$(comp.ids.formItem), rowData); + }); + } + + this.timerId = null; + } + + displayParentData(rowData) { + const dc = this.datacollection; + + // If the cursor is selected, then it will not update value of the parent field + const currCursor = dc.getCursor(); + if (currCursor) return; + + const relationField = dc.fieldLink; + if (!relationField) return; + + const baseView = this.view; + // Pull a component of relation field + const relationFieldCom = baseView.fieldComponents((comp) => { + if (!(comp instanceof ABViewFormItem)) return false; + + return comp.field()?.id === relationField.id; + })[0]; + if (!relationFieldCom) return; + + const relationFieldView = baseView.viewComponents[relationFieldCom.id]; + if (!relationFieldView) return; + + const $relationFieldView = $$(relationFieldView.ids.formItem), + relationName = relationField.relationName(); + + // pull data of parent's dc + const formData = {}; + + formData[relationName] = rowData; + + // set data of parent to default value + relationField.setValue($relationFieldView, formData); + } + + detatch() { + // TODO: remove any handlers we have attached. + } + + focusOnFirst() { + const baseView = this.view; + + let topPosition = 0; + let topPositionId = ""; + + baseView.views().forEach((item) => { + if (item.key === "textbox" || item.key === "numberbox") + if (item.position.y === topPosition) { + topPosition = item.position.y; + topPositionId = item.id; + } + }); + + const childComponent = baseView.viewComponents[topPositionId]; + + if (childComponent && $$(childComponent.ids.formItem)) + $$(childComponent.ids.formItem).focus(); + } + + get saveButton() { + return $$(this.ids.form)?.queryView({ + view: "button", + type: "form", + }); + } + + + }; + +} diff --git a/test/AppBuilder/platform/views/ABViewDetail.test.js b/test/AppBuilder/platform/views/ABViewDetail.test.js index a3aed430..3fa4ed7d 100644 --- a/test/AppBuilder/platform/views/ABViewDetail.test.js +++ b/test/AppBuilder/platform/views/ABViewDetail.test.js @@ -1,20 +1,47 @@ import assert from "assert"; import ABFactory from "../../../../AppBuilder/ABFactory"; -import ABViewDetail from "../../../../AppBuilder/platform/views/ABViewDetail"; -import ABViewDetailComponent from "../../../../AppBuilder/platform/views/viewComponent/ABViewDetailComponent"; +import ABViewContainer from "../../../../AppBuilder/platform/views/ABViewContainer"; +import ABViewComponent from "../../../../AppBuilder/platform/views/viewComponent/ABViewComponent"; -function getTarget() { - const AB = new ABFactory(); - const application = AB.applicationNew({}); - return new ABViewDetail({}, application); -} +describe("ABViewDetail plugin", function () { -describe("ABViewDetail widget", function () { - it(".component - should return a instance of ABViewDetailComponent", function () { - const target = getTarget(); + let AB; + let application; + let viewDetail; - const result = target.component(); + before(function () { + AB = new ABFactory(); + // Only load plugins so "detail" is registered; skip full init() to avoid Network/socket.io in test env. + AB.pluginLocalLoad(); + application = AB.applicationNew({}); + viewDetail = application.viewNew({ key: "detail" }, application); + }); + + it("can pull a view from ABFactory given { key: 'detail' } values", function () { + assert.ok(viewDetail, "viewNew({ key: 'detail' }) should return a view"); + }); + + it("the resulting object is a type of ABViewContainer class", function () { + assert.ok( + viewDetail instanceof ABViewContainer, + "Detail view should extend ABViewContainer" + ); + }); + + it("the object has .component() method", function () { + assert.strictEqual( + typeof viewDetail.component, + "function", + "Detail view should have a .component() method" + ); + }); - assert.equal(true, result instanceof ABViewDetailComponent); + it(".component() returns an object of type ABViewComponent class", function () { + const result = viewDetail.component(); + assert.ok( + result instanceof ABViewComponent, + ".component() should return an instance of ABViewComponent" + ); }); + });