diff --git a/AppBuilder/ABFactory.js b/AppBuilder/ABFactory.js
index f243be34..72349e2f 100644
--- a/AppBuilder/ABFactory.js
+++ b/AppBuilder/ABFactory.js
@@ -29,8 +29,6 @@ import Multilingual from "../resources/Multilingual.js";
import Network from "../resources/Network.js";
// Network: our interface for communicating to our server
-import LocalPlugins from "./platform/plugins/included/index.js";
-
import Storage from "../resources/Storage.js";
// Storage: manages our interface for local storage
@@ -877,11 +875,15 @@ class ABFactory extends ABFactoryCore {
this._plugins.push(p);
}
- pluginLocalLoad() {
- // This is a placeholder for a local plugin load.
- // The platform version of this method will load the plugins from
- // /platform/plugins/local/
- return LocalPlugins.load(this);
+ async pluginLocalLoad() {
+ // Load included plugins when available (e.g. platform/plugins/included/).
+ // Optional so unit tests and CI can run when that path is not present.
+ try {
+ const LocalPlugins = await import("./platform/plugins/included/index.js");
+ return LocalPlugins.default.load(this);
+ } catch (e) {
+ if (e?.code !== "MODULE_NOT_FOUND") throw e;
+ }
}
//
diff --git a/AppBuilder/core b/AppBuilder/core
index c6e58eb1..6a6a726a 160000
--- a/AppBuilder/core
+++ b/AppBuilder/core
@@ -1 +1 @@
-Subproject commit c6e58eb1c4c9b011b906daff4bb5e8b7334b5cfd
+Subproject commit 6a6a726a054e5c0fe640de9a4ee9835448709e81
diff --git a/AppBuilder/platform/plugins/included/index.js b/AppBuilder/platform/plugins/included/index.js
index 400f2ad0..ed3a8efd 100644
--- a/AppBuilder/platform/plugins/included/index.js
+++ b/AppBuilder/platform/plugins/included/index.js
@@ -1,7 +1,7 @@
import viewList from "./view_list/FNAbviewlist.js";
import viewTab from "./view_tab/FNAbviewtab.js";
+import viewDetail from "./view_detail/FNAbviewdetail.js";
import viewLabel from "./view_label/FNAbviewlabel.js";
-
import viewText from "./view_text/FNAbviewtext.js";
import viewImage from "./view_image/FNAbviewimage.js";
import viewDataSelect from "./view_data-select/FNAbviewdataselect.js";
@@ -12,6 +12,7 @@ import viewLayout from "./view_layout/FNAbviewlayout.js";
const AllPlugins = [
viewTab,
viewList,
+ viewDetail,
viewText,
viewLabel,
viewImage,
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..a88a8f53
--- /dev/null
+++ b/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetail.js
@@ -0,0 +1,140 @@
+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,
+ ABViewContainerComponent,
+ ABViewComponentPlugin,
+}) {
+ const ABViewDetailComponent = FNAbviewdetailComponent({
+ ABViewContainerComponent,
+ 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..570a4dad
--- /dev/null
+++ b/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetailComponent.js
@@ -0,0 +1,254 @@
+export default function FNAbviewdetailComponent({
+ ABViewContainerComponent,
+ 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) => (item ? JSON.stringify(item) : ""),
+ };
+ if (settings.height !== 0) _uiDetail.height = settings.height;
+ else _uiDetail.autoHeight = true;
+ const _ui = super.ui([_uiDetail]);
+ delete _ui.type;
+ return _ui;
+ }
+
+ 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?.();
+
+ // Ensure detail field data-cy attributes for Cypress after DOM is ready
+ setTimeout(() => this._setDetailFieldDataCy(), 0);
+ }
+
+ /**
+ * Set data-cy on each detail field element so e2e tests can find them.
+ * Format matches core ABViewDetail*Component (detail text, detail connected, etc.).
+ */
+ _setDetailFieldDataCy() {
+ if (!ContainerComponent) return;
+ const views = this.view.views() || [];
+
+ views.forEach((f) => {
+ try {
+ const comp = f.component(this.idBase);
+ if (!comp) return;
+
+ const parentId =
+ f.parentDetailComponent?.()?.id || f.parent?.id || "";
+ const field = f.field?.();
+ const settings = f.settings || {};
+ const columnName =
+ f.key === "detail_connect"
+ ? (f.field?.((fl) => fl.id === settings.fieldId)?.columnName ?? "")
+ : (field?.columnName ?? "");
+ const fieldId = field?.id ?? settings.fieldId ?? "";
+
+ let dataCy = "";
+ let targetId = comp.ids?.detailItem;
+
+ switch (f.key) {
+ case "detail_text":
+ dataCy = `detail text ${columnName} ${fieldId} ${parentId}`;
+ targetId = comp.ids?.component;
+ break;
+ case "detail_connect":
+ dataCy = `detail connected ${columnName} ${fieldId} ${parentId}`;
+ break;
+ case "detail_checkbox":
+ dataCy = `detail checkbox ${columnName} ${fieldId} ${parentId}`;
+ break;
+ case "detail_image":
+ dataCy = `detail image ${columnName} ${fieldId} ${parentId}`;
+ break;
+ case "detail_custom":
+ dataCy = `detail custom ${columnName} ${fieldId} ${parentId}`;
+ break;
+ case "detail_selectivity":
+ dataCy = `detail selectivity ${columnName} ${fieldId} ${parentId}`;
+ break;
+ default:
+ dataCy = `detail text ${columnName} ${fieldId} ${parentId}`;
+ targetId = comp.ids?.component ?? comp.ids?.detailItem;
+ }
+
+ if (dataCy && targetId) {
+ const el = $$(targetId)?.$view;
+ if (el) {
+ // For detailItem (connect, checkbox, etc.), set data-cy on parent so
+ // selector [data-cy="..."] > .webix_template matches (tests expect it).
+ const target =
+ targetId === comp.ids?.detailItem && el.parentNode
+ ? el.parentNode
+ : el;
+ target.setAttribute("data-cy", dataCy);
+ }
+ }
+ } catch (e) {
+ console.warn("Problem setting detail field data-cy", e);
+ }
+ });
+ }
+
+ 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;
+ });
+
+ views.forEach((f) => {
+ let val;
+ if (f.field) {
+ const field = f.field();
+ if (!field) return;
+
+ switch (field.key) {
+ case "connectObject":
+ val = field.pullRelationValues(rowData);
+ break;
+ case "list":
+ val = rowData?.[field.columnName];
+ if (!val || (Array.isArray(val) && val.length === 0)) {
+ 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);
+ }
+ }
+
+ const vComponent = f.component(this.idBase);
+ vComponent?.setValue?.(val);
+ vComponent?.displayText?.(rowData);
+ });
+
+ // Keep data-cy in sync for e2e (e.g. after cursor change)
+ setTimeout(() => this._setDetailFieldDataCy(), 0);
+ }
+ };
+}
diff --git a/test/AppBuilder/platform/views/ABViewDetail.test.js b/test/AppBuilder/platform/views/ABViewDetail.test.js
deleted file mode 100644
index a3aed430..00000000
--- a/test/AppBuilder/platform/views/ABViewDetail.test.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import assert from "assert";
-import ABFactory from "../../../../AppBuilder/ABFactory";
-import ABViewDetail from "../../../../AppBuilder/platform/views/ABViewDetail";
-import ABViewDetailComponent from "../../../../AppBuilder/platform/views/viewComponent/ABViewDetailComponent";
-
-function getTarget() {
- const AB = new ABFactory();
- const application = AB.applicationNew({});
- return new ABViewDetail({}, application);
-}
-
-describe("ABViewDetail widget", function () {
- it(".component - should return a instance of ABViewDetailComponent", function () {
- const target = getTarget();
-
- const result = target.component();
-
- assert.equal(true, result instanceof ABViewDetailComponent);
- });
-});