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..980817bd 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/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..e0bbd15f
--- /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/plugins/included/view_kanban/FNAbviewkanban.js b/AppBuilder/platform/plugins/included/view_kanban/FNAbviewkanban.js
new file mode 100644
index 00000000..29de9b32
--- /dev/null
+++ b/AppBuilder/platform/plugins/included/view_kanban/FNAbviewkanban.js
@@ -0,0 +1,136 @@
+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 ABAbviewkanbanComponent = 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 ABAbviewkanbanComponent(this, parentId);
+ }
+
+ //
+ // Editor Related
+ //
+
+ 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/plugins/included/view_kanban/FNAbviewkanbanComponent.js b/AppBuilder/platform/plugins/included/view_kanban/FNAbviewkanbanComponent.js
new file mode 100644
index 00000000..5c211c22
--- /dev/null
+++ b/AppBuilder/platform/plugins/included/view_kanban/FNAbviewkanbanComponent.js
@@ -0,0 +1,585 @@
+export default function FNAbviewkanbanComponent({
+ AB,
+ ABViewComponentPlugin,
+ FNAbviewKanbanFormSidePanel,
+}) {
+ return class ABAbviewkanbanComponent 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/FNaBviewKanbanFormSidePanel.js b/AppBuilder/platform/plugins/included/view_kanban/FNaBviewKanbanFormSidePanel.js
new file mode 100644
index 00000000..e0bbd15f
--- /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);
+ }
+ };
+}