From 056cd34f7b3a5b0df88148b637139cf9f40509ef Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 5 Jun 2025 16:41:52 +0700 Subject: [PATCH 01/11] [wip] initial web platform plugin interface --- AppBuilder/core | 2 +- AppBuilder/platform/ABClassManager.js | 88 ++++++ AppBuilder/platform/plugins/ABModelPlugin.js | 3 + AppBuilder/platform/plugins/ABObjectPlugin.js | 78 +++++ .../plugins/ABPropertiesObjectPlugin.js | 273 ++++++++++++++++++ AppBuilder/platform/plugins/ABUIPlugin.js | 215 ++++++++++++++ ui/ClassUI.js | 10 +- 7 files changed, 664 insertions(+), 5 deletions(-) create mode 100644 AppBuilder/platform/ABClassManager.js create mode 100644 AppBuilder/platform/plugins/ABModelPlugin.js create mode 100644 AppBuilder/platform/plugins/ABObjectPlugin.js create mode 100644 AppBuilder/platform/plugins/ABPropertiesObjectPlugin.js create mode 100644 AppBuilder/platform/plugins/ABUIPlugin.js diff --git a/AppBuilder/core b/AppBuilder/core index 9a1cc265..6e61f28d 160000 --- a/AppBuilder/core +++ b/AppBuilder/core @@ -1 +1 @@ -Subproject commit 9a1cc2651159889944bfad043a6019f2deee8896 +Subproject commit 6e61f28d7833f54bc65b98a7f9312c4f7f243db8 diff --git a/AppBuilder/platform/ABClassManager.js b/AppBuilder/platform/ABClassManager.js new file mode 100644 index 00000000..7402afb2 --- /dev/null +++ b/AppBuilder/platform/ABClassManager.js @@ -0,0 +1,88 @@ +import ABUIPlugin from "./plugins/ABUIPlugin.js"; +import ABPropertiesObjectPlugin from "./plugins/ABPropertiesObjectPlugin"; +import ABObjectPlugin from "./plugins/ABObjectPlugin.js"; +import ABModelPlugin from "./plugins/ABModelPlugin.js"; + +const classRegistry = { + ObjectTypes: new Map(), + ObjectPropertiesTypes: new Map(), + FieldTypes: new Map(), + ViewTypes: new Map(), +}; + +export function getPluginAPI() { + return { + ABUIPlugin, + ABPropertiesObjectPlugin, + ABObjectPlugin, + ABModelPlugin, + // ABFieldPlugin, + // ABViewPlugin, + registerObjectPropertiesTypes: (name, ctor) => + classRegistry.ObjectPropertiesTypes.set(name, ctor), + registerObjectTypes: (name, ctor) => + classRegistry.ObjectTypes.set(name, ctor), + // registerObjectPropertyType: (name, ctor) => classRegistry.ObjectPropertiesTypes.set(name, ctor), + // registerFieldType: (name, ctor) => classRegistry.FieldTypes.set(name, ctor), + // registerViewType: (name, ctor) => classRegistry.ViewTypes.set(name, ctor), + }; +} + +// export function createField(type, config) { +// const FieldClass = classRegistry.FieldTypes.get(type); +// if (!FieldClass) throw new Error(`Unknown object type: ${type}`); +// return new FieldClass(config); +// } +export function createObject(key, config, AB) { + const ObjectClass = classRegistry.ObjectTypes.get(key); + if (!ObjectClass) throw new Error(`Unknown object type: ${key}`); + return new ObjectClass(config, AB); +} + +export function createPropertiesObject(key, config, AB) { + const ObjectClass = classRegistry.ObjectPropertiesTypes.get(key); + if (!ObjectClass) throw new Error(`Unknown object type: ${key}`); + return new ObjectClass(config, AB); +} + +export function allObjectProperties() { + return Array.from(classRegistry.ObjectPropertiesTypes.values()); +} + +// export function createObjectProperty(key, config) { +// const ObjectClass = classRegistry.ObjectPropertiesTypes.get(key); +// if (!ObjectClass) throw new Error(`Unknown object type: ${key}`); +// return new ObjectClass(config); +// } + +// export function createView(type, config) { +// const ViewClass = classRegistry.ViewTypes.get(type); +// if (!ViewClass) throw new Error(`Unknown object type: ${type}`); +// return new ViewClass(config); +// } + +/// +/// For development +/// +import propertyNSAPI from "./plugins/developer/ABPropertiesObjectNetsuiteAPI.js"; +import objectNSAPI from "./plugins/developer/ABObjectNetsuiteAPI.js"; + +export function registerLocalPlugins(API) { + let { registerObjectTypes, registerObjectPropertiesTypes } = API; + + let cPropertyNSAPI = propertyNSAPI(API); + registerObjectPropertiesTypes(cPropertyNSAPI.getPluginKey(), cPropertyNSAPI); + + let cObjectNSAPI = objectNSAPI(API); + registerObjectTypes(cObjectNSAPI.getPluginKey(), cObjectNSAPI); +} + +// module.exports = { +// getPluginAPI, +// createPropertiesObject, +// // createField, +// // createObjectProperty, +// // createView, +// // classRegistry, // Expose the registry for testing or introspection +// registerLocalPlugins, +// }; diff --git a/AppBuilder/platform/plugins/ABModelPlugin.js b/AppBuilder/platform/plugins/ABModelPlugin.js new file mode 100644 index 00000000..aaa88e00 --- /dev/null +++ b/AppBuilder/platform/plugins/ABModelPlugin.js @@ -0,0 +1,3 @@ +const ABModel = require("../ABModel"); + +export default class ABModelPlugin extends ABModel {} diff --git a/AppBuilder/platform/plugins/ABObjectPlugin.js b/AppBuilder/platform/plugins/ABObjectPlugin.js new file mode 100644 index 00000000..3efe58e3 --- /dev/null +++ b/AppBuilder/platform/plugins/ABObjectPlugin.js @@ -0,0 +1,78 @@ +import ABObject from "../ABObject.js"; + +export default class ABObjectPlugin extends ABObject { + // constructor(...params) { + // super(...params); + + // } + + static getPluginKey() { + return "ab-object-plugin"; + } + + // Format our getDbInfo() response for the ABDesigner info options. + async getDbInfo() { + /* + // Data format: + { + "definitionId": "f2416a1a-d75c-40f2-8180-bad9b5f8b9cc", + "tableName": "AB_MockupHR_TeamTargetLocation", + "fields": [ + { + "Field": "uuid", + "Type": "varchar(255)", + "Null": "NO", + "Key": "PRI", + "Default": null, + "Extra": "" + }, + { + "Field": "created_at", + "Type": "datetime", + "Null": "YES", + "Key": "", + "Default": null, + "Extra": "" + }, + { + "Field": "updated_at", + "Type": "datetime", + "Null": "YES", + "Key": "", + "Default": null, + "Extra": "" + }, + { + "Field": "properties", + "Type": "text", + "Null": "YES", + "Key": "", + "Default": null, + "Extra": "" + } + ] + } + */ + let PK = this.PK(); + let fieldInfo = []; + this.fields().forEach((f) => { + let field = { + Field: f.columnName, + Type: f.key, + Null: f.settings.required ? "NO" : "YES", + Key: PK == f.columnName ? "PRI" : "", + Default: "", + Extra: "", + }; + fieldInfo.push(field); + }); + + let TableInfo = { + definitionId: this.id, + tableName: this.tableName, + fields: fieldInfo, + }; + + return TableInfo; + } +} diff --git a/AppBuilder/platform/plugins/ABPropertiesObjectPlugin.js b/AppBuilder/platform/plugins/ABPropertiesObjectPlugin.js new file mode 100644 index 00000000..42d4d4da --- /dev/null +++ b/AppBuilder/platform/plugins/ABPropertiesObjectPlugin.js @@ -0,0 +1,273 @@ +import ABUIPlugin from "./ABUIPlugin.js"; + +function scanForSaveButton(el, idButtonSave) { + if (el.rows || el.cols || el.cells) { + let res = false; + (el.rows || el.cols || el.cells).forEach((e) => { + if (e) { + res = res || scanForSaveButton(e, idButtonSave); + } + }); + return res; + } + if (el.id && el.id == idButtonSave) { + return true; + } + return false; +} + +export default class ABPropertiesObjectPlugin extends ABUIPlugin { + constructor(key, ids = {}, AB) { + key = key ?? ABPropertiesObjectPlugin.getPluginKey(); + // make sure we have these ids defined: + ids = Object.assign( + { + form: "", + buttonSave: "", + buttonCancel: "", + }, + ids + ); + super(key, ids, AB); + // console.log("ABPropertiesObjectPlugin constructor", this); + + this.width = 800; + this.height = 500; + } + + static getPluginKey() { + return "ab-properties-object-plugin"; + } + + async init(AB) { + this.AB = AB; + + // + // setup our listeners + // + this.on("save.error", (...params) => { + this.onError(...params); + }); + + this.on("save.success", (...params) => { + this.onSuccess(...params); + }); + } + + /** + * @method onError() + * Our Error handler when the data we provided our parent + * ui_work_object_list_newObject object had an error saving + * the values. + * @param {Error|ABValidation|other} err + * The error information returned. This can be several + * different types of objects: + * - A javascript Error() object + * - An ABValidation object returned from our .isValid() + * method + * - An error response from our API call. + */ + onError(err) { + let L = this.L(); + if (err) { + console.error(err); + let message = L("the entered data is invalid"); + // if this was our Validation() object: + if (err.updateForm) { + err.updateForm(this.$form); + } else { + if (err.code && err.data) { + message = err.data?.sqlMessage ?? message; + } else { + message = err?.message ?? message; + } + } + + const values = this.$form.getValues(); + webix.alert({ + title: L("Error creating Object: {0}", [values.name]), + ok: L("fix it"), + text: message, + type: "alert-error", + }); + } + // get notified if there was an error saving. + $$(this.ids.buttonSave).enable(); + } + + /** + * @method onSuccess() + * Our success handler when the data we provided our parent + * ui_work_object_list_newObject successfully saved the values. + */ + onSuccess() { + this.formClear(); + $$(this.ids.buttonSave).enable(); + } + + ui() { + return { + id: this.ids.component, + header: this.header(), + body: { + view: "form", + id: this.ids.form, + width: this.width, + height: this.height, + rules: this.rules(), + elements: this.elementsCombined(), + }, + }; + } + + elementsCombined() { + let elements = this.elements(); + + // function scan(el) { + // if (el.rows || el.cols || el.cells) { + // let res = false; + // (el.rows || el.cols || el.cells).forEach((e) => { + // res = res || scan(e); + // }); + // return res; + // } + // if (el.id && el.id == this.ids.buttonSave) { + // return true; + // } + // return false; + // } + + let hasSaveButton = false; + elements.forEach((el) => { + if (scanForSaveButton(el, this.ids.buttonSave)) { + hasSaveButton = true; + } + }); + if (!hasSaveButton) { + let L = this.L(); + elements.push({ + margin: 5, + cols: [ + { fillspace: true }, + { + view: "button", + id: this.ids.buttonCancel, + value: L("Cancel"), + css: "ab-cancel-button", + autowidth: true, + click: () => { + this.cancel(); + }, + on: { + onAfterRender() { + ABUIPlugin.CYPRESS_REF(this); + }, + }, + }, + { + view: "button", + id: this.ids.buttonSave, + css: "webix_primary", + value: L("Add Object"), + autowidth: true, + type: "form", + click: () => { + return this.save(); + }, + on: { + onAfterRender() { + ABUIPlugin.CYPRESS_REF(this); + }, + }, + }, + ], + }); + } + return elements; + } + + cancel() { + this.formClear(); + this.emit("cancel"); + } + + formClear() { + $$(this.ids.form).clearValidation(); + $$(this.ids.form).clear(); + } + + /** + * @function save + * + * verify the current info is ok, package it, and return it to be + * added to the application.createModel() method. + */ + async save() { + var saveButton = $$(this.ids.buttonSave); + saveButton.disable(); + + // if it doesn't pass the basic form validation, return: + if (!(await this.formIsValid())) { + saveButton.enable(); + return false; + } + + var values = await this.formValues(); + + this.emit("save", values); + } + + busy() { + const $form = $$(this.ids.form); + const $saveButton = $$(this.ids.buttonSave); + + $form.showProgress({ type: "icon" }); + $saveButton.disable(); + } + + ready() { + const $form = $$(this.ids.form); + const $saveButton = $$(this.ids.buttonSave); + + $form.hideProgress(); + $saveButton.enable(); + } + + /// + /// These methods are to be overridden by the Plugin definition + /// + header() { + // this is the name used when choosing the Object Type + // tab selector. + let L = this.L(); + return L("PropertiesObjectPlugin"); + } + + rules() { + return { + // name: webix.rules.isNotEmpty, + }; + } + + elements() { + // return the webix form element definitions to appear on the page. + return []; + } + + async formIsValid() { + var Form = $$(this.ids.form); + + Form?.clearValidation(); + + // if it doesn't pass the basic form validation, return: + if (!Form.validate()) { + $$(this.ids.buttonSave)?.enable(); + return false; + } + } + + async formValues() { + var Form = $$(this.ids.form); + return Form?.getValues(); + } +} diff --git a/AppBuilder/platform/plugins/ABUIPlugin.js b/AppBuilder/platform/plugins/ABUIPlugin.js new file mode 100644 index 00000000..bd5af64c --- /dev/null +++ b/AppBuilder/platform/plugins/ABUIPlugin.js @@ -0,0 +1,215 @@ +import ClassUI from "../../../ui/ClassUI.js"; + +export default class ABUIPlugin extends ClassUI { + constructor(...params) { + super(...params); + + // this.AB = AB; + // {ABFactory} + // Our common ABFactory for our application. + + this.CurrentApplicationID = null; + // {string} uuid + // The current ABApplication.id we are working with. + + this.CurrentDatacollectionID = null; + // {string} + // the ABDataCollection.id of the datacollection we are working with. + + this.CurrentObjectID = null; + // {string} + // the ABObject.id of the object we are working with. + + this.CurrentProcessID = null; + // {string} + // the ABProcess.id of the process we are working with. + + this.CurrentQueryID = null; + // {string} + // the ABObjectQuery.id of the query we are working with. + + this.CurrentViewID = null; + // {string} + // the ABView.id of the view we are working with. + } + + static getPluginKey() { + return "ab-ui-plugin"; + } + + /** + * @method L() + * return a function that can be used to retrieve the a multilingual + * label for this plugin. + * @returns {string} + */ + L() { + let _self = this; + return function (...params) { + return _self.AB.Multilingual.labelPlugin( + _self.constructor.getPluginKey(), + ...params + ); + }; + } + + /** + * @function applicationLoad + * save the ABApplication.id of the current application. + * @param {ABApplication} app + */ + applicationLoad(app) { + this.CurrentApplicationID = app?.id; + } + + datacollectionLoad(dc) { + this.CurrentDatacollectionID = dc?.id; + } + + objectLoad(obj) { + this.CurrentObjectID = obj?.id; + } + + processLoad(process) { + this.CurrentProcessID = process?.id; + } + + queryLoad(query) { + this.CurrentQueryID = query?.id; + } + + versionLoad(version) { + this.CurrentVersionID = version?.id; + } + + viewLoad(view) { + this.CurrentViewID = view?.id; + + if (view?.application) { + this.applicationLoad(view.application); + } + } + + /** + * @method CurrentApplication + * return the current ABApplication being worked on. + * @return {ABApplication} application + */ + get CurrentApplication() { + return this.AB.applicationByID(this.CurrentApplicationID); + } + + /** + * @method CurrentDatacollection() + * A helper to return the current ABDataCollection we are working with. + * @return {ABObject} + */ + get CurrentDatacollection() { + return this.AB.datacollectionByID(this.CurrentDatacollectionID); + } + + /** + * @method CurrentObject() + * A helper to return the current ABObject we are working with. + * @return {ABObject} + */ + get CurrentObject() { + let obj = this.AB.objectByID(this.CurrentObjectID); + if (!obj) { + obj = this.AB.queryByID(this.CurrentObjectID); + } + return obj; + } + + /** + * @method CurrentProcess() + * A helper to return the current ABProcess we are working with. + * @return {ABProcess} + */ + get CurrentProcess() { + return this.AB.processByID(this.CurrentProcessID); + } + + /** + * @method CurrentQuery() + * A helper to return the current ABObjectQuery we are working with. + * @return {ABObjectQuery} + */ + get CurrentQuery() { + return this.AB.queryByID(this.CurrentQueryID); + } + + /** + * @method CurrentView() + * A helper to return the current ABView we are working with. + * @return {ABView} + */ + get CurrentView() { + return this.CurrentApplication?.views( + (v) => v.id == this.CurrentViewID + )[0]; + } + + /** + * @method datacollectionsIncluded() + * return a list of datacollections that are included in the current + * application. + * @return [{id, value, icon}] + * id: {string} the ABDataCollection.id + * value: {string} the label of the ABDataCollection + * icon: {string} the icon to display + */ + datacollectionsIncluded() { + return this.CurrentApplication?.datacollectionsIncluded() + .filter((dc) => { + const obj = dc.datasource; + return ( + dc.sourceType == "object" && !obj?.isImported && !obj?.isReadOnly + ); + }) + .map((d) => { + let entry = { id: d.id, value: d.label }; + if (d.sourceType == "query") { + entry.icon = "fa fa-filter"; + } else { + entry.icon = "fa fa-database"; + } + return entry; + }); + } + + /** + * @method uniqueIDs() + * add a unique identifier to each of our this.ids to ensure they are + * unique. Useful for components that are repeated, like items in a list. + */ + uniqueIDs() { + let uniqueInstanceID = webix.uid(); + Object.keys(this.ids).forEach((k) => { + this.ids[k] = `${this.ids[k]}_${uniqueInstanceID}`; + }); + } + + /** + * @method warningsRefresh() + * reset the warnings on the provided ABObject and then start propogating + * the "warnings" display updates. + */ + warningsRefresh(ABObject) { + ABObject?.warningsEval?.(); + this.emit("warnings"); + } + + /** + * @method warningsPropogate() + * If any of the passed in ui elements issue a "warnings" event, we will + * propogate that upwards. + */ + warningsPropogate(elements = []) { + elements.forEach((e) => { + e.on("warnings", () => { + this.emit("warnings"); + }); + }); + } +} diff --git a/ui/ClassUI.js b/ui/ClassUI.js index f4e22c22..49487460 100644 --- a/ui/ClassUI.js +++ b/ui/ClassUI.js @@ -1,7 +1,7 @@ import { EventEmitter } from "events"; -class ClassUI extends EventEmitter { - constructor(base, ids) { +export default class ClassUI extends EventEmitter { + constructor(base, ids, AB = null) { super(); this.ids = {}; @@ -56,6 +56,10 @@ class ClassUI extends EventEmitter { // and make sure there is a .component set: this.ids.component = this.ids.component || base; + + if (AB) { + this.AB = AB; + } } /** @@ -160,5 +164,3 @@ class ClassUI extends EventEmitter { return this.WARNING_ICON.replace("pulseLight", "pulseDark"); } } - -export default ClassUI; From 1bf9af37101288c0249dc26e94794697b0644e7a Mon Sep 17 00:00:00 2001 From: Johnny Hausman Date: Wed, 18 Jun 2025 13:28:00 -0500 Subject: [PATCH 02/11] [wip] Initial Properties tab for ObjectNetsuite --- .../plugins/developer/ABObjectNetsuiteAPI.js | 108 +++ .../ABPropertiesObjectNetsuiteAPI.js | 337 +++++++ .../plugins/developer/FNUIConnections.js | 819 ++++++++++++++++++ .../plugins/developer/FNUICredentials.js | 236 +++++ .../plugins/developer/FNUIFieldTest.js | 324 +++++++ .../platform/plugins/developer/FNUIFields.js | 347 ++++++++ .../platform/plugins/developer/FNUITables.js | 304 +++++++ 7 files changed, 2475 insertions(+) create mode 100644 AppBuilder/platform/plugins/developer/ABObjectNetsuiteAPI.js create mode 100644 AppBuilder/platform/plugins/developer/ABPropertiesObjectNetsuiteAPI.js create mode 100644 AppBuilder/platform/plugins/developer/FNUIConnections.js create mode 100644 AppBuilder/platform/plugins/developer/FNUICredentials.js create mode 100644 AppBuilder/platform/plugins/developer/FNUIFieldTest.js create mode 100644 AppBuilder/platform/plugins/developer/FNUIFields.js create mode 100644 AppBuilder/platform/plugins/developer/FNUITables.js diff --git a/AppBuilder/platform/plugins/developer/ABObjectNetsuiteAPI.js b/AppBuilder/platform/plugins/developer/ABObjectNetsuiteAPI.js new file mode 100644 index 00000000..622de729 --- /dev/null +++ b/AppBuilder/platform/plugins/developer/ABObjectNetsuiteAPI.js @@ -0,0 +1,108 @@ +export default function FNObjectNetsuiteAPI({ ABObjectPlugin, ABModelPlugin }) { + // + // Our ABModel for interacting with the website + // + class ABModelNetsuiteAPI extends ABModelPlugin { + /** + * @method normalizeData() + * For a Netsuite object, there are additional steps we need to handle + * to normalize our data. + */ + normalizeData(data) { + super.normalizeData(data); + + if (!Array.isArray(data)) { + data = [data]; + } + + var boolFields = this.object.fields((f) => f.key == "boolean"); + let allFields = this.object.fields(); + + data.forEach((d) => { + // Netsuite sometimes keeps keys all lowercase + // which might not match up with what it told us in the meta-catalog + // which we need: + for (var i = 0; i < allFields.length; i++) { + let actualColumn = allFields[i].columnName; + let lcColumn = actualColumn.toLowerCase(); + + if ( + typeof d[actualColumn] == "undefined" && + typeof d[lcColumn] != "undefined" + ) { + d[actualColumn] = d[lcColumn]; + delete d[lcColumn]; + } + } + + // Netsuite Booleans are "T" or "F" + boolFields.forEach((bField) => { + let val = d[bField.columnName]; + // just how many ways can a DB indicate True/False? + if (typeof val == "string") { + val = val.toLowerCase(); + + if (val === "t") val = true; + else val = false; + + d[bField.columnName] = val; + } + }); + }); + } + } + + /// + /// We return the ABObject here + /// + return class ABObjectNetsuiteAPI extends ABObjectPlugin { + constructor(...params) { + super(...params); + + this.isNetsuite = true; + } + static getPluginKey() { + return "ab-object-netsuite-api"; + } + + fromValues(attributes) { + super.fromValues(attributes); + + this.credentials = attributes.credentials ?? {}; + this.columnRef = attributes.columnRef ?? {}; + } + + /** + * @method toObj() + * + * properly compile the current state of this ABObjectQuery instance + * into the values needed for saving to the DB. + * + * @return {json} + */ + toObj() { + const result = super.toObj(); + result.plugin_key = this.constructor.getPluginKey(); + result.isNetsuite = true; + result.credentials = this.credentials; + result.columnRef = this.columnRef; + + return result; + } + + /** + * @method model + * return a Model object that will allow you to interact with the data for + * this ABObjectQuery. + */ + model() { + var model = new ABModelNetsuiteAPI(this); + + // default the context of this model's operations to this object + model.contextKey(this.constructor.contextKey()); + model.contextValues({ id: this.id }); // the datacollection.id + + return model; + } + }; +} // end of FNObjectNetsuiteAPI diff --git a/AppBuilder/platform/plugins/developer/ABPropertiesObjectNetsuiteAPI.js b/AppBuilder/platform/plugins/developer/ABPropertiesObjectNetsuiteAPI.js new file mode 100644 index 00000000..56b2e17d --- /dev/null +++ b/AppBuilder/platform/plugins/developer/ABPropertiesObjectNetsuiteAPI.js @@ -0,0 +1,337 @@ +import FNCredentials from "./FNUICredentials.js"; +import FNTables from "./FNUITables.js"; +import FNFields from "./FNUIFields.js"; +import FNFieldTest from "./FNUIFieldTest.js"; +import FNUIConnections from "./FNUIConnections.js"; + +export default function FNPropertiesObjectNetsuiteAPI({ + ABPropertiesObjectPlugin, + ABUIPlugin, +}) { + return class ABPropertiesObjectNetsuiteAPI extends ABPropertiesObjectPlugin { + constructor(...params) { + super(...params); + + this.width = 820; + this.height = 650; + + let myBase = ABPropertiesObjectNetsuiteAPI.getPluginKey(); + this.UI_Credentials = FNCredentials(this.AB, myBase, ABUIPlugin); + this.UI_Tables = FNTables(this.AB, myBase, ABUIPlugin); + this.UI_Fields = FNFields(this.AB, myBase, ABUIPlugin); + this.UI_FieldTest = FNFieldTest(this.AB, myBase, ABUIPlugin); + this.UI_Connections = FNUIConnections(this.AB, myBase, ABUIPlugin); + } + static getPluginKey() { + return "ab-object-netsuite-api"; + } + + header() { + // this is the name used when choosing the Object Type + // tab selector. + let L = this.L(); + return L("Plugin Netsuite API"); + } + + rules() { + return { + // name: webix.rules.isNotEmpty, + }; + } + + elements() { + let L = this.L(); + // return the webix form element definitions to appear on the page. + return [ + { + rows: [ + { + view: "text", + label: L("Name"), + name: "name", + required: true, + placeholder: L("Object name"), + labelWidth: 70, + }, + { + view: "checkbox", + label: L("Read Only"), + name: "readonly", + value: 0, + // disabled: true, + }, + ], + }, + { + view: "tabview", + cells: [ + this.UI_Credentials.ui(), + this.UI_Tables.ui(), + this.UI_Fields.ui(), + this.UI_Connections.ui(), + this.UI_FieldTest.ui(), + ], + }, + ]; + } + + async init(AB) { + this.AB = AB; + + this.$form = $$(this.ids.form); + AB.Webix.extend(this.$form, webix.ProgressBar); + + await this.UI_Credentials.init(AB); + this.UI_Tables.init(AB); + this.UI_Fields.init(AB); + this.UI_Connections.init(AB); + this.UI_FieldTest.init(AB); + + this.UI_Credentials.show(); + this.UI_Tables.disable(); + this.UI_Fields.disable(); + this.UI_Connections.disable(); + this.UI_FieldTest.disable(); + + // "verified" is triggered on the credentials tab once we verify + // the entered data is successful. + this.UI_Credentials.on("verified", () => { + this.UI_Tables.enable(); + let creds = this.UI_Credentials.credentials(); + this.UI_Tables.setCredentials(creds); + this.UI_Fields.setCredentials(creds); + this.UI_FieldTest.setCredentials(creds); + this.UI_Connections.setCredentials(creds); + this.UI_Tables.show(); + }); + + this.UI_Credentials.on("notverified", () => { + this.UI_Tables.disable(); + }); + + this.UI_Tables.on("tables", (tables) => { + this.UI_Connections.setAllTables(tables); + }); + + this.UI_Tables.on("table.selected", (table) => { + this.UI_Fields.enable(); + this.UI_Fields.loadFields(table); + this.UI_Fields.show(); + this.UI_Connections.setTable(table); + this.UI_FieldTest.setTable(table); + }); + + this.UI_Fields.on("connections", (list) => { + this.UI_Connections.loadConnections(list); + this.UI_Connections.enable(); + }); + + this.UI_Fields.on("fields.ready", (config) => { + this.UI_FieldTest.enable(); + this.UI_FieldTest.loadConfig(config); + }); + + this.UI_FieldTest.on("data.verfied", () => { + $$(this.ids.buttonSave).enable(); + }); + } + + formClear() { + this.$form.clearValidation(); + this.$form.clear(); + + this.UI_Credentials.formClear(); + this.UI_Tables.formClear(); + this.UI_Fields.formClear(); + this.UI_Connections.formClear(); + this.UI_FieldTest.formClear(); + + $$(this.ids.buttonSave).disable(); + } + + async formIsValid() { + var Form = $$(this.ids.form); + + Form?.clearValidation(); + + // if it doesn't pass the basic form validation, return: + if (!Form.validate()) { + $$(this.ids.buttonSave)?.enable(); + return false; + } + return true; + } + + async formValues() { + let L = this.L(); + + var Form = $$(this.ids.form); + let values = Form.getValues(); + + values.credentials = this.UI_Credentials.getValues(); + values.tableName = this.UI_Tables.getValues(); + + let allFields = this.UI_Fields.getValues(); + + // Pick out our special columns: pk, created_at, updated_at + let pkField = allFields.find((f) => f.pk); + if (!pkField) { + webix.alert({ + title: L("Error creating Object: {0}", [values.name]), + ok: L("fix it"), + text: L("No primary key specified."), + type: "alert-error", + }); + return; + } + values.primaryColumnName = pkField.column; + + values.columnRef = { created_at: null, updated_at: null }; + + ["created_at", "updated_at"].forEach((field) => { + let foundField = allFields.find((f) => f[field]); + if (foundField) { + values.columnRef[field] = foundField.column; + } + }); + + // Create a new Object + const object = AB.objectNew( + Object.assign( + { + isNetsuite: true, + plugin_key: ABPropertiesObjectNetsuiteAPI.getPluginKey(), + }, + values + ) + ); + + try { + // Add fields + + for (const f of allFields) { + let def = { + name: f.title, + label: f.title, + columnName: f.column, + key: f.abType, + }; + if (f.default) { + def.settings = {}; + def.settings.default = f.default; + } + const field = AB.fieldNew(def, object); + await field.save(true); + + // values.fieldIDs.push(field.id); + } + // values.id = object.id; + } catch (err) { + console.error(err); + } + + let allConnectFields = this.UI_Connections.getValues(); + for (var i = 0; i < allConnectFields.length; i++) { + let f = allConnectFields[i]; + /* f = + { + "thisField": "_this_object_", + "thatObject": "b7c7cca2-b919-4a90-b199-650a7a4693c1", + "thatObjectField": "custrecord_whq_teams_strategy_strtgy_cod", + "linkType": "many:one" + } + */ + + let linkObject = this.AB.objectByID(f.thatObject); + if (!linkObject) continue; + + let linkType = f.linkType; + let parts = linkType.split(":"); + let link = parts[0]; + let linkVia = parts[1]; + + let thisField = { + key: "connectObject", + // columnName: f.thisField, + label: linkObject.label, + settings: { + showIcon: "1", + + linkObject: linkObject.id, + linkType: link, + linkViaType: linkVia, + isCustomFK: 0, + indexField: "", + indexField2: "", + isSource: 0, + width: 100, + }, + }; + + let linkField = this.AB.cloneDeep(thisField); + // linkField.columnName = f.thatObjectField; + linkField.label = object.label || object.name; + linkField.settings.linkObject = object.id; + linkField.settings.linkType = linkVia; + linkField.settings.linkViaType = link; + + switch (linkType) { + case "one:one": + if (f.whichSource == "_this_") { + thisField.settings.isSource = 1; + } else { + linkField.settings.isSource = 1; + } + thisField.columnName = f.sourceField; + linkField.columnName = f.sourceField; + break; + + case "one:many": + case "many:one": + thisField.columnName = f.thatField; + linkField.columnName = f.thatField; + break; + + case "many:many": + thisField.settings.joinTable = f.joinTable; + linkField.settings.joinTable = f.joinTable; + + thisField.settings.joinTableReference = f.thisObjReference; + linkField.settings.joinTableReference = f.thatObjReference; + + thisField.settings.joinTablePK = f.joinTablePK; + linkField.settings.joinTablePK = f.joinTablePK; + + thisField.settings.joinTableEntity = f.joinTableEntity; + linkField.settings.joinTableEntity = f.joinTableEntity; + + if (f.joinActiveField != "_none_") { + thisField.settings.joinActiveField = f.joinActiveField; + thisField.settings.joinActiveValue = f.joinActiveValue; + thisField.settings.joinInActiveValue = f.joinInActiveValue; + + linkField.settings.joinActiveField = f.joinActiveField; + linkField.settings.joinActiveValue = f.joinActiveValue; + linkField.settings.joinInActiveValue = f.joinInActiveValue; + } + break; + } + + // create an initial LinkColumn + let fieldLink = linkObject.fieldNew(linkField); + await fieldLink.save(true); // should get an .id now + + // make sure I can reference field => linkColumn + thisField.settings.linkColumn = fieldLink.id; + let field = object.fieldNew(thisField); + await field.save(); + + // now update reference linkColumn => field + fieldLink.settings.linkColumn = field.id; + await fieldLink.save(); + } + + return object.toObj(); + } + }; +} diff --git a/AppBuilder/platform/plugins/developer/FNUIConnections.js b/AppBuilder/platform/plugins/developer/FNUIConnections.js new file mode 100644 index 00000000..3875f927 --- /dev/null +++ b/AppBuilder/platform/plugins/developer/FNUIConnections.js @@ -0,0 +1,819 @@ +export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { + let this_base = `${keyPlugin}-connections`; + class UIConnections extends ABUIPlugin { + constructor() { + super( + this_base, + { + form: "", + + // fieldSelector: "", + connections: "", + displayConnections: "", + displayNoConnections: "", + + fieldGrid: "", + buttonVerify: "", + buttonLookup: "", + tableName: "", + }, + AB + ); + + this.allTables = []; + // [ { id, name }, ... ] + // A list of all the available tables. This is used for identifying the + // join tables in many:many connections. + // We get this list from the Tables interface tab. + + this.credentials = {}; + // { CRED_KEY : CRED_VAL } + // The entered credential references necessary to perform our Netsuite + // operations. + + this.connectionList = null; + // {array} + // Holds an array of connection descriptions + + this.connections = null; + // {array} + // Holds the array of chosen/verified connections + } + + ui() { + let L = this.L(); + let uiConfig = this.AB.Config.uiSettings(); + + // Our webix UI definition: + return { + id: this.ids.component, + header: L("Connections"), + body: { + view: "form", + id: this.ids.form, + width: 800, + height: 450, + rules: { + // TODO: + // name: inputValidator.rules.validateObjectName + }, + elements: [ + { + view: "layout", + padding: 10, + rows: [ + { + id: this.ids.tableName, + label: L("Selected Table: {0}", [this.table]), + view: "label", + height: 40, + }, + ], + }, + + // Field Selector + { + view: "multiview", + animate: false, + borderless: true, + rows: [ + { + id: this.ids.displayNoConnections, + rows: [ + { + maxHeight: uiConfig.xxxLargeSpacer, + hidden: uiConfig.hideMobile, + }, + { + view: "label", + align: "center", + height: 200, + label: "
", + }, + { + // id: ids.error_msg, + view: "label", + align: "center", + label: L( + "You have no other Netsuite Objects imported" + ), + }, + { + // id: ids.error_msg, + view: "label", + align: "center", + label: L( + "Continue creating this object now, then create the connections on the other objects you import." + ), + }, + { + maxHeight: uiConfig.xxxLargeSpacer, + hidden: uiConfig.hideMobile, + }, + ], + }, + { + id: this.ids.displayConnections, + rows: [ + { + // id: ids.tabFields, + view: "layout", + padding: 10, + rows: [ + { + cols: [ + { + label: L("Connections"), + view: "label", + }, + { + icon: "wxi-plus", + view: "icon", + width: 38, + click: () => { + this._addConnection(); + }, + }, + ], + }, + { + view: "scrollview", + scroll: "y", + borderless: true, + padding: 0, + margin: 0, + body: { + id: this.ids.connections, + view: "layout", + padding: 0, + margin: 0, + rows: [], + }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + } + + async init(AB) { + this.AB = AB; + this.$form = $$(this.ids.form); + AB.Webix.extend(this.$form, webix.ProgressBar); + } + + disable() { + $$(this.ids.form).disable(); + } + + enable() { + $$(this.ids.form).enable(); + } + + formClear() { + this.$form.clearValidation(); + this.$form.clear(); + this.disable(); + } + + setTable(table) { + this.table = table; + $$(this.ids.tableName).setValue( + `${this.table}` + ); + } + + loadConnections(allConnections) { + this.connectionList = allConnections; + // refresh more often than on init(); + this.listNetsuiteObjects = this.AB.objects( + (o) => o.plugin_key == keyPlugin + ); + if (this.listNetsuiteObjects.length == 0) { + $$(this.ids.displayNoConnections)?.show(); + } else { + $$(this.ids.displayConnections)?.show(); + } + } + + _fieldItem(key, type) { + const self = this; + const fieldTypes = this.AB.Class.ABFieldManager.allFields(); + const fieldKeys = ["string", "LongText", "number", "date", "boolean"]; + + const L = this.L(); + + const linkTypes = ["one:one", "one:many", "many:one", "many:many"]; + const linkOptions = linkTypes.map((l) => { + return { id: l, value: l }; + }); + linkOptions.unshift({ id: "_choose", value: L("choose link type") }); + + // For the Base Object, let's include all fields that are clearly + // objects. + let fieldOptions = this.connectionList.map((conn) => { + return { + id: conn.column, + value: conn.column, + }; + }); + + let thisObjectFields = fieldOptions; + let thatObjectFields = []; + + let listOtherObjects = this.listNetsuiteObjects.map((nObj) => { + return { + id: nObj.id, + value: nObj.label, + }; + }); + listOtherObjects.unshift({ id: "_choose", value: L("Choose Object") }); + + return { + view: "form", + elements: [ + { + cols: [ + // object and type + { + rows: [ + { + placeholder: L("Existing Netsuite Object"), + options: listOtherObjects, + view: "select", + name: "thatObject", + label: L("To:"), + // value: type, + on: { + onChange: async function ( + newVal, + oldVal, + config + ) { + let connObj = self.AB.objectByID(newVal); + if (connObj) { + let result = await self.AB.Network.get({ + url: `/netsuite/table/${connObj.tableName}`, + params: { + credentials: JSON.stringify( + self.credentials + ), + }, + }); + let fields = result.filter( + (r) => r.type == "object" + ); + let options = fields.map((f) => { + return { + id: f.column, + value: f.column, + }; + }); + + // include a "_that_object_" incase this is a one:xxx + // connection. + // options.unshift({ + // id: "_that_object_", + // value: L("That Object"), + // }); + + thatObjectFields = options; + /* + let $linkColumn = + this.getParentView().getChildViews()[1]; + + $linkColumn.define("options", options); + $linkColumn.refresh(); + */ + let $rowsFieldsets = this.getParentView() + .getParentView() + .getChildViews()[1]; + + // update one:one ThatObject: + let whichOptions = $rowsFieldsets + .getChildViews()[0] + .getChildViews()[0] + .getChildViews()[1]; + let newOptions = [ + { id: "_choose", value: L("Choose") }, + { + id: "_this_", + value: L("This Object"), + }, + ]; + newOptions.push({ + id: connObj.id, + value: connObj.label, + }); + whichOptions.define( + "options", + newOptions + ); + whichOptions.refresh(); + } + }, + }, + }, + { + placeholder: "Link Type", + options: linkOptions, + view: "select", + name: "linkType", + label: L("link type"), + on: { + onChange: async function ( + newVal, + oldVal, + config + ) { + let $toObj = + this.getParentView().getChildViews()[0]; + let $linkColumn = + this.getParentView().getChildViews()[1]; + + let objID = $toObj.getValue(); + let Obj = self.AB.objectByID(objID); + + let linkVal = $linkColumn.getValue(); + let links = linkVal.split(":"); + let messageA = self.message( + L("This object"), + links[0], + Obj.label + ); + + let messageB = self.message( + Obj.label, + links[1], + L("This object") + ); + + if (newVal == "_choose") { + messageA = messageB = ""; + } + + let $linkTextA = + this.getParentView().getChildViews()[2]; + let $linkTextB = + this.getParentView().getChildViews()[3]; + + $linkTextA.define("label", messageA); + $linkTextA.refresh(); + + $linkTextB.define("label", messageB); + $linkTextB.refresh(); + + let $rowsFieldsets = this.getParentView() + .getParentView() + .getChildViews()[1]; + + let $thatFieldOptions; + + switch (linkVal) { + case "one:one": + $rowsFieldsets + .getChildViews()[0] + .show(); + break; + + case "one:many": + // This Object's fields must be in field picker: + $thatFieldOptions = $rowsFieldsets + .getChildViews()[1] + .getChildViews()[0] + .getChildViews()[1]; + $thatFieldOptions.define( + "options", + thisObjectFields + ); + $thatFieldOptions.refresh(); + $rowsFieldsets + .getChildViews()[1] + .show(); + break; + + case "many:one": + // This Object's fields must be in field picker: + $thatFieldOptions = $rowsFieldsets + .getChildViews()[1] + .getChildViews()[0] + .getChildViews()[1]; + $thatFieldOptions.define( + "options", + thatObjectFields + ); + $thatFieldOptions.refresh(); + $rowsFieldsets + .getChildViews()[1] + .show(); + break; + + case "many:many": + $rowsFieldsets + .getChildViews()[2] + .show(); + break; + } + }, + }, + // value: type, + }, + { + // this to that + // id: ids.fieldLink2, + view: "label", + // width: 200, + }, + { + // that to this + view: "label", + // width: 200, + }, + ], + }, + { + rows: [ + { + view: "fieldset", + label: L("one to one"), + hidden: true, + body: { + rows: [ + { + view: "label", + label: L( + "which object holds the connection value?" + ), + }, + { + view: "select", + options: [ + { + id: "_choose", + value: L("Choose Object"), + }, + { + id: "_this_", + value: L("This Object"), + }, + { + id: "_that_", + value: L("That Object"), + }, + ], + name: "whichSource", + on: { + onChange: async function ( + newVal, + oldVal, + config + ) { + if (newVal == "_choose") return; + + let $fieldPicker = + this.getParentView().getChildViews()[2]; + + if (newVal == "_this_") { + $fieldPicker.define( + "options", + thisObjectFields + ); + } else { + $fieldPicker.define( + "options", + thatObjectFields + ); + } + $fieldPicker.refresh(); + $fieldPicker.show(); + }, + }, + }, + { + view: "select", + label: L("which field"), + name: "sourceField", + options: [], + hidden: true, + }, + ], + }, + }, + { + view: "fieldset", + label: L("one:X"), + hidden: true, + body: { + rows: [ + { + view: "label", + label: L( + "which field defines the connection?" + ), + }, + { + view: "select", + // label: L("which field"), + name: "thatField", + options: [], + // hidden: false, + }, + ], + }, + }, + { + view: "fieldset", + label: L("many:many"), + hidden: true, + body: { + rows: [ + { + view: "label", + label: L( + "which table is the join table?" + ), + }, + { + view: "combo", + name: "joinTable", + options: { + filter: (item, value) => { + return ( + item.value + .toLowerCase() + .indexOf( + value.toLowerCase() + ) > -1 + ); + }, + body: { + // template: "#value#", + data: this.allTables, + }, + }, + on: { + onChange: async function ( + newVal, + oldVal, + config + ) { + let result = + await self.AB.Network.get({ + url: `/netsuite/table/${newVal}`, + params: { + credentials: + JSON.stringify( + self.credentials + ), + }, + }); + // let fields = result.filter( + // (r) => r.type == "object" + // ); + let options = result.map((f) => { + return { + id: f.column, + value: f.column, + }; + }); + + let $thisObjRef = + this.getParentView().getChildViews()[2]; + $thisObjRef.define( + "options", + options + ); + $thisObjRef.refresh(); + $thisObjRef.show(); + + let $thatObjRef = + this.getParentView().getChildViews()[3]; + $thatObjRef.define( + "options", + options + ); + $thatObjRef.refresh(); + $thatObjRef.show(); + + let $objectPK = + this.getParentView().getChildViews()[4]; + $objectPK.define( + "options", + options + ); + + let pkField = result.find( + (r) => r.title == "Internal ID" + ); + if (pkField) { + $objectPK.setValue( + pkField.column + ); + } + $objectPK.refresh(); + $objectPK.show(); + + let $entityField = + this.getParentView().getChildViews()[5]; + $entityField.define( + "options", + options + ); + + let fieldEntity = result.find( + (r) => { + if (!r.column) return false; + + return ( + r.column.indexOf( + "entity" + ) > -1 + ); + } + ); + if (fieldEntity) { + $entityField.setValue( + fieldEntity.column + ); + } + $entityField.refresh(); + $entityField.show(); + + let fOptions = + self.AB.cloneDeep(options); + fOptions.unshift({ + id: "_none_", + value: "", + }); + let $activeField = + this.getParentView().getChildViews()[6]; + $activeField.define( + "options", + fOptions + ); + $activeField.refresh(); + $activeField.show(); + }, + }, + }, + + { + view: "select", + label: L("This Object's reference"), + labelPosition: "top", + options: [], + name: "thisObjReference", + hidden: true, + }, + { + view: "select", + label: L("That Object's reference"), + labelPosition: "top", + options: [], + name: "thatObjReference", + hidden: true, + }, + { + view: "select", + label: L("Join Table Primary Key:"), + labelPosition: "top", + options: [], + name: "joinTablePK", + hidden: true, + }, + { + view: "select", + label: L( + "Which field holds the Entity:" + ), + labelPosition: "top", + options: [], + name: "joinTableEntity", + hidden: true, + }, + { + view: "select", + label: L("Join Table isActive Field:"), + labelPosition: "top", + options: [], + name: "joinActiveField", + hidden: true, + on: { + onChange: async function ( + newVal, + oldVal, + config + ) { + if (newVal != "_none_") { + // show the active/inactive value + let siblings = + this.getParentView().getChildViews(); + siblings[ + siblings.length - 2 + ].show(); + siblings[ + siblings.length - 1 + ].show(); + } + }, + }, + }, + { + view: "text", + label: L("Active Value"), + name: "joinActiveValue", + hidden: true, + value: "", + }, + { + view: "text", + label: L("InActive Value"), + name: "joinInActiveValue", + hidden: true, + value: "", + }, + ], + }, + }, + ], + }, + { + // Delete Column + rows: [ + {}, + { + icon: "wxi-trash", + view: "icon", + width: 38, + click: function () { + const $item = this.getParentView() + .getParentView() + .getParentView(); + $$(self.ids.connections).removeView($item); + }, + }, + {}, + ], + // delete Row Icon + }, + ], + }, + ], + }; + } + + _addConnection(key, type) { + const uiItem = this._fieldItem(key, type); + $$(this.ids.connections).addView(uiItem); + } + + _clearFieldItems() { + const $connections = $$(this.ids.connections); + AB.Webix.ui([], $connections); + } + + message(a, link, b) { + let L = this.L(); + + let msg; + if (link == "many") { + msg = L("{0} has many {1} entities", [a, b]); + } else { + msg = L("{0} has one {1} entity", [a, b]); + } + + return msg; + } + + ready() { + $$(this.ids.buttonVerify).enable(); + } + + setCredentials(creds) { + this.credentials = creds; + } + + setAllTables(tables) { + let L = this.L(); + this.allTables = this.AB.cloneDeep(tables); + this.allTables.unshift({ id: "_choose", value: L("Choose") }); + } + + getValues() { + let values = []; + $$(this.ids.connections) + .getChildViews() + .forEach(($row) => { + values.push($row.getValues()); + }); + return values; + } + } + + return new UIConnections(); +} diff --git a/AppBuilder/platform/plugins/developer/FNUICredentials.js b/AppBuilder/platform/plugins/developer/FNUICredentials.js new file mode 100644 index 00000000..0bed1417 --- /dev/null +++ b/AppBuilder/platform/plugins/developer/FNUICredentials.js @@ -0,0 +1,236 @@ +const KeysCredentials = [ + "NETSUITE_CONSUMER_KEY", + "NETSUITE_CONSUMER_SECRET", + "NETSUITE_TOKEN_KEY", + "NETSUITE_TOKEN_SECRET", +]; +const KeysAPI = [ + "NETSUITE_REALM", + "NETSUITE_BASE_URL", + "NETSUITE_QUERY_BASE_URL", +]; + +const KeysALL = KeysCredentials.concat(KeysAPI); + +export default function FNUICredentials(AB, base, ABUIPlugin) { + let this_base = `${base}-credentials`; + class UICredentials extends ABUIPlugin { + constructor() { + super( + this_base, + { + form: "", + buttonVerify: "", + labelVerified: "", + }, + AB + ); + + this.entries = {}; + } + static getPluginKey() { + return "ui-credentials"; + } + + ui() { + let L = this.L(); + + // Our webix UI definition: + let ui = { + id: this.ids.component, + header: L("Credentials"), + body: { + view: "form", + id: this.ids.form, + width: 820, + height: 700, + rules: { + // TODO: + // name: inputValidator.rules.validateObjectName + }, + elements: [ + { + rows: [], + }, + { + cols: [ + { fillspace: true }, + // { + // view: "button", + // id: this.ids.buttonCancel, + // value: L("Cancel"), + // css: "ab-cancel-button", + // autowidth: true, + // click: () => { + // this.cancel(); + // }, + // }, + { + view: "button", + id: this.ids.buttonVerify, + css: "webix_primary", + value: L("Verify"), + autowidth: true, + type: "form", + click: () => { + return this.verify(); + }, + }, + ], + }, + { + cols: [ + { fillspace: true }, + { + id: this.ids.labelVerified, + view: "label", + label: L( + "All parameters are valid. Continue on to select a Table to work with." + ), + hidden: true, + }, + ], + }, + ], + }, + }; + + let rows = ui.body.elements[0].rows; + let fsOauth = { + view: "fieldset", + label: L("Netsuite OAuth 1.0 Credentials"), + body: { + rows: [], + }, + }; + + let EnvInput = (k) => { + return { + cols: [ + { + id: k, + view: "text", + label: k, + name: k, + required: true, + placeholder: `ENV:${k}`, + value: `ENV:${k}`, + labelWidth: 230, + on: { + onChange: (nV /*, oV*/) => { + this.envVerify(k, nV); + }, + }, + }, + { + id: `${k}_verified`, + view: "label", + width: 20, + label: '', + hidden: true, + }, + ], + }; + }; + + KeysCredentials.forEach((k) => { + fsOauth.body.rows.push(EnvInput(k)); + }); + rows.push(fsOauth); + rows.push({ height: 15 }); + + let fsAPI = { + view: "fieldset", + label: L("Netsuite API Config"), + body: { + rows: [], + }, + }; + + KeysAPI.forEach((k) => { + fsAPI.body.rows.push(EnvInput(k)); + }); + rows.push(fsAPI); + + return ui; + } + + async init(AB) { + this.AB = AB; + + this.$form = $$(this.ids.form); + AB.Webix.extend(this.$form, webix.ProgressBar); + } + + formClear() { + this.$form.clearValidation(); + this.$form.clear(); + + KeysALL.forEach((k) => { + $$(k).setValue(`ENV:${k}`); + }); + } + + getValues() { + return this.credentials(); + } + + credentials() { + let creds = {}; + KeysALL.forEach((k) => { + let $input = $$(k); + if ($input) { + creds[k] = $input.getValue(); + } + }); + + return creds; + } + + envVerify(k, nV) { + let envKey = nV.replace("ENV:", ""); + return this.AB.Network.get({ + url: `/env/verify/${envKey}`, + }) + .then((result) => { + // console.log(result); + if (result.status == "success") { + $$(`${k}_verified`).show(); + this.entries[k] = true; + } else { + $$(`${k}_verified`).hide(); + this.entries[k] = false; + } + }) + .catch((err) => { + console.error(err); + $$(`${k}_verified`).hide(); + this.entries[k] = false; + }); + } + + async verify() { + let isVerified = true; + let AllVerifies = []; + KeysALL.forEach((k) => { + let nV = $$(k).getValue(); + AllVerifies.push(this.envVerify(k, nV)); + }); + await Promise.all(AllVerifies); + + KeysALL.forEach((k) => { + isVerified = isVerified && this.entries[k]; + }); + + if (isVerified) { + this.emit("verified"); + $$(this.ids.labelVerified)?.show(); + } else { + this.emit("notverified"); + $$(this.ids.labelVerified)?.hide(); + } + } + } + + return new UICredentials(); +} diff --git a/AppBuilder/platform/plugins/developer/FNUIFieldTest.js b/AppBuilder/platform/plugins/developer/FNUIFieldTest.js new file mode 100644 index 00000000..a0a4a3e9 --- /dev/null +++ b/AppBuilder/platform/plugins/developer/FNUIFieldTest.js @@ -0,0 +1,324 @@ +export default function FNUIFieldTest(AB, base, ABUIPlugin) { + let this_base = `${base}-field-test`; + class UIFieldTest extends ABUIPlugin { + constructor() { + super( + this_base, + { + form: "", + network: "", + dataView: "", + + buttonVerify: "", + tableName: "", + }, + AB + ); + + this.credentials = {}; + // { CRED_KEY : CRED_VAL } + // The entered credential references necessary to perform our Netsuite + // operations. + + this.fieldKeys = [ + "string", + "LongText", + "number", + "date", + "boolean", + "json", + "list", + ]; + // {array} of types of ABFields we can translate into. + + this.fieldList = null; + // {array} + // Holds an array of field descriptions + + this.table = null; + // {string} + // name of the table we are working with + } + + ui() { + let L = this.L(); + + // Our webix UI definition: + return { + id: this.ids.component, + header: L("Data Verification"), + body: { + view: "form", + id: this.ids.form, + width: 800, + height: 400, + rules: { + // TODO: + // name: inputValidator.rules.validateObjectName + }, + elements: [ + // Field Selector + { + view: "layout", + padding: 10, + rows: [ + { + cols: [ + { + id: this.ids.tableName, + label: L("Selected Table: {0}", [this.table]), + view: "label", + height: 40, + }, + {}, + ], + }, + { + view: "multiview", + // keepViews: true, + cells: [ + // Select Table indicator + { + id: this.ids.network, + rows: [ + {}, + { + view: "label", + align: "center", + height: 200, + label: "
", + }, + { + view: "label", + align: "center", + label: L( + "Gathering data from Netsuite." + ), + }, + {}, + ], + }, + { + id: this.ids.dataView, + rows: [ + {}, + { + view: "label", + label: "Waiting for response", + }, + {}, + ], + // hidden: true, + }, + ], + }, + + // { + // id: this.ids.fieldGrid, + // view: "datatable", + // resizeColumn: true, + // height: 300, + // columns: [ + // { + // id: "title", + // header: L("title"), + // editor: "text", + // }, + // { id: "column", header: L("column") }, + + // { id: "nullable", header: L("nullable") }, + // { id: "readOnly", header: L("read only") }, + // { + // id: "pk", + // header: L("is primary key"), + // template: "{common.radio()}", + // }, + // // { + // // id: "description", + // // header: L("description"), + // // fillspace: true, + // // }, + // { + // id: "abType", + // header: L("AB Field Type"), + // editor: "select", + // options: [" "].concat(this.fieldKeys), + // }, + // { + // id: "delme", + // header: "", + // template: "{common.trashIcon()}", + // }, + // ], + // editable: true, + // scroll: "y", + // onClick: { + // "wxi-trash": (e, id) => { + // debugger; + // $$(this.ids.fieldGrid).remove(id); + // }, + // }, + // }, + ], + }, + + { + cols: [ + { fillspace: true }, + // { + // view: "button", + // id: this.ids.buttonCancel, + // value: L("Cancel"), + // css: "ab-cancel-button", + // autowidth: true, + // click: () => { + // this.cancel(); + // }, + // }, + { + view: "button", + id: this.ids.buttonVerify, + css: "webix_primary", + value: L("Verify"), + autowidth: true, + type: "form", + click: () => { + return this.verify(); + }, + }, + ], + }, + ], + }, + }; + } + + async init(AB) { + this.AB = AB; + + this.$form = $$(this.ids.form); + AB.Webix.extend(this.$form, webix.ProgressBar); + } + + disable() { + $$(this.ids.form).disable(); + } + + enable() { + $$(this.ids.form).enable(); + } + + formClear() { + this.$form.clearValidation(); + this.$form.clear(); + + // reset the data view to blank + let table = { + id: this.ids.dataView, + rows: [ + {}, + { + view: "label", + label: "Waiting for response", + }, + {}, + ], + // hidden: true, + }; + webix.ui(table, $$(this.ids.dataView)); + this.disable(); + } + + setTableName() { + $$(this.ids.tableName).setValue( + `${this.table}` + ); + } + + setTable(table) { + this.table = table; + this.setTableName(); + + // this is called when a table name has been selected. + // but we need to be disabled until they have verified the + // fields. + this.formClear(); + } + + async loadConfig(config) { + this.credentials = config.credentials; + this.setTable(config.table); + this.fieldList = config.fieldList; + + $$(this.ids.network).show(); + this.busy(); + + let result = await this.AB.Network.get({ + url: `/netsuite/dataVerify/${this.table}`, + params: { + credentials: JSON.stringify(this.credentials), + }, + }); + + this.data = result; + // this.ids.dataView, + + // convert all the json types to strings for display: + this.fieldList + .filter((f) => f.abType == "json") + .forEach((f) => { + this.data.forEach((d) => { + try { + d[f.column] = JSON.stringify(d[f.column]); + } catch (e) { + console.log(e); + } + }); + }); + + this.showTable(); + this.enable(); + this.ready(); + } + + showTable() { + let table = { + id: this.ids.dataView, + view: "datatable", + columns: this.fieldList.map((f) => { + return { + id: f.column, + header: f.title, + }; + }), + data: this.data, + }; + + webix.ui(table, $$(this.ids.dataView)); + $$(this.ids.dataView).show(); + } + + verify() { + this.emit("data.verfied"); + } + + busy() { + const $verifyButton = $$(this.ids.buttonVerify); + + this.$form.showProgress({ type: "icon" }); + $verifyButton.disable(); + } + + ready() { + const $verifyButton = $$(this.ids.buttonVerify); + + this.$form.hideProgress(); + $verifyButton.enable(); + } + + setCredentials(creds) { + this.credentials = creds; + } + } + return new UIFieldTest(); +} diff --git a/AppBuilder/platform/plugins/developer/FNUIFields.js b/AppBuilder/platform/plugins/developer/FNUIFields.js new file mode 100644 index 00000000..f30faba4 --- /dev/null +++ b/AppBuilder/platform/plugins/developer/FNUIFields.js @@ -0,0 +1,347 @@ +export default function FNUICredentials(AB, base, ABUIPlugin) { + let this_base = `${base}-fields`; + class UIFields extends ABUIPlugin { + constructor() { + super( + this_base, + { + form: "", + tableName: "", + fieldSelector: "", + fieldGrid: "", + buttonVerify: "", + }, + AB + ); + + this.credentials = {}; + // { CRED_KEY : CRED_VAL } + // The entered credential references necessary to perform our Netsuite + // operations. + + this.fieldKeys = [ + "string", + "LongText", + "number", + "date", + "datetime", + "boolean", + "json", + "list", + // "connectObject", + ]; + // {array} of types of ABFields we can translate into. + + this.fieldList = null; + // {array} + // Holds an array of field descriptions + + this.fields = null; + // {array} + // Holds the array of chosen/verified fields + } + + ui() { + let L = this.L(); + + // Our webix UI definition: + return { + id: this.ids.component, + header: L("Fields"), + body: { + view: "form", + id: this.ids.form, + width: 800, + height: 450, + rules: { + // TODO: + // name: inputValidator.rules.validateObjectName + }, + elements: [ + // Field Selector + { + id: this.ids.fieldSelector, + view: "layout", + padding: 10, + rows: [ + { + rows: [ + { + id: this.ids.tableName, + label: L("Selected Table: {0}", [this.table]), + view: "label", + height: 40, + }, + {}, + ], + }, + // { + // view: "scrollview", + // scroll: "y", + // borderless: true, + // padding: 0, + // margin: 0, + // body: { + // id: this.ids.fields, + // view: "layout", + // padding: 0, + // margin: 0, + // rows: [], + // }, + // }, + { + id: this.ids.fieldGrid, + view: "datatable", + resizeColumn: true, + height: 300, + columns: [ + { + id: "title", + header: L("title"), + editor: "text", + }, + { id: "column", header: L("column") }, + + { id: "nullable", header: L("nullable") }, + { id: "readOnly", header: L("read only") }, + { + id: "default", + header: L("Default Value"), + editor: "text", + }, + { + id: "pk", + header: L("is primary key"), + template: "{common.radio()}", + }, + { + id: "created_at", + header: L("Created At"), + template: "{common.radio()}", + }, + { + id: "updated_at", + header: L("Updated At"), + template: "{common.radio()}", + }, + // { + // id: "description", + // header: L("description"), + // fillspace: true, + // }, + { + id: "abType", + header: L("AB Field Type"), + editor: "select", + options: [" "].concat(this.fieldKeys), + on: { + onChange: (newValue, oldValue) => { + // nothing to do here... + }, + }, + }, + { + id: "delme", + header: "", + template: "{common.trashIcon()}", + }, + ], + editable: true, + scroll: "xy", + onClick: { + "wxi-trash": (e, id) => { + $$(this.ids.fieldGrid).remove(id); + this.fields = this.fields.filter( + (f) => f.id != id.row + ); + }, + }, + }, + { + cols: [ + { fillspace: true }, + // { + // view: "button", + // id: this.ids.buttonCancel, + // value: L("Cancel"), + // css: "ab-cancel-button", + // autowidth: true, + // click: () => { + // this.cancel(); + // }, + // }, + { + view: "button", + id: this.ids.buttonVerify, + css: "webix_primary", + value: L("Verify"), + autowidth: true, + type: "form", + click: () => { + return this.verify(); + }, + }, + ], + }, + ], + }, + ], + }, + }; + } + + async init(AB) { + this.AB = AB; + + this.$form = $$(this.ids.form); + + this.$fieldSelector = $$(this.ids.fieldSelector); + AB.Webix.extend(this.$form, webix.ProgressBar); + AB.Webix.extend(this.$fieldSelector, webix.ProgressBar); + } + + disable() { + $$(this.ids.form).disable(); + } + + enable() { + $$(this.ids.form).enable(); + } + + formClear() { + this.$form.clearValidation(); + this.$form.clear(); + + $$(this.ids.fieldGrid)?.clearAll(); + this.disable(); + $$(this.ids.buttonVerify).disable(); + } + + addABType(f) { + switch (f.type) { + case "array": + // this is most likely a MANY:x connection. + // Q:what do we default this to? + f.abType = "json"; + break; + + case "object": + // this is most likely a ONE:X[ONE,MANY] connection. + // // lets scan the properties of the dest obj, + // // find a property with title = "Internal Identifier" + // // and make this ABType == that property.type + + // if (f.properties) { + // Object.keys(f.properties).forEach((k) => { + // if (f.properties[k].title == "Internal Identifier") { + // f.abType = f.properties[k].type; + // } + // }); + // } + // // default to "string" if an Internal Identifier isn't found. + // if (!f.abType) { + // f.abType = "string"; + // } + f.abType = "connectObject"; + break; + + case "boolean": + f.abType = "boolean"; + break; + + default: + f.abType = "string"; + } + + // just in case: + // lets see if this looks like a date field instead + + if (f.abType == "string") { + let lcTitle = f.title?.toLowerCase(); + if (lcTitle) { + let indxDate = lcTitle.indexOf("date") > -1; + let indxDay = lcTitle.indexOf("day") > -1; + if (indxDate || indxDay) { + f.abType = "date"; + } + } + + if (f.format == "date-time") { + f.abType = "datetime"; + } + } + + // Seems like the PKs have title == "Internal ID" + if (f.title == "Internal ID") { + f.pk = true; + } + } + + getValues() { + return this.fields; + } + + setTableName() { + $$(this.ids.tableName).setValue( + `${this.table}` + ); + } + async loadFields(table) { + $$(this.ids.fieldGrid)?.clearAll(); + this.table = table; + $$(this.ids.fieldSelector).show(); + this.busy(); + this.setTableName(); + + let result = await this.AB.Network.get({ + url: `/netsuite/table/${table}`, + params: { credentials: JSON.stringify(this.credentials) }, + }); + + this.fieldList = result; + (result || []).forEach((f) => { + this.addABType(f); + }); + + // ok, in this pane, we are just looking at the base fields + // leave the connections to the next pane: + this.fields = result.filter((r) => r.type != "object"); + + // let our other pane know about it's connections + this.emit( + "connections", + result.filter((r) => r.type == "object") + ); + + $$(this.ids.fieldGrid).parse(this.fields); + this.ready(); + } + + busy() { + const $verifyButton = $$(this.ids.buttonVerify); + + this.$fieldSelector.showProgress({ type: "icon" }); + $verifyButton.disable(); + } + + ready() { + const $verifyButton = $$(this.ids.buttonVerify); + + this.$fieldSelector.hideProgress(); + $verifyButton.enable(); + } + + setCredentials(creds) { + this.credentials = creds; + } + + verify() { + this.emit("fields.ready", { + credentials: this.credentials, + table: this.table, + fieldList: this.fields, + }); + } + } + return new UIFields(); +} diff --git a/AppBuilder/platform/plugins/developer/FNUITables.js b/AppBuilder/platform/plugins/developer/FNUITables.js new file mode 100644 index 00000000..bb773764 --- /dev/null +++ b/AppBuilder/platform/plugins/developer/FNUITables.js @@ -0,0 +1,304 @@ +export default function FNUICredentials(AB, base, ABUIPlugin) { + let this_base = `${base}-tables`; + class UICredentials extends ABUIPlugin { + constructor() { + super( + this_base, + { + form: "", + + searchText: "", + tableList: "", + // fieldSelector: "", + fields: "", + buttonVerify: "", + buttonLookup: "", + }, + AB + ); + + this.credentials = {}; + // { CRED_KEY : CRED_VAL } + // The entered credential references necessary to perform our Netsuite + // operations. + + this.lastSelectedTable = null; + // {string} + // the table name of the last selected table. + } + + ui() { + let L = this.L(); + + // Our webix UI definition: + return { + id: this.ids.component, + header: L("Tables"), + body: { + view: "form", + id: this.ids.form, + width: 800, + height: 700, + rules: { + // TODO: + // name: inputValidator.rules.validateObjectName + }, + elements: [ + { + cols: [ + // The Table Selector Column + { + rows: [ + { + cols: [ + { + view: "label", + align: "left", + label: L( + "Use the provided credentials to request a list of tables to work with." + ), + }, + { + view: "button", + id: this.ids.buttonLookup, + value: L("Load Catalog"), + // css: "ab-cancel-button", + autowidth: true, + click: () => { + this.loadCatalog(); + }, + }, + ], + }, + { + id: this.ids.searchText, + view: "search", + icon: "fa fa-search", + label: L("Search"), + labelWidth: 80, + placeholder: L("tablename"), + height: 35, + keyPressTimeout: 100, + on: { + onAfterRender() { + AB.ClassUI.CYPRESS_REF(this); + }, + onTimedKeyPress: () => { + let searchText = $$(this.ids.searchText) + .getValue() + .toLowerCase(); + + this.$list.filter(function (item) { + return ( + !item.value || + item.value + .toLowerCase() + .indexOf(searchText) > -1 + ); + }); + }, + }, + }, + { + id: this.ids.tableList, + view: "list", + select: 1, + height: 400, + on: { + onItemClick: (id, e) => { + if (id != this.lastSelectedTable) { + this.lastSelectedTable = id; + this.emit("table.selected", id); + } + }, + }, + }, + ], + }, + + // Select Table indicator + { + rows: [ + {}, + { + view: "label", + align: "center", + height: 200, + label: "
", + }, + { + view: "label", + align: "center", + label: L("Select an table to work with."), + }, + {}, + ], + }, + ], + }, + // { + // cols: [ + // { fillspace: true }, + // // { + // // view: "button", + // // id: this.ids.buttonCancel, + // // value: L("Cancel"), + // // css: "ab-cancel-button", + // // autowidth: true, + // // click: () => { + // // this.cancel(); + // // }, + // // }, + // { + // view: "button", + // id: this.ids.buttonVerify, + // css: "webix_primary", + // value: L("Verify"), + // autowidth: true, + // type: "form", + // click: () => { + // return this.verify(); + // }, + // }, + // ], + // }, + ], + }, + }; + } + + async init(AB) { + this.AB = AB; + + this.$form = $$(this.ids.form); + this.$list = $$(this.ids.tableList); + // this.$fieldSelector = $$(this.ids.fieldSelector); + AB.Webix.extend(this.$form, webix.ProgressBar); + AB.Webix.extend(this.$list, webix.ProgressBar); + // AB.Webix.extend(this.$fieldSelector, webix.ProgressBar); + } + + disable() { + $$(this.ids.form)?.disable(); + } + + enable() { + $$(this.ids.form)?.enable(); + } + + formClear() { + this.$form.clearValidation(); + this.$form.clear(); + + $$(this.ids.searchText).setValue(""); + this.$list.filter(() => true); + this.lastSelectedTable = null; + } + + getValues() { + return this.lastSelectedTable; + } + + async loadCatalog() { + this.busy(); + let result = await this.AB.Network.get({ + url: "/netsuite/metadata", + params: { credentials: JSON.stringify(this.credentials) }, + }); + + let data = []; + result.forEach((r) => { + data.push({ id: r, value: r }); + }); + let $table = $$(this.ids.tableList); + $table.clearAll(); + $table.parse(data); + + // console.error(data); + this.emit("tables", data); + } + + _fieldItem(def) { + const self = this; + const fieldTypes = this.AB.Class.ABFieldManager.allFields(); + const fieldKeys = ["string", "LongText", "number", "date", "boolean"]; + + let key = def.column || def.title; + let type = def.type; + + return { + cols: [ + { + view: "text", + value: key, + placeholder: "key", + }, + { + placeholder: "Type", + options: fieldKeys.map((fKey) => { + return { + id: fKey, + value: fieldTypes + .filter((f) => f.defaults().key == fKey)[0] + ?.defaults().menuName, + }; + }), + view: "select", + value: type, + }, + { + icon: "wxi-trash", + view: "icon", + width: 38, + click: function () { + const $item = this.getParentView(); + $$(self.ids.fields).removeView($item); + }, + }, + ], + }; + } + + async loadFields(table) { + // $$(this.ids.fieldSelector).show(); + this.busyFields(); + + let result = await this.AB.Network.get({ + url: `/netsuite/table/${table}`, + params: { credentials: JSON.stringify(this.credentials) }, + }); + + this.fieldList = result; + (result || []).forEach((f) => { + const uiItem = this._fieldItem(f); + $$(this.ids.fields).addView(uiItem); + }); + this.readyFields(); + } + + setCredentials(creds) { + this.credentials = creds; + } + + busy() { + const $list = $$(this.ids.tableList); + // const $verifyButton = $$(this.ids.buttonVerify); + + $list.showProgress({ type: "icon" }); + // $verifyButton.disable(); + } + + // busyFields() { + // this.$fieldSelector.showProgress({ type: "icon" }); + // } + + ready() { + const $form = $$(this.ids.form); + // const $verifyButton = $$(this.ids.buttonVerify); + + $form.hideProgress(); + // $verifyButton.enable(); + } + } + return new UICredentials(); +} From af9de27a6cec07622a4e99cb9b8609e02e27744f Mon Sep 17 00:00:00 2001 From: Johnny Hausman Date: Mon, 18 Aug 2025 17:32:31 -0500 Subject: [PATCH 03/11] [fix] proper reference to AB factory --- .../plugins/developer/ABPropertiesObjectNetsuiteAPI.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/AppBuilder/platform/plugins/developer/ABPropertiesObjectNetsuiteAPI.js b/AppBuilder/platform/plugins/developer/ABPropertiesObjectNetsuiteAPI.js index 56b2e17d..d6142089 100644 --- a/AppBuilder/platform/plugins/developer/ABPropertiesObjectNetsuiteAPI.js +++ b/AppBuilder/platform/plugins/developer/ABPropertiesObjectNetsuiteAPI.js @@ -196,14 +196,14 @@ export default function FNPropertiesObjectNetsuiteAPI({ }); // Create a new Object - const object = AB.objectNew( + const object = this.AB.objectNew( Object.assign( { isNetsuite: true, plugin_key: ABPropertiesObjectNetsuiteAPI.getPluginKey(), }, - values - ) + values, + ), ); try { @@ -220,7 +220,7 @@ export default function FNPropertiesObjectNetsuiteAPI({ def.settings = {}; def.settings.default = f.default; } - const field = AB.fieldNew(def, object); + const field = this.AB.fieldNew(def, object); await field.save(true); // values.fieldIDs.push(field.id); From 8b0735f3050ea1b1d25047fb68fa55fe1f00959b Mon Sep 17 00:00:00 2001 From: Johnny Hausman Date: Mon, 18 Aug 2025 17:32:51 -0500 Subject: [PATCH 04/11] [wip] eslint changes --- .../plugins/developer/FNUIConnections.js | 87 ++++++++++--------- 1 file changed, 44 insertions(+), 43 deletions(-) diff --git a/AppBuilder/platform/plugins/developer/FNUIConnections.js b/AppBuilder/platform/plugins/developer/FNUIConnections.js index 3875f927..39dd3565 100644 --- a/AppBuilder/platform/plugins/developer/FNUIConnections.js +++ b/AppBuilder/platform/plugins/developer/FNUIConnections.js @@ -17,7 +17,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { buttonLookup: "", tableName: "", }, - AB + AB, ); this.allTables = []; @@ -95,7 +95,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { view: "label", align: "center", label: L( - "You have no other Netsuite Objects imported" + "You have no other Netsuite Objects imported", ), }, { @@ -103,7 +103,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { view: "label", align: "center", label: L( - "Continue creating this object now, then create the connections on the other objects you import." + "Continue creating this object now, then create the connections on the other objects you import.", ), }, { @@ -184,7 +184,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { setTable(table) { this.table = table; $$(this.ids.tableName).setValue( - `${this.table}` + `${this.table}`, ); } @@ -192,7 +192,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { this.connectionList = allConnections; // refresh more often than on init(); this.listNetsuiteObjects = this.AB.objects( - (o) => o.plugin_key == keyPlugin + (o) => o.plugin_key == keyPlugin, ); if (this.listNetsuiteObjects.length == 0) { $$(this.ids.displayNoConnections)?.show(); @@ -201,10 +201,10 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { } } - _fieldItem(key, type) { + _fieldItem(/*key, type */) { const self = this; - const fieldTypes = this.AB.Class.ABFieldManager.allFields(); - const fieldKeys = ["string", "LongText", "number", "date", "boolean"]; + // const fieldTypes = this.AB.Class.ABFieldManager.allFields(); + // const fieldKeys = ["string", "LongText", "number", "date", "boolean"]; const L = this.L(); @@ -252,8 +252,9 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { on: { onChange: async function ( newVal, - oldVal, - config + /* oldVal, + config, + */ ) { let connObj = self.AB.objectByID(newVal); if (connObj) { @@ -261,12 +262,12 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { url: `/netsuite/table/${connObj.tableName}`, params: { credentials: JSON.stringify( - self.credentials + self.credentials, ), }, }); let fields = result.filter( - (r) => r.type == "object" + (r) => r.type == "object", ); let options = fields.map((f) => { return { @@ -312,7 +313,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { }); whichOptions.define( "options", - newOptions + newOptions, ); whichOptions.refresh(); } @@ -328,8 +329,8 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { on: { onChange: async function ( newVal, - oldVal, - config + /*oldVal, + config, */ ) { let $toObj = this.getParentView().getChildViews()[0]; @@ -344,13 +345,13 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { let messageA = self.message( L("This object"), links[0], - Obj.label + Obj.label, ); let messageB = self.message( Obj.label, links[1], - L("This object") + L("This object"), ); if (newVal == "_choose") { @@ -389,7 +390,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { .getChildViews()[1]; $thatFieldOptions.define( "options", - thisObjectFields + thisObjectFields, ); $thatFieldOptions.refresh(); $rowsFieldsets @@ -405,7 +406,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { .getChildViews()[1]; $thatFieldOptions.define( "options", - thatObjectFields + thatObjectFields, ); $thatFieldOptions.refresh(); $rowsFieldsets @@ -447,7 +448,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { { view: "label", label: L( - "which object holds the connection value?" + "which object holds the connection value?", ), }, { @@ -470,8 +471,8 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { on: { onChange: async function ( newVal, - oldVal, - config + /* oldVal, + config, */ ) { if (newVal == "_choose") return; @@ -481,12 +482,12 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { if (newVal == "_this_") { $fieldPicker.define( "options", - thisObjectFields + thisObjectFields, ); } else { $fieldPicker.define( "options", - thatObjectFields + thatObjectFields, ); } $fieldPicker.refresh(); @@ -513,7 +514,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { { view: "label", label: L( - "which field defines the connection?" + "which field defines the connection?", ), }, { @@ -535,7 +536,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { { view: "label", label: L( - "which table is the join table?" + "which table is the join table?", ), }, { @@ -547,7 +548,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { item.value .toLowerCase() .indexOf( - value.toLowerCase() + value.toLowerCase(), ) > -1 ); }, @@ -559,8 +560,8 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { on: { onChange: async function ( newVal, - oldVal, - config + /* oldVal, + config, */ ) { let result = await self.AB.Network.get({ @@ -568,7 +569,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { params: { credentials: JSON.stringify( - self.credentials + self.credentials, ), }, }); @@ -586,7 +587,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { this.getParentView().getChildViews()[2]; $thisObjRef.define( "options", - options + options, ); $thisObjRef.refresh(); $thisObjRef.show(); @@ -595,7 +596,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { this.getParentView().getChildViews()[3]; $thatObjRef.define( "options", - options + options, ); $thatObjRef.refresh(); $thatObjRef.show(); @@ -604,15 +605,15 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { this.getParentView().getChildViews()[4]; $objectPK.define( "options", - options + options, ); let pkField = result.find( - (r) => r.title == "Internal ID" + (r) => r.title == "Internal ID", ); if (pkField) { $objectPK.setValue( - pkField.column + pkField.column, ); } $objectPK.refresh(); @@ -622,7 +623,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { this.getParentView().getChildViews()[5]; $entityField.define( "options", - options + options, ); let fieldEntity = result.find( @@ -631,14 +632,14 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { return ( r.column.indexOf( - "entity" + "entity", ) > -1 ); - } + }, ); if (fieldEntity) { $entityField.setValue( - fieldEntity.column + fieldEntity.column, ); } $entityField.refresh(); @@ -654,7 +655,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { this.getParentView().getChildViews()[6]; $activeField.define( "options", - fOptions + fOptions, ); $activeField.refresh(); $activeField.show(); @@ -689,7 +690,7 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { { view: "select", label: L( - "Which field holds the Entity:" + "Which field holds the Entity:", ), labelPosition: "top", options: [], @@ -706,8 +707,8 @@ export default function FNUIConnections(AB, keyPlugin, ABUIPlugin) { on: { onChange: async function ( newVal, - oldVal, - config + /* oldVal, + config, */ ) { if (newVal != "_none_") { // show the active/inactive value From a7102b9d7d436484132ddf1052708d3bbca1de18 Mon Sep 17 00:00:00 2001 From: Johnny Hausman Date: Mon, 18 Aug 2025 17:33:30 -0500 Subject: [PATCH 05/11] [wip] STEP 1: transition to non integrated plugin code --- AppBuilder/platform/ABClassManager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AppBuilder/platform/ABClassManager.js b/AppBuilder/platform/ABClassManager.js index 7402afb2..b4c1187d 100644 --- a/AppBuilder/platform/ABClassManager.js +++ b/AppBuilder/platform/ABClassManager.js @@ -64,7 +64,7 @@ export function allObjectProperties() { /// /// For development /// -import propertyNSAPI from "./plugins/developer/ABPropertiesObjectNetsuiteAPI.js"; +import propertyNSAPI from "../../../plugins/ab-object-netsuite-api/properties/ABPropertiesObjectNetsuiteAPI.js"; import objectNSAPI from "./plugins/developer/ABObjectNetsuiteAPI.js"; export function registerLocalPlugins(API) { From b1e6cc08cc0284531a7c80c571d864dc5b5906b4 Mon Sep 17 00:00:00 2001 From: Johnny Hausman Date: Sat, 8 Nov 2025 17:36:18 +0700 Subject: [PATCH 06/11] [wip] include ABFormUrl --- AppBuilder/core | 2 +- AppBuilder/platform/views/ABViewForm.js | 34 +++++++++--------- AppBuilder/platform/views/ABViewFormURL.js | 40 ++++++++++++++++++++++ webpack.dev.js | 2 ++ 4 files changed, 61 insertions(+), 17 deletions(-) create mode 100644 AppBuilder/platform/views/ABViewFormURL.js diff --git a/AppBuilder/core b/AppBuilder/core index 58d850db..3c69b2ab 160000 --- a/AppBuilder/core +++ b/AppBuilder/core @@ -1 +1 @@ -Subproject commit 58d850db85a3fae52d0716d93a03fb5033226a0d +Subproject commit 3c69b2abdf2d213d91c1217a8958c0b9a68210b2 diff --git a/AppBuilder/platform/views/ABViewForm.js b/AppBuilder/platform/views/ABViewForm.js index 02cbcb2a..6b36da57 100644 --- a/AppBuilder/platform/views/ABViewForm.js +++ b/AppBuilder/platform/views/ABViewForm.js @@ -327,10 +327,6 @@ module.exports = class ABViewForm extends ABViewFormCore { const obj = dv.datasource; if (obj == null) return; - // get ABModel - const model = dv.model; - if (model == null) return; - // show progress icon $formView.showProgress?.({ type: "icon" }); @@ -414,23 +410,16 @@ module.exports = class ABViewForm extends ABViewFormCore { $formView.hideProgress?.(); return; } - let newFormVals; - // {obj} - // The fully populated values returned back from service call - // We use this in our post processing Rules - try { - // is this an update or create? - if (formVals.id) { - newFormVals = await model.update(formVals.id, formVals); - } else { - newFormVals = await model.create(formVals); - } + newFormVals = await this.submitValues(formVals); } catch (err) { formError(err.data); - throw err; + return; } + // {obj} + // The fully populated values returned back from service call + // We use this in our post processing Rules /* // OLD CODE: @@ -544,6 +533,19 @@ module.exports = class ABViewForm extends ABViewFormCore { } } + 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 diff --git a/AppBuilder/platform/views/ABViewFormURL.js b/AppBuilder/platform/views/ABViewFormURL.js new file mode 100644 index 00000000..1458debc --- /dev/null +++ b/AppBuilder/platform/views/ABViewFormURL.js @@ -0,0 +1,40 @@ +const ABViewForm = require("./ABViewForm"); + +const ABViewFormURLDefaults = { + key: "form-url", // unique key identifier for this ABViewForm + icon: "list-alt", // icon reference: (without 'fa-' ) + labelKey: "FormUrl", // {string} the multilingual label key for the class label +}; + +module.exports = class ABViewFormURL extends ABViewForm { + static common() { + return ABViewFormURLDefaults; + } + + async submitValues(formVals) { + let url = this.settings.url; + let method = this.settings.method || "get"; + method = method.toLowerCase(); + if (!["get", "post", "put", "delete"].includes(method)) { + throw new Error( + `Invalid method "${method}" specified for ABViewFormURL` + ); + } + + // remove empty id from formVals + if (formVals.id === "") { + delete formVals.id; + } + + let params = { + data: formVals, + url, + }; + + if (this.settings.headers) { + params.headers = this.settings.headers; + } + + return await this.AB.Network[method](params); + } +}; diff --git a/webpack.dev.js b/webpack.dev.js index ce3f74a8..01f0de8c 100644 --- a/webpack.dev.js +++ b/webpack.dev.js @@ -7,6 +7,8 @@ const webpack = require("webpack"); module.exports = merge(common, { mode: "development", + // Use 'eval-source-map' for faster builds, or 'source-map' for better quality + // 'source-map' provides the best debugging experience for library code devtool: "source-map", module: { rules: [ From 54efabc0b0f2bfaa8ee4ad4091ada28097d552aa Mon Sep 17 00:00:00 2001 From: Johnny Hausman Date: Sat, 8 Nov 2025 17:38:04 +0700 Subject: [PATCH 07/11] [wip] dynamically loading plugin objects --- AppBuilder/platform/ABClassManager.js | 48 ++++++++++++++++++++----- init/Bootstrap.js | 51 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/AppBuilder/platform/ABClassManager.js b/AppBuilder/platform/ABClassManager.js index b4c1187d..c5fbe3a7 100644 --- a/AppBuilder/platform/ABClassManager.js +++ b/AppBuilder/platform/ABClassManager.js @@ -10,6 +10,14 @@ const classRegistry = { ViewTypes: new Map(), }; +/** + * @method getPluginAPI() + * This is the data structure we provide to each of our plugins so they + * can register their custom classes. + * We provide base objects from which they can extend, as well as functions to + * call to register their custom classes. + * @returns {Object} + */ export function getPluginAPI() { return { ABUIPlugin, @@ -61,20 +69,42 @@ export function allObjectProperties() { // return new ViewClass(config); // } +export function pluginRegister(pluginClass) { + let type = pluginClass.getPluginType(); + switch (type) { + case "object": + // eslint-disable-next-line no-case-declarations + let { registerObjectTypes } = getPluginAPI(); + registerObjectTypes(pluginClass.getPluginKey(), pluginClass); + break; + case "properties-object": + // eslint-disable-next-line no-case-declarations + let { registerObjectPropertiesTypes } = getPluginAPI(); + registerObjectPropertiesTypes(pluginClass.getPluginKey(), pluginClass); + break; + // case "field": + // break; + // case "view": + // break; + default: + throw new Error( + `ABClassManager.pluginRegister():: Unknown plugin type: ${type}` + ); + } +} + /// /// For development /// -import propertyNSAPI from "../../../plugins/ab-object-netsuite-api/properties/ABPropertiesObjectNetsuiteAPI.js"; -import objectNSAPI from "./plugins/developer/ABObjectNetsuiteAPI.js"; +// import propertyNSAPI from "../../../plugins/ab_plugin_object_netsuite_api/properties/ABPropertiesObjectNetsuiteAPI.js"; +// import objectNSAPI from "./plugins/developer/ABObjectNetsuiteAPI.js"; export function registerLocalPlugins(API) { - let { registerObjectTypes, registerObjectPropertiesTypes } = API; - - let cPropertyNSAPI = propertyNSAPI(API); - registerObjectPropertiesTypes(cPropertyNSAPI.getPluginKey(), cPropertyNSAPI); - - let cObjectNSAPI = objectNSAPI(API); - registerObjectTypes(cObjectNSAPI.getPluginKey(), cObjectNSAPI); + // let { registerObjectTypes, registerObjectPropertiesTypes } = API; + // let cPropertyNSAPI = propertyNSAPI(API); + // registerObjectPropertiesTypes(cPropertyNSAPI.getPluginKey(), cPropertyNSAPI); + // let cObjectNSAPI = objectNSAPI(API); + // registerObjectTypes(cObjectNSAPI.getPluginKey(), cObjectNSAPI); } // module.exports = { diff --git a/init/Bootstrap.js b/init/Bootstrap.js index 98c8b4d3..a0d4a05e 100644 --- a/init/Bootstrap.js +++ b/init/Bootstrap.js @@ -185,6 +185,57 @@ class Bootstrap extends EventEmitter { networkTestWorker, networkIsSlow ); + + const loadPlugin = async (purl) => { + // try ESM dynamic import first; fall back to UMD global + const tryImport = async (url) => { + try { + const mod = await import(/* webpackIgnore: true */ url); + // Try multiple ways to get the function + let fn = mod?.default || mod?.registerService; + if (!fn && typeof mod === "object") { + // If still not found, try to find any function export + const values = Object.values(mod); + fn = values.find((v) => typeof v === "function"); + } + return fn || null; + } catch (e) { + return null; + } + }; + + const tryUMD = async (url) => { + // inject a script tag, then look for a global export + await this.AB.scriptLoad(url); + // Conventional UMD name used by our plugin builds + const globalExport = window.Plugin; + return ( + (globalExport && + (globalExport.default || + globalExport.registerService || + globalExport)) || + null + ); + }; + + let registerFn = await tryImport(purl); + if (!registerFn) { + registerFn = await tryUMD(purl); + } + if (typeof registerFn === "function") { + // Register with the ABFactory core (expects a function taking PluginAPI) + this.AB.pluginRegister(registerFn); + } else { + console.warn("Plugin did not export a function:", purl); + } + }; + const loadPlugins = async (plugins) => { + const urls = plugins || []; + await Promise.all(urls.map((p) => loadPlugin(p))); + }; + // load our installed plugins here: + await loadPlugins(window.__AB_plugins_v1); + await this.AB.init(); await webixLoading; // NOTE: special case: User has no Roles defined. From 0d6c81a5592e103325caf4e975065785fe6d60b2 Mon Sep 17 00:00:00 2001 From: Johnny Hausman Date: Sat, 8 Nov 2025 17:41:57 +0700 Subject: [PATCH 08/11] [wip] removing unnecessary code and error checking --- AppBuilder/platform/ABModel.js | 26 ------------------- .../viewComponent/ABViewFormComponent.js | 20 +++++++++----- 2 files changed, 13 insertions(+), 33 deletions(-) diff --git a/AppBuilder/platform/ABModel.js b/AppBuilder/platform/ABModel.js index 537f21c6..f70cf741 100644 --- a/AppBuilder/platform/ABModel.js +++ b/AppBuilder/platform/ABModel.js @@ -173,32 +173,6 @@ module.exports = class ABModel extends ABModelCore { /// Instance Methods /// - // Prepare multilingual fields to be untranslated - // Before untranslating we need to ensure that values.translations is set. - prepareMultilingualData(values) { - // if this object has some multilingual fields, translate the data: - var mlFields = this.object.multilingualFields(); - // if mlFields are inside of the values saved we want to translate otherwise do not because it will reset the translation field and you may loose unchanged translations - var shouldTranslate = false; - if (mlFields.length) { - mlFields.forEach(function (field) { - if (values[field] != null) { - shouldTranslate = true; - } - }); - } - if (shouldTranslate) { - if ( - values.translations == null || - typeof values.translations == "undefined" || - values.translations == "" - ) { - values.translations = []; - } - this.object.unTranslate(values, values, mlFields); - } - } - request(method, params) { return this.AB.Network[method](params); } diff --git a/AppBuilder/platform/views/viewComponent/ABViewFormComponent.js b/AppBuilder/platform/views/viewComponent/ABViewFormComponent.js index b2de3d48..ddff2b1b 100644 --- a/AppBuilder/platform/views/viewComponent/ABViewFormComponent.js +++ b/AppBuilder/platform/views/viewComponent/ABViewFormComponent.js @@ -280,7 +280,7 @@ module.exports = class ABViewFormComponent extends ABViewComponent { 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); + 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); @@ -291,11 +291,14 @@ module.exports = class ABViewFormComponent extends ABViewComponent { complexValidations[f.columnName] = []; // now we can push the rules into the hash - complexValidations[f.columnName].push({ - filters: $$(f.view).getFilterHelper(), - // values: $$(ids.form).getValues(), - invalidMessage: f.invalidMessage, - }); + // 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; @@ -307,14 +310,17 @@ module.exports = class ABViewFormComponent extends ABViewComponent { 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({ + const fieldValidating = $$(ids.form)?.queryView({ name: field, }); + if (!fieldValidating) return true; // default valid is true let isValid = true; From c575c6c67b137c65203eb2a23c534ea674226e7d Mon Sep 17 00:00:00 2001 From: Johnny Hausman Date: Tue, 11 Nov 2025 00:48:27 +0700 Subject: [PATCH 09/11] [sync] with core --- AppBuilder/core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AppBuilder/core b/AppBuilder/core index 3c69b2ab..e82c0b9d 160000 --- a/AppBuilder/core +++ b/AppBuilder/core @@ -1 +1 @@ -Subproject commit 3c69b2abdf2d213d91c1217a8958c0b9a68210b2 +Subproject commit e82c0b9da5fb097f1327327de3737dc594fdd4b4 From 30a134ad67cc1230636ac333ad173ef6de45c0e4 Mon Sep 17 00:00:00 2001 From: Johnny Hausman Date: Wed, 12 Nov 2025 15:39:33 +0700 Subject: [PATCH 10/11] [wip] plugin for ABViews now implemented --- AppBuilder/ABFactory.js | 1 + AppBuilder/platform/ABApplication.js | 18 ++ AppBuilder/platform/ABClassManager.js | 83 ++++-- AppBuilder/platform/ABViewManager.js | 25 +- .../platform/plugins/ABClassUIPlugin.js | 271 ++++++++++++++++++ AppBuilder/platform/plugins/ABObjectPlugin.js | 12 + .../platform/plugins/ABViewComponentPlugin.js | 7 + .../platform/plugins/ABViewEditorPlugin.js | 123 ++++++++ AppBuilder/platform/plugins/ABViewPlugin.js | 31 ++ .../plugins/ABViewPropertiesPlugin.js | 247 ++++++++++++++++ resources/Multilingual.js | 4 + 11 files changed, 801 insertions(+), 21 deletions(-) create mode 100644 AppBuilder/platform/plugins/ABClassUIPlugin.js create mode 100644 AppBuilder/platform/plugins/ABViewComponentPlugin.js create mode 100644 AppBuilder/platform/plugins/ABViewEditorPlugin.js create mode 100644 AppBuilder/platform/plugins/ABViewPlugin.js create mode 100644 AppBuilder/platform/plugins/ABViewPropertiesPlugin.js diff --git a/AppBuilder/ABFactory.js b/AppBuilder/ABFactory.js index 56df120f..1c9786e6 100644 --- a/AppBuilder/ABFactory.js +++ b/AppBuilder/ABFactory.js @@ -45,6 +45,7 @@ class ABValidator { constructor(AB) { this.AB = AB; this.errors = []; + this.platform = "web"; } addError(name, message) { diff --git a/AppBuilder/platform/ABApplication.js b/AppBuilder/platform/ABApplication.js index f501cd48..626b2fb4 100644 --- a/AppBuilder/platform/ABApplication.js +++ b/AppBuilder/platform/ABApplication.js @@ -332,6 +332,24 @@ module.exports = class ABClassApplication extends ABApplicationCore { return super.save(); } + viewAll(fn = () => true) { + let vmViews = super.viewAll(fn); + let pluginViews = this.AB.ClassManager.viewAll(fn); + let allViews = [...vmViews, ...pluginViews]; + let L = this.AB.Label(); + + // Sort by label from common() if available, otherwise by key + return allViews.sort((a, b) => { + const aCommon = a.common ? a.common() : {}; + const bCommon = b.common ? b.common() : {}; + const aLabel = + aCommon.label || L(aCommon.labelKey || aCommon.key) || ""; + const bLabel = + bCommon.label || L(bCommon.labelKey || bCommon.key) || ""; + return aLabel.localeCompare(bLabel); + }); + } + warningsEval() { super.warningsEval(); diff --git a/AppBuilder/platform/ABClassManager.js b/AppBuilder/platform/ABClassManager.js index c5fbe3a7..d7a3e752 100644 --- a/AppBuilder/platform/ABClassManager.js +++ b/AppBuilder/platform/ABClassManager.js @@ -2,20 +2,45 @@ import ABUIPlugin from "./plugins/ABUIPlugin.js"; import ABPropertiesObjectPlugin from "./plugins/ABPropertiesObjectPlugin"; import ABObjectPlugin from "./plugins/ABObjectPlugin.js"; import ABModelPlugin from "./plugins/ABModelPlugin.js"; +import ABViewPlugin from "./plugins/ABViewPlugin.js"; +import ABViewComponentPlugin from "./plugins/ABViewComponentPlugin.js"; +import ABViewPropertiesPlugin from "./plugins/ABViewPropertiesPlugin.js"; +import ABViewEditorPlugin from "./plugins/ABViewEditorPlugin.js"; const classRegistry = { ObjectTypes: new Map(), ObjectPropertiesTypes: new Map(), FieldTypes: new Map(), ViewTypes: new Map(), + ViewPropertiesTypes: new Map(), + ViewEditorTypes: new Map(), }; +function registerViewPropertiesTypes(name, ctor) { + classRegistry.ViewPropertiesTypes.set(name, ctor); +} + +function registerViewEditorTypes(name, ctor) { + classRegistry.ViewEditorTypes.set(name, ctor); +} + +function registerObjectPropertiesTypes(name, ctor) { + classRegistry.ObjectPropertiesTypes.set(name, ctor); +} + +function registerObjectTypes(name, ctor) { + classRegistry.ObjectTypes.set(name, ctor); +} + +function registerViewTypes(name, ctor) { + classRegistry.ViewTypes.set(name, ctor); +} + /** * @method getPluginAPI() * This is the data structure we provide to each of our plugins so they * can register their custom classes. - * We provide base objects from which they can extend, as well as functions to - * call to register their custom classes. + * We provide base objects from which they can extend. * @returns {Object} */ export function getPluginAPI() { @@ -24,15 +49,12 @@ export function getPluginAPI() { ABPropertiesObjectPlugin, ABObjectPlugin, ABModelPlugin, + ABViewPlugin, + ABViewComponentPlugin, + ABViewPropertiesPlugin, + ABViewEditorPlugin, // ABFieldPlugin, // ABViewPlugin, - registerObjectPropertiesTypes: (name, ctor) => - classRegistry.ObjectPropertiesTypes.set(name, ctor), - registerObjectTypes: (name, ctor) => - classRegistry.ObjectTypes.set(name, ctor), - // registerObjectPropertyType: (name, ctor) => classRegistry.ObjectPropertiesTypes.set(name, ctor), - // registerFieldType: (name, ctor) => classRegistry.FieldTypes.set(name, ctor), - // registerViewType: (name, ctor) => classRegistry.ViewTypes.set(name, ctor), }; } @@ -63,29 +85,50 @@ export function allObjectProperties() { // return new ObjectClass(config); // } -// export function createView(type, config) { -// const ViewClass = classRegistry.ViewTypes.get(type); -// if (!ViewClass) throw new Error(`Unknown object type: ${type}`); -// return new ViewClass(config); -// } +export function viewCreate(type, config, application, parent) { + const ViewClass = classRegistry.ViewTypes.get(type); + if (!ViewClass) throw new Error(`Unknown View type: ${type}`); + return new ViewClass(config, application, parent); +} + +export function viewAll(fn = () => true) { + return Array.from(classRegistry.ViewTypes.values()).filter(fn); +} + +export function viewPropertiesAll(fn = () => true) { + return Array.from(classRegistry.ViewPropertiesTypes.values()).filter(fn); +} + +export function viewEditorCreate(key, view, base, ids) { + const EditorClass = classRegistry.ViewEditorTypes.get(key); + if (!EditorClass) throw new Error(`Unknown View Editor type: ${key}`); + return new EditorClass(view, base, ids); +} + +export function viewEditorAll(fn = () => true) { + return Array.from(classRegistry.ViewEditorTypes.values()).filter(fn); +} export function pluginRegister(pluginClass) { let type = pluginClass.getPluginType(); switch (type) { case "object": - // eslint-disable-next-line no-case-declarations - let { registerObjectTypes } = getPluginAPI(); registerObjectTypes(pluginClass.getPluginKey(), pluginClass); break; case "properties-object": - // eslint-disable-next-line no-case-declarations - let { registerObjectPropertiesTypes } = getPluginAPI(); registerObjectPropertiesTypes(pluginClass.getPluginKey(), pluginClass); break; // case "field": // break; - // case "view": - // break; + case "view": + registerViewTypes(pluginClass.getPluginKey(), pluginClass); + break; + case "properties-view": + registerViewPropertiesTypes(pluginClass.getPluginKey(), pluginClass); + break; + case "editor-view": + registerViewEditorTypes(pluginClass.getPluginKey(), pluginClass); + break; default: throw new Error( `ABClassManager.pluginRegister():: Unknown plugin type: ${type}` diff --git a/AppBuilder/platform/ABViewManager.js b/AppBuilder/platform/ABViewManager.js index cb2ffe34..d81fc71e 100644 --- a/AppBuilder/platform/ABViewManager.js +++ b/AppBuilder/platform/ABViewManager.js @@ -1,3 +1,26 @@ const ABViewManagerCore = require("../core/ABViewManagerCore"); +const ClassManager = require("./ABClassManager"); -module.exports = class ABViewManager extends ABViewManagerCore {}; +module.exports = class ABViewManager extends ABViewManagerCore { + /** + * @function newView + * return an instance of an ABView based upon the values.key value. + * @return {ABView} + */ + static newView(values, application, parent) { + parent = parent || null; + + // check to see if this is a plugin view + if (values.plugin_key) { + // If this is from a plugin, create it from ClassManager + return ClassManager.viewCreate( + values.plugin_key, + values, + application, + parent + ); + } + + return super.newView(values, application, parent); + } +}; diff --git a/AppBuilder/platform/plugins/ABClassUIPlugin.js b/AppBuilder/platform/plugins/ABClassUIPlugin.js new file mode 100644 index 00000000..1b262d2b --- /dev/null +++ b/AppBuilder/platform/plugins/ABClassUIPlugin.js @@ -0,0 +1,271 @@ +import ClassUI from "../../../ui/ClassUI.js"; + +export default class ABClassUIPlugin extends ClassUI { + constructor(base = "class_ui", ids = {}) { + // base: {string} unique base id reference + // ids: {hash} { key => '' } + // this is provided by the Sub Class and has the keys + // unique to the Sub Class' interface elements. + + super(base, ids); + + this.base = base; + + this.AB = null; + // {ABFactory} + // Our common ABFactory for our application. + // Should be set via init(AB) method + + this.CurrentApplicationID = null; + // {string} uuid + // The current ABApplication.id we are working with. + + this.CurrentDatacollectionID = null; + // {string} + // the ABDataCollection.id of the datacollection we are working with. + + this.CurrentObjectID = null; + // {string} + // the ABObject.id of the object we are working with. + + this.CurrentProcessID = null; + // {string} + // the ABProcess.id of the process we are working with. + + this.CurrentQueryID = null; + // {string} + // the ABObjectQuery.id of the query we are working with. + + this.CurrentViewID = null; + // {string} + // the ABView.id of the view we are working with. + + this.CurrentVersionID = null; + // {string} + // the ABVersion.id of the version we are working with. + } + + /** + * @method static L() + * A static method to return a multilingual label function. + * NOTE: Sub classes should override this to provide their plugin name. + * @return {function} A function that returns multilingual labels + */ + // static L() { + // return function (...params) { + // // Default implementation - sub classes should override + // return params[0] || ""; + // }; + // } + + /** + * @function applicationLoad + * save the ABApplication.id of the current application. + * @param {ABApplication} app + */ + applicationLoad(app) { + this.CurrentApplicationID = app?.id; + } + + /** + * @function datacollectionLoad + * save the ABDataCollection.id of the current datacollection. + * @param {ABDataCollection} dc + */ + datacollectionLoad(dc) { + this.CurrentDatacollectionID = dc?.id; + } + + /** + * @function objectLoad + * save the ABObject.id of the current object. + * @param {ABObject} obj + */ + objectLoad(obj) { + this.CurrentObjectID = obj?.id; + } + + /** + * @function processLoad + * save the ABProcess.id of the current process. + * @param {ABProcess} process + */ + processLoad(process) { + this.CurrentProcessID = process?.id; + } + + /** + * @function queryLoad + * save the ABObjectQuery.id of the current query. + * @param {ABObjectQuery} query + */ + queryLoad(query) { + this.CurrentQueryID = query?.id; + } + + /** + * @function versionLoad + * save the ABVersion.id of the current version. + * @param {ABVersion} version + */ + versionLoad(version) { + this.CurrentVersionID = version?.id; + } + + /** + * @function viewLoad + * save the ABView.id of the current view. + * @param {ABView} view + */ + viewLoad(view) { + this.CurrentViewID = view?.id; + + if (view?.application) { + this.applicationLoad(view.application); + } + } + + /** + * @method CurrentApplication + * return the current ABApplication being worked on. + * @return {ABApplication} application + */ + get CurrentApplication() { + return this.AB?.applicationByID(this.CurrentApplicationID); + } + + /** + * @method CurrentDatacollection() + * A helper to return the current ABDataCollection we are working with. + * @return {ABDataCollection} + */ + get CurrentDatacollection() { + return this.AB?.datacollectionByID(this.CurrentDatacollectionID); + } + + /** + * @method CurrentObject() + * A helper to return the current ABObject we are working with. + * @return {ABObject} + */ + get CurrentObject() { + let obj = this.AB?.objectByID(this.CurrentObjectID); + if (!obj) { + obj = this.AB?.queryByID(this.CurrentObjectID); + } + return obj; + } + + /** + * @method CurrentProcess() + * A helper to return the current ABProcess we are working with. + * @return {ABProcess} + */ + get CurrentProcess() { + return this.AB?.processByID(this.CurrentProcessID); + } + + /** + * @method CurrentQuery() + * A helper to return the current ABObjectQuery we are working with. + * @return {ABObjectQuery} + */ + get CurrentQuery() { + return this.AB?.queryByID(this.CurrentQueryID); + } + + /** + * @method CurrentView() + * A helper to return the current ABView we are working with. + * @return {ABView} + */ + get CurrentView() { + return this.CurrentApplication?.views( + (v) => v.id == this.CurrentViewID + )[0]; + } + + /** + * @method CurrentVersion() + * A helper to return the current ABVersion we are working with. + * @return {ABVersion} + */ + // get CurrentVersion() { + // return this.AB?.versionByID?.(this.CurrentVersionID); + // } + + /** + * @method datacollectionsIncluded() + * return a list of datacollections that are included in the current + * application. + * @return [{id, value, icon}] + * id: {string} the ABDataCollection.id + * value: {string} the label of the ABDataCollection + * icon: {string} the icon to display + */ + datacollectionsIncluded() { + return this.CurrentApplication?.datacollectionsIncluded() + .filter((dc) => { + const obj = dc.datasource; + return ( + dc.sourceType == "object" && !obj?.isImported && !obj?.isReadOnly + ); + }) + .map((d) => { + let entry = { id: d.id, value: d.label }; + if (d.sourceType == "query") { + entry.icon = "fa fa-filter"; + } else { + entry.icon = "fa fa-database"; + } + return entry; + }); + } + + /** + * @method uniqueIDs() + * add a unique identifier to each of our this.ids to ensure they are + * unique. Useful for components that are repeated, like items in a list. + */ + uniqueIDs() { + let uniqueInstanceID = webix.uid(); + Object.keys(this.ids).forEach((k) => { + this.ids[k] = `${this.ids[k]}_${uniqueInstanceID}`; + }); + } + + /** + * @method warningsRefresh() + * reset the warnings on the provided ABObject and then start propogating + * the "warnings" display updates. + * @param {ABObject} ABObject + */ + warningsRefresh(ABObject) { + ABObject?.warningsEval?.(); + this.emit("warnings"); + } + + /** + * @method warningsPropogate() + * If any of the passed in ui elements issue a "warnings" event, we will + * propogate that upwards. + * @param {Array} elements + * Array of UI elements that can emit "warnings" events + */ + warningsPropogate(elements = []) { + elements.forEach((e) => { + e.on("warnings", () => { + this.emit("warnings"); + }); + }); + } + + /** + * @method init() + * Initialize the plugin with the ABFactory instance. + * @param {ABFactory} AB + */ + async init(AB) { + this.AB = AB; + } +} diff --git a/AppBuilder/platform/plugins/ABObjectPlugin.js b/AppBuilder/platform/plugins/ABObjectPlugin.js index 3efe58e3..8c6d3b5a 100644 --- a/AppBuilder/platform/plugins/ABObjectPlugin.js +++ b/AppBuilder/platform/plugins/ABObjectPlugin.js @@ -7,9 +7,14 @@ export default class ABObjectPlugin extends ABObject { // } static getPluginKey() { + console.error("ABObjectPlugin.getPluginKey() not overwritten!"); return "ab-object-plugin"; } + static getPluginType() { + return "object"; + } + // Format our getDbInfo() response for the ABDesigner info options. async getDbInfo() { /* @@ -75,4 +80,11 @@ export default class ABObjectPlugin extends ABObject { return TableInfo; } + + toObj() { + const result = super.toObj(); + result.plugin_key = this.constructor.getPluginKey(); + // plugin_key : is what tells our ABFactory.objectNew() to create this object from the plugin class. + return result; + } } diff --git a/AppBuilder/platform/plugins/ABViewComponentPlugin.js b/AppBuilder/platform/plugins/ABViewComponentPlugin.js new file mode 100644 index 00000000..a9101b3d --- /dev/null +++ b/AppBuilder/platform/plugins/ABViewComponentPlugin.js @@ -0,0 +1,7 @@ +import ABViewComponent from "../views/viewComponent/ABViewComponent.js"; + +export default class ABViewComponentPlugin extends ABViewComponent { + constructor(...params) { + super(...params); + } +} diff --git a/AppBuilder/platform/plugins/ABViewEditorPlugin.js b/AppBuilder/platform/plugins/ABViewEditorPlugin.js new file mode 100644 index 00000000..1e72eb85 --- /dev/null +++ b/AppBuilder/platform/plugins/ABViewEditorPlugin.js @@ -0,0 +1,123 @@ +import ABClassUIPlugin from "./ABClassUIPlugin.js"; + +export default class ABViewEditorPlugin extends ABClassUIPlugin { + constructor(view, base = "view_editor", ids = {}) { + // view: {ABView} The ABView instance this editor is for + // base: {string} unique base id reference + // ids: {hash} { key => '' } + // this is provided by the Sub Class and has the keys + // unique to the Sub Class' interface elements. + + var common = { + component: "", + }; + + Object.keys(ids).forEach((k) => { + if (typeof common[k] != "undefined") { + console.error( + `!!! ABViewEditorPlugin:: passed in ids contains a restricted field : ${k}` + ); + return; + } + common[k] = ""; + }); + + super(base, common); + + this.AB = view.AB; + this.view = view; + // {ABView} + // The ABView instance this editor is editing + + this.settings = view?.settings || {}; + // {hash} + // shortcut to reference the view's settings + + this.base = base; + + this.component = this.view.component(this.ids.component); + // {ABComponent} + // The component instance for this view. + // Should be set via init() or component() method + + // Load the view to set CurrentViewID + if (view) { + this.viewLoad(view); + } + } + + /** + * @method static key + * Return the key identifier for this editor type. + * NOTE: Sub classes should override this to return their specific key. + * @return {string} + */ + static get key() { + return this.getPluginKey(); + } + + /** + * @method ui() + * Return the Webix UI definition for this editor. + * NOTE: Sub classes should override this to provide their specific UI. + * @return {object} Webix UI definition + */ + ui() { + // Default implementation - try to get UI from component + if (this.component) { + return typeof this.component.ui == "function" + ? this.component.ui() + : this.component.ui; + } + + // Fallback: return a simple placeholder + return { + view: "template", + template: `
${ + this.view?.label || "View Editor" + }
`, + }; + } + + /** + * @method init() + * Initialize the editor with the ABFactory instance. + * @param {ABFactory} AB + */ + async init(AB) { + await super.init(AB); + + // Initialize the component if it has an init method + if (this.component?.init) { + return this.component.init(AB, 2); + // in our editor, we provide accessLv = 2 + } + } + + /** + * @method detatch() + * Detach the editor component. + * Called when the editor is being removed or hidden. + */ + detatch() { + this.component?.detatch?.(); + } + + /** + * @method onShow() + * Called when the editor is shown. + * Sub classes can override this to perform actions when the editor becomes visible. + */ + onShow() { + this.component?.onShow?.(); + } + + /** + * @method onHide() + * Called when the editor is hidden. + * Sub classes can override this to perform actions when the editor becomes hidden. + */ + onHide() { + this.component?.onHide?.(); + } +} diff --git a/AppBuilder/platform/plugins/ABViewPlugin.js b/AppBuilder/platform/plugins/ABViewPlugin.js new file mode 100644 index 00000000..a992d8b8 --- /dev/null +++ b/AppBuilder/platform/plugins/ABViewPlugin.js @@ -0,0 +1,31 @@ +import ABView from "../views/ABView.js"; + +export default class ABViewPlugin extends ABView { + constructor(...params) { + super(...params); + } + + static getPluginKey() { + return "ab-view-plugin"; + } + + static getPluginType() { + return "view"; + } + + toObj() { + const result = super.toObj(); + result.plugin_key = this.constructor.getPluginKey(); + // plugin_key : is what tells our ABFactory.objectNew() to create this object from the plugin class. + return result; + } + + static newInstance(application, parent) { + // return a new instance from ABViewManager: + return application.viewNew( + { key: this.common().key, plugin_key: this.getPluginKey() }, + application, + parent + ); + } +} diff --git a/AppBuilder/platform/plugins/ABViewPropertiesPlugin.js b/AppBuilder/platform/plugins/ABViewPropertiesPlugin.js new file mode 100644 index 00000000..7c1c6485 --- /dev/null +++ b/AppBuilder/platform/plugins/ABViewPropertiesPlugin.js @@ -0,0 +1,247 @@ +import ABClassUIPlugin from "./ABClassUIPlugin.js"; + +export default class ABViewPropertiesPlugin extends ABClassUIPlugin { + constructor(base = "properties_abview", ids = {}) { + // base: {string} unique base id reference + // ids: {hash} { key => '' } + // this is provided by the Sub Class and has the keys + // unique to the Sub Class' interface elements. + + var common = { + label: "", + }; + + Object.keys(ids).forEach((k) => { + if (typeof common[k] != "undefined") { + console.error( + `!!! ABFieldProperty:: passed in ids contains a restricted field : ${k}` + ); + return; + } + common[k] = ""; + }); + + super(base, common); + + this.base = base; + + this.fieldsHide = { + /* id.tag : bool */ + }; + // {hash} + // indicates if a given field should be hidden. + // this allows sub classes to hide fields from parent classes: + // this.fieldsHide.required = true; hides the required field. + } + + static get key() { + return this.getPluginKey(); + } + + // + // ABView + // + + ui(elements = [], rules = {}) { + let ids = this.ids; + + let L = this.AB.Label(); + + let _ui = { + view: "form", + id: ids.component, + scroll: true, + elements: [ + { + id: ids.label, + view: "text", + label: L("Name"), + name: "name", + value: "", + hidden: this.fieldsHide.label ? true : false, + }, + ], + rules: { + // label: webix.rules.isNotEmpty, + }, + }; + + elements.forEach((e) => { + _ui.elements.push(e); + }); + + Object.keys(rules).forEach((r) => { + _ui.rules[r] = rules[r]; + }); + + return _ui; + } + + async init(AB) { + await super.init(AB); + + this.$form = $$(this.ids.component); + AB.Webix.extend(this.$form, webix.ProgressBar); + + var VC = this.ViewClass(); + if (VC) { + /* +// TODO: + $$(this.ids.fieldDescription).define( + "label", + L(FC.defaults().description) + ); + } else { + $$(this.ids.fieldDescription).hide(); +*/ + } + } + + /** + * @method clear() + * clear the property form. + */ + clear() { + $$(this.ids.label).setValue(""); + } + + propertyDatacollections(view) { + return view.application.datacollectionsIncluded().map((d) => { + return { id: d.id, value: d.label }; + }); + } + + /** + * @method defaults() + * Return the ViewClass() default values. + * NOTE: the child class MUST implement ViewClass() to return the + * proper ABViewXXX class definition. + * @return {obj} + */ + defaults() { + var ViewClass = this.ViewClass(); + if (!ViewClass) { + console.error("!!! properties/views/ABView: could not find ViewClass"); + return null; + } + return ViewClass.common(); + } + + formValues() { + return $$(this.ids.component).getValues(); + } + + /** + * @method isValid() + * Verify the common ABField settings are valid before allowing + * us to create the new field. + * @return {bool} + */ + isValid() { + /* +// TODO: + var ids = this.ids; + var isValid = $$(ids.component).validate(), + colName = this.formValues()["columnName"]; + + // validate reserve column names + var FC = this.FieldClass(); + if (!FC) { + this.AB.notify.developer( + new Error("Unable to resolve FieldClass"), + { + context: "ABFieldProperty: isValid()", + base: this.ids.component, + } + ); + } + + // columnName should not be one of the reserved names: + if (FC?.reservedNames.indexOf(colName.trim().toLowerCase()) > -1) { + this.markInvalid("columnName", L("This is a reserved name")); + isValid = false; + } + + // columnName should not be in use by other fields on this object + // get All fields with matching colName + var fieldColName = this.currentObject?.fields( + (f) => f.columnName == colName + ); + // ignore current edit field + if (this._CurrentField) { + fieldColName = fieldColName.filter( + (f) => f.id != this._CurrentField.id + ); + } + // if any more matches, this is a problem + if (fieldColName.length > 0) { + this.markInvalid( + "columnName", + L("This column name is in use by another field ({0})", [ + fieldColName.label, + ]) + ); + isValid = false; + } + + return isValid; +*/ + } + + markInvalid(name, message) { + $$(this.ids.component).markInvalid(name, message); + } + + /** + * @method onChange() + * emit a "changed" event so our property manager can know + * there are new values that need saving. + */ + onChange() { + this.emit("changed"); + } + + /** + * @function populate + * populate the property form with the given ABField instance provided. + * @param {ABView} view + * The ABViewXXX instance that we are editing the settings for. + */ + populate(view) { + this.viewLoad(view); + $$(this.ids.label)?.setValue(view.label); + } + + requiredOnChange() { + // Sub Class should overwrite this if it is necessary. + } + + /* + * @function values + * + * return the values for this form. + * @return {obj} + */ + values() { + let vals = {}; + vals.label = $$(this.ids.label).getValue(); + return vals; + } + + /** + * @method ViewClass() + * A method to return the proper ABViewXXX Definition. + * NOTE: Can be overwritten by the Child Class + */ + ViewClass() { + return this._ViewClass(this.constructor.key); + } + + _ViewClass(key) { + var app = this.CurrentApplication; + if (!app) { + app = this.AB.applicationNew({}); + } + return app.viewAll((V) => V.common().key == key)[0]; + } +} diff --git a/resources/Multilingual.js b/resources/Multilingual.js index 7d3f092d..4102d22c 100644 --- a/resources/Multilingual.js +++ b/resources/Multilingual.js @@ -49,6 +49,10 @@ class Multilingual extends MLClass { } label(key, altText, values = [], postMissing = true) { + if (typeof key == "undefined") { + return ""; + } + // part of our transition: L("single string") should start to work: if (typeof altText == "undefined" && key) { altText = key; From 4ced07757a377ace6ed1c7b6b3dc9a479f4eb0dd Mon Sep 17 00:00:00 2001 From: Johnny Hausman Date: Fri, 14 Nov 2025 13:59:17 +0700 Subject: [PATCH 11/11] [fix] try to enforce application/javascript on plugin loading. --- init/Bootstrap.js | 169 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 150 insertions(+), 19 deletions(-) diff --git a/init/Bootstrap.js b/init/Bootstrap.js index a0d4a05e..6a09bdfd 100644 --- a/init/Bootstrap.js +++ b/init/Bootstrap.js @@ -187,35 +187,166 @@ class Bootstrap extends EventEmitter { ); const loadPlugin = async (purl) => { + // Helper to load script with proper MIME type headers + const loadScriptWithMimeType = async (url) => { + try { + // Fetch with Accept header to request JavaScript MIME type + const response = await fetch(url, { + headers: { + Accept: + "application/javascript, text/javascript, */*;q=0.8", + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to load script: ${response.status} ${response.statusText}` + ); + } + + // Verify we got JavaScript content type + const contentType = response.headers.get("content-type"); + if ( + contentType && + !contentType.includes("javascript") && + !contentType.includes("ecmascript") + ) { + console.warn( + `Unexpected content type for ${url}: ${contentType}` + ); + } + + const text = await response.text(); + // Create a blob URL with explicit JavaScript MIME type + const blob = new Blob([text], { + type: "application/javascript", + }); + return URL.createObjectURL(blob); + } catch (e) { + console.error(`Error loading script with fetch: ${url}`, e); + return null; + } + }; + // try ESM dynamic import first; fall back to UMD global const tryImport = async (url) => { try { - const mod = await import(/* webpackIgnore: true */ url); - // Try multiple ways to get the function - let fn = mod?.default || mod?.registerService; - if (!fn && typeof mod === "object") { - // If still not found, try to find any function export - const values = Object.values(mod); - fn = values.find((v) => typeof v === "function"); + // Try direct import first (may work if server is configured correctly) + try { + const mod = await import(/* webpackIgnore: true */ url); + // Try multiple ways to get the function + let fn = mod?.default || mod?.registerService; + if (!fn && typeof mod === "object") { + // If still not found, try to find any function export + const values = Object.values(mod); + fn = values.find((v) => typeof v === "function"); + } + if (fn) return fn; + } catch (directImportError) { + // If direct import fails, try with fetch + blob URL + const blobUrl = await loadScriptWithMimeType(url); + if (blobUrl) { + try { + const mod = await import( + /* webpackIgnore: true */ blobUrl + ); + URL.revokeObjectURL(blobUrl); // Clean up + let fn = mod?.default || mod?.registerService; + if (!fn && typeof mod === "object") { + const values = Object.values(mod); + fn = values.find((v) => typeof v === "function"); + } + if (fn) return fn; + } catch (blobImportError) { + URL.revokeObjectURL(blobUrl); // Clean up on error + throw blobImportError; + } + } } - return fn || null; + return null; } catch (e) { return null; } }; const tryUMD = async (url) => { - // inject a script tag, then look for a global export - await this.AB.scriptLoad(url); - // Conventional UMD name used by our plugin builds - const globalExport = window.Plugin; - return ( - (globalExport && - (globalExport.default || - globalExport.registerService || - globalExport)) || - null - ); + // Load script with proper MIME type, then inject as script tag + const blobUrl = await loadScriptWithMimeType(url); + if (!blobUrl) { + // Fallback to original scriptLoad if available + if (this.AB.scriptLoad) { + await this.AB.scriptLoad(url); + // Look for global export after script loads + const globalExport = window.Plugin; + return ( + (globalExport && + (globalExport.default || + globalExport.registerService || + globalExport)) || + null + ); + } else { + // Manual script tag creation with explicit type + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.type = "text/javascript"; + script.src = url; + script.onload = () => resolve(); + script.onerror = () => + reject(new Error(`Failed to load script: ${url}`)); + document.head.appendChild(script); + }).then(() => { + // Look for global export after script loads + const globalExport = window.Plugin; + return ( + (globalExport && + (globalExport.default || + globalExport.registerService || + globalExport)) || + null + ); + }); + } + } + + // Load via blob URL with explicit type + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.type = "text/javascript"; + script.src = blobUrl; + script.onload = () => { + URL.revokeObjectURL(blobUrl); // Clean up + // Conventional UMD name used by our plugin builds + const globalExport = window.Plugin; + resolve( + (globalExport && + (globalExport.default || + globalExport.registerService || + globalExport)) || + null + ); + }; + script.onerror = () => { + URL.revokeObjectURL(blobUrl); // Clean up on error + reject(new Error(`Failed to load script: ${url}`)); + }; + document.head.appendChild(script); + }).catch(() => { + // Fallback to original scriptLoad if available + if (this.AB.scriptLoad) { + return this.AB.scriptLoad(url).then(() => { + const globalExport = window.Plugin; + return ( + (globalExport && + (globalExport.default || + globalExport.registerService || + globalExport)) || + null + ); + }); + } + return null; + }); }; let registerFn = await tryImport(purl);