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"
+ );
});
+
});