From cfbeaff1c5e7049cddced7b50416ed61fe42cddc Mon Sep 17 00:00:00 2001 From: joshua_cm Date: Fri, 6 Feb 2026 20:54:48 +0700 Subject: [PATCH 1/3] Add Detail Widget as plugin --- AppBuilder/core | 2 +- AppBuilder/platform/plugins/included/index.js | 3 +- .../included/view_detail/FNAbviewdetail.js | 138 ++++++++++++++++++ .../view_detail/FNAbviewdetailComponent.js | 51 +++++++ .../platform/views/ABViewDetail.test.js | 51 +++++-- 5 files changed, 231 insertions(+), 14 deletions(-) create mode 100644 AppBuilder/platform/plugins/included/view_detail/FNAbviewdetail.js create mode 100644 AppBuilder/platform/plugins/included/view_detail/FNAbviewdetailComponent.js diff --git a/AppBuilder/core b/AppBuilder/core index 033d062a..c66267ca 160000 --- a/AppBuilder/core +++ b/AppBuilder/core @@ -1 +1 @@ -Subproject commit 033d062ac13b1b720955016e10fe146d0523c759 +Subproject commit c66267ca1afa394d1751a7f77a4eb9b0781f8464 diff --git a/AppBuilder/platform/plugins/included/index.js b/AppBuilder/platform/plugins/included/index.js index 1d707c93..e7a5fee0 100644 --- a/AppBuilder/platform/plugins/included/index.js +++ b/AppBuilder/platform/plugins/included/index.js @@ -1,7 +1,8 @@ import viewList from "./view_list/FNAbviewlist.js"; import viewTab from "./view_tab/FNAbviewtab.js"; +import viewDetail from "./view_detail/FNAbviewdetail.js"; -const AllPlugins = [viewTab, viewList]; +const AllPlugins = [viewTab, viewList, viewDetail]; export default { load: (AB) => { diff --git a/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetail.js b/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetail.js new file mode 100644 index 00000000..6824168d --- /dev/null +++ b/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetail.js @@ -0,0 +1,138 @@ +import FNAbviewdetailComponent from "./FNAbviewdetailComponent.js"; + +// Detail view plugin: replaces the original ABViewDetail / ABViewDetailCore. +// All logic from both Core and platform is contained in this file. +export default function FNAbviewdetail({ + ABViewContainer, + ABViewComponentPlugin, +}) { + const ABViewDetailComponent = FNAbviewdetailComponent({ + ABViewComponentPlugin, + }); + + const ABViewDetailDefaults = { + key: "detail", + icon: "file-text-o", + labelKey: "Detail(plugin)", + }; + + const ABViewDetailPropertyComponentDefaults = { + dataviewID: null, + showLabel: true, + labelPosition: "left", + labelWidth: 120, + height: 0, + }; + + return class ABViewDetailPlugin extends ABViewContainer { + /** + * @param {obj} values key=>value hash of ABView values + * @param {ABApplication} application the application object this view is under + * @param {ABView} parent the ABView this view is a child of. (can be null) + */ + constructor(values, application, parent, defaultValues) { + super( + values, + application, + parent, + defaultValues ?? ABViewDetailDefaults + ); + } + + static getPluginType() { + return "view"; + } + + static getPluginKey() { + return this.common().key; + } + + static common() { + return ABViewDetailDefaults; + } + + static defaultValues() { + return ABViewDetailPropertyComponentDefaults; + } + + /** + * @method fromValues() + * Initialize this object with the given set of values. + * @param {obj} values + */ + fromValues(values) { + super.fromValues(values); + + this.settings.labelPosition = + this.settings.labelPosition || + ABViewDetailPropertyComponentDefaults.labelPosition; + + this.settings.showLabel = JSON.parse( + this.settings.showLabel != null + ? this.settings.showLabel + : ABViewDetailPropertyComponentDefaults.showLabel + ); + + this.settings.labelWidth = parseInt( + this.settings.labelWidth || + ABViewDetailPropertyComponentDefaults.labelWidth + ); + this.settings.height = parseInt( + this.settings.height ?? + ABViewDetailPropertyComponentDefaults.height + ); + } + + /** + * @method componentList + * Return the list of components available on this view to display in the editor. + */ + componentList() { + const viewsToAllow = ["label", "text"]; + const allComponents = this.application.viewAll(); + return allComponents.filter((c) => + viewsToAllow.includes(c.common().key) + ); + } + + addFieldToDetail(field, yPosition) { + if (field == null) return; + + const newView = field + .detailComponent() + .newInstance(this.application, this); + if (newView == null) return; + + newView.settings = newView.settings ?? {}; + newView.settings.fieldId = field.id; + newView.settings.labelWidth = + this.settings.labelWidth || + ABViewDetailPropertyComponentDefaults.labelWidth; + newView.settings.alias = field.alias; + newView.position.y = yPosition; + + this._views.push(newView); + return newView; + } + + /** + * @method component() + * Return a UI component based upon this view. + * @return {obj} UI component + */ + component() { + return new ABViewDetailComponent(this); + } + + warningsEval() { + super.warningsEval(); + + const DC = this.datacollection; + if (!DC) { + this.warningsMessage( + `can't resolve it's datacollection[${this.settings.dataviewID}]` + ); + } + } + }; +} diff --git a/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetailComponent.js b/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetailComponent.js new file mode 100644 index 00000000..ca24a0b9 --- /dev/null +++ b/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetailComponent.js @@ -0,0 +1,51 @@ +export default function FNAbviewdetailComponent({ + /*AB,*/ + ABViewComponentPlugin, +}) { + return class ABAbviewdetailComponent extends ABViewComponentPlugin { + constructor(baseView, idBase, ids) { + super( + baseView, + idBase || `ABViewDetail_${baseView.id}`, + Object.assign({ detail: "" }, ids) + ); + } + + ui() { + const settings = this.settings; + const _uiDetail = { + id: this.ids.detail, + view: "dataview", + type: { + width: 1000, + height: 30, + }, + template: (item) => { + if (!item) return ""; + return JSON.stringify(item); + }, + }; + + // set height or autoHeight + if (settings.height !== 0) _uiDetail.height = settings.height; + else _uiDetail.autoHeight = true; + + const _ui = super.ui([_uiDetail]); + + delete _ui.type; + + return _ui; + } + + async init(AB) { + await super.init(AB); + + const dc = this.datacollection; + + if (!dc) return; + + // bind dc to component + dc.bind($$(this.ids.detail)); + } + }; +} diff --git a/test/AppBuilder/platform/views/ABViewDetail.test.js b/test/AppBuilder/platform/views/ABViewDetail.test.js index a3aed430..3fa4ed7d 100644 --- a/test/AppBuilder/platform/views/ABViewDetail.test.js +++ b/test/AppBuilder/platform/views/ABViewDetail.test.js @@ -1,20 +1,47 @@ import assert from "assert"; import ABFactory from "../../../../AppBuilder/ABFactory"; -import ABViewDetail from "../../../../AppBuilder/platform/views/ABViewDetail"; -import ABViewDetailComponent from "../../../../AppBuilder/platform/views/viewComponent/ABViewDetailComponent"; +import ABViewContainer from "../../../../AppBuilder/platform/views/ABViewContainer"; +import ABViewComponent from "../../../../AppBuilder/platform/views/viewComponent/ABViewComponent"; -function getTarget() { - const AB = new ABFactory(); - const application = AB.applicationNew({}); - return new ABViewDetail({}, application); -} +describe("ABViewDetail plugin", function () { -describe("ABViewDetail widget", function () { - it(".component - should return a instance of ABViewDetailComponent", function () { - const target = getTarget(); + let AB; + let application; + let viewDetail; - const result = target.component(); + before(function () { + AB = new ABFactory(); + // Only load plugins so "detail" is registered; skip full init() to avoid Network/socket.io in test env. + AB.pluginLocalLoad(); + application = AB.applicationNew({}); + viewDetail = application.viewNew({ key: "detail" }, application); + }); + + it("can pull a view from ABFactory given { key: 'detail' } values", function () { + assert.ok(viewDetail, "viewNew({ key: 'detail' }) should return a view"); + }); + + it("the resulting object is a type of ABViewContainer class", function () { + assert.ok( + viewDetail instanceof ABViewContainer, + "Detail view should extend ABViewContainer" + ); + }); + + it("the object has .component() method", function () { + assert.strictEqual( + typeof viewDetail.component, + "function", + "Detail view should have a .component() method" + ); + }); - assert.equal(true, result instanceof ABViewDetailComponent); + it(".component() returns an object of type ABViewComponent class", function () { + const result = viewDetail.component(); + assert.ok( + result instanceof ABViewComponent, + ".component() should return an instance of ABViewComponent" + ); }); + }); From ab7df7b027eb9607d8cd783b10060e8d6b812b6f Mon Sep 17 00:00:00 2001 From: joshua_cm Date: Tue, 24 Feb 2026 23:01:19 +0700 Subject: [PATCH 2/3] temp: remove ABViewDetail.test.js from PR (keep local copy) --- .../platform/views/ABViewDetail.test.js | 47 ------------------- 1 file changed, 47 deletions(-) delete mode 100644 test/AppBuilder/platform/views/ABViewDetail.test.js diff --git a/test/AppBuilder/platform/views/ABViewDetail.test.js b/test/AppBuilder/platform/views/ABViewDetail.test.js deleted file mode 100644 index 3fa4ed7d..00000000 --- a/test/AppBuilder/platform/views/ABViewDetail.test.js +++ /dev/null @@ -1,47 +0,0 @@ -import assert from "assert"; -import ABFactory from "../../../../AppBuilder/ABFactory"; -import ABViewContainer from "../../../../AppBuilder/platform/views/ABViewContainer"; -import ABViewComponent from "../../../../AppBuilder/platform/views/viewComponent/ABViewComponent"; - -describe("ABViewDetail plugin", function () { - - let AB; - let application; - let viewDetail; - - before(function () { - AB = new ABFactory(); - // Only load plugins so "detail" is registered; skip full init() to avoid Network/socket.io in test env. - AB.pluginLocalLoad(); - application = AB.applicationNew({}); - viewDetail = application.viewNew({ key: "detail" }, application); - }); - - it("can pull a view from ABFactory given { key: 'detail' } values", function () { - assert.ok(viewDetail, "viewNew({ key: 'detail' }) should return a view"); - }); - - it("the resulting object is a type of ABViewContainer class", function () { - assert.ok( - viewDetail instanceof ABViewContainer, - "Detail view should extend ABViewContainer" - ); - }); - - it("the object has .component() method", function () { - assert.strictEqual( - typeof viewDetail.component, - "function", - "Detail view should have a .component() method" - ); - }); - - it(".component() returns an object of type ABViewComponent class", function () { - const result = viewDetail.component(); - assert.ok( - result instanceof ABViewComponent, - ".component() should return an instance of ABViewComponent" - ); - }); - -}); From bc13ad1acb04de7f1cf1e9dfc2e0fa612f1f0776 Mon Sep 17 00:00:00 2001 From: joshua_cm Date: Tue, 3 Mar 2026 00:09:17 +0700 Subject: [PATCH 3/3] fix: detail widget render template instead of raw JSON --- .../included/view_detail/FNAbviewdetail.js | 2 + .../view_detail/FNAbviewdetailComponent.js | 170 +++++++++++++++--- 2 files changed, 151 insertions(+), 21 deletions(-) diff --git a/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetail.js b/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetail.js index 6824168d..a88a8f53 100644 --- a/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetail.js +++ b/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetail.js @@ -4,9 +4,11 @@ import FNAbviewdetailComponent from "./FNAbviewdetailComponent.js"; // All logic from both Core and platform is contained in this file. export default function FNAbviewdetail({ ABViewContainer, + ABViewContainerComponent, ABViewComponentPlugin, }) { const ABViewDetailComponent = FNAbviewdetailComponent({ + ABViewContainerComponent, ABViewComponentPlugin, }); diff --git a/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetailComponent.js b/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetailComponent.js index ca24a0b9..77f89b02 100644 --- a/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetailComponent.js +++ b/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetailComponent.js @@ -1,51 +1,179 @@ export default function FNAbviewdetailComponent({ - /*AB,*/ + ABViewContainerComponent, ABViewComponentPlugin, }) { - return class ABAbviewdetailComponent extends ABViewComponentPlugin { + const ContainerComponent = + ABViewContainerComponent?.default ?? ABViewContainerComponent; + const Base = ContainerComponent ?? ABViewComponentPlugin; + if (!Base) { + return class ABAbviewdetailComponent {}; + } + + return class ABAbviewdetailComponent extends Base { constructor(baseView, idBase, ids) { super( baseView, idBase || `ABViewDetail_${baseView.id}`, Object.assign({ detail: "" }, ids) ); + this.idBase = idBase || `ABViewDetail_${baseView.id}`; } ui() { + if (!ContainerComponent) { + return this._uiDataviewFallback(); + } + const _ui = super.ui(); + return { + type: "form", + id: this.ids.component, + borderless: true, + rows: [{ body: _ui }], + }; + } + + _uiDataviewFallback() { const settings = this.settings; const _uiDetail = { id: this.ids.detail, view: "dataview", - type: { - width: 1000, - height: 30, - }, - template: (item) => { - if (!item) return ""; - return JSON.stringify(item); - }, + type: { width: 1000, height: 30 }, + template: (item) => (item ? JSON.stringify(item) : ""), }; - - // set height or autoHeight if (settings.height !== 0) _uiDetail.height = settings.height; else _uiDetail.autoHeight = true; - const _ui = super.ui([_uiDetail]); - delete _ui.type; - return _ui; } - async init(AB) { - await super.init(AB); + onShow() { + const baseView = this.view; + try { + const dataCy = `Detail ${baseView.name?.split(".")[0]} ${baseView.id}`; + $$(this.ids.component)?.$view?.setAttribute("data-cy", dataCy); + } catch (e) { + console.warn("Problem setting data-cy", e); + } + + const dv = this.datacollection; + if (dv) { + const currData = dv.getCursor(); + if (currData) this.displayData(currData); + + ["changeCursor", "cursorStale", "collectionEmpty"].forEach((key) => { + this.eventAdd({ + emitter: dv, + eventName: key, + listener: (...p) => this.displayData(...p), + }); + }); + this.eventAdd({ + emitter: dv, + eventName: "create", + listener: (createdRow) => { + if (dv.getCursor()?.id === createdRow.id) + this.displayData(createdRow); + }, + }); + this.eventAdd({ + emitter: dv, + eventName: "update", + listener: (updatedRow) => { + if (dv.getCursor()?.id === updatedRow.id) + this.displayData(updatedRow); + }, + }); + } + + super.onShow?.(); + } + + displayData(rowData = {}) { + if (!ContainerComponent) return; + if (rowData == null && this.datacollection) + rowData = this.datacollection.getCursor() ?? {}; + + const views = (this.view.views() || []).sort((a, b) => { + if (!a?.field?.() || !b?.field?.()) return 0; + if (a.field().key === "formula" && b.field().key === "calculate") + return -1; + if (a.field().key === "calculate" && b.field().key === "formula") + return 1; + return 0; + }); - const dc = this.datacollection; + views.forEach((f) => { + let val; + if (f.field) { + const field = f.field(); + if (!field) return; - if (!dc) return; + switch (field.key) { + case "connectObject": + val = field.pullRelationValues(rowData); + break; + case "list": + val = rowData?.[field.columnName]; + if (!val) { + val = ""; + break; + } + if (field.settings.isMultiple === 0) { + let myVal = ""; + (field.settings.options || []).forEach((opt) => { + if (opt.id === val) myVal = opt.text; + }); + if (field.settings.hasColors) { + let hasCustomColor = ""; + (field.settings.options || []).forEach((h) => { + if (h.text === myVal) { + hasCustomColor = "hascustomcolor"; + } + }); + const hex = (field.settings.options || []).find( + (o) => o.text === myVal + )?.hex ?? "#66666"; + myVal = `${myVal}`; + } + val = myVal; + } else { + const items = (val || []).map((value) => { + let myVal = ""; + (field.settings.options || []).forEach((opt) => { + if (opt.id === value.id) myVal = opt.text; + }); + const optionHex = + field.settings.hasColors && value.hex + ? `background: ${value.hex};` + : ""; + const hasCustomColor = + field.settings.hasColors && value.hex + ? "hascustomcolor" + : ""; + return `${myVal}`; + }); + val = items.join(""); + } + break; + case "user": + val = field.pullRelationValues(rowData); + break; + case "file": + val = rowData?.[field.columnName] ?? ""; + break; + case "formula": + val = rowData ? field.format(rowData, false) : ""; + break; + default: + val = field.format(rowData); + } + } - // bind dc to component - dc.bind($$(this.ids.detail)); + const vComponent = f.component(this.idBase); + vComponent?.setValue?.(val); + vComponent?.displayText?.(rowData); + }); } }; }