diff --git a/AppBuilder/core b/AppBuilder/core index 692e247a..e3784f40 160000 --- a/AppBuilder/core +++ b/AppBuilder/core @@ -1 +1 @@ -Subproject commit 692e247a484ef0ae1275b6889a2ab5b417747b59 +Subproject commit e3784f40e6e78211ea962470d16c7e638c91760b diff --git a/AppBuilder/platform/plugins/included/index.js b/AppBuilder/platform/plugins/included/index.js index bd2fbc35..01cf8e1c 100644 --- a/AppBuilder/platform/plugins/included/index.js +++ b/AppBuilder/platform/plugins/included/index.js @@ -1,11 +1,12 @@ -import viewDataview from "./view_dataview/FNAbviewdataview.js"; import viewCarousel from "./view_carousel/FNAbviewcarousel.js"; import viewComment from "./view_comment/FNAbviewcomment.js"; import viewCsvExporter from "./view_csvExporter/FNAbviewcsvexporter.js"; import viewCsvImporter from "./view_csvImporter/FNAbviewcsvimporter.js"; import viewDataSelect from "./view_data-select/FNAbviewdataselect.js"; +import viewDataview from "./view_dataview/FNAbviewdataview.js"; import viewDetail from "./view_detail/FNAbviewdetail.js"; import viewImage from "./view_image/FNAbviewimage.js"; +import viewKanban from "./view_kanban/FNABViewKanban.js"; import viewLabel from "./view_label/FNAbviewlabel.js"; import viewLayout from "./view_layout/FNAbviewlayout.js"; import viewList from "./view_list/FNAbviewlist.js"; @@ -18,17 +19,18 @@ const AllPlugins = [ viewComment, viewCsvExporter, viewCsvImporter, - viewCsvImporter, viewDataSelect, + viewDataview, viewDetail, viewImage, + viewKanban, viewLabel, viewLayout, viewList, viewPdfImporter, viewTab, viewText, -, viewDataview]; +]; export default { load: (AB) => { diff --git a/AppBuilder/platform/plugins/included/view_kanban/FNABViewKanban.js b/AppBuilder/platform/plugins/included/view_kanban/FNABViewKanban.js new file mode 100644 index 00000000..a0ce6dfc --- /dev/null +++ b/AppBuilder/platform/plugins/included/view_kanban/FNABViewKanban.js @@ -0,0 +1,131 @@ +import FNABViewKanbanComponent from "./FNABViewKanbanComponent.js"; +import FNABViewKanbanDetachedFormSave from "./FNABViewKanbanForm.js"; +import FNABViewKanbanFormSidePanel from "./FNABViewKanbanFormSidePanel.js"; + +// FNABViewKanban Web +// A web side import for an ABView. +// +export default function FNABViewKanban({ + AB, + ABViewWidgetPlugin, + ABViewComponentPlugin, + ABViewPropertyLinkPage, + ABViewPlugin, +}) { + const ABViewKanbanDetachedFormSave = FNABViewKanbanDetachedFormSave({ + AB, + ABViewPlugin, + ABViewComponentPlugin, + }); + const KanbanFormSidePanel = FNABViewKanbanFormSidePanel({ + ABViewComponentPlugin, + ABViewKanbanDetachedFormSave, + }); + const ABViewKanbanComponent = FNABViewKanbanComponent({ + AB, + ABViewComponentPlugin, + FNABViewKanbanFormSidePanel: KanbanFormSidePanel, + }); + + const ABViewKanbanPropertyComponentDefaults = { + dataviewID: null, // uuid ABDataCollection; DC resolves ABObject + editFields: [], // ABField.id[] fields shown in editor + verticalGroupingField: "", // ABField.id vertical lanes + horizontalGroupingField: "", // ABField.id optional horizontal grouping + ownerField: "", // ABFieldUser.id card owner + template: "", // json ABViewText card body; placeholders {field.id} + }; + + const ABViewDefaults = { + key: "kanban", // {string} unique view key + icon: "columns", // {string} font-awesome (no fa- prefix) + labelKey: "Kanban", // {string} multilingual label key → L(labelKey) + }; + + class ABViewKanbanCore extends ABViewWidgetPlugin { + constructor(values, application, parent, defaultValues) { + super(values, application, parent, defaultValues || ABViewDefaults); + } + + /// + /// Instance Methods + /// + + /** + * @method componentList + * return the list of components available on this view to display in the editor. + */ + componentList() { + return []; + } + + fromValues(values) { + super.fromValues(values); + + // set a default .template value + if (!this.settings.template) { + this.settings.template = { id: `${this.id}_template`, key: "text" }; + this.settings.template.text = this.settings.textTemplate; + } + + this.TextTemplate = AB.viewNewDetatched(this.settings.template); + } + + toObj() { + var obj = super.toObj(); + obj.settings.template = this.TextTemplate.toObj(); + // NOTE: this corrects the initial save where this.id == undefined + // all the rest will set the .id correctly. + obj.settings.template.id = `${this.id}_template`; + return obj; + } + + static common() { + return ABViewDefaults; + } + + static defaultValues() { + return ABViewKanbanPropertyComponentDefaults; + } + } + + return class ABViewKanban extends ABViewKanbanCore { + /** + * @method getPluginKey + * return the plugin key for this view. + * @return {string} plugin key + */ + static getPluginKey() { + return this.common().key; + } + get linkPageHelper() { + if (this.__linkPageHelper == null) + this.__linkPageHelper = new ABViewPropertyLinkPage(); + + return this.__linkPageHelper; + } + + /** + * @method component() + * return a UI component based upon this view. + * @return {obj} UI component + */ + component(parentId) { + return new ABViewKanbanComponent(this, parentId); + } + + // + // Editor Related + // + + warningsEval() { + super.warningsEval(); + let DC = this.datacollection; + if (!DC) { + this.warningsMessage( + `can't resolve it's datacollection[${this.settings.dataviewID}]` + ); + } + } + }; +} diff --git a/AppBuilder/platform/plugins/included/view_kanban/FNABViewKanbanComponent.js b/AppBuilder/platform/plugins/included/view_kanban/FNABViewKanbanComponent.js new file mode 100644 index 00000000..d0a78b2c --- /dev/null +++ b/AppBuilder/platform/plugins/included/view_kanban/FNABViewKanbanComponent.js @@ -0,0 +1,585 @@ +export default function FNABViewKanbanComponent({ + AB, + ABViewComponentPlugin, + FNABViewKanbanFormSidePanel, +}) { + return class ABViewKanbanComponent extends ABViewComponentPlugin { + constructor(baseView, idBase, ids) { + super( + baseView, + idBase || `ABViewKanban_${baseView.id}`, + Object.assign( + { + kanbanView: "", + + kanban: "", + resizer: "", + formSidePanel: "", + }, + ids + ) + ); + + this.FormSide = new FNABViewKanbanFormSidePanel( + this, + this.ids.formSidePanel, + this.settings.editFields + ); + + this.CurrentVerticalField = null; + this.CurrentHorizontalField = null; + this.CurrentOwnerField = null; + + this.TextTemplate = baseView.TextTemplate; + + this._updatingOwnerRowId = null; + this._ABFieldConnect = null; + this._ABFieldUser = null; + this._ABFieldList = null; + } + + get ABFieldConnect() { + return (this._ABFieldConnect = + this._ABFieldConnect || + AB.Class.ABFieldManager.fieldByKey("connectObject")); + } + + get ABFieldUser() { + return (this._ABFieldUser = + this._ABFieldUser || AB.Class.ABFieldManager.fieldByKey("user")); + } + + get ABFieldList() { + return (this._ABFieldList = + this._ABFieldList || AB.Class.ABFieldManager.fieldByKey("list")); + } + + ui() { + const ids = this.ids; + const baseView = this.view; + const self = this; + this.linkPage = baseView.linkPageHelper.component(); + + const _ui = super.ui([ + { + id: ids.kanbanView, + cols: [ + { + id: ids.kanban, + view: "kanban", + cols: [], + userList: { + view: "menu", + // yCount: 8, + // scroll: false, + template: ' #value#', + width: 150, + on: { + onSelectChange: function () { + // get this row id from onAvatarClick event + if (!self._updatingOwnerRowId) return; + + const userId = this.getSelectedId(false); + if (!userId) return; + + self.updateOwner(self._updatingOwnerRowId, userId); + }, + }, + }, + editor: false, // we use side bar + users: [], + tags: [], + data: [], + on: { + onListAfterSelect: (itemId, list) => { + this.CurrentDatacollection?.setCursor(itemId); + this.emit("select", itemId); + + // link pages events + const editPage = this.settings.editPage; + if (editPage) + this.linkPage.changePage(editPage, itemId); + + const detailsPage = this.settings.detailsPage; + if (detailsPage) + this.linkPage.changePage(detailsPage, itemId); + }, + onAfterStatusChange: (rowId, status /*, list */) => { + this.updateStatus(rowId, status); + }, + onAvatarClick: (rowId /*, ev, node, list */) => { + // keep this row id for update owner data in .userList + this._updatingOwnerRowId = rowId; + }, + }, + }, + { + id: ids.resizer, + view: "resizer", + css: "bg_gray", + width: 11, + hidden: true, + }, + this.FormSide.ui(), + ], + }, + ]); + + delete _ui.type; + + return _ui; + } + + async init(AB) { + await super.init(AB); + + const abWebix = AB.Webix; + const baseView = this.view; + + if (this.$kb) abWebix.extend(this.$kb, abWebix.ProgressBar); + + this.FormSide.init(AB); + this.FormSide.on("add", (newVals) => { + this.saveData(newVals); + }); + this.FormSide.on("update", (updateVals) => { + this.saveData(updateVals); + }); + + let dc = baseView.datacollection; + if (dc) this.datacollectionLoad(dc); + + this.linkPage.init({ + view: baseView, + datacollection: dc, + }); + + this.show(); + } + + get $kb() { + return (this._kb = this._kb || $$(this.ids.kanban)); + } + + kanbanListTemplate() { + return { + icons: [ + // { icon: "mdi mdi-comment", show: function (obj) { return !!obj.comments }, template: "#comments.length#" }, + { + icon: "fa fa-trash-o", + click: (rowId /*, e */) => { + this.removeCard(rowId); + }, + }, + ], + // avatar template + templateAvatar: (obj) => { + if ( + this.CurrentOwnerField && + obj[this.CurrentOwnerField.columnName] + ) + return this.CurrentOwnerField.format(obj); + else return ""; + }, + // template for item body + // show item image and text + templateBody: (data) => { + // if (!this.settings.template) + if (!this.TextTemplate.text) + return this.CurrentObject?.displayData(data); + + // return our default text template + return this.TextTemplate.displayText(data); + }, + }; + } + + /** + * @function hide() + * + * hide this component. + */ + hide() { + $$(this.ids.kanbanView)?.hide(); + } + + /** + * @function show() + * Show this component. + */ + async show() { + const ids = this.ids; + + $$(ids.kanbanView)?.show(); + + this.FormSide.hide(); + + $$(ids.resizer)?.hide(); + + var CurrentObject = this.CurrentObject; + if (!CurrentObject) { + CurrentObject = this.datacollection?.datasource; + } + if (!CurrentObject) return; + + // Get vertical grouping field and populate to kanban list + // NOTE: this field should be the select list type + const CurrentVerticalField = CurrentObject.fieldByID( + this.settings.verticalGroupingField + ); + if (!CurrentVerticalField) return; + + this.CurrentVerticalField = CurrentVerticalField; + + let horizontalOptions = []; + + const CurrentHorizontalField = CurrentObject.fieldByID( + this.settings.horizontalGroupingField + ); + + this.CurrentHorizontalField = CurrentHorizontalField; + + if ( + CurrentHorizontalField && + CurrentHorizontalField instanceof this.ABFieldConnect + ) + // Pull horizontal options + horizontalOptions = await CurrentHorizontalField.getOptions(); + + // Option format - { id: "1543563751920", text: "Normal", hex: "#4CAF50" } + const verticalOptions = (CurrentVerticalField.settings.options || []).map( + (opt) => { + // Vertical & Horizontal fields + if (CurrentVerticalField && CurrentHorizontalField) { + let rows = [], + // [{ + // id: '', + // text: '' + // }] + horizontalVals = []; + + // pull options of the Horizontal field + if (CurrentHorizontalField instanceof this.ABFieldList) { + // make a copy of the settings. + horizontalVals = ( + CurrentHorizontalField.settings.options || [] + ).map((o) => o); + } else if (CurrentHorizontalField instanceof this.ABFieldUser) { + horizontalVals = CurrentHorizontalField.getUsers().map( + (u) => { + return { + id: u.id, + text: u.text || u.value, + }; + } + ); + } else if (CurrentHorizontalField instanceof this.ABFieldConnect) + horizontalVals = horizontalOptions.map(({ id, text }) => ({ + id, + text, + })); + + horizontalVals.push({ + id: null, + text: this.label("Other"), + }); + + horizontalVals.forEach((val) => { + const statusOps = {}; + + statusOps[CurrentVerticalField.columnName] = opt.id; + statusOps[CurrentHorizontalField.columnName] = val.id; + + // Header + rows.push({ + template: val.text, + height: 20, + css: "progress_header", + }); + + // Kanban list + rows.push({ + view: "kanbanlist", + status: statusOps, + type: this.kanbanListTemplate(), + }); + }); + + return { + header: opt.text, + body: { + margin: 0, + rows: rows, + }, + }; + } + // Vertical field only + else if (CurrentVerticalField) { + const statusOps = {}; + + statusOps[CurrentVerticalField.columnName] = opt.id; + + return { + header: opt.text, + body: { + view: "kanbanlist", + status: statusOps, + type: this.kanbanListTemplate(), + }, + }; + } + } + ); + + const ab = AB; + const abWebix = ab.Webix; + + // Rebuild kanban that contains options + // NOTE: webix kanban does not support dynamic vertical list + abWebix.ui(verticalOptions, $$(ids.kanban)); + $$(ids.kanban).reconstruct(); + + // Owner field + const CurrentOwnerField = CurrentObject.fieldByID( + this.settings.ownerField + ); + + this.CurrentOwnerField = CurrentOwnerField; + + if (CurrentOwnerField) { + const $menuUser = $$(ids.kanban).getUserList(); + + $menuUser.clearAll(); + + if (CurrentOwnerField instanceof this.ABFieldUser) { + const users = ab.Account.userList().map((u) => { + return { + id: u.username, + value: u.username, + }; + }); + + $menuUser.parse(users); + } else if (CurrentOwnerField instanceof this.ABFieldConnect) { + const options = await CurrentOwnerField.getOptions(); + + try { + $menuUser.parse( + options.map((opt) => { + return { + id: opt.id, + value: opt.text, + }; + }) + ); + } catch (e) { + // TODO: remove this. Trying to catch a random webix error: + // Cannot read properties of null (reading 'driver') + console.error(e); + console.warn(options); + } + } + } + } + + busy() { + this.$kb?.showProgress?.({ type: "icon" }); + } + + ready() { + this.$kb?.hideProgress?.(); + } + + objectLoad(object) { + super.objectLoad(object); + + this.TextTemplate.objectLoad(object); + this.FormSide.objectLoad(object); + } + + /** + * @method datacollectionLoad + * + * @param datacollection {ABDatacollection} + */ + datacollectionLoad(datacollection) { + super.datacollectionLoad(datacollection); + + const DC = this.CurrentDatacollection || datacollection; + + if (DC) { + DC.bind(this.$kb); + + const obj = DC.datasource; + + if (obj) this.objectLoad(obj); + + return; + } + + this.$kb.unbind(); + } + + async updateStatus(rowId, status) { + if (!this.CurrentVerticalField) return; + + // Show loading cursor + this.busy(); + + let patch = {}; + + // update multi-values + if (status instanceof Object) patch = status; + // update single value + else patch[this.CurrentVerticalField.columnName] = status; + + // update empty value + let needRefresh = false; + + for (const key in patch) + if (!patch[key]) { + patch[key] = ""; + + // WORKAROUND: if update data is empty, then it will need to refresh + // the kanban after update + needRefresh = true; + } + + try { + await this.CurrentObject?.model().update(rowId, patch); + + this.ready(); + + if (needRefresh) this.show(); + + // update form data + if (this.FormSide.isVisible()) { + const data = $$(this.ids.kanban).getItem(rowId); + + this.FormSide.refresh(data); + } + } catch (err) { + AB.notify.developer(err, { + context: "ABViewKanban:updateStatus(): Error saving item:", + rowId, + patch, + }); + } + } + + async updateOwner(rowId, val) { + if (!this.CurrentOwnerField) return; + + // Show loading cursor + this.busy(); + + const patch = {}; + + patch[this.CurrentOwnerField.columnName] = val; + + try { + const updatedRow = await this.CurrentObject?.model().update( + rowId, + patch + ); + + // update card + this.$kb?.updateItem(rowId, updatedRow); + + // update form data + if (this.FormSide.isVisible()) { + const data = this.$kb.getItem(rowId); + + this.FormSide.refresh(data); + } + + this.ready(); + } catch (err) { + AB.notify.developer(err, { + context: "ABViewKanban:updateOwner(): Error saving item:", + rowId, + val, + }); + + this.ready(); + } + } + + saveData(data) { + // update + if (data.id && this.$kb.exists(data.id)) + this.$kb.updateItem(data.id, data); + // insert + else this.$kb.add(data); + } + + unselect() { + if (this.$kb) + this.$kb.eachList((list /*, status*/) => { + list?.unselect?.(); + }); + } + + addCard() { + this.unselect(); + + // show the side form + this.FormSide.show(); + $$(this.ids.resizer).show(); + } + + async removeCard(rowId) { + const ab = AB; + const abWebix = ab.Webix; + + abWebix.confirm({ + title: this.label("Remove card"), + text: this.label("Do you want to delete this card?"), + callback: async (result) => { + if (!result) return; + + this.busy(); + + try { + const response = await this.CurrentObject?.model().delete(rowId); + + if (response.numRows > 0) { + this.$kb.remove(rowId); + } else { + abWebix.alert({ + text: this.label( + "No rows were effected. This does not seem right." + ), + }); + } + } catch (err) { + ab.notify.developer(err, { + message: "ABViewKanban:removeCard(): Error deleting item:", + rowId, + }); + } + + this.ready(); + }, + }); + } + + /** + * @method setFields() + * Save the current view options. + * @param options - { + * verticalGrouping: {ABField} - required + * horizontalGrouping: {ABField} - optional + * ownerField: {ABField} - optional + * } + */ + setFields(options) { + this.CurrentVerticalField = options.verticalGrouping; + this.CurrentHorizontalField = options.horizontalGrouping; + this.CurrentOwnerField = options.ownerField; + } + + + }; + +} diff --git a/AppBuilder/platform/plugins/included/view_kanban/FNABViewKanbanForm.js b/AppBuilder/platform/plugins/included/view_kanban/FNABViewKanbanForm.js new file mode 100644 index 00000000..5bfba3bc --- /dev/null +++ b/AppBuilder/platform/plugins/included/view_kanban/FNABViewKanbanForm.js @@ -0,0 +1,297 @@ +/** + * Kanban sidebar detached form: save button only. + * No cross-folder imports — bases come from pluginAPI (see ABClassManager.getPluginAPI). + */ + +export default function createABViewKanbanDetachedFormSave({ + AB, + ABViewPlugin, + ABViewComponentPlugin, +}) { + + const ABViewFormButtonPropertyComponentDefaults = { + includeSave: true, + saveLabel: "", + includeCancel: false, + cancelLabel: "", + includeReset: false, + resetLabel: "", + includeDelete: false, + deleteLabel: "", + afterCancel: null, + alignment: "right", + isDefault: false, // mark default button of form widget + }; + + + class ABViewFormButtonCore extends ABViewPlugin { + static common() { + return { + key: "button", + // {string} unique key for this view + + icon: "square", + // {string} fa-[icon] reference for this view + + labelKey: "ab.components.button", + // {string} the multilingual label key for the class label + }; + } + constructor(values, application, parent, defaultValues) { + const ABViewFormButtonDefaults = { + key: "button", + // {string} unique key for this view + + icon: "square", + // {string} fa-[icon] reference for this view + + labelKey: "ab.components.button", + // {string} the multilingual label key for the class label + }; + super( + values, + application, + parent, + defaultValues || ABViewFormButtonDefaults + ) + } + + + static defaultValues() { + return ABViewFormButtonPropertyComponentDefaults; + } + + /// + /// Instance Methods + /// + + toObj() { + // labels are multilingual values: + let labels = []; + + if (this.settings.saveLabel) labels.push("saveLabel"); + + if (this.settings.cancelLabel) labels.push("cancelLabel"); + + if (this.settings.resetLabel) labels.push("resetLabel"); + + if (this.settings.deleteLabel) labels.push("deleteLabel"); + + this.unTranslate(this.settings, this.settings, labels); + + let result = super.toObj(); + + return result; + } + + /** + * @property datacollection + * return data source + * NOTE: this view doesn't track a DataCollection. + * @return {ABDataCollection} + */ + get datacollection() { + return null; + } + + fromValues(values) { + super.fromValues(values); + + // labels are multilingual values: + let labels = []; + + if (this.settings.saveLabel) labels.push("saveLabel"); + + if (this.settings.cancelLabel) labels.push("cancelLabel"); + + if (this.settings.resetLabel) labels.push("resetLabel"); + + if (this.settings.deleteLabel) labels.push("deleteLabel"); + + this.unTranslate(this.settings, this.settings, labels); + + // this.settings.includeSave = JSON.parse( + // (this.settings?.includeSave ?? true) && + // ABViewFormButtonPropertyComponentDefaults.includeSave + // ); + // this.settings.includeCancel = JSON.parse( + // this.settings.includeCancel || + // ABViewFormButtonPropertyComponentDefaults.includeCancel + // ); + // this.settings.includeReset = JSON.parse( + // this.settings.includeReset || + // ABViewFormButtonPropertyComponentDefaults.includeReset + // ); + // this.settings.includeDelete = JSON.parse( + // this.settings.includeDelete || + // ABViewFormButtonPropertyComponentDefaults.includeDelete + // ); + + // this.settings.isDefault = JSON.parse( + // this.settings.isDefault || + // ABViewFormButtonPropertyComponentDefaults.isDefault + // ); + } + + /** + * @method componentList + * return the list of components available on this view to display in the editor. + */ + componentList() { + return []; + } + } + + class formComponent extends ABViewComponentPlugin { + constructor(baseView, idBase, ids) { + super( + baseView, + idBase || `ABViewFormItem_${baseView.id}`, + Object.assign({ formItem: "" }, ids) + ); + } + + ui(uiFormItemComponent = {}) { + // setup 'label' of the element + const baseView = this.view; + const form = baseView.parentFormComponent(), + field = baseView.field?.() || null, + label = ""; + const settings = form?.settings || {}; + const _uiFormItem = { + id: this.ids.formItem, + labelPosition: settings.labelPosition, + labelWidth: settings.labelWidth, + label, + }; + + if (field) { + _uiFormItem.name = field.columnName; + + // default value + const data = {}; + + field.defaultValue(data); + + if (data[field.columnName]) _uiFormItem.value = data[field.columnName]; + + if (settings.showLabel) _uiFormItem.label = field.label; + + if (field.settings.required || baseView.settings?.required) + _uiFormItem.required = 1; + + if (baseView.settings?.disable === 1) _uiFormItem.disabled = true; + + // this may be needed if we want to format data at this point + // if (field.format) data = field.format(data); + + _uiFormItem.validate = (val, data, colName) => { + const validator = AB.Validation.validator(); + + field.isValidData(data, validator); + + return validator.pass(); + }; + } + + const _ui = super.ui([ + Object.assign({}, _uiFormItem, uiFormItemComponent), + ]); + + delete _ui.type; + + return _ui; + } + }; + + + class ABViewKanbanDetachedFormSaveComponent extends formComponent { + constructor(baseView, idBase, ids) { + super( + baseView, + idBase || `ABViewKanbanDetachedFormSave_${baseView.id}`, + ids + ); + } + + ui() { + const self = this; + const baseView = this.view; + const form = baseView.parentFormComponent(); + const settings = baseView.settings ?? {}; + const alignment = + settings.alignment || + baseView.constructor.defaultValues().alignment; + + const _ui = { cols: [] }; + + if (alignment === "center" || alignment === "right") { + _ui.cols.push({}); + } + + if (settings.includeSave) { + _ui.cols.push({ + view: "button", + type: "form", + css: "webix_primary", + autowidth: true, + value: settings.saveLabel || this.label("Save"), + click: function () { + self.onSave(this); + }, + on: { + onAfterRender: function () { + this.getInputNode().setAttribute( + "data-cy", + `button save ${form.id}` + ); + }, + }, + }); + } + + if (alignment === "center" || alignment === "left") { + _ui.cols.push({}); + } + + return super.ui(_ui); + } + + onSave(saveButton) { + if (!saveButton) { + console.error("Require the button element"); + return; + } + const form = this.view.parentFormComponent(); + const formView = saveButton.getFormView(); + + saveButton.disable?.(); + + form + .saveData(formView) + .then(() => { + saveButton.enable?.(); + form.focusOnFirst(); + }) + .catch((err) => { + console.error(err); + try { + saveButton.enable?.(); + } catch (e) { + AB.notify.developer(e, { + context: + "ABViewKanbanDetachedFormSave.onSave > saveButton.enable()", + buttonID: this?.view?.id, + formID: this?.view?.parent?.id, + }); + } + }); + } + } + + return class ABViewKanbanDetachedFormSave extends ABViewFormButtonCore { + component() { + return new ABViewKanbanDetachedFormSaveComponent(this); + } + }; +} diff --git a/AppBuilder/platform/plugins/included/view_kanban/FNABViewKanbanFormSidePanel.js b/AppBuilder/platform/plugins/included/view_kanban/FNABViewKanbanFormSidePanel.js new file mode 100644 index 00000000..be3eafe9 --- /dev/null +++ b/AppBuilder/platform/plugins/included/view_kanban/FNABViewKanbanFormSidePanel.js @@ -0,0 +1,201 @@ +/* + * FNABViewKanbanFormSidePanel + * + * Form area for editing Kanban cards (included plugin; ESM). + */ + +export default function FNABViewKanbanFormSidePanel({ + ABViewComponentPlugin, + ABViewKanbanDetachedFormSave, +}) { + return class FNABViewKanbanFormSidePanel extends ABViewComponentPlugin { + constructor(comKanBan, idBase, editFields) { + super(comKanBan, idBase || `${comKanBan.view?.id}_formSidePanel`, { + form: "", + }); + + this.editFields = editFields; + + this._mockApp = this.AB.applicationNew({}); + } + + ui() { + const ids = this.ids; + const L = (...params) => this.AB.Multilingual.label(...params); + + return { + id: ids.component, + width: 300, + hidden: true, + rows: [ + { + view: "toolbar", + css: "webix_dark", + cols: [ + { + view: "label", + label: L("Edit Record"), + }, + { + view: "icon", + icon: "wxi-close", + align: "right", + click: () => { + this.hide(); + }, + }, + ], + }, + { + view: "scrollview", + body: { + rows: [ + { + id: ids.form, + view: "form", + type: "clean", + borderless: true, + rows: [], + }, + ], + }, + }, + ], + }; + } + + hide() { + $$(this.ids.component)?.hide(); + + this.emit("close"); + } + + show(data) { + $$(this.ids.component)?.show(); + + this.refreshForm(data); + } + + isVisible() { + return $$(this.ids.component)?.isVisible() ?? false; + } + + refreshForm(data) { + const ids = this.ids; + const $formView = $$(ids.form); + const CurrentObject = this.CurrentObject; + + if (!CurrentObject || !$formView) return; + + data = data || {}; + + const formAttrs = { + id: `${this.ids.component}_sideform`, + key: "form", + settings: { + columns: 1, + labelPosition: "top", + showLabel: 1, + clearOnLoad: 0, + clearOnSave: 0, + labelWidth: 120, + height: 0, + }, + }; + + const form = this.AB.viewNewDetatched(formAttrs); + + form.objectLoad(CurrentObject); + + CurrentObject.fields().forEach((f, index) => { + if (!this.editFields || this.editFields.indexOf(f.id) > -1) { + form.addFieldToForm(f, index); + } + }); + + form._views.push( + new ABViewKanbanDetachedFormSave( + { + settings: { + includeSave: true, + includeCancel: false, + includeReset: false, + }, + position: { + y: CurrentObject.fields().length, + }, + }, + this._mockApp, + form + ) + ); + + form._views.forEach( + (v, index) => (v.id = `${form.id}_${v.key}_${index}`) + ); + + const formCom = form.component(this.AB._App); + + webix.ui(formCom.ui().rows.concat({}), $formView); + webix.extend($formView, webix.ProgressBar); + + formCom.init( + this.AB, + 2, + { + onBeforeSaveData: () => { + const formVals = form.getFormValues($formView, CurrentObject); + + if (!form.validateData($formView, CurrentObject, formVals)) + return false; + + $formView?.showProgress({ type: "icon" }); + + if (formVals.id) { + CurrentObject.model() + .update(formVals.id, formVals) + .then((updateVals) => { + this.emit("update", updateVals); + + $formView?.hideProgress({ type: "icon" }); + }) + .catch((err) => { + this.AB.notify.developer(err, { + context: + "ABViewKanbanFormSidePanel:onBeforeSaveData():update(): Error updating value", + formVals, + }); + $formView?.hideProgress({ type: "icon" }); + }); + } else { + CurrentObject.model() + .create(formVals) + .then((newVals) => { + this.emit("add", newVals); + + $formView?.hideProgress({ type: "icon" }); + }) + .catch((err) => { + this.AB.notify.developer(err, { + context: + "ABViewKanbanFormSidePanel:onBeforeSaveData():.create(): Error creating value", + formVals, + }); + + $formView?.hideProgress({ type: "icon" }); + }); + } + + return false; + }, + }, + 2 + ); + + $formView.clear(); + $formView.parse(data); + + formCom.onShow(data); + } + }; +} diff --git a/AppBuilder/platform/views/ABViewKanban.js b/AppBuilder/platform/views/ABViewKanban.js deleted file mode 100644 index 19f4accf..00000000 --- a/AppBuilder/platform/views/ABViewKanban.js +++ /dev/null @@ -1,36 +0,0 @@ -const ABViewKanbanCore = require("../../core/views/ABViewKanbanCore"); -const ABViewKanbanComponent = require("./viewComponent/ABViewKanbanComponent"); - -const ABViewPropertyLinkPage = - require("./viewProperties/ABViewPropertyLinkPage").default; - -export default class ABViewKanban extends ABViewKanbanCore { - // - // Editor Related - // - - /** - * @method component() - * return a UI component based upon this view. - * @return {obj} UI component - */ - - component() { - return new ABViewKanbanComponent(this); - } - - get linkPageHelper() { - return (this.__linkPageHelper = - this.__linkPageHelper || new ABViewPropertyLinkPage()); - } - - warningsEval() { - super.warningsEval(); - let DC = this.datacollection; - if (!DC) { - this.warningsMessage( - `can't resolve it's datacollection[${this.settings.dataviewID}]` - ); - } - } -} diff --git a/AppBuilder/platform/views/ABViewKanbanFormSidePanel.js b/AppBuilder/platform/views/ABViewKanbanFormSidePanel.js deleted file mode 100644 index a98847b3..00000000 --- a/AppBuilder/platform/views/ABViewKanbanFormSidePanel.js +++ /dev/null @@ -1,256 +0,0 @@ -/* - * ABViewKanbanFormSidePanel - * - * Provide a form area for editing data in the Kan Ban view. - * - */ - -const ABViewComponent = require("./viewComponent/ABViewComponent").default; -const ABViewForm = require("./ABViewForm"); -const ABViewFormButton = require("./ABViewFormButton"); - -var L = null; -// multilingual Label fn() - -module.exports = class ABWorkObjectKanBan extends ABViewComponent { - constructor(comKanBan, idBase, editFields) { - idBase = idBase || `${comKanBan.view?.id}_formSidePanel`; - super(idBase, { - form: "", - }); - - if (!L) { - L = (...params) => { - return this.AB.Multilingual.label(...params); - }; - } - - this.AB = comKanBan.AB; - - this.CurrentObjectID = null; - // {string} - // the ABObject.id of the object we are working with. - - this.editFields = editFields; - // {array} - // An array of {ABField.id} that determines which fields should show up - // in the editor. - - this._mockApp = this.AB.applicationNew({}); - // {ABApplication} - // Any ABViews we create are expected to be in relation to - // an ABApplication, so we create a "mock" app for our - // workspace views to use to display. - } - - /** - * @method CurrentObject() - * A helper to return the current ABObject we are working with. - * @return {ABObject} - */ - get CurrentObject() { - return this.AB.objectByID(this.CurrentObjectID); - } - - ui() { - var ids = this.ids; - - // Our webix UI definition: - return { - id: ids.component, - width: 300, - hidden: true, - rows: [ - { - view: "toolbar", - css: "webix_dark", - cols: [ - { - view: "label", - label: L("Edit Record"), - }, - { - view: "icon", - icon: "wxi-close", - align: "right", - click: (/* id */) => { - this.hide(); - }, - }, - ], - }, - { - view: "scrollview", - body: { - rows: [ - { - id: ids.form, - view: "form", - borderless: true, - rows: [], - }, - ], - }, - }, - ], - }; - } - - async init(AB) { - this.AB = AB; - } - - objectLoad(object) { - this.CurrentObjectID = object.id; - } - - hide() { - $$(this.ids.component)?.hide(); - - this.emit("close"); - } - - show(data) { - $$(this.ids.component)?.show(); - - this.refreshForm(data); - } - - isVisible() { - return $$(this.ids.component)?.isVisible() ?? false; - } - - refreshForm(data) { - var ids = this.ids; - let $formView = $$(ids.form); - let CurrentObject = this.CurrentObject; - - if (!CurrentObject || !$formView) return; - - data = data || {}; - - let formAttrs = { - id: `${this.ids.component}_sideform`, - key: ABViewForm.common().key, - settings: { - columns: 1, - labelPosition: "top", - showLabel: 1, - clearOnLoad: 0, - clearOnSave: 0, - labelWidth: 120, - height: 0, - }, - }; - - // let form = new ABViewForm(formAttrs, this._mockApp); - let form = this.AB.viewNewDetatched(formAttrs); - - form.objectLoad(CurrentObject); - - // Populate child elements - CurrentObject.fields().forEach((f, index) => { - // if this is one of our .editFields - if (!this.editFields || this.editFields.indexOf(f.id) > -1) { - form.addFieldToForm(f, index); - } - }); - - // add default button (Save button) - form._views.push( - new ABViewFormButton( - { - settings: { - includeSave: true, - includeCancel: false, - includeReset: false, - }, - position: { - y: CurrentObject.fields().length, // yPosition - }, - }, - this._mockApp, - form - ) - ); - - // add temp id to views - form._views.forEach( - (v, index) => (v.id = `${form.id}_${v.key}_${index}`) - ); - - let formCom = form.component(this.AB._App); - - // Rebuild form - webix.ui(formCom.ui().rows.concat({}), $formView); - webix.extend($formView, webix.ProgressBar); - - formCom.init( - this.AB, - 2, - { - onBeforeSaveData: () => { - // get update data - var formVals = form.getFormValues($formView, CurrentObject); - - // validate data - if (!form.validateData($formView, CurrentObject, formVals)) - return false; - - // show progress icon - $formView?.showProgress({ type: "icon" }); - - if (formVals.id) { - CurrentObject.model() - .update(formVals.id, formVals) - .then((updateVals) => { - this.emit("update", updateVals); - // _logic.callbacks.onUpdateData(updateVals); - - $formView?.hideProgress({ type: "icon" }); - }) - .catch((err) => { - // TODO : error message - this.AB.notify.developer(err, { - context: - "ABViewKanbanFormSidePanel:onBeforeSaveData():update(): Error updating value", - formVals, - }); - $formView?.hideProgress({ type: "icon" }); - }); - } - // else add new row - else { - CurrentObject.model() - .create(formVals) - .then((newVals) => { - // _logic.callbacks.onAddData(newVals); - this.emit("add", newVals); - - $formView?.hideProgress({ type: "icon" }); - }) - .catch((err) => { - // TODO : error message - this.AB.notify.developer(err, { - context: - "ABViewKanbanFormSidePanel:onBeforeSaveData():.create(): Error creating value", - formVals, - }); - - $formView?.hideProgress({ type: "icon" }); - }); - } - - return false; - }, - }, - 2 /* NOTE: if you can see this KanBan, you should be able to see the side form? */ - ); - - // display data - $formView.clear(); - $formView.parse(data); - - formCom.onShow(data); - } -}; diff --git a/AppBuilder/platform/views/viewComponent/ABViewKanbanComponent.js b/AppBuilder/platform/views/viewComponent/ABViewKanbanComponent.js deleted file mode 100644 index ed3da56e..00000000 --- a/AppBuilder/platform/views/viewComponent/ABViewKanbanComponent.js +++ /dev/null @@ -1,577 +0,0 @@ -const ABViewComponent = require("./ABViewComponent").default; -const ABFormSidePanel = require("../ABViewKanbanFormSidePanel"); - -module.exports = class ABViewKanbanComponent extends ABViewComponent { - constructor(baseView, idBase, ids) { - super( - baseView, - idBase || `ABViewKanBan_${baseView.id}`, - Object.assign( - { - kanbanView: "", - - kanban: "", - resizer: "", - formSidePanel: "", - }, - ids - ) - ); - - this.FormSide = new ABFormSidePanel( - this, - this.ids.formSidePanel, - this.settings.editFields - ); - - this.CurrentVerticalField = null; - this.CurrentHorizontalField = null; - this.CurrentOwnerField = null; - - this.TextTemplate = baseView.TextTemplate; - - this._updatingOwnerRowId = null; - this._ABFieldConnect = null; - this._ABFieldUser = null; - this._ABFieldList = null; - } - - get ABFieldConnect() { - return (this._ABFieldConnect = - this._ABFieldConnect || - this.AB.Class.ABFieldManager.fieldByKey("connectObject")); - } - - get ABFieldUser() { - return (this._ABFieldUser = - this._ABFieldUser || this.AB.Class.ABFieldManager.fieldByKey("user")); - } - - get ABFieldList() { - return (this._ABFieldList = - this._ABFieldList || this.AB.Class.ABFieldManager.fieldByKey("list")); - } - - ui() { - const ids = this.ids; - const self = this; - this.linkPage = this.view.linkPageHelper.component(); - - const _ui = super.ui([ - { - id: ids.kanbanView, - cols: [ - { - id: ids.kanban, - view: "kanban", - cols: [], - userList: { - view: "menu", - // yCount: 8, - // scroll: false, - template: ' #value#', - width: 150, - on: { - onSelectChange: function () { - // get this row id from onAvatarClick event - if (!self._updatingOwnerRowId) return; - - const userId = this.getSelectedId(false); - if (!userId) return; - - self.updateOwner(self._updatingOwnerRowId, userId); - }, - }, - }, - editor: false, // we use side bar - users: [], - tags: [], - data: [], - on: { - onListAfterSelect: (itemId, list) => { - this.CurrentDatacollection?.setCursor(itemId); - this.emit("select", itemId); - - // link pages events - const editPage = this.settings.editPage; - if (editPage) - this.linkPage.changePage(editPage, itemId); - - const detailsPage = this.settings.detailsPage; - if (detailsPage) - this.linkPage.changePage(detailsPage, itemId); - }, - onAfterStatusChange: (rowId, status /*, list */) => { - this.updateStatus(rowId, status); - }, - onAvatarClick: (rowId /*, ev, node, list */) => { - // keep this row id for update owner data in .userList - this._updatingOwnerRowId = rowId; - }, - }, - }, - { - id: ids.resizer, - view: "resizer", - css: "bg_gray", - width: 11, - hidden: true, - }, - this.FormSide.ui(), - ], - }, - ]); - - delete _ui.type; - - return _ui; - } - - async init(AB) { - await super.init(AB); - - const abWebix = this.AB.Webix; - - if (this.$kb) abWebix.extend(this.$kb, abWebix.ProgressBar); - - this.FormSide.init(AB); - this.FormSide.on("add", (newVals) => { - this.saveData(newVals); - }); - this.FormSide.on("update", (updateVals) => { - this.saveData(updateVals); - }); - - let dc = this.view.datacollection; - if (dc) this.datacollectionLoad(dc); - - this.linkPage.init({ - view: this.view, - datacollection: dc, - }); - - this.show(); - } - - get $kb() { - return (this._kb = this._kb || $$(this.ids.kanban)); - } - - kanbanListTemplate() { - return { - icons: [ - // { icon: "mdi mdi-comment", show: function (obj) { return !!obj.comments }, template: "#comments.length#" }, - { - icon: "fa fa-trash-o", - click: (rowId /*, e */) => { - this.removeCard(rowId); - }, - }, - ], - // avatar template - templateAvatar: (obj) => { - if ( - this.CurrentOwnerField && - obj[this.CurrentOwnerField.columnName] - ) - return this.CurrentOwnerField.format(obj); - else return ""; - }, - // template for item body - // show item image and text - templateBody: (data) => { - // if (!this.settings.template) - if (!this.TextTemplate.text) - return this.CurrentObject?.displayData(data); - - // return our default text template - return this.TextTemplate.displayText(data); - }, - }; - } - - /** - * @function hide() - * - * hide this component. - */ - hide() { - $$(this.ids.kanbanView)?.hide(); - } - - /** - * @function show() - * Show this component. - */ - async show() { - const ids = this.ids; - - $$(ids.kanbanView)?.show(); - - this.FormSide.hide(); - - $$(ids.resizer)?.hide(); - - var CurrentObject = this.CurrentObject; - if (!CurrentObject) { - CurrentObject = this.datacollection?.datasource; - } - if (!CurrentObject) return; - - // Get vertical grouping field and populate to kanban list - // NOTE: this field should be the select list type - const CurrentVerticalField = CurrentObject.fieldByID( - this.settings.verticalGroupingField - ); - if (!CurrentVerticalField) return; - - this.CurrentVerticalField = CurrentVerticalField; - - let horizontalOptions = []; - - const CurrentHorizontalField = CurrentObject.fieldByID( - this.settings.horizontalGroupingField - ); - - this.CurrentHorizontalField = CurrentHorizontalField; - - if ( - CurrentHorizontalField && - CurrentHorizontalField instanceof this.ABFieldConnect - ) - // Pull horizontal options - horizontalOptions = await CurrentHorizontalField.getOptions(); - - // Option format - { id: "1543563751920", text: "Normal", hex: "#4CAF50" } - const verticalOptions = (CurrentVerticalField.settings.options || []).map( - (opt) => { - // Vertical & Horizontal fields - if (CurrentVerticalField && CurrentHorizontalField) { - let rows = [], - // [{ - // id: '', - // text: '' - // }] - horizontalVals = []; - - // pull options of the Horizontal field - if (CurrentHorizontalField instanceof this.ABFieldList) { - // make a copy of the settings. - horizontalVals = ( - CurrentHorizontalField.settings.options || [] - ).map((o) => o); - } else if (CurrentHorizontalField instanceof this.ABFieldUser) { - horizontalVals = CurrentHorizontalField.getUsers().map( - (u) => { - return { - id: u.id, - text: u.text || u.value, - }; - } - ); - } else if (CurrentHorizontalField instanceof this.ABFieldConnect) - horizontalVals = horizontalOptions.map(({ id, text }) => ({ - id, - text, - })); - - horizontalVals.push({ - id: null, - text: this.label("Other"), - }); - - horizontalVals.forEach((val) => { - const statusOps = {}; - - statusOps[CurrentVerticalField.columnName] = opt.id; - statusOps[CurrentHorizontalField.columnName] = val.id; - - // Header - rows.push({ - template: val.text, - height: 20, - css: "progress_header", - }); - - // Kanban list - rows.push({ - view: "kanbanlist", - status: statusOps, - type: this.kanbanListTemplate(), - }); - }); - - return { - header: opt.text, - body: { - margin: 0, - rows: rows, - }, - }; - } - // Vertical field only - else if (CurrentVerticalField) { - const statusOps = {}; - - statusOps[CurrentVerticalField.columnName] = opt.id; - - return { - header: opt.text, - body: { - view: "kanbanlist", - status: statusOps, - type: this.kanbanListTemplate(), - }, - }; - } - } - ); - - const ab = this.AB; - const abWebix = ab.Webix; - - // Rebuild kanban that contains options - // NOTE: webix kanban does not support dynamic vertical list - abWebix.ui(verticalOptions, $$(ids.kanban)); - $$(ids.kanban).reconstruct(); - - // Owner field - const CurrentOwnerField = CurrentObject.fieldByID( - this.settings.ownerField - ); - - this.CurrentOwnerField = CurrentOwnerField; - - if (CurrentOwnerField) { - const $menuUser = $$(ids.kanban).getUserList(); - - $menuUser.clearAll(); - - if (CurrentOwnerField instanceof this.ABFieldUser) { - const users = ab.Account.userList().map((u) => { - return { - id: u.username, - value: u.username, - }; - }); - - $menuUser.parse(users); - } else if (CurrentOwnerField instanceof this.ABFieldConnect) { - const options = await CurrentOwnerField.getOptions(); - - try { - $menuUser.parse( - options.map((opt) => { - return { - id: opt.id, - value: opt.text, - }; - }) - ); - } catch (e) { - // TODO: remove this. Trying to catch a random webix error: - // Cannot read properties of null (reading 'driver') - console.error(e); - console.warn(options); - } - } - } - } - - busy() { - this.$kb?.showProgress?.({ type: "icon" }); - } - - ready() { - this.$kb?.hideProgress?.(); - } - - objectLoad(object) { - super.objectLoad(object); - - this.TextTemplate.objectLoad(object); - this.FormSide.objectLoad(object); - } - - /** - * @method datacollectionLoad - * - * @param datacollection {ABDatacollection} - */ - datacollectionLoad(datacollection) { - super.datacollectionLoad(datacollection); - - const DC = this.CurrentDatacollection || datacollection; - - if (DC) { - DC.bind(this.$kb); - - const obj = DC.datasource; - - if (obj) this.objectLoad(obj); - - return; - } - - this.$kb.unbind(); - } - - async updateStatus(rowId, status) { - if (!this.CurrentVerticalField) return; - - // Show loading cursor - this.busy(); - - let patch = {}; - - // update multi-values - if (status instanceof Object) patch = status; - // update single value - else patch[this.CurrentVerticalField.columnName] = status; - - // update empty value - let needRefresh = false; - - for (const key in patch) - if (!patch[key]) { - patch[key] = ""; - - // WORKAROUND: if update data is empty, then it will need to refresh - // the kanban after update - needRefresh = true; - } - - try { - await this.CurrentObject?.model().update(rowId, patch); - - this.ready(); - - if (needRefresh) this.show(); - - // update form data - if (this.FormSide.isVisible()) { - const data = $$(this.ids.kanban).getItem(rowId); - - this.FormSide.refresh(data); - } - } catch (err) { - this.AB.notify.developer(err, { - context: "ABViewKanban:updateStatus(): Error saving item:", - rowId, - patch, - }); - } - } - - async updateOwner(rowId, val) { - if (!this.CurrentOwnerField) return; - - // Show loading cursor - this.busy(); - - const patch = {}; - - patch[this.CurrentOwnerField.columnName] = val; - - try { - const updatedRow = await this.CurrentObject?.model().update( - rowId, - patch - ); - - // update card - this.$kb?.updateItem(rowId, updatedRow); - - // update form data - if (this.FormSide.isVisible()) { - const data = this.$kb.getItem(rowId); - - this.FormSide.refresh(data); - } - - this.ready(); - } catch (err) { - this.AB.notify.developer(err, { - context: "ABViewKanban:updateOwner(): Error saving item:", - rowId, - val, - }); - - this.ready(); - } - } - - saveData(data) { - // update - if (data.id && this.$kb.exists(data.id)) - this.$kb.updateItem(data.id, data); - // insert - else this.$kb.add(data); - } - - unselect() { - if (this.$kb) - this.$kb.eachList((list /*, status*/) => { - list?.unselect?.(); - }); - } - - addCard() { - this.unselect(); - - // show the side form - this.FormSide.show(); - $$(this.ids.resizer).show(); - } - - async removeCard(rowId) { - const ab = this.AB; - const abWebix = ab.Webix; - - abWebix.confirm({ - title: this.label("Remove card"), - text: this.label("Do you want to delete this card?"), - callback: async (result) => { - if (!result) return; - - this.busy(); - - try { - const response = await this.CurrentObject?.model().delete(rowId); - - if (response.numRows > 0) { - this.$kb.remove(rowId); - } else { - abWebix.alert({ - text: this.label( - "No rows were effected. This does not seem right." - ), - }); - } - } catch (err) { - ab.notify.developer(err, { - message: "ABViewKanban:removeCard(): Error deleting item:", - rowId, - }); - } - - this.ready(); - }, - }); - } - - /** - * @method setFields() - * Save the current view options. - * @param options - { - * verticalGrouping: {ABField} - required - * horizontalGrouping: {ABField} - optional - * ownerField: {ABField} - optional - * } - */ - setFields(options) { - this.CurrentVerticalField = options.verticalGrouping; - this.CurrentHorizontalField = options.horizontalGrouping; - this.CurrentOwnerField = options.ownerField; - } -};