diff --git a/AppBuilder/platform/ABClassManager.js b/AppBuilder/platform/ABClassManager.js
index eaf1ff05..8562ab66 100644
--- a/AppBuilder/platform/ABClassManager.js
+++ b/AppBuilder/platform/ABClassManager.js
@@ -10,6 +10,7 @@ import ABViewEditorPlugin from "./plugins/ABViewEditorPlugin.js";
// some views need to reference ABViewContainer,
import ABViewContainer from "./views/ABViewContainer.js";
+import ABViewContainerComponent from "./views/viewComponent/ABViewContainerComponent.js";
// view property helpers used by plugins
import ABViewPropertyFilterData from "./views/viewProperties/ABViewPropertyFilterData";
@@ -66,6 +67,7 @@ export function getPluginAPI() {
ABViewPropertiesPlugin,
ABViewEditorPlugin,
ABViewContainer,
+ ABViewContainerComponent,
ABViewPropertyFilterData,
ABViewPropertyLinkPage,
// ABFieldPlugin,
diff --git a/AppBuilder/platform/plugins/included/index.js b/AppBuilder/platform/plugins/included/index.js
index 6cd432c7..f7bb47b8 100644
--- a/AppBuilder/platform/plugins/included/index.js
+++ b/AppBuilder/platform/plugins/included/index.js
@@ -9,6 +9,7 @@ import viewPdfImporter from "./view_pdfImporter/FNAbviewpdfimporter.js";
import viewCarousel from "./view_carousel/FNAbviewcarousel.js";
import viewLayout from "./view_layout/FNAbviewlayout.js";
import viewComment from "./view_comment/FNAbviewcomment.js";
+import viewDetail from "./view_detail/FNAbviewdetail.js";
const AllPlugins = [
viewTab,
@@ -21,6 +22,7 @@ const AllPlugins = [
viewCarousel,
viewLayout,
viewComment,
+ viewDetail,
];
export default {
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..5f70b943
--- /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}]`
+ );
+ }
+ }
+ };
+}
\ No newline at end of file
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..b946d01b
--- /dev/null
+++ b/AppBuilder/platform/plugins/included/view_detail/FNAbviewdetailComponent.js
@@ -0,0 +1,421 @@
+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();
+ const hasContent = (this.view.views() || []).length > 0;
+ return {
+ type: "form",
+ id: this.ids.component,
+ borderless: true,
+ minHeight: hasContent ? undefined : 120,
+ rows: [{ body: _ui }],
+ };
+ }
+
+ /**
+ * Override getElements to inject data-cy into each child view config (like Carousel does in init).
+ * Webix applies attributes when the view is created, so this avoids timing issues in tabs/CI.
+ */
+ getElements(views) {
+ const rows = [];
+ const componentMap = {};
+ let curRowIndex;
+ let curColIndex;
+ const settings = this.settings;
+ const defaultSettings = this.view.constructor.defaultValues();
+
+ views.forEach((v) => {
+ let component;
+ try {
+ component = v.component(this.idBase);
+ v.removeAllListeners("changePage");
+ } catch (err) {
+ component = v.component(this.idBase);
+ const ui = component.ui;
+ component.ui = (() => ui).bind(component);
+ }
+
+ this.viewComponents[v.id] = component;
+
+ if (v.position.y == null || v.position.y !== curRowIndex) {
+ curRowIndex = v.position.y || rows.length;
+ curColIndex = 0;
+ const rowNew = { cols: [] };
+ const colNumber = settings.columns || defaultSettings.columns;
+ for (let i = 0; i < colNumber; i++)
+ rowNew.cols.push({
+ gravity: settings.gravity?.[i]
+ ? parseInt(settings.gravity[i])
+ : defaultSettings.gravity,
+ });
+ rows.push(rowNew);
+ }
+
+ const rowIndx = rows.length - 1;
+ const curRow = rows[rowIndx];
+ const newPos = v.position.x ?? 0;
+ const mapKey = `${rowIndx}-${newPos}`;
+ let getGrav = 1;
+ if (componentMap[mapKey])
+ console.error(
+ `Component[${component?.ids?.component}] is overwriting component[${componentMap[mapKey].ids?.component}]. <-- Reorder them to fix.`
+ );
+ componentMap[mapKey] = component;
+ if (curRow.cols[newPos]?.gravity)
+ getGrav = curRow.cols[newPos].gravity;
+
+ const _ui = component.ui();
+ const info = this._dataCyForView(v);
+ if (info?.dataCy) {
+ const dataCy = info.dataCy;
+ const useRoot = info.useRoot;
+ const detailItemId = component.ids?.detailItem;
+ _ui.attributes = Object.assign({}, _ui.attributes, {
+ "data-cy": dataCy,
+ });
+ const prevOnAfterRender = _ui.on?.onAfterRender;
+ _ui.on = _ui.on || {};
+ _ui.on.onAfterRender = function () {
+ if (typeof prevOnAfterRender === "function")
+ prevOnAfterRender.call(this);
+ try {
+ const idToUse = useRoot ? this.config?.id : detailItemId;
+ let node =
+ (typeof $$ !== "undefined" && idToUse && $$(idToUse)?.$view) ||
+ (typeof document !== "undefined" &&
+ idToUse &&
+ document.getElementById(idToUse));
+ if (!node?.setAttribute && typeof document !== "undefined" && idToUse)
+ node = document.querySelector(`[id$="${idToUse}"]`);
+ if (node?.setAttribute) node.setAttribute("data-cy", dataCy);
+ } catch (e) {}
+ };
+ }
+
+ this.viewComponentIDs[v.id] = _ui.id;
+ _ui.gravity = getGrav;
+ curRow.cols[newPos] = _ui;
+
+ this.eventAdd({
+ emitter: v,
+ eventName: "changePage",
+ listener: this._handlerChangePage,
+ });
+ curColIndex++;
+ });
+
+ return rows;
+ }
+
+ _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;
+ }
+
+ async init(AB, accessLevel = 0, options = {}) {
+ await super.init(AB, accessLevel, options);
+ try {
+ this._setDetailFieldDataCy();
+ } catch (e) {
+ console.warn("Detail _setDetailFieldDataCy (init)", e);
+ }
+ }
+
+ 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?.();
+
+ try {
+ this._setDetailFieldDataCy();
+ } catch (e) {
+ console.warn("Detail _setDetailFieldDataCy (sync)", e);
+ }
+ if (typeof requestAnimationFrame !== "undefined") {
+ requestAnimationFrame(() => {
+ try {
+ this._setDetailFieldDataCy();
+ } catch (err) {
+ console.warn("Detail _setDetailFieldDataCy (rAF)", err);
+ }
+ });
+ }
+ [0, 100, 300, 600, 1200].forEach((ms) =>
+ setTimeout(() => {
+ try {
+ this._setDetailFieldDataCy();
+ } catch (err) {
+ console.warn("Detail _setDetailFieldDataCy (timeout)", err);
+ }
+ }, ms)
+ );
+ }
+
+ /** Build data-cy string for a detail view (matches core). Values trimmed for exact e2e match. */
+ _dataCyForView(f) {
+ const parentId = String(
+ f.parentDetailComponent?.()?.id || f.parent?.id || ""
+ ).trim();
+ const field = f.field?.();
+ const settings = f.settings || {};
+ const columnName = String(
+ f.key === "detail_connect"
+ ? (f.field?.((fl) => fl.id === settings.fieldId)?.columnName ?? "")
+ : (field?.columnName ?? "")
+ ).trim();
+ const fieldId = String(field?.id ?? settings.fieldId ?? "").trim();
+
+ let dataCy = "";
+ let useRoot = false;
+ switch (f.key) {
+ case "detail_text":
+ dataCy = `detail text ${columnName} ${fieldId} ${parentId}`;
+ useRoot = true;
+ 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}`;
+ useRoot = true;
+ }
+ if (dataCy) dataCy = dataCy.replace(/\s+/g, " ").trim();
+ return dataCy ? { dataCy, useRoot } : null;
+ }
+
+ /** Set data-cy on one component; use $$(id) or document.getElementById so CI finds element. */
+ _setDataCyOnComponent(comp, _f, { dataCy, useRoot }) {
+ if (!comp?.ids || !dataCy) return;
+ try {
+ const id = useRoot ? comp.ids.component : comp.ids.detailItem;
+ if (!id) return;
+ let el =
+ typeof $$ !== "undefined" && $$(id)?.$view
+ ? $$(id).$view
+ : null;
+ if (!el && typeof document !== "undefined")
+ el = document.getElementById(id);
+ if (!el?.setAttribute) return;
+ const target =
+ !useRoot && el.parentNode ? el.parentNode : el;
+ target.setAttribute("data-cy", dataCy);
+ } catch (e) {
+ console.warn("Problem setting detail field data-cy", e);
+ }
+ }
+
+ /** Set data-cy on all detail fields; try comp.ids then viewComponentIDs then getElementById. */
+ _setDetailFieldDataCy() {
+ if (!ContainerComponent || !this.viewComponents) return;
+ const viewList = this.view.views() || [];
+ const viewComponentIDs = this.viewComponentIDs || {};
+
+ Object.keys(this.viewComponents).forEach((viewId) => {
+ const comp = this.viewComponents[viewId];
+ const f = viewList.find((v) => v.id === viewId);
+ if (!comp || !f) return;
+
+ const info = this._dataCyForView(f);
+ if (!info) return;
+
+ const id =
+ (info.useRoot
+ ? comp.ids?.component
+ : comp.ids?.detailItem) ||
+ viewComponentIDs[viewId];
+ if (!id) return;
+
+ let el =
+ (typeof $$ !== "undefined" && $$(id)?.$view) ||
+ (typeof document !== "undefined" && document.getElementById(id));
+ if (!el?.setAttribute && typeof document !== "undefined")
+ el = document.querySelector(`[id$="${id}"]`);
+ if (!el?.setAttribute) return;
+
+ const target =
+ !info.useRoot && el.parentNode ? el.parentNode : el;
+ target.setAttribute("data-cy", info.dataCy);
+ });
+ }
+
+ 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 =
+ this.viewComponents?.[f.id] ?? f.component(this.idBase);
+ vComponent?.setValue?.(val);
+ vComponent?.displayText?.(rowData);
+
+ try {
+ const dataCyInfo = this._dataCyForView(f);
+ if (dataCyInfo)
+ this._setDataCyOnComponent(vComponent, f, dataCyInfo);
+ } catch (e) {
+ console.warn("Detail data-cy in displayData", e);
+ }
+ });
+
+ [0, 100, 400].forEach((ms) =>
+ setTimeout(() => this._setDetailFieldDataCy(), ms)
+ );
+ }
+ };
+}
\ No newline at end of file