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