diff --git a/AppBuilder/platform/ABClassManager.js b/AppBuilder/platform/ABClassManager.js index f3832316..eaf1ff05 100644 --- a/AppBuilder/platform/ABClassManager.js +++ b/AppBuilder/platform/ABClassManager.js @@ -11,6 +11,10 @@ import ABViewEditorPlugin from "./plugins/ABViewEditorPlugin.js"; // some views need to reference ABViewContainer, import ABViewContainer from "./views/ABViewContainer.js"; +// view property helpers used by plugins +import ABViewPropertyFilterData from "./views/viewProperties/ABViewPropertyFilterData"; +import ABViewPropertyLinkPage from "./views/viewProperties/ABViewPropertyLinkPage"; + // MIGRATION: ABViewManager is depreciated. Use ABClassManager instead. import ABViewManager from "./ABViewManager.js"; @@ -62,6 +66,8 @@ export function getPluginAPI() { ABViewPropertiesPlugin, ABViewEditorPlugin, ABViewContainer, + ABViewPropertyFilterData, + ABViewPropertyLinkPage, // ABFieldPlugin, // ABViewPlugin, }; diff --git a/AppBuilder/platform/plugins/included/index.js b/AppBuilder/platform/plugins/included/index.js index 17d382b3..a690bc22 100644 --- a/AppBuilder/platform/plugins/included/index.js +++ b/AppBuilder/platform/plugins/included/index.js @@ -6,6 +6,7 @@ import viewText from "./view_text/FNAbviewtext.js"; import viewImage from "./view_image/FNAbviewimage.js"; import viewDataSelect from "./view_data-select/FNAbviewdataselect.js"; import viewPdfImporter from "./view_pdfImporter/FNAbviewpdfimporter.js"; +import viewCarousel from "./view_carousel/FNAbviewcarousel.js"; const AllPlugins = [ viewTab, @@ -15,6 +16,7 @@ const AllPlugins = [ viewImage, viewDataSelect, viewPdfImporter, + viewCarousel, ]; export default { diff --git a/AppBuilder/platform/plugins/included/view_carousel/FNAbviewcarousel.js b/AppBuilder/platform/plugins/included/view_carousel/FNAbviewcarousel.js new file mode 100644 index 00000000..404504f4 --- /dev/null +++ b/AppBuilder/platform/plugins/included/view_carousel/FNAbviewcarousel.js @@ -0,0 +1,190 @@ +import FNAbviewcarouselComponent from "./FNAbviewcarouselComponent.js"; + +// FNAbviewcarousel Web +// A web side import for an ABView. +// +export default function FNAbviewcarousel({ + /*AB,*/ + ABViewWidgetPlugin, + ABViewComponentPlugin, + ABViewPropertyFilterData, + ABViewPropertyLinkPage, +}) { + const ABAbviewcarouselComponent = FNAbviewcarouselComponent({ + ABViewComponentPlugin, + }); + + const ABViewCarouselPropertyComponentDefaults = { + dataviewID: null, // uuid of ABDatacollection + field: null, // uuid + + width: 460, + height: 275, + showLabel: true, + hideItem: false, + hideButton: false, + navigationType: "corner", // "corner" || "side" + filterByCursor: false, + + detailsPage: null, // uuid + detailsTab: null, // uuid + editPage: null, // uuid + editTab: null, // uuid + }; + + const ABViewDefaults = { + key: "carousel", // {string} unique key for this view + icon: "clone", // {string} fa-[icon] reference for this view + labelKey: "Carousel", // {string} the multilingual label key for the class label + }; + + function parseIntOrDefault(_this, key) { + if (typeof _this.settings[key] != "undefined") { + _this.settings[key] = parseInt(_this.settings[key]); + } else { + _this.settings[key] = ABViewCarouselPropertyComponentDefaults[key]; + } + } + + function parseOrDefault(_this, key) { + try { + _this.settings[key] = JSON.parse(_this.settings[key]); + } catch (e) { + _this.settings[key] = ABViewCarouselPropertyComponentDefaults[key]; + } + } + + class ABViewCarouselCore extends ABViewWidgetPlugin { + constructor(values, application, parent, defaultValues) { + super(values, application, parent, defaultValues || ABViewDefaults); + } + + static common() { + return ABViewDefaults; + } + + static defaultValues() { + return ABViewCarouselPropertyComponentDefaults; + } + + /// + /// Instance Methods + /// + + /** + * @method fromValues() + * + * initialze this object with the given set of values. + * @param {obj} values + */ + fromValues(values) { + super.fromValues(values); + + // convert from "0" => 0 + parseIntOrDefault(this, "width"); + parseIntOrDefault(this, "height"); + + // json + parseOrDefault(this, "showLabel"); + parseOrDefault(this, "hideItem"); + parseOrDefault(this, "hideButton"); + + this.settings.navigationType = + this.settings.navigationType || + ABViewCarouselPropertyComponentDefaults.navigationType; + + parseOrDefault(this, "filterByCursor"); + } + + /** + * @method componentList + * return the list of components available on this view to display in the editor. + */ + componentList() { + return []; + } + + get imageField() { + let dc = this.datacollection; + if (!dc) return null; + + let obj = dc.datasource; + if (!obj) return null; + + return obj.fieldByID(this.settings.field); + } + } + + return class ABViewCarousel extends ABViewCarouselCore { + /** + * @method getPluginKey + * return the plugin key for this view. + * @return {string} plugin key + */ + static getPluginKey() { + return this.common().key; + } + + /** + * @method component() + * return a UI component based upon this view. + * @return {obj} UI component + */ + component(parentId) { + return new ABAbviewcarouselComponent(this, parentId); + } + + constructor(values, application, parent, defaultValues) { + super(values, application, parent, defaultValues); + } + + /// + /// Instance Methods + /// + + /** + * @method fromValues() + * + * initialze this object with the given set of values. + * @param {obj} values + */ + fromValues(values) { + super.fromValues(values); + + // filter property + this.filterHelper.fromSettings(this.settings.filter); + } + + get idBase() { + return `ABViewCarousel_${this.id}`; + } + + get filterHelper() { + if (this.__filterHelper == null) + this.__filterHelper = new ABViewPropertyFilterData( + this.AB, + this.idBase + ); + + return this.__filterHelper; + } + + get linkPageHelper() { + if (this.__linkPageHelper == null) + this.__linkPageHelper = new ABViewPropertyLinkPage(); + + return this.__linkPageHelper; + } + + warningsEval() { + super.warningsEval(); + + let field = this.imageField; + if (!field) { + this.warningsMessage( + `can't resolve image field[${this.settings.field}]` + ); + } + } + }; +} diff --git a/AppBuilder/platform/plugins/included/view_carousel/FNAbviewcarouselComponent.js b/AppBuilder/platform/plugins/included/view_carousel/FNAbviewcarouselComponent.js new file mode 100644 index 00000000..38327066 --- /dev/null +++ b/AppBuilder/platform/plugins/included/view_carousel/FNAbviewcarouselComponent.js @@ -0,0 +1,536 @@ +export default function FNAbviewcarouselComponent({ + /*AB,*/ + ABViewComponentPlugin, +}) { + return class ABAbviewcarouselComponent extends ABViewComponentPlugin { + constructor(baseView, idBase, ids) { + super( + baseView, + idBase || `ABViewCarousel_${baseView.id}`, + Object.assign( + { + carousel: "", + }, + ids + ) + ); + + this._handler_doOnShow = () => { + this.onShow(); + }; + + this._handler_doReload = () => { + // this.datacollection?.reloadData(); + }; + + this._handler_doFilter = (fnFilter, filterRules) => { + // NOTE: fnFilter is depreciated and will be removed. + + // this.onShow(filterRules); + const dv = this.datacollection; + + if (!dv) return; + + dv.filterCondition(filterRules); + dv.reloadData(); + }; + + this._handler_busy = () => { + this.busy(); + }; + + this._handler_ready = () => { + this.ready(); + }; + } + + ui() { + const ids = this.ids; + + const baseView = this.view; + + this.filterUI = baseView.filterHelper; // component(/* App, idBase */); + this.linkPage = baseView.linkPageHelper.component(/* App, idBase */); + + const spacer = {}; + const settings = this.settings; + + if (settings.width === 0) + Object.assign(spacer, { + width: 1, + }); + + const _ui = super.ui([ + { + borderless: true, + cols: [ + spacer, // spacer + { + borderless: true, + rows: [ + this.filterUI.ui(), // filter UI + { + id: ids.carousel, + view: "carousel", + cols: [], + width: settings.width, + height: settings.height, + navigation: { + items: !settings.hideItem, + buttons: !settings.hideButton, + type: settings.navigationType, + }, + on: { + onShow: () => { + const activeIndex = $$( + ids.carousel + ).getActiveIndex(); + + this.switchImage(activeIndex); + }, + }, + }, + ], + }, + spacer, // spacer + ], + }, + ]); + + delete _ui.type; + + return _ui; + } + + // make sure each of our child views get .init() called + async init(AB) { + await super.init(AB); + + const dv = this.datacollection; + + if (!dv) { + AB.notify.builder(`Datacollection is ${dv}`, { + message: "This is an invalid datacollection", + }); + + return; + } + + const object = dv.datasource; + + if (!object) { + AB.notify.developer(`Object is ${dv}`, { + message: "This is an invalid object", + }); + + return; + } + + dv.removeListener("loadData", this._handler_doOnShow); + dv.on("loadData", this._handler_doOnShow); + + dv.removeListener("update", this._handler_doReload); + dv.on("update", this._handler_doReload); + + dv.removeListener("delete", this._handler_doReload); + dv.on("delete", this._handler_doReload); + + dv.removeListener("create", this._handler_doReload); + dv.on("create", this._handler_doReload); + + dv.removeListener("initializingData", this._handler_busy); + dv.on("initializingData", this._handler_busy); + + dv.removeListener("initializedData", this._handler_ready); + dv.on("initializedData", this._handler_ready); + + if (this.settings.filterByCursor) { + ["changeCursor", "cursorStale"].forEach((key) => { + dv.removeListener(key, this._handler_doOnShow); + dv.on(key, this._handler_doOnShow); + }); + } + + const baseView = this.view; + + // filter helper + baseView.filterHelper.objectLoad(object); + baseView.filterHelper.viewLoad(this); + + this.filterUI.init(this.AB); + this.filterUI.removeListener("filter.data", this._handler_doFilter); + this.filterUI.on("filter.data", this._handler_doFilter); + + // link page helper + this.linkPage.init({ + view: baseView, + datacollection: dv, + }); + + // set data-cy + const $carouselView = $$(this.ids.carousel)?.$view; + + if ($carouselView) { + $carouselView.setAttribute( + "data-cy", + `${baseView.key} ${baseView.id}` + ); + $carouselView + .querySelector(".webix_nav_button_prev") + ?.firstElementChild?.setAttribute( + "data-cy", + `${baseView.key} button previous ${baseView.id}` + ); + $carouselView + .querySelector(".webix_nav_button_next") + ?.firstElementChild?.setAttribute( + "data-cy", + `${baseView.key} button next ${baseView.id}` + ); + } + } + + /** + * @method detatch() + * Will make sure all our handlers are removed from any object + * we have attached them to. + * + * You'll want to call this in situations when we are dynamically + * creating and recreating instances of the same Widget (like in + * the ABDesigner). + */ + detatch() { + const dv = this.datacollection; + + if (!dv) return; + + dv.removeListener("loadData", this._handler_doOnShow); + + if (this._handler_doReload) { + dv.removeListener("update", this._handler_doReload); + dv.removeListener("delete", this._handler_doReload); + dv.removeListener("create", this._handler_doReload); + } + + dv.removeListener("initializingData", this._handler_busy); + + dv.removeListener("initializedData", this._handler_ready); + + if (this.settings.filterByCursor) + ["changeCursor", "cursorStale"].forEach((key) => { + dv.removeListener(key, this._handler_doOnShow); + }); + + this.filterUI.removeListener("filter.data", this._handler_doFilter); + } + + myTemplate(row) { + if (row?.src) { + const settings = this.settings; + + return ``; + } + // empty image + else return ""; + } + + busy() { + const $carousel = $$(this.ids.carousel); + + $carousel?.disable(); + $carousel?.showProgress?.({ type: "icon" }); + } + + ready() { + const $carousel = $$(this.ids.carousel); + + $carousel?.enable(); + $carousel?.hideProgress?.(); + } + + async switchImage(currentPosition) { + const dv = this.datacollection; + + if (!dv) return; + + // Check want to load more images + if ( + currentPosition >= this._imageCount - 1 && // check last image + dv.totalCount > this._rowCount + ) { + // loading cursor + this.busy(); + + try { + await dv.loadData(this._rowCount || 0); + } catch (err) { + this.AB.notify.developer(err, { + message: + "ABViewCarousel:switchImage():Error when load data from a Data collection", + }); + } + + this.ready(); + } + } + + onShow(fnFilter = this.filterUI.getFilter()) { + const ids = this.ids; + const dv = this.datacollection; + + if (!dv) return; + + const obj = dv.datasource; + + if (!obj) return; + + const field = this.view.imageField; + + if (!field) return; + + if (dv.dataStatus == dv.dataStatusFlag.notInitial) { + // load data when a widget is showing + dv.loadData(); + + // it will call .onShow again after dc loads completely + return; + } + + const settings = this.settings; + + let rows = dv.getData(fnFilter); + + // Filter images by cursor + if (settings.filterByCursor) { + const cursor = dv.getCursor(); + + if (cursor) + rows = rows.filter( + (r) => + (r[obj.PK()] || r.id || r) === + (cursor[obj.PK()] || cursor.id || cursor) + ); + } + + const images = []; + + rows.forEach((r) => { + const imgFile = r[field.columnName]; + + if (imgFile) { + const imgData = { + id: r.id, + src: `/file/${imgFile}`, + imgFile, + }; + + // label of row data + if (settings.showLabel) imgData.label = obj.displayData(r); + + images.push({ + css: "image", + borderless: true, + template: (...params) => { + return this.myTemplate(...params); + }, + data: imgData, + }); + } + }); + + const ab = this.AB; + + // insert the default image to first item + if (field.settings.defaultImageUrl) + images.unshift({ + css: "image", + template: (...params) => this.myTemplate(...params), + data: { + id: ab.uuid(), + src: `/file/${field.settings.defaultImageUrl}`, + label: this.label("Default image"), + }, + }); + + // empty image + if (images.length < 1) + images.push({ + rows: [ + { + view: "label", + align: "center", + height: settings.height, + label: "
", + }, + { + view: "label", + align: "center", + label: this.label("No image"), + }, + ], + }); + + // store total of rows + this._rowCount = rows.length; + + // store total of images + this._imageCount = images.length; + + const $carousel = $$(ids.carousel); + const abWebix = ab.Webix; + + if ($carousel) { + // re-render + abWebix.ui(images, $carousel); + + // add loading cursor + abWebix.extend($carousel, abWebix.ProgressBar); + + // link pages events + const editPage = settings.editPage; + const detailsPage = settings.detailsPage; + + // if (detailsPage || editPage) { + $carousel.$view.onclick = async (e) => { + if (e.target.className) { + if (e.target.className.indexOf("ab-carousel-edit") > -1) { + abWebix.html.removeCss($carousel.getNode(), "fullscreen"); + abWebix.fullscreen.exit(); + let rowId = e.target.getAttribute("ab-row-id"); + this.linkPage.changePage(editPage, rowId); + } else if ( + e.target.className.indexOf("ab-carousel-detail") > -1 + ) { + abWebix.html.removeCss($carousel.getNode(), "fullscreen"); + abWebix.fullscreen.exit(); + let rowId = e.target.getAttribute("ab-row-id"); + this.linkPage.changePage(detailsPage, rowId); + } else if ( + e.target.className.indexOf("ab-carousel-fullscreen") > -1 + ) { + $carousel.define("css", "fullscreen"); + abWebix.fullscreen.set(ids.carousel, { + head: { + view: "toolbar", + css: "webix_dark", + elements: [ + {}, + { + view: "icon", + icon: "fa fa-times", + click: function () { + abWebix.html.removeCss( + $carousel.getNode(), + "fullscreen" + ); + abWebix.fullscreen.exit(); + }, + }, + ], + }, + }); + } else if ( + e.target.className.indexOf("ab-carousel-rotate-left") > -1 + ) { + const rowId = e.target.getAttribute("ab-row-id"); + const imgFile = e.target.getAttribute("ab-img-file"); + this.rotateImage(rowId, imgFile, field, "left"); + } else if ( + e.target.className.indexOf("ab-carousel-rotate-right") > -1 + ) { + const rowId = e.target.getAttribute("ab-row-id"); + const imgFile = e.target.getAttribute("ab-img-file"); + this.rotateImage(rowId, imgFile, field, "right"); + } else if ( + e.target.className.indexOf("ab-carousel-zoom-in") > -1 + ) { + this.zoom("in"); + } else if ( + e.target.className.indexOf("ab-carousel-zoom-out") > -1 + ) { + this.zoom("out"); + } + } + }; + } + } + + showFilterPopup($view) { + this.filterUI.showPopup($view); + } + + async rotateImage(rowId, imgFile, field, direction = "right") { + this.busy(); + + // call api to rotate + if (direction == "left") await field.rotateLeft(imgFile); + else await field.rotateRight(imgFile); + + // refresh image + const imgElm = document.getElementById( + `${this.ids.component}-${rowId}` + ); + if (imgElm) { + await fetch(imgElm.src, { cache: "reload", mode: "no-cors" }); + imgElm.src = `${imgElm.src}#${new Date().getTime()}`; + } + + this.ready(); + } + + zoom(inOrOut = "in") { + const imgContainer = document.getElementsByClassName( + "ab-carousel-image-container" + )[0]; + if (!imgContainer) return; + + const imgElem = imgContainer.getElementsByTagName("img")[0]; + if (!imgElem) return; + + const step = 15; + const height = parseInt( + (imgElem.style.height || 100).toString().replace("%", "") + ); + const newHeight = inOrOut == "in" ? height + step : height - step; + imgElem.style.height = `${newHeight}%`; + + imgContainer.style.overflow = newHeight > 100 ? "auto" : ""; + } + }; +}