diff --git a/AppBuilder/ABFactory.js b/AppBuilder/ABFactory.js index e31735e2..fb4b9ce3 100644 --- a/AppBuilder/ABFactory.js +++ b/AppBuilder/ABFactory.js @@ -130,6 +130,8 @@ class ABFactory extends ABFactoryCore { } }; + this.performance = performance; + this.UISettings = UISettings; this.Validation = { diff --git a/AppBuilder/core b/AppBuilder/core index b151db79..a45f2151 160000 --- a/AppBuilder/core +++ b/AppBuilder/core @@ -1 +1 @@ -Subproject commit b151db79e95155571132a8a68579c459800ea577 +Subproject commit a45f21514a1be50256f217d60491847438f52af6 diff --git a/AppBuilder/platform/ABModelApiNetsuite.js b/AppBuilder/platform/ABModelApiNetsuite.js new file mode 100644 index 00000000..8b845425 --- /dev/null +++ b/AppBuilder/platform/ABModelApiNetsuite.js @@ -0,0 +1,60 @@ +// +// ABModelAPINetsuite +// +// Represents the Data interface for a connection to Netsuite. + +const ABModel = require("./ABModel"); + +module.exports = class ABModelAPINetsuite extends ABModel { + /// + /// Instance Methods + /// + + /** + * @method normalizeData() + * For a Netsuite object, there are additional steps we need to handle + * to normalize our data. + */ + normalizeData(data) { + super.normalizeData(data); + + if (!Array.isArray(data)) { + data = [data]; + } + + var boolFields = this.object.fields((f) => f.key == "boolean"); + let allFields = this.object.fields(); + + data.forEach((d) => { + // Netsuite sometimes keeps keys all lowercase + // which might not match up with what it told us in the meta-catalog + // which we need: + for (var i = 0; i < allFields.length; i++) { + let actualColumn = allFields[i].columnName; + let lcColumn = actualColumn.toLowerCase(); + + if ( + typeof d[actualColumn] == "undefined" && + typeof d[lcColumn] != "undefined" + ) { + d[actualColumn] = d[lcColumn]; + delete d[lcColumn]; + } + } + + // Netsuite Booleans are "T" or "F" + boolFields.forEach((bField) => { + let val = d[bField.columnName]; + // just how many ways can a DB indicate True/False? + if (typeof val == "string") { + val = val.toLowerCase(); + + if (val === "t") val = true; + else val = false; + + d[bField.columnName] = val; + } + }); + }); + } +}; diff --git a/AppBuilder/platform/ABObjectApi.js b/AppBuilder/platform/ABObjectApi.js index ebfaecb8..a99384ca 100644 --- a/AppBuilder/platform/ABObjectApi.js +++ b/AppBuilder/platform/ABObjectApi.js @@ -69,4 +69,12 @@ module.exports = class ABObjectApi extends ABObjectApiCore { async save() { return await super.save(true); } + + migrateCreate() { + return Promise.resolve(); + } + + migrateDrop() { + return Promise.resolve(); + } }; diff --git a/AppBuilder/platform/ABObjectApiNetsuite.js b/AppBuilder/platform/ABObjectApiNetsuite.js new file mode 100644 index 00000000..4fabb341 --- /dev/null +++ b/AppBuilder/platform/ABObjectApiNetsuite.js @@ -0,0 +1,72 @@ +const ABObjectApiNetsuiteCore = require("../core/ABObjectApiNetsuiteCore"); + +module.exports = class ABObjectApiNetsuite extends ABObjectApiNetsuiteCore { + constructor(attributes, AB) { + super(attributes, AB); + } + + async getDbInfo() { + /* + // Data format: + { + "definitionId": "f2416a1a-d75c-40f2-8180-bad9b5f8b9cc", + "tableName": "AB_MockupHR_TeamTargetLocation", + "fields": [ + { + "Field": "uuid", + "Type": "varchar(255)", + "Null": "NO", + "Key": "PRI", + "Default": null, + "Extra": "" + }, + { + "Field": "created_at", + "Type": "datetime", + "Null": "YES", + "Key": "", + "Default": null, + "Extra": "" + }, + { + "Field": "updated_at", + "Type": "datetime", + "Null": "YES", + "Key": "", + "Default": null, + "Extra": "" + }, + { + "Field": "properties", + "Type": "text", + "Null": "YES", + "Key": "", + "Default": null, + "Extra": "" + } + ] + } + */ + let PK = this.PK(); + let fieldInfo = []; + this.fields().forEach((f) => { + let field = { + Field: f.columnName, + Type: f.key, + Null: f.settings.required ? "NO" : "YES", + Key: PK == f.columnName ? "PRI" : "", + Default: "", + Extra: "", + }; + fieldInfo.push(field); + }); + + let TableInfo = { + definitionId: this.id, + tableName: this.tableName, + fields: fieldInfo, + }; + + return TableInfo; + } +}; diff --git a/AppBuilder/platform/FilterComplex.js b/AppBuilder/platform/FilterComplex.js index 977e81a9..a34f7b4e 100644 --- a/AppBuilder/platform/FilterComplex.js +++ b/AppBuilder/platform/FilterComplex.js @@ -40,6 +40,13 @@ function _toInternal(cond, fields = []) { }; if (Array.isArray(cond.value)) cond.includes = cond.value; + if ( + cond.rule === "in_query_field" || + cond.rule === "not_in_query_field" + ) { + cond.includes = cond.value.split(":"); + } + // else cond.includes = cond.value?.split?.(/,|:/) ?? []; // if (field?.key == "date" || field?.key == "datetime") { diff --git a/AppBuilder/platform/dataFields/ABField.js b/AppBuilder/platform/dataFields/ABField.js index 2d9bdea4..1fc8dd13 100644 --- a/AppBuilder/platform/dataFields/ABField.js +++ b/AppBuilder/platform/dataFields/ABField.js @@ -160,7 +160,9 @@ module.exports = class ABField extends ABFieldCore { // NOTE: our .migrateXXX() routines expect the object to currently exist // in the DB before we perform the DB operations. So we need to // .migrateDrop() before we actually .objectDestroy() this. - await this.migrateDrop(); + if (!this.object.isAPI) { + await this.migrateDrop(); + } // the server still references an ABField in relationship to it's // ABObject, so we need to destroy the Field 1st, then remove it @@ -231,7 +233,7 @@ module.exports = class ABField extends ABFieldCore { // but not connectObject fields: // ABFieldConnect.migrateXXX() gets called from the UI popupNewDataField // in order to handle the timings of the 2 fields that need to be created - if (!this.isConnection && !skipMigrate) { + if (!this.isConnection && !skipMigrate && !this.object.isAPI) { const fnMigrate = isAdd ? this.migrateCreate() : this.migrateUpdate(); await fnMigrate; } diff --git a/AppBuilder/platform/dataFields/ABFieldConnect.js b/AppBuilder/platform/dataFields/ABFieldConnect.js index f68c83d9..f18fdd15 100644 --- a/AppBuilder/platform/dataFields/ABFieldConnect.js +++ b/AppBuilder/platform/dataFields/ABFieldConnect.js @@ -244,7 +244,7 @@ module.exports = class ABFieldConnect extends ABFieldConnectCore { * * @return {Promise} */ - async getOptions(whereClause, term, sort, editor) { + async getOptions(whereClause, term, sort, editor, populate = false) { const theEditor = editor; if (theEditor) { @@ -383,7 +383,7 @@ module.exports = class ABFieldConnect extends ABFieldConnectCore { return linkedModel.findAll({ where: where, sort: sort, - populate: false, + populate, }); }; @@ -423,9 +423,14 @@ module.exports = class ABFieldConnect extends ABFieldConnectCore { whereRels.glue = "or"; whereRels.rules = []; + // make sure values are unique: + let valHash = {}; values.split(",").forEach((v) => { + valHash[v] = v; + }); + Object.keys(valHash).forEach((v) => { whereRels.rules.push({ - key: "uuid", + key: linkedObj.PK(), rule: "equals", value: v, }); diff --git a/AppBuilder/platform/views/ABViewOrgChartTeams.js b/AppBuilder/platform/views/ABViewOrgChartTeams.js new file mode 100644 index 00000000..c2560653 --- /dev/null +++ b/AppBuilder/platform/views/ABViewOrgChartTeams.js @@ -0,0 +1,13 @@ +const ABViewOrgChartTeamsCore = require("../../core/views/ABViewOrgChartTeamsCore"); +const ABViewOrgChartTeamsComponent = require("./viewComponent/ABViewOrgChartTeamsComponent"); + +module.exports = class ABViewOrgChartTeams extends ABViewOrgChartTeamsCore { + /** + * @method component() + * return a UI component based upon this view. + * @return {obj} UI component + */ + component() { + return new ABViewOrgChartTeamsComponent(this); + } +}; diff --git a/AppBuilder/platform/views/viewComponent/ABViewDataSelectComponent.js b/AppBuilder/platform/views/viewComponent/ABViewDataSelectComponent.js index eaac84a5..a68dbaa0 100644 --- a/AppBuilder/platform/views/viewComponent/ABViewDataSelectComponent.js +++ b/AppBuilder/platform/views/viewComponent/ABViewDataSelectComponent.js @@ -16,10 +16,11 @@ export default class ABViewDataSelectComponent extends ABViewComponent { ui() { const _ui = super.ui([ { - view: "richselect", + view: "combo", id: this.ids.select, on: { onChange: (n, o) => { + if (!o) return; if (n !== o) this.cursorChange(n); }, }, @@ -30,26 +31,25 @@ export default class ABViewDataSelectComponent extends ABViewComponent { return _ui; } - async init(AB) { - await super.init(AB); - this.dc = AB.datacollectionByID(this.settings.dataviewID); - } - async onShow() { - if (!this.dc) return; - await this.dc.waitForDataCollectionToInitialize(this.dc); + super.onShow(); + const dc = this.datacollection; + if (!dc) return; + await dc.waitReady(); const labelField = this.AB.definitionByID( this.settings.labelField )?.columnName; - const options = this.dc + const options = dc .getData() - .map((o) => ({ id: o.id, value: o[labelField] })); - $$(this.ids.select).define("options", options); - $$(this.ids.select).refresh(); - $$(this.ids.select).setValue(this.dc.getCursor().id); + .map((o) => ({ id: o.id, value: o[labelField] })) + .sort((a, b) => (a.value > b.value ? 1 : -1)); + const $select = $$(this.ids.select); + $select.define("options", options); + $select.refresh(); + $select.setValue(dc.getCursor().id); } cursorChange(n) { - this.dc.setCursor(n); + this.datacollection.setCursor(n); } } diff --git a/AppBuilder/platform/views/viewComponent/ABViewGridComponent.js b/AppBuilder/platform/views/viewComponent/ABViewGridComponent.js index ffc89ce9..9f21aab9 100644 --- a/AppBuilder/platform/views/viewComponent/ABViewGridComponent.js +++ b/AppBuilder/platform/views/viewComponent/ABViewGridComponent.js @@ -267,6 +267,12 @@ export default class ABViewGridComponent extends ABViewComponent { self.toggleUpdateDelete(); } else { if (settings.isEditable) { + // get the field related to this col + const currObject = self.datacollection.datasource; + const selectField = currObject.fields( + (f) => f.columnName === col + )[0]; + // if the colum is not the select item column move on to // the next step to save const state = { @@ -275,7 +281,7 @@ export default class ABViewGridComponent extends ABViewComponent { const editor = { row: row, column: col, - config: null, + config: { fieldID: selectField?.id ?? null }, }; self.onAfterEditStop(state, editor); @@ -1350,6 +1356,8 @@ export default class ABViewGridComponent extends ABViewComponent { return false; } + const CurrentObject = this.datacollection.datasource; + if (editor.config) switch (editor.config.editor) { case "number": @@ -1369,9 +1377,23 @@ export default class ABViewGridComponent extends ABViewComponent { // code block } - if (state.value !== state.old) { + // lets make sure we are comparing things properly: + // reduce newValue and oldValue down to PK if they were objects + let newVal = state.value; + if (newVal) { + newVal = newVal[CurrentObject.PK()] || newVal; + } + let oldVal = state.old; + if (oldVal) { + oldVal = oldVal[CurrentObject.PK()] || oldVal; + } + + // NOTE: != vs !== : + // want to handle when newVal = "3" and oldVal = 3 + // that is why we don't use !== so that we convert the values into + // the same case. + if (newVal != oldVal) { const item = $DataTable?.getItem(editor.row); - const CurrentObject = this.datacollection.datasource; item[editor.column] = state.value; @@ -1379,9 +1401,9 @@ export default class ABViewGridComponent extends ABViewComponent { $DataTable.removeCellCss(item.id, editor.column, "webix_invalid_cell"); //maxlength field - const f = CurrentObject.fieldByID(editor.config.fieldID); + const f = CurrentObject.fieldByID(editor.config?.fieldID); if ( - f.settings.maxLength && + f?.settings.maxLength && state.value.length > f.settings.maxLength ) { this.AB.alert({ diff --git a/AppBuilder/platform/views/viewComponent/ABViewOrgChartTeamsComponent.js b/AppBuilder/platform/views/viewComponent/ABViewOrgChartTeamsComponent.js new file mode 100644 index 00000000..3627354c --- /dev/null +++ b/AppBuilder/platform/views/viewComponent/ABViewOrgChartTeamsComponent.js @@ -0,0 +1,3291 @@ +const ABViewComponent = require("./ABViewComponent").default; +const DC_OFFSET = 20; +const RECORD_LIMIT = 20; +const TEAM_CHART_MAX_DEPTH = 10; // prevent inifinite loop +module.exports = class ABViewOrgChartTeamsComponent extends ABViewComponent { + constructor(baseView, idBase, ids) { + super( + baseView, + idBase || `ABViewOrgChart_${baseView.id}`, + Object.assign( + { + chartView: "", + // chartDom: "", + chartContent: "", + chartHeader: "", + dataPanel: "", + dataPanelButton: "", + dataPanelPopup: "", + filterButton: "", + filterPopup: "", + filterForm: "", + contentForm: "", + contentFormData: "", + teamForm: "", + teamFormCode: "", + teamFormPopup: "", + teamFormStrategy: "", + teamFormSubmit: "", + teamFormTitle: "", + }, + ids + ) + ); + this._resources = [ + import( + /* webpackPrefetch: true */ + "../../../../js/orgchart-webcomponents.js" + ), + import( + /* webpackPrefetch: true */ + "../../../../styles/orgchart-webcomponents.css" + ), + import( + /* webpackPrefetch: true */ + "../../../../styles/team-widget.css" + ), + ]; + this.__filters = { + inactive: 0, + }; + this._OrgChart = null; + this._resolveInit = null; + this._promiseInit = new Promise((resolve) => { + this._resolveInit = resolve; + }); + this._promisePageData = null; + this._contentDC = null; + this._contentGroupDC = null; + this._contentDisplayDCs = []; + this._dataPanelDCs = []; + this._entityDC = null; + this._chartData = null; + + // DRAG EVENTS + this._fnContentDragEnd = (event) => { + // event.target.style.opacity = "1"; + }; + this._fnContentDragOver = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + this._fnContentDragStart = (event) => { + event.stopPropagation(); + const $eventTarget = event.target; + const dataset = $eventTarget.dataset; + const dataTransfer = event.dataTransfer; + const data = {}; + switch ($eventTarget.className) { + case "webix_list_item": + data.pk = dataset.pk; + data.contentLinkedFieldID = dataset.contentLinkedFieldId; + break; + default: + data.source = dataset.source; + break; + } + dataTransfer.setData("text/plain", JSON.stringify(data)); + // $eventTarget.style.opacity = "0.5"; + }; + this._fnContentDrop = async (event) => { + const settings = this.view.settings; + const dropContentToCreate = settings.dropContentToCreate === 1; + const nodeObj = this.view.datacollection?.datasource; + const nodeObjPK = nodeObj.PK(); + const contentFieldLink = nodeObj.fieldByID( + settings.contentField + )?.fieldLink; + const contentObj = contentFieldLink?.object; + const contentDateStartFieldColumnName = contentObj?.fieldByID( + settings.contentFieldDateStart + )?.columnName; + const contentDateEndFieldColumnName = contentObj?.fieldByID( + settings.contentFieldDateEnd + )?.columnName; + const contentGroupByField = contentObj?.fieldByID( + settings.contentGroupByField + ); + const contentGroupByFieldColumnName = contentGroupByField?.columnName; + const contentFieldLinkColumnName = contentFieldLink?.columnName; + const contentModel = contentObj?.model(); + + const dataTransfer = event.dataTransfer; + if (dataTransfer.getData("isnode") == 1) return; + event.stopPropagation(); + if (contentFieldLinkColumnName == null) return; + this.busy(); + const $group = event.currentTarget; + const $content = $group.parentElement; + const newGroupDataPK = $group.dataset.pk; + const newNodeDataPK = JSON.parse($content.parentElement.dataset.source) + ._rawData[nodeObjPK]; + let { + source: updatedData, + pk: dataPK, + contentLinkedFieldID, + } = JSON.parse(dataTransfer.getData("text/plain")); + const draggedNodes = []; + let isRefreshed = true; + try { + if (!updatedData) { + // This is a drop from Employee list (new assignment) + const contentLinkedFieldColumnName = + contentObj.fieldByID(contentLinkedFieldID).columnName; + const pendingPromises = []; + const newDate = new Date(); + + // Employee can have multiple assignments but not the same team, so don't close + // existing + const $contentRecords = + $content.getElementsByClassName("team-group-record"); + let isUpdated = false; + for (const $contentRecord of $contentRecords) { + const contentData = JSON.parse($contentRecord.dataset.source); + if (contentData[contentLinkedFieldColumnName] == dataPK) { + this._setUpdatedBy(contentObj, contentData); + if (!isUpdated) { + if ( + contentData[contentGroupByFieldColumnName] == + newGroupDataPK + ) { + isRefreshed = false; + isUpdated = true; + continue; + } else if ( + this._isLessThanDay( + new Date( + contentData[contentDateStartFieldColumnName] + ) + ) + ) { + contentData[contentGroupByFieldColumnName] = + this._parseDataPK(newGroupDataPK); + pendingPromises.push( + contentModel.update( + contentData.id, + this._parseFormValueByType( + contentObj, + contentData, + contentData + ) + ) + ); + draggedNodes.push($contentRecord); + isRefreshed = true; + isUpdated = true; + continue; + } + } + contentData[contentDateEndFieldColumnName] = newDate; + pendingPromises.push( + contentModel.update( + contentData.id, + this._parseFormValueByType( + contentObj, + contentData, + contentData + ) + ) + ); + draggedNodes.push($contentRecord); + isRefreshed = true; + } + } + if (!isUpdated) { + updatedData = {}; + updatedData[contentDateStartFieldColumnName] = newDate; + updatedData[contentLinkedFieldColumnName] = + this._parseDataPK(dataPK); + updatedData[contentFieldLinkColumnName] = + this._parseDataPK(newNodeDataPK); + updatedData[contentGroupByFieldColumnName] = + this._parseDataPK(newGroupDataPK); + const entityDC = this._entityDC; + if (entityDC) { + const entityLink = entityDC.datasource.connectFields( + (f) => f.settings.linkObject === contentObj.id + )[0].id; + const entityCol = + this.AB.definitionByID(entityLink).columnName; + updatedData[entityCol] = this._parseDataPK( + entityDC.getCursor() + ); + } + this._setUpdatedBy(contentObj, updatedData); + pendingPromises.push( + contentModel.create( + this._parseFormValueByType( + contentObj, + updatedData, + updatedData + ) + ), + (async () => { + const $draggedNode = await this._createUIContentRecord( + updatedData, + "grey" + ); + $group + .querySelector(".team-group-content") + .appendChild($draggedNode); + draggedNodes.push($draggedNode); + })() + ); + } + await Promise.all(pendingPromises); + } else { + updatedData = JSON.parse(updatedData); + const $contentRecords = + $content.getElementsByClassName("team-group-record"); + const dataPanelDCs = this._dataPanelDCs; + for (const dataPanelDC of dataPanelDCs) { + const dataPanelLinkedFieldColumnName = + dataPanelDC.datasource.connectFields( + (connectField) => + connectField.datasourceLink === contentObj + )[0].columnName; + for (const $contentRecord of $contentRecords) { + const contentRecord = JSON.parse( + $contentRecord.dataset.source + ); + if ( + (updatedData.id != contentRecord.id && + contentRecord[dataPanelLinkedFieldColumnName] == + updatedData[dataPanelLinkedFieldColumnName]) || + (updatedData.id == contentRecord.id && + updatedData[contentGroupByFieldColumnName] == + $group.dataset.pk) + ) { + this.ready(); + return; + } + } + } + + // This is move form another team node + // Move the child node to the target + const $draggedNode = document.querySelector( + `#${this.contentNodeID(updatedData.id)}` + ); + $draggedNode.parentNode.removeChild($draggedNode); + $group + .querySelector(".team-group-content") + .appendChild($draggedNode); + draggedNodes.push($draggedNode); + delete updatedData["created_at"]; + delete updatedData["updated_at"]; + delete updatedData["properties"]; + this._setUpdatedBy(contentObj, updatedData); + if ( + !dropContentToCreate || + (updatedData[contentFieldLinkColumnName] == newNodeDataPK && + this._isLessThanDay( + new Date(updatedData[contentDateStartFieldColumnName]) + )) + ) { + updatedData[contentFieldLinkColumnName] = newNodeDataPK; + updatedData[contentGroupByFieldColumnName] = newGroupDataPK; + await contentModel.update( + updatedData.id, + this._parseFormValueByType( + contentObj, + updatedData, + updatedData + ) + ); + } else { + const pendingPromises = []; + + // TODO (Guy): Force update Date End with a current date. + updatedData[contentDateEndFieldColumnName] = new Date(); + pendingPromises.push( + contentModel.update( + updatedData.id, + this._parseFormValueByType( + contentObj, + updatedData, + updatedData + ) + ) + ); + updatedData[contentDateStartFieldColumnName] = + updatedData[contentDateEndFieldColumnName]; + delete updatedData["id"]; + delete updatedData["uuid"]; + delete updatedData[contentDateEndFieldColumnName]; + updatedData[contentFieldLinkColumnName] = newNodeDataPK; + updatedData[contentGroupByFieldColumnName] = newGroupDataPK; + pendingPromises.push( + contentModel.create( + this._parseFormValueByType( + contentObj, + updatedData, + updatedData + ) + ) + ); + await Promise.all(pendingPromises); + } + } + } catch (err) { + // TODO (Guy): The update data error. + console.log(err); + } + if (!isRefreshed) { + this.ready(); + return; + } + try { + // TODO (Guy): Logic to not reload dcs. + await Promise.all([ + this._reloadDCData(this.datacollection), + this._reloadDCData(this._contentDC), + ]); + // await this._reloadAllDC(); + } catch (err) { + // TODO (Guy): The reload DCs error. + console.error(err); + } + await this.refresh(); + draggedNodes.forEach(($draggedNode) => { + $draggedNode.remove(); + }); + this.ready(); + }; + this._fnCreateNode = async ($node, data) => { + // remove built in icon + $node.querySelector(".title > i")?.remove(); + + // customize + const $content = $node.children.item(1); + $content.innerHTML = ""; + const contentGroupDC = this._contentGroupDC; + const groupObjPKColumeName = contentGroupDC.datasource.PK(); + await this._waitDCReady(contentGroupDC); + const contentGroupOptions = contentGroupDC.getData(); + const contentGroupOptionsLength = contentGroupOptions.length; + if (data.filteredOut || contentGroupOptionsLength === 0) { + // This node doesn't pass the filter, but it's children do so + // simplify the display. + $content.style.display = "none"; + return; + } + const settings = this.settings; + const $nodeSpacer = element("div", "spacer"); + $content.appendChild($nodeSpacer); + const nodeSpacerStyle = $nodeSpacer.style; + nodeSpacerStyle.backgroundColor = ""; + for (const group of contentGroupOptions) { + const $group = element("div", "team-group-section"); + $content.appendChild($group); + const groupStyle = $group.style; + groupStyle["minHeight"] = `${325 / contentGroupOptionsLength}px`; + + // TODO: should this be a config option + const groupColor = group.name === "Leader" ? "#003366" : "#DDDDDD"; + groupStyle["backgroundColor"] = groupColor; + nodeSpacerStyle.backgroundColor === "" && + (nodeSpacerStyle.backgroundColor = groupColor); + + // TODO: should this be a config option + const groupText = group.name; + $group.setAttribute("data-pk", group[groupObjPKColumeName]); + if (settings.showGroupTitle === 1) { + const $groupTitle = element("div", "team-group-title"); + const groupTitleStyle = $groupTitle.style; + groupTitleStyle["backgroundColor"] = groupColor; + $groupTitle.appendChild(document.createTextNode(groupText)); + $group.appendChild($groupTitle); + } + const $groupContent = element("div", "team-group-content"); + $group.appendChild($groupContent); + if (settings.draggable === 1) { + $group.addEventListener("dragover", this._fnContentDragOver); + $group.addEventListener("drop", this._fnContentDrop); + } + } + const $buttons = element("div", "team-button-section"); + $content.appendChild($buttons); + const $editButton = element("div", "team-button"); + $editButton.append(element("i", "fa fa-pencil")); + const $addButton = element("div", "team-button"); + $addButton.append(element("i", "fa fa-plus")); + $buttons.append($editButton, $addButton); + const dataID = this.teamRecordID(data.id); + const values = this.datacollection.getData((e) => e.id == dataID)[0]; + $addButton.onclick = () => { + this.teamForm("Add", { __parentID: dataID }); + }; + $editButton.onclick = () => this.teamForm("Edit", values); + $node.querySelector(".title").ondblclick = () => + this.teamForm("Edit", values); + if (this.teamCanDelete(values)) { + const $deleteButton = element("div", "team-button"); + $deleteButton.append(element("i", "fa fa-trash")); + $deleteButton.onclick = () => this.teamDelete(values); + $buttons.append($deleteButton); + } + if (this.__filters.inactive == 1) { + const isInactive = data.isInactive; + const activeClass = isInactive ? "is-inactive" : "is-active"; + const $active = element("div", `team-button ${activeClass}`); + const $span = element("span", "active-text"); + $span.innerHTML = isInactive ? "INACTIVE" : "ACTIVE"; + $active.append($span); + $buttons.append($active); + } + }; + this._fnNodeDrop = async (event) => { + const eventDetail = event.detail; + const dragedRecord = JSON.parse( + eventDetail.draggedNode.dataset.source + )._rawData; + dragedRecord[ + // Parent node definition. + this.AB.definitionByID( + this.getSettingField("teamLink").settings.linkColumn + ).columnName + ] = JSON.parse(eventDetail.dropZone.dataset.source)._rawData.id; + const dc = this.datacollection; + this.busy(); + this._setUpdatedBy(dc.datasource, dragedRecord); + try { + await dc.model.update(dragedRecord.id, dragedRecord); + } catch (err) { + // TODO (Guy): The update data error. + console.error(err); + } + try { + // TODO (Guy): Logic to not reload dcs. + await Promise.all([ + this._reloadDCData(dc), + this._reloadDCData(this._contentDC), + ]); + // await this._reloadAllDC(); + } catch (err) { + // TODO (Guy): The reload DCs error. + console.error(err); + } + await this.refresh(); + this.ready(); + }; + this._fnPageContentCallback = ( + contentRecords, + isContentDone, + contentDC, + resolve + ) => { + const linkedContentFieldColumnName = this.AB.definitionByID( + this.getSettingField("contentField").settings.linkColumn + ).columnName; + for (const contentRecord of contentRecords) { + const $teamNode = document.getElementById( + this.teamNodeID(contentRecord[linkedContentFieldColumnName]) + ); + if ($teamNode == null) continue; + this._addContentRecordToGroup($teamNode, contentRecord); + } + + // TODO (Guy): Hardcode data panel DCs for Employee. + $$(this.ids.dataPanel) + ?.getChildViews()[1] + .getChildViews() + .forEach(($childView) => $childView.callEvent("onViewShow")); + if (isContentDone) resolve(); + else + this._callPagingEvent( + contentDC, + this._fnPageContentCallback, + resolve + ); + }; + this._fnPageContentGroupCallback = async ( + contentGroupRecords, + isContentGroupDone, + contentGroupDC, + resolve + ) => { + const teamDC = this.datacollection; + this._fnPageTeamCallback(teamDC.getData(), true, teamDC, () => {}); + if (isContentGroupDone) resolve(); + else + this._callPagingEvent( + contentGroupDC, + this._fnPageContentGroupCallback, + resolve + ); + }; + this._fnPageContentDisplayCallback = ( + contentDisplayRecords, + isContentDisplayDone, + contentDisplayDC, + resolve + ) => { + const contentDC = this._contentDC; + this._fnPageContentCallback( + contentDC.getData(), + true, + contentDC, + () => {} + ); + if (isContentDisplayDone) resolve(); + else + this._callPagingEvent( + contentDisplayDC, + this._fnPageContentDisplayCallback, + resolve + ); + }; + this._fnPageData = async (dc, callback, resolve) => { + await this._waitDCReady(dc); + let records = dc.getData(); + try { + // TODO (Guy): Figure out later why the employee dc which is not reloaded lost the data. + if (records.length < DC_OFFSET) await dc.loadData(); + records = dc.getData(); + if ( + records.length < DC_OFFSET || + (records.length - DC_OFFSET) % RECORD_LIMIT > 0 + ) + throw null; + try { + await dc.loadData( + RECORD_LIMIT * parseInt(records.length / RECORD_LIMIT), + RECORD_LIMIT + ); + } catch {} + if (dc.getData().length === records.length) throw null; + records = dc.getData(); + if ((records.length - DC_OFFSET) % RECORD_LIMIT > 0) throw null; + callback && (await callback(records, false, dc, resolve)); + } catch { + callback && (await callback(records, true, dc, resolve)); + } + }; + this._fnPageTeamCallback = async ( + teamRecords, + isTeamDone, + teamDC, + resolve + ) => { + const contentFieldColumnName = + this.getSettingField("contentField").columnName; + const contentDC = this._contentDC; + const contentRecordPK = contentDC.datasource.PK(); + for (const teamRecord of teamRecords) { + const teamNodeID = this.teamNodeID(teamRecord.id); + let $teamNode = document.getElementById(teamNodeID); + if ($teamNode == null) { + await this.teamAddChild(teamRecord, false); + $teamNode = document.getElementById(teamNodeID); + if ($teamNode == null) continue; + } else await this.teamEdit(teamRecord, false); + const contentRecords = contentDC.getData( + (contentRecord) => + teamRecord[contentFieldColumnName].indexOf( + contentRecord[contentRecordPK] + ) > -1 + ); + for (const contentRecord of contentRecords) + this._addContentRecordToGroup($teamNode, contentRecord); + } + if (isTeamDone) { + await this.pullData(); + resolve(); + } else + this._callPagingEvent( + this.datacollection, + this._fnPageTeamCallback, + resolve + ); + }; + this._fnRefresh = async () => { + this.busy(); + const entityDC = this._entityDC; + const teamDC = this.datacollection; + const contentDC = this._contentDC; + const contentGroupDC = this._contentGroupDC; + await Promise.all([ + teamDC.datacollectionLink != null && this._waitDCPending(teamDC), + contentDC.datacollectionLink != null && + this._waitDCPending(contentDC), + contentGroupDC.datacollectionLink != null && + this._waitDCPending(contentGroupDC), + ...this._contentDisplayDCs + .filter( + (contentDisplayDC) => + contentDisplayDC !== entityDC && + contentDisplayDC !== teamDC && + contentDisplayDC !== contentDC && + contentDisplayDC !== contentGroupDC + ) + .map( + (contentDisplayDC) => + contentDisplayDC.datacollectionLink != null && + this._waitDCPending(contentDisplayDC) + ), + ]); + this.__orgchart?.remove(); + this.__orgchart = null; + await this.refresh(); + this.ready(); + }; + this._fnShowContentForm = (event) => { + const contentDC = this._contentDC; + const contentObj = contentDC.datasource; + const contentModel = contentDC.model; + const settings = this.settings; + const editContentFieldsToCreateNew = + settings.editContentFieldsToCreateNew; + const contentDateStartField = contentObj.fields( + (field) => field.id === settings.contentFieldDateStart + )[0]; + const contentDateStartFieldLabel = contentDateStartField?.label; + const contentDateStartFieldColumnName = + contentDateStartField?.columnName; + const contentDateEndFieldColumnName = this.getSettingField( + "contentFieldDateEnd" + )?.columnName; + const contentDataRecord = JSON.parse( + event.currentTarget.dataset.source || + event.currentTarget.parentElement.dataset.source + ); + const rules = {}; + const labelWidth = 200; + const ids = this.ids; + const contentFormElements = settings.setEditableContentFields.map( + (fieldID) => { + const field = contentObj.fields( + (field) => field.id === fieldID + )[0]; + if (field == null) + return { + view: "label", + label: this.label("Missing Field"), + labelWidth, + }; + const fieldKey = field.key; + const fieldName = field.columnName; + + // TODO (Guy): Add validators. + let invalidMessage = ""; + switch (fieldName) { + case contentDateEndFieldColumnName: + invalidMessage = `The ${field.label} must be later than the ${contentDateStartFieldLabel}.`; + rules[fieldName] = (value) => + value > + $$(ids.contentFormData).getValues()[ + contentDateStartFieldColumnName + ] || + value === "" || + value == null; + break; + default: + rules[fieldName] = () => true; + break; + } + const fieldLabel = field.label; + const settings = field.settings; + switch (fieldKey) { + case "boolean": + return { + view: "checkbox", + name: fieldName, + label: fieldLabel, + labelWidth, + invalidMessage, + }; + case "number": + return { + view: "counter", + name: fieldName, + label: fieldLabel, + labelWidth, + type: "number", + invalidMessage, + }; + case "list": + return { + view: + (settings.isMultiple === 1 && "muticombo") || + "combo", + name: fieldName, + label: fieldLabel, + labelWidth, + options: settings.options.map((option) => ({ + id: option.id, + value: option.text, + })), + invalidMessage, + }; + case "user": + case "connectObject": + const abWebix = this.AB.Webix; + const fieldLinkObj = field.datasourceLink; + + // TODO (Guy): Hardcode for the employee field + if (fieldLabel === "NS Employee Record") + return { + view: "text", + label: "Name", + disabled: true, + labelWidth, + invalidMessage, + on: { + async onViewShow() { + abWebix.extend(this, abWebix.ProgressBar); + this.showProgress({ type: "icon" }); + try { + this.setValue( + fieldLinkObj.displayData( + ( + await fieldLinkObj + .model() + .findAll({ + where: { + glue: "and", + rules: [ + { + key: fieldLinkObj.PK(), + rule: "equals", + value: contentDataRecord[ + fieldName + ], + }, + ], + }, + }) + ).data[0] + ) + ); + this.hideProgress(); + } catch { + // Close popup before response or possily response fail + } + }, + }, + }; + const onViewShow = async function () { + abWebix.extend(this, abWebix.ProgressBar); + this.showProgress({ type: "icon" }); + try { + this.define( + "options", + (await fieldLinkObj.model().findAll()).data.map( + (e) => ({ + id: e.id, + value: fieldLinkObj.displayData(e), + }) + ) + ); + this.refresh(); + this.enable(); + this.hideProgress(); + } catch { + // Close popup before response or possily response fail + } + }; + return field.linkType() === "one" + ? { + view: "combo", + name: fieldName, + label: fieldLabel, + disabled: true, + labelWidth, + invalidMessage, + options: [], + on: { + onViewShow, + }, + } + : { + view: "multicombo", + name: fieldName, + label: fieldLabel, + labelWidth, + stringResult: false, + labelAlign: "left", + invalidMessage, + options: [], + on: { + onViewShow, + }, + }; + case "date": + case "datetime": + return { + view: "datepicker", + name: fieldName, + label: fieldLabel, + stringResult: true, + labelWidth, + invalidMessage, + timepicker: fieldKey === "datetime", + }; + case "file": + case "image": + // TODO (Guy): Add logic + return { + // view: "", + name: fieldName, + label: fieldLabel, + labelWidth, + invalidMessage, + }; + // case "json": + // case "LongText": + // case "string": + // case "email": + default: + return { + view: "text", + name: fieldName, + label: fieldLabel, + labelWidth, + invalidMessage, + }; + } + } + ); + const Webix = AB.Webix; + contentFormElements.push({ + view: "button", + value: this.label("Save"), + css: "webix_primary", + click: async () => { + const $contentFormData = $$(ids.contentFormData); + if (!$contentFormData.validate()) return; + const newFormData = this._parseFormValueByType( + contentObj, + contentDataRecord, + $contentFormData.getValues() + ); + const $contentForm = $$(ids.contentForm); + $contentForm.blockEvent(); + $contentForm.$view.remove(); + $contentForm.destructor(); + if (!this._checkDataIsChanged(contentDataRecord, newFormData)) + return; + const teamDC = this.datacollection; + const contentDC = this._contentDC; + const dataID = newFormData.id; + const $contentNode = document.getElementById( + this.contentNodeID(dataID) + ); + delete newFormData["created_at"]; + delete newFormData["updated_at"]; + delete newFormData["properties"]; + this._setUpdatedBy(contentObj, newFormData); + if ( + !this._isLessThanDay( + new Date( + contentDataRecord[contentDateStartFieldColumnName] + ) + ) + ) { + let isCreated = false; + for (const editContentFieldToCreateNew of editContentFieldsToCreateNew) { + const editContentFieldToCreateNewColumnName = + contentObj.fieldByID( + editContentFieldToCreateNew + )?.columnName; + if ( + !isCreated && + contentDataRecord[ + editContentFieldToCreateNewColumnName + ] != null && + contentDataRecord[ + editContentFieldToCreateNewColumnName + ] !== "" && + JSON.stringify( + newFormData[editContentFieldToCreateNewColumnName] ?? + "" + ) !== + JSON.stringify( + contentDataRecord[ + editContentFieldToCreateNewColumnName + ] + ) + ) { + isCreated = true; + break; + } + } + if (isCreated) { + Webix.confirm({ + title: this.label("Caution: Creating New Assignment"), + ok: this.label("Continue with new assignment"), + cancel: this.label("Cancel"), + text: this.label( + "When you change the Role type or Job title, then the current assignment is closed with the current date and a new assignment is created for this team." + ), + css: "orgchart-teams-edit-content-confirm-popup", + }).then(async () => { + this.busy(); + const pendingPromises = []; + const oldData = {}; + + oldData[contentDateEndFieldColumnName] = new Date(); + this._setUpdatedBy(contentObj, oldData); + pendingPromises.push( + contentModel.update(dataID, oldData) + ); + newFormData[contentDateStartFieldColumnName] = + oldData[contentDateEndFieldColumnName]; + delete newFormData["id"]; + delete newFormData["uuid"]; + delete newFormData[contentDateEndFieldColumnName]; + pendingPromises.push(contentModel.create(newFormData)); + try { + await Promise.all(pendingPromises); + } catch (err) { + // TODO (Guy): The update data error. + console.error(err); + } + try { + // TODO (Guy): Logic to not reload dcs. + await Promise.all([ + this._reloadDCData(teamDC), + this._reloadDCData(contentDC), + ]); + // await this._reloadAllDC(); + } catch (err) { + // TODO (Guy): The reload DCs error. + console.error(err); + } + await this.refresh(); + $contentNode.remove(); + this.ready(); + }); + return; + } + } + if ( + new Date(newFormData[contentDateEndFieldColumnName]) <= + new Date() + ) { + Webix.confirm({ + title: this.label("Caution: Ending Current Assignment"), + ok: this.label("Continue with ending this assignment"), + cancel: this.label("Cancel"), + text: [ + this.label( + "When you provide an End Date, the current assignment is ended when the date = the current date and the assignment will no longer show on this team." + ), + this.label( + "This will put the team member back into the unassigned list box if they have no other active assignments." + ), + ].join("\n"), + css: "orgchart-teams-edit-content-confirm-popup", + }).then(async () => { + this.busy(); + try { + await contentModel.update(dataID, newFormData); + } catch (err) { + // TODO (Guy): The update data error. + console.error(err); + } + try { + // TODO (Guy): Logic to not reload dcs. + await Promise.all([ + this._reloadDCData(teamDC), + this._reloadDCData(contentDC), + ]); + // await this._reloadAllDC(); + } catch (err) { + // TODO (Guy): The reload DCs error. + console.error(err); + } + await this.refresh(); + $contentNode.remove(); + this.ready(); + }); + return; + } + this.busy(); + try { + await contentModel.update(dataID, newFormData); + } catch (err) { + // TODO (Guy): The update data error. + console.error(err); + } + try { + // TODO (Guy): Logic to not reload dcs. + await Promise.all([ + this._reloadDCData(teamDC), + this._reloadDCData(contentDC), + ]); + // await this._reloadAllDC(); + } catch (err) { + // TODO (Guy): The reload DCs error. + console.error(err); + } + await this.refresh(); + $contentNode.remove(); + this.ready(); + }, + }); + Webix.ui({ + view: "popup", + id: ids.contentForm, + close: true, + position: "center", + css: { "border-radius": "10px" }, + body: { + width: 600, + rows: [ + { + view: "toolbar", + css: "webix_dark", + cols: [ + { width: 5 }, + { + view: "label", + label: `${this.label("Edit")} ${contentObj.label}`, + align: "left", + }, + { + view: "icon", + icon: "fa fa-times", + align: "right", + width: 60, + click: () => { + const $contentForm = $$(ids.contentForm); + $contentForm.blockEvent(); + $contentForm.$view.remove(); + $contentForm.destructor(); + }, + }, + ], + }, + { + view: "form", + id: ids.contentFormData, + hidden: true, + elements: contentFormElements, + rules, + }, + ], + }, + on: { + onHide() { + this.$view.remove(); + this.destructor(); + }, + }, + }).show(); + const $contentFormData = $$(ids.contentFormData); + $contentFormData.setValues( + this._convertToFormValueByType(structuredClone(contentDataRecord)) + ); + $contentFormData.show(); + }; + this._fnShowFilterPopup = async (event) => { + const contentDisplayedFieldFilters = + this.settings.contentDisplayedFieldFilters; + const ids = this.ids; + let $popup = $$(ids.filterPopup); + if (!$popup) { + const self = this; + $popup = webix.ui({ + view: "popup", + css: "filter-popup", + id: ids.filterPopup, + body: { + rows: [ + { + view: "form", + borderless: true, + hidden: true, + id: ids.filterForm, + elements: [ + { + view: "text", + label: this.label("Team Name"), + labelWidth: 100, + name: "teamName", + clear: true, + }, + { + view: "combo", + label: this.label("Strategy"), + labelWidth: 100, + options: [], + name: "strategy", + clear: "replace", + on: { + async onViewShow() { + webix.extend(this, webix.ProgressBar); + this.showProgress({ type: "icon" }); + try { + this.define( + "options", + await self.strategyCodeOptions() + ); + this.refresh(); + this.enable(); + this.hideProgress(); + } catch { + // Close popup before response or possily response fail + } + }, + }, + }, + { + view: "checkbox", + name: "inactive", + labelRight: this.label("Show Inactive Teams"), + labelWidth: 0, + }, + ...(() => { + const contentDisplayedFieldFilterViews = []; + for (const contentDisplayedFieldFilterKey in contentDisplayedFieldFilters) { + const [, objID, fieldID, isActive] = + contentDisplayedFieldFilterKey.split("."); + if (isActive == 1) + switch (fieldID) { + // TODO (Guy): Hardcode for the role type filter. + case "96dc0d8d-7fb4-4bb1-8b80-a262aae41eed": + const obj = this.AB.objectByID(objID); + const model = obj.model(); + const fieldColumnName = + obj.fieldByID(fieldID).columnName; + contentDisplayedFieldFilterViews.push( + { + view: "combo", + label: contentDisplayedFieldFilters[ + contentDisplayedFieldFilterKey + ], + labelWidth: 100, + options: [], + name: contentDisplayedFieldFilterKey, + clear: "replace", + on: { + async onViewShow() { + webix.extend( + this, + webix.ProgressBar + ); + this.showProgress({ + type: "icon", + }); + try { + this.define( + "options", + ( + await model.findAll() + ).data.map((e) => ({ + id: e[ + fieldColumnName + ], + value: e[ + fieldColumnName + ], + })) + ); + this.refresh(); + this.enable(); + this.hideProgress(); + } catch { + // Close popup before response or possily response fail + } + }, + }, + } + ); + break; + default: + contentDisplayedFieldFilterViews.push( + { + view: "text", + label: contentDisplayedFieldFilters[ + contentDisplayedFieldFilterKey + ], + labelWidth: 100, + name: contentDisplayedFieldFilterKey, + clear: true, + } + ); + break; + } + } + return contentDisplayedFieldFilterViews; + })(), + { + cols: [ + {}, + { + view: "icon", + icon: "fa fa-check", + css: "filter-apply", + click: () => this.filterApply(), + }, + ], + }, + ], + }, + ], + }, + on: { + onShow() { + $$(ids.filterForm).show(); + }, + onHide() { + $$(ids.filterForm).hide(); + }, + }, + }); + } + $popup.show($$(ids.filterButton).$view); + }; + + // Generate strategy css + const css = [ + "org-chart .strategy-external .title{background:#989898 !important;}", + ]; + const colors = this.settings.strategyColors; + for (let key in colors) { + css.push( + `org-chart .strategy-${key} .title{background:${colors[key]} !important;}` + ); + } + const style = document.createElement("style"); + style.innerHTML = css.join(""); + document.getElementsByTagName("head")[0].appendChild(style); + this.on("pageData", this._fnPageData); + } + + _addContentRecordToGroup($teamNode, contentRecord) { + const contentGroupDC = this._contentGroupDC; + const contentGroupDataPK = + contentRecord[this.getSettingField("contentGroupByField").columnName]; + const contentGroupPKField = contentGroupDC.datasource.PK(); + if ( + contentGroupDC.getData( + (e) => e[contentGroupPKField] == contentGroupDataPK + )[0] == null + ) + return; + const $groupSection = $teamNode.querySelector( + `.team-group-section[data-pk="${contentGroupDataPK}"] > .team-group-content` + ); + if ($groupSection == null) return; + (async () => { + await this._callAfterRender(async () => { + const contentNodeID = this.contentNodeID(contentRecord.id); + let $contentNode = document.getElementById(contentNodeID); + while ($contentNode != null) { + $contentNode.remove(); + $contentNode = document.getElementById(contentNodeID); + } + $groupSection.appendChild( + await this._createUIContentRecord( + contentRecord, + this.settings.strategyColors[ + $teamNode.classList.item(1).replace("strategy-", "") + ] + ) + ); + }); + })(); + } + + _convertToFormValueByType(contentRecord) { + const contentAllFields = this._contentDC.datasource.fields(); + for (const field of contentAllFields) { + const columnName = field.columnName; + const value = contentRecord[columnName]; + switch (field.key) { + case "boolean": + if (value === true) contentRecord[columnName] = 1; + else if (value === false) contentRecord[columnName] = 0; + else { + const parsedValue = parseInt(value); + contentRecord[columnName] = isNaN(parsedValue) + ? 0 + : parsedValue; + } + break; + case "date": + case "datetime": + contentRecord[columnName] = new Date(value); + break; + default: + break; + } + } + return contentRecord; + } + + async _createUIContentRecord(data, color) { + const $ui = element("div", "team-group-record"); + $ui.setAttribute("id", this.contentNodeID(data.id)); + $ui.setAttribute("data-source", JSON.stringify(data)); + $ui.style.borderColor = color; + $ui.addEventListener("dblclick", this._fnShowContentForm); + if (this.settings.draggable === 1) { + $ui.setAttribute("draggable", "true"); + $ui.addEventListener("dragstart", this._fnContentDragStart); + $ui.addEventListener("dragend", this._fnContentDragEnd); + } + + // TODO (Guy): Now we are hardcoding for each display + const hardcodedDisplays = [ + element("div", "display-block"), + element("div", "display-block"), + element("div", "display-block display-block-right"), + ]; + hardcodedDisplays[1].style.width = "50%"; + const $hardcodedSpecialDisplay = element( + "div", + "team-group-record-display" + ); + let currentDataRecords = []; + let currentField = null; + let currentDisplayIndex = 0; + const contentDC = this._contentDC; + const contentObjID = contentDC.datasource.id; + const contentDisplayedFields = this.settings.contentDisplayedFields; + const contentDisplayedFieldsKeys = Object.keys(contentDisplayedFields); + const contentDisplayDCs = this._contentDisplayDCs; + for (let j = 0; j < contentDisplayedFieldsKeys.length; j++) { + const displayedFieldKey = contentDisplayedFieldsKeys[j]; + const [atDisplay, objID] = displayedFieldKey.split("."); + const displayedObj = AB.objectByID(objID); + const displayedFieldID = contentDisplayedFields[displayedFieldKey]; + const displayedField = displayedObj.fieldByID(displayedFieldID); + const displayDC = contentDisplayDCs.find( + (contentDisplayDC) => contentDisplayDC.datasource.id === objID + ); + switch (objID) { + case contentObjID: + currentDataRecords = [data]; + break; + default: + if (currentField == null) break; + if (currentDataRecords.length > 0) { + const currentFieldColumnName = currentField.columnName; + const currentDataPKs = []; + do { + const currentFieldData = + currentDataRecords.pop()[currentFieldColumnName]; + if (Array.isArray(currentFieldData)) { + if (currentFieldData.length > 0) + currentDataPKs.push(...currentFieldData); + } else if (currentFieldData != null) + currentDataPKs.push(currentFieldData); + } while (currentDataRecords.length > 0); + await this._waitDCReady(displayDC); + currentDataRecords = displayDC.getData((r) => { + return currentDataPKs.some((id) => id == r.id); + }); + } + break; + } + if (contentDisplayedFieldsKeys[j + 1]?.split(".")[0] === atDisplay) { + currentField = displayedField; + continue; + } + const $currentDisplay = element("div", "team-group-record-display"); + const displayedFieldColumnName = displayedField.columnName; + // TODO (Guy): Now we are hardcoding for each display. + // $rowData.appendChild($currentDisplay); + switch (currentDisplayIndex) { + case 0: + hardcodedDisplays[0].appendChild($currentDisplay); + break; + case 1: + let i = 0; + while ( + currentDataRecords.length > 0 && + currentDataRecords[i] != null + ) + if ( + currentDataRecords[i][displayedFieldColumnName] == null || + currentDataRecords[i][displayedFieldColumnName] === "" + ) + currentDataRecords.splice(i, 1); + else i++; + if (currentDataRecords.length === 0) + hardcodedDisplays[2].style.display = "none"; + hardcodedDisplays[2].appendChild($currentDisplay); + break; + case 2: + hardcodedDisplays[1].appendChild($hardcodedSpecialDisplay); + $hardcodedSpecialDisplay.appendChild($currentDisplay); + break; + case 3: + $hardcodedSpecialDisplay.appendChild($currentDisplay); + break; + default: + hardcodedDisplays[1].appendChild($currentDisplay); + break; + } + currentDisplayIndex++; + const contentDisplayedFieldTypePrefix = `${displayedFieldKey}.${displayedFieldID}`; + const contentDisplayedFieldMappingDataObj = + JSON.parse( + this.settings.contentDisplayedFieldMappingData?.[ + contentDisplayedFieldTypePrefix + ] || null + ) || {}; + if ( + this.settings.contentDisplayedFieldTypes[ + `${contentDisplayedFieldTypePrefix}.0` + ] != null + ) + $currentDisplay.style.display = "none"; + switch ( + this.settings.contentDisplayedFieldTypes[ + `${contentDisplayedFieldTypePrefix}.1` + ] + ) { + case "icon": + // TODO (Guy): Add logic. + break; + case "image": + while (currentDataRecords.length > 0) { + const currentDataRecordValue = + currentDataRecords.pop()[displayedFieldColumnName]; + const $img = document.createElement("img"); + $currentDisplay.appendChild($img); + $img.setAttribute( + "src", + contentDisplayedFieldMappingDataObj[ + currentDataRecordValue + ] ?? currentDataRecordValue + ); + } + break; + case "svg": + while (currentDataRecords.length > 0) { + const currentDataRecord = currentDataRecords.pop(); + const currentDataRecordID = currentDataRecord.id; + const currentDataRecordValue = + currentDataRecord[displayedFieldColumnName]; + const SVG_NS = "http://www.w3.org/2000/svg"; + const X_LINK_NS = "http://www.w3.org/1999/xlink"; + const $svg = document.createElementNS(SVG_NS, "svg"); + $currentDisplay.appendChild($svg); + $svg.setAttribute("viewBox", "0 0 6 6"); + $svg.setAttribute("fill", "none"); + $svg.setAttribute("xmlns", SVG_NS); + $svg.setAttribute("xmlns:xlink", X_LINK_NS); + const $rect = document.createElementNS(SVG_NS, "rect"); + const $defs = document.createElementNS(SVG_NS, "defs"); + $svg.append($rect, $defs); + $rect.setAttribute("width", "6"); + $rect.setAttribute("height", "6"); + const patternID = `display-svg.pattern.${currentDataRecordID}`; + $rect.setAttribute("fill", `url(#${patternID})`); + const $pattern = document.createElementNS(SVG_NS, "pattern"); + const $image = document.createElementNS(SVG_NS, "image"); + $defs.append($pattern, $image); + $pattern.id = patternID; + $pattern.setAttributeNS( + null, + "patternContentUnits", + "objectBoundingBox" + ); + $pattern.setAttribute("width", "1"); + $pattern.setAttribute("height", "1"); + const imageID = `display-svg.image.${currentDataRecordID}`; + $image.id = imageID; + $image.setAttribute("width", "512"); + $image.setAttribute("height", "512"); + $image.setAttributeNS( + X_LINK_NS, + "xlink:href", + contentDisplayedFieldMappingDataObj[ + currentDataRecordValue + ] ?? currentDataRecordValue + ); + const $use = document.createElementNS(SVG_NS, "use"); + $pattern.appendChild($use); + $use.setAttributeNS(X_LINK_NS, "xlink:href", `#${imageID}`); + $use.setAttribute("transform", "scale(0.002)"); + } + break; + default: + while (currentDataRecords.length > 0) { + const currentDataRecordValue = + currentDataRecords.pop()[displayedFieldColumnName]; + $currentDisplay.appendChild( + document.createTextNode( + contentDisplayedFieldMappingDataObj[ + currentDataRecordValue + ] ?? currentDataRecordValue + ) + ); + } + + // TODO (Guy): Hardcode limit text. + if (currentDisplayIndex - 1 === 1) + $currentDisplay.textContent = + $currentDisplay.textContent.slice(0, 35); + break; + } + currentField = null; + } + + // TODO (Guy): Now we are hardcoding for each display. + const hardcodedDisplaysLength = hardcodedDisplays.length; + for (let i = 0; i < hardcodedDisplaysLength; i++) { + const $hardcodedDisplay = hardcodedDisplays[i]; + $ui.appendChild($hardcodedDisplay); + const children = $hardcodedDisplay.children; + let isShown = false; + let j = 0; + let child, grandChildren, grandChildrenLength; + switch (i) { + case 1: + child = children.item(j); + grandChildren = child.children; + grandChildrenLength = grandChildren.length; + for (; j < grandChildrenLength; j++) + if (grandChildren[j].style.display !== "none") { + isShown = true; + break; + } + if (isShown) continue; + child.style.display = "none"; + j = 1; + break; + default: + break; + } + const childrenLength = children.length; + const hardcodedDisplayStyle = $hardcodedDisplay.style; + for (; j < childrenLength; j++) + if (children.item(j).style.display !== "none") { + isShown = true; + break; + } + !isShown && (hardcodedDisplayStyle.display = "none"); + } + const $editIcon = element("div", "team-group-record-edit-icon"); + $editIcon.appendChild(element("i", "fa fa-pencil")); + $editIcon.addEventListener("click", this._fnShowContentForm); + $ui.appendChild($editIcon); + return $ui; + } + + async _callAfterRender(callback, ...params) { + await new Promise((resolve, reject) => { + requestAnimationFrame(() => { + requestAnimationFrame(async () => { + try { + await callback(...params); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + }); + } + + _callPagingEvent(dc, callback, resolve) { + this.emit("pageData", dc, callback, resolve); + } + + _checkDataIsChanged(olaValues, newValues) { + // TODO (Guy): Check array in the future. + for (const key in newValues) + if (JSON.stringify(newValues[key]) !== JSON.stringify(olaValues[key])) + return true; + return false; + } + + _isLessThanDay(date) { + return Math.abs(new Date() - date) / 36e5 < 24; + } + + _initDC(dc) { + dc.init(); + if (dc.dataStatus === dc.dataStatusFlag.notInitial) dc.loadData(); + } + + _pageData() { + if (this._promisePageData != null) return; + let resolvePageData = null; + this._promisePageData = new Promise((resolve) => { + resolvePageData = resolve; + }); + const entityDC = this._entityDC; + const teamDC = this.datacollection; + const contentDC = this._contentDC; + const contentGroupDC = this._contentGroupDC; + (async () => { + try { + await Promise.all([ + new Promise((resolve) => { + this._callPagingEvent( + teamDC, + this._fnPageTeamCallback, + resolve + ); + }), + new Promise((resolve) => { + this._callPagingEvent( + contentDC, + this._fnPageContentCallback, + resolve + ); + }), + new Promise((resolve) => { + this._callPagingEvent( + contentGroupDC, + this._fnPageContentGroupCallback, + resolve + ); + }), + ...this._contentDisplayDCs + .filter( + (contentDisplayDC) => + contentDisplayDC !== entityDC && + contentDisplayDC !== teamDC && + contentDisplayDC !== contentDC && + contentDisplayDC !== contentGroupDC + ) + .map( + (contentDisplayDC) => + new Promise((resolve) => { + this._callPagingEvent( + contentDisplayDC, + this._fnPageContentDisplayCallback, + resolve + ); + }) + ), + ]); + } catch (err) { + // TODO (Guy): The paging error. + console.error(err); + } + resolvePageData(); + this._promisePageData = null; + })(); + } + + _parseDataPK(dataPK) { + const intDataPk = parseInt(dataPK); + return ( + ((isNaN(intDataPk) || intDataPk.toString().length !== dataPK.length) && + dataPK) || + intDataPk + ); + } + + _parseFormValueByType(obj, oldFormData, newFormData) { + const allFields = obj.fields(); + for (const field of allFields) { + const fieldKey = field.key; + const columnName = field.columnName; + const oldValue = oldFormData?.[columnName]; + const newValue = newFormData[columnName]; + switch (fieldKey) { + case "date": + if (oldValue === undefined && newValue == null) + delete newFormData[columnName]; + else { + newFormData[columnName] = new Date(newValue); + if (isNaN(newFormData[columnName])) + delete newFormData[columnName]; + else + newFormData[columnName] = `${newFormData[ + columnName + ].getFullYear()}-${String( + newFormData[columnName].getMonth() + 1 + ).padStart(2, "0")}-${String( + newFormData[columnName].getDate() + ).padStart(2, "0")}`; + } + break; + case "datetime": + if (oldValue === undefined && newValue == null) + delete newFormData[columnName]; + try { + newValue instanceof Date && + (newFormData[columnName] = newValue.toISOString()); + } catch { + delete newFormData[columnName]; + } + break; + case "connectObject": + delete newFormData[`${columnName}__relation`]; + if (field.linkType() === "one") { + if (oldValue === undefined && newFormData[columnName] == null) + delete newFormData[columnName]; + else + switch (typeof oldValue) { + case "number": + newFormData[columnName] = parseInt(newValue) || null; + break; + case "string": + newFormData[columnName] = + newValue?.toString() || null; + break; + default: + break; + } + } + // TODO (Guy): Many logic in the future. Now we don't have an array data changed. + else delete newFormData[columnName]; + break; + default: + if (newValue == null || newValue === "") + if (oldValue === undefined) { + delete newFormData[columnName]; + break; + } else if (oldValue === "") { + newFormData[columnName] = ""; + break; + } + switch (fieldKey) { + case "boolean": + switch (typeof oldValue) { + case "number": + newFormData[columnName] = newValue; + break; + case "string": + newFormData[columnName] = newValue === 1 ? "1" : "0"; + break; + default: + newFormData[columnName] = newValue == 1; + break; + } + break; + case "number": + const paredNewValue = parseInt(newValue); + if (isNaN(parseInt(newValue))) { + if (oldValue === undefined) + delete newFormData[columnName]; + else newFormData[columnName] = oldValue; + break; + } + switch (typeof oldValue) { + case "string": + newFormData[columnName] = paredNewValue.toString(); + break; + default: + newFormData[columnName] = paredNewValue; + break; + } + break; + case "string": + newFormData[columnName] = newValue?.toString() || ""; + break; + default: + break; + } + break; + } + } + return newFormData; + } + + async _reloadAllDC() { + const entityDC = this._entityDC; + const teamDC = this.datacollection; + const contentDC = this._contentDC; + const contentGroupDC = this._contentGroupDC; + await Promise.all([ + this._reloadDCData(teamDC), + ...this._dataPanelDCs.map((dataPanelDC) => + this._reloadDCData(dataPanelDC) + ), + this._reloadDCData(contentDC), + ...this._contentDisplayDCs + .filter( + (contentDisplayDC) => + contentDisplayDC !== entityDC && + contentDisplayDC !== teamDC && + contentDisplayDC !== contentDC && + contentDisplayDC !== contentGroupDC + ) + .map((contentDisplayDC) => this._reloadDCData(contentDisplayDC)), + ]); + } + + async _reloadDCData(dc) { + await this._promisePageData; + if (dc.dataStatus === dc.dataStatusFlag.initializing) + await this._waitDCReady(dc); + dc.clearAll(); + this._initDC(dc); + await this._waitDCReady(dc); + } + + _setUpdatedBy(obj, values) { + values[ + // TODO (Guy): This should be the ABDesigner setting. + obj.fields( + (field) => + field.columnName.indexOf("_last_upd_by_in_app") > -1 || + field.columnName.indexOf("_update_in_app") > -1 + )[0].columnName + ] = this.AB.Account.email(); + } + + _showDataPanel() { + let $panel = $$(this.ids.dataPanelPopup); + if (!$panel) { + $panel = this.AB.Webix.ui({ + id: this.ids.dataPanelPopup, + view: "popup", + width: 250, + body: this._uiDataPanel(), + css: "data-panel-popup", + modal: true, + }); + } + const $dpButtonWebix = $$(this.ids.dataPanelButton).$view; + const $dpButtonElem = $dpButtonWebix.querySelector(".data-panel-button"); + // Ensure the popup will stay to the right when resizing + if (!this._resizeObserver) { + this._resizeObserver = new ResizeObserver(([e]) => { + // Hide the panel when the widget is hidden (ex. switched to another App) + if (e.contentRect.width == 0 && e.contentRect.height == 0) { + return $panel.hide(); + } + $panel.show($dpButtonElem, { x: -30, y: -35 }); + }); + } + this._resizeObserver.observe($dpButtonWebix); + $panel.show($dpButtonElem, { x: -30, y: -35 }); + $$(this.ids.dataPanel) + .getChildViews()[1] + .getChildViews() + .forEach(($childView) => $childView.callEvent("onViewShow")); + } + + _showOrgChart() { + const settings = this.settings; + const AB = this.AB; + const draggable = settings.draggable === 1; + const ids = this.ids; + const chartData = this._chartData; + if (chartData == null) { + this.__orgchart = null; + return; + } + const orgchart = new this._OrgChart({ + data: AB.cloneDeep(chartData), + direction: settings.direction, + // depth: settings.depth, + // chartContainer: `#${ids.chartDom}`, + pan: true, // settings.pan == 1, + zoom: false, // settings.zoom == 1, + draggable, + // visibleLevel: settings.visibleLevel, + parentNodeSymbol: false, + exportButton: settings.export, + exportFilename: settings.exportFilename, + createNode: this._fnCreateNode, + nodeContent: "description", + }); + + // On drop update the parent (dropZone) of the node + if (draggable) + orgchart.addEventListener("nodedropped.orgchart", this._fnNodeDrop); + if (this.__orgchart != null) { + const oldOrgchart = this.__orgchart; + orgchart.dataset.panStart = oldOrgchart.dataset.panStart; + orgchart.setAttribute("style", oldOrgchart.getAttribute("style")); + oldOrgchart.remove(); + } + $$(ids.chartContent).$view.appendChild((this.__orgchart = orgchart)); + } + + _uiDataPanel() { + const self = this; + const _dataPanelDCs = self._dataPanelDCs; + const dataPanelDCs = self.settings.dataPanelDCs; + const contentObjID = this._contentDC?.datasource?.id; + const cells = []; + for (const key in dataPanelDCs) { + const [tabIndex, dataPanelDCID] = key.split("."); + + // TODO (Guy): Hardcode data panel DCs for Employee. + // const _dataPanelDC = _dataPanelDCs.find( + // (dataPanelDC) => dataPanelDC.id === dataPanelDCID + // ); + const _dataPanelDC = self._contentDisplayDCs.find( + (contentDisplayDC) => + contentDisplayDC.datasource.id === + _dataPanelDCs.find( + (dataPanelDC) => dataPanelDC.id === dataPanelDCID + ).datasource.id + ); + const contentDC = this._contentDC; + const header = dataPanelDCs[key]; + if (_dataPanelDC == null) + cells.push({ + header, + body: { + view: "list", + css: { overflow: "auto", "max-height": "90%" }, + data: [], + }, + }); + else { + const panelObj = _dataPanelDC.datasource; + cells.push({ + header, + body: { + view: "list", + template: (data) => + `
${panelObj.displayData( + data + )}
`, + borderless: true, + css: "data-panel-employee-list", + data: [], + on: { + async onViewShow() { + await self._waitDCReady(_dataPanelDC); + const contentLinkedField = panelObj.connectFields( + (field) => field.datasourceLink.id == contentObjID + )[0].fieldLink; + const contentLinkedFieldColumnName = + contentLinkedField.columnName; + this.clearAll(); + this.define( + "data", + // TODO (Guy): Hardcode Employee DC. + (parseInt(tabIndex) < 2 + ? _dataPanelDC.getData( + (panelRecord) => + panelRecord.isinactive !== "T" && + (tabIndex === "0" + ? contentDC.getData( + (contentRecord) => + contentRecord[ + contentLinkedFieldColumnName + ] == panelRecord.id + )[0] == null + : contentDC.getData( + (contentRecord) => + contentRecord[ + contentLinkedFieldColumnName + ] == panelRecord.id + )[0] != null) + ) + : _dataPanelDCs + .find( + (dataPanelDC) => + dataPanelDC.id === dataPanelDCID + ) + .getData() + ).sort(sort) + ); + await self._callAfterRender(() => { + const $itemElements = + this.$view.children.item(0).children; + const itemElementsLength = $itemElements.length; + const contentFieldID = contentLinkedField.id; + let count = 0; + while (count < itemElementsLength) { + const $itemElement = $itemElements.item(count++); + $itemElement.setAttribute( + "data-content-linked-field-id", + contentFieldID + ); + const dataPanelRecord = _dataPanelDC.getData( + (e) => + e.id == + $itemElement.getAttribute("webix_l_id") + )[0]; + if (dataPanelRecord == null) continue; + $itemElement.setAttribute( + "data-pk", + dataPanelRecord[panelObj.PK()] + ); + $itemElement.setAttribute("draggable", "true"); + $itemElement.addEventListener( + "dragstart", + self._fnContentDragStart + ); + $itemElement.addEventListener( + "dragend", + self._fnContentDragEnd + ); + } + }); + }, + }, + }, + }); + } + } + return { + height: 600, + type: "clean", + rows: [ + { + view: "template", + borderless: true, + template: `
+ ${this.label("Staff Assignment")} + +
`, + height: 35, + onClick: { + "data-panel-close": () => { + $$(this.ids.dataPanelPopup).hide(); + this._resizeObserver?.unobserve( + $$(this.ids.dataPanelButton).$view + ); + return false; + }, + }, + }, + { + id: this.ids.dataPanel, + view: "tabview", + css: "data-panel-tabview", + width: 250, + borderless: true, + tabbar: { + height: 25, + // width: 300, + align: "left", + // type: "bottom", + css: "data-panel-tabbar", + }, + cells, + }, + ], + }; + } + + async _waitDCPending(dc) { + switch (dc.dataStatus) { + case dc.dataStatusFlag.notInitial: + case dc.dataStatusFlag.initialized: + await new Promise((resolve) => { + dc.once("initializingData", resolve); + }); + break; + default: + break; + } + } + + // TODO (Guy): Some DC.waitReady() won't be resolved. + async _waitDCReady(dc) { + const dataStatusFlag = dc.dataStatusFlag; + switch (dc.dataStatus) { + case dataStatusFlag.notInitial: + case dataStatusFlag.initializing: + await new Promise((resolve) => { + dc.once("initializedData", resolve); + }); + break; + default: + break; + } + } + + ui() { + const self = this; + const ids = self.ids; + const AB = self.AB; + const Webix = AB.Webix; + const _ui = super.ui([ + { + id: ids.chartView, + // view: "template", + responsive: true, + type: "clean", + rows: [ + { + responsive: true, + id: ids.chartHeader, + view: "toolbar", + height: 50, + type: "clean", + cols: [ + { + view: "template", + id: this.ids.filterButton, + template: ``, + align: "left", + onClick: { + "filter-button": (ev) => self._fnShowFilterPopup(ev), + }, + }, + { + view: "template", + id: this.ids.dataPanelButton, + template: `
+ +
+ ${self.label("Staff Assignment")} + +
+
`, + align: "right", + onClick: { + "data-panel-open": (ev) => self._showDataPanel(ev), + }, + }, + ], + }, + { + responsive: true, + id: ids.chartContent, + view: "template", + scroll: "auto", + + on: { + onAfterRender() { + Webix.extend(this, Webix.ProgressBar); + }, + }, + }, + ], + }, + ]); + delete _ui.type; + return _ui; + } + + async init(AB, accessLevel) { + await super.init(AB, accessLevel); + const settings = this.settings; + this._resources = await Promise.all(this._resources); + this._OrgChart || + (this._OrgChart = (() => { + const OrgChart = this._resources[0].default; + const _oldOnDragStart = OrgChart.prototype._onDragStart; + OrgChart.prototype._onDragStart = (event) => { + event.dataTransfer.setData("isnode", 1); + this.__orgchart != null && + _oldOnDragStart.call(this.__orgchart, event); + }; + return OrgChart; + })()); + + // Preparing for the entity DC and wait for setting a cursor. + const entityDC = + (() => { + const entityDC = this._entityDC; + if (entityDC != null) this._initDC(entityDC); + return entityDC; + })() || + (this._entityDC = await (async () => { + const entityDC = this.AB.datacollectionByID( + settings.entityDatacollection + ); + if (entityDC != null) { + this._initDC(entityDC); + await Promise.all([ + this._waitDCReady(entityDC), + new Promise((resolve) => { + const CHANGE_CURSOR = "changeCursor"; + entityDC.off(CHANGE_CURSOR, this._fnRefresh); + if (entityDC.getCursor() != null) { + entityDC.on(CHANGE_CURSOR, this._fnRefresh); + resolve(); + } else + entityDC.once(CHANGE_CURSOR, () => { + entityDC.on(CHANGE_CURSOR, this._fnRefresh); + resolve(); + }); + }), + ]); + } + return entityDC; + })()); + + // Preparing for the data panel DCs. + if (settings.showDataPanel === 1) { + const _dataPanelDCs = this._dataPanelDCs; + const dataPanelDCs = settings.dataPanelDCs; + for (const key in dataPanelDCs) { + const [, dataPanelDCID] = key.split("."); + const _dataPanelDC = AB.datacollectionByID(dataPanelDCID); + _dataPanelDCs.findIndex( + (_dataPanelDC) => _dataPanelDC.id === dataPanelDCID + ) < 0 && _dataPanelDCs.push(_dataPanelDC); + this._initDC(_dataPanelDC); + } + } + + // Preparing for the content DC. + const contentDC = + this._contentDC || + (this._contentDC = (() => { + const contentObj = this.AB.objectByID( + this.getSettingField("contentField").settings.linkObject + ); + const contentObjID = contentObj.id; + const contentFieldFilter = JSON.parse(settings.contentFieldFilter); + const contentDCSettings = { + datasourceID: contentObjID, + linkDatacollectionID: null, + linkFieldID: null, + objectWorkspace: { + filterConditions: { + glue: "and", + rules: [ + // TODO (Guy): Hardcode date start filter. + { + key: contentObj.fieldByID( + settings.contentFieldDateStart + )?.id, + rule: "is_not_null", + value: "", + }, + { + glue: "or", + rules: + (contentFieldFilter.rules?.length > 0 && [ + contentFieldFilter, + + // TODO (Guy): Hardcode date end filter. + { + key: contentObj.fieldByID( + settings.contentFieldDateEnd + )?.id, + rule: "is_null", + value: "", + }, + ]) || + [], + }, + ], + }, + }, + }; + if (entityDC != null) { + const entityObjID = entityDC.datasource.id; + (contentDCSettings.linkFieldID = contentObj.connectFields( + (f) => f.settings.linkObject === entityObjID + )[0]?.id) && + (contentDCSettings.linkDatacollectionID = entityDC.id); + } + const contentDC = AB.datacollectionNew({ + id: `dc.${contentObjID}`, + settings: contentDCSettings, + }); + contentDC.$dc.__prevLinkDcCursor = entityDC + ?.getCursor() + ?.id?.toString(); + this._initDC(contentDC); + return contentDC; + })()); + + // Preparing for the content group DC. + const contentGroupDC = + this._contentGroupDC || + (this._contentGroupDC = (() => { + const contentGroupObjID = contentDC.datasource.fieldByID( + settings.contentGroupByField + ).settings.linkObject; + const contentGroupDCSettings = { + datasourceID: contentGroupObjID, + linkDatacollectionID: null, + linkFieldID: null, + }; + if (entityDC != null) { + const entityObjID = entityDC.datasource.id; + (contentGroupDCSettings.linkFieldID = this.AB.objectByID( + contentGroupObjID + ).connectFields( + (f) => f.settings.linkObject === entityObjID + )[0]?.id) && + (contentGroupDCSettings.linkDatacollectionID = entityDC.id); + } + const contentGroupDC = this.AB.datacollectionNew({ + id: `dc.${contentGroupObjID}`, + settings: contentGroupDCSettings, + }); + contentGroupDC.$dc.__prevLinkDcCursor = entityDC + ?.getCursor() + ?.id?.toString(); + this._initDC(contentGroupDC); + return contentGroupDC; + })()); + + // Prepare display DCs. + const contentDisplayedFieldKeys = Object.keys( + settings.contentDisplayedFields + ); + if (contentDisplayedFieldKeys.length > 0) { + const teamDC = this.datacollection; + const teamObjID = teamDC.datasource.id; + const contentObjID = contentDC.datasource.id; + const contentGroupObjID = contentGroupDC.datasource.id; + const contentDisplayDCSettings = { + datasourceID: null, + linkDatacollectionID: null, + linkFieldID: null, + fixSelect: "", + }; + const contentDisplayDCs = this._contentDisplayDCs; + let [, objID] = contentDisplayedFieldKeys.pop().split("."); + while (contentDisplayedFieldKeys.length > 0) { + if ( + contentDisplayDCs.findIndex( + (contentDisplayDC) => contentDisplayDC.datasource.id === objID + ) < 0 + ) + switch (objID) { + case teamObjID: + this._initDC(teamDC); + contentDisplayDCs.push(teamDC); + break; + case contentObjID: + this._initDC(contentDC); + contentDisplayDCs.push(contentDC); + break; + case contentGroupObjID: + contentDisplayDCs.push(contentGroupDC); + this._initDC(contentGroupDC); + break; + default: + if (entityDC?.datasource.id === objID) { + this._initDC(entityDC); + contentDisplayDCs.push(entityDC); + } else { + contentDisplayDCSettings.datasourceID = objID; + if (entityDC != null) { + const entityObjID = entityDC.datasource.id; + (contentDisplayDCSettings.linkFieldID = + AB.objectByID(objID).connectFields( + (f) => f.settings.linkObject === entityObjID + )[0]?.id) && + (contentDisplayDCSettings.linkDatacollectionID = + entityDC.id); + } + const contentDisplayDC = AB.datacollectionNew({ + id: `dc.${objID}`, + settings: contentDisplayDCSettings, + }); + contentDisplayDC.$dc.__prevLinkDcCursor = entityDC + ?.getCursor() + ?.id?.toString(); + this._initDC(contentDisplayDC); + contentDisplayDCs.push(contentDisplayDC); + } + break; + } + [, objID] = contentDisplayedFieldKeys.pop().split("."); + } + } + this._resolveInit(); + } + + getChartData(id, chartData = this._chartData) { + if (this.teamNodeID(id) === chartData.id) return chartData; + if (chartData.children?.length > 0) { + for (let child of chartData.children) { + child = this.getChartData(id, child); + if (child != null) return child; + } + } + } + + async onShow() { + this.busy(); + this.AB.performance.mark("TeamChart.onShow"); + await this._promiseInit; + super.onShow(); + this.AB.performance.mark("TeamChart.load"); + await this.refresh(); + this.AB.performance.measure("TeamChart.load"); + this.AB.performance.measure("TeamChart.onShow"); + this.ready(); + } + + /** + * load the data and format it for display + */ + async pullData() { + this._chartData = null; + const dc = this.datacollection; + if (dc == null) return; + const settings = this.settings; + await this._waitDCReady(dc); + let topNode = dc.getCursor(); + const topNodeColumn = this.getSettingField("topTeam").columnName; + if (settings.topTeam) { + const topFromField = dc.getData((e) => e[topNodeColumn] == 1)[0]; + topNode = topFromField ? topFromField : topNode; + } + if (!topNode) return; + + /** + * Recursive function to prepare child node data + * @param {object} node the current node + * @param {number} [depth=0] a count of how many times we have recursed + */ + const teamLinkDef = this.getSettingField("teamLink"); + const teamLinkDefColumnName = teamLinkDef.columnName; + const teamLinkedColumnDefColumnName = this.AB.definitionByID( + teamLinkDef.settings.linkColumn + ).columnName; + const pullChildData = (node, depth = 0) => { + if (depth >= TEAM_CHART_MAX_DEPTH) return; + node.children = []; + node._rawData[this.getSettingField("teamLink").columnName].forEach( + (id) => { + const childData = dc.getData((e) => e.id == id)[0]; + + // Don't show inactive teams + if ( + !childData || + childData[teamLinkedColumnDefColumnName] == id || + (this.__filters?.inactive == 0 && + childData[this.getSettingField("teamInactive").columnName]) + ) + return; + const child = { + name: childData[this.getSettingField("teamName").columnName], + id: this.teamNodeID(id), + className: `strategy-${ + childData[ + `${ + this.getSettingField("teamStrategy").columnName + }__relation` + ]?.[this.getSettingField("strategyCode").columnName] + }`, + isInactive: + childData[this.getSettingField("teamInactive").columnName], + _rawData: childData, + }; + child.filteredOut = this.filterTeam(child); + if (child.name === "External Support") + child.className = `strategy-external`; + if (childData[teamLinkDefColumnName].length > 0) { + pullChildData(child, depth + 1); + } + // If this node is filtered we still need it if it has children + // that pass + if (!child.filteredOut || child.children?.length > 0) { + node.children.push(child); + } + } + ); + if (node.children.length === 0) { + delete node.children; + } else { + // sort children alphaetically + node.children = node.children.sort(sort); + } + }; + const chartData = (this._chartData = { + id: this.teamNodeID(topNode.id), + name: topNode[this.getSettingField("teamName").columnName] ?? "", + className: `strategy-${ + topNode[ + `${this.getSettingField("teamStrategy").columnName}__relation` + ]?.[this.getSettingField("strategyCode").columnName] + }`, + isInactive: topNode[this.getSettingField("teamInactive").columnName], + _rawData: topNode, + filteredOut: false, + }); + chartData.filteredOut = this.filterTeam(chartData); + pullChildData(chartData); + } + + async refresh(force = true) { + const ids = this.ids; + $$(ids.teamFormPopup)?.destructor(); + $$(ids.contentForm)?.destructor(); + await this.pullData(); + // this._showDataPanel(); + this._showOrgChart(); + (force && this._pageData()) || + (await this._callAfterRender(() => { + const contentDC = this._contentDC; + this._fnPageContentCallback( + contentDC.getData(), + true, + contentDC, + () => {} + ); + })); + } + + async filterApply() { + this.busy(); + await this._promisePageData; + const ids = this.ids; + $$(ids.filterPopup).hide(); + this.__filters = $$(ids.filterForm).getValues(); + this.__orgchart?.remove(); + this.__orgchart = null; + await this.refresh(false); + this.ready(); + } + + filterTeam(team) { + const filters = this.__filters; + let filter = false; + filters.strategy = filters.strategy ?? ""; + filters.teamName = filters.teamName ?? ""; + + // Apply filters (match using or) + if (filters.strategy || filters.teamName) { + filter = true; + if ( + filters.strategy !== "" && + filters.strategy == team.className.replace("strategy-", "") + ) + filter = false; + if ( + filters.teamName !== "" && + team.name.toLowerCase().includes(filters.teamName.toLowerCase()) + ) + filter = false; + if (!filter) return filter; + } + const AB = this.AB; + const settings = this.settings; + const contentDisplayFieldFilters = settings.contentDisplayedFieldFilters; + for (const key in contentDisplayFieldFilters) { + filters[key] = filters[key] ?? ""; + if (filters[key] !== "") filter = true; + } + if (!filter) return filter; + const contentField = settings.contentField; + const teamObj = this.datacollection.datasource; + const contentFieldLinkColumnName = teamObj.connectFields( + (connectField) => connectField.id === contentField + )[0].fieldLink.columnName; + const contentDC = this._contentDC; + const contentObj = contentDC.datasource; + const contentObjID = contentObj.id; + const contentObjPK = contentObj.PK(); + const teamRecordPK = team._rawData[teamObj.PK()]; + const contentDisplayedFields = settings.contentDisplayedFields; + const contentDisplayedFieldKeys = Object.keys(contentDisplayedFields); + const contentDisplayDCs = this._contentDisplayDCs; + let currentContentDisplayFieldKey = null; + let currentContentDisplayDC = null; + let currentContentDisplayObjID = null; + let currentContentDisplayObjPK = null; + let currentContentDisplayFieldColumnName = null; + let currentContentDisplayFilterValue = null; + let currentContentDisplayRecords = []; + while (contentDisplayedFieldKeys.length > 0) { + currentContentDisplayFieldKey = contentDisplayedFieldKeys.pop(); + currentContentDisplayObjID = + currentContentDisplayFieldKey.split(".")[1]; + currentContentDisplayFilterValue = + filters[ + `${currentContentDisplayFieldKey}.${contentDisplayedFields[currentContentDisplayFieldKey]}.0` + ]; + if (currentContentDisplayFilterValue == null) + currentContentDisplayFilterValue = + filters[ + `${currentContentDisplayFieldKey}.${contentDisplayedFields[currentContentDisplayFieldKey]}.1` + ]; + if (currentContentDisplayFilterValue != null) { + if (currentContentDisplayFilterValue === "") continue; + currentContentDisplayFilterValue = currentContentDisplayFilterValue + .toString() + .toLowerCase(); + currentContentDisplayFieldColumnName = AB.definitionByID( + contentDisplayedFields[currentContentDisplayFieldKey] + ).columnName; + currentContentDisplayDC = contentDisplayDCs.find( + (contentDisplayDC) => + contentDisplayDC.datasource.id === currentContentDisplayObjID + ); + currentContentDisplayObjPK = + currentContentDisplayDC.datasource.PK(); + currentContentDisplayRecords = currentContentDisplayDC + .getData( + (contentDisplayRecord) => + contentDisplayRecord[currentContentDisplayFieldColumnName] + ?.toString() + .toLowerCase() + .indexOf(currentContentDisplayFilterValue) > -1 + ) + .map((contentDisplayRecord) => + contentDisplayRecord[currentContentDisplayObjPK]?.toString() + ); + } else if (currentContentDisplayRecords.length > 0) { + currentContentDisplayFieldColumnName = AB.definitionByID( + contentDisplayedFields[currentContentDisplayFieldKey] + ).columnName; + currentContentDisplayDC = contentDisplayDCs.find( + (contentDisplayDC) => + contentDisplayDC.datasource.id === currentContentDisplayObjID + ); + currentContentDisplayObjPK = + currentContentDisplayDC.datasource.PK(); + currentContentDisplayRecords = currentContentDisplayDC + .getData((contentDisplayRecord) => { + const contentDisplayRecordData = + contentDisplayRecord[currentContentDisplayFieldColumnName]; + return Array.isArray(contentDisplayRecordData) + ? contentDisplayRecordData.findIndex( + (e) => + currentContentDisplayRecords.indexOf( + e.toString() + ) > -1 + ) > -1 + : currentContentDisplayRecords.indexOf( + contentDisplayRecordData?.toString() + ) > -1; + }) + .map((contentDisplayRecord) => + contentDisplayRecord[currentContentDisplayObjPK].toString() + ); + } + if ( + currentContentDisplayObjID === contentObjID && + currentContentDisplayRecords.length > 0 && + contentDC + .getData( + (contentRecord) => + contentRecord[contentFieldLinkColumnName] == teamRecordPK + ) + .findIndex( + (contentRecord) => + currentContentDisplayRecords.indexOf( + contentRecord[contentObjPK].toString() + ) > -1 + ) > -1 + ) { + filter = false; + break; + } + } + return filter; + } + + /** + * Get the ABField from settings + * @param {string} setting key in this.view.settings - should be an id for an + * ABField + */ + getSettingField(setting) { + return this.AB.definitionByID(this.settings[setting]); + } + + async teamAddChild(values, isServerSideUpdate = true, children = []) { + const entityDC = this._entityDC; + const teamDC = this.datacollection; + const teamObj = teamDC.datasource; + const teamObjID = teamObj.id; + + // Add the entity value + if (entityDC) { + const connection = + isServerSideUpdate && + entityDC.datasource.connectFields( + (f) => f.settings.linkObject === teamObjID + )[0]; + if (connection) { + const entity = entityDC.getCursor(); + const cName = this.AB.definitionByID( + connection.settings.linkColumn + ).columnName; + values[cName] = entity; + } + } + let _rawData = values; + if (isServerSideUpdate) { + this.busy(); + this._setUpdatedBy(teamObj, values); + try { + _rawData = await teamDC.model.create(values); + } catch (err) { + // TODO (Guy): The update error. + console.error(err); + } + this.ready(); + } + if ( + this.__filters?.inactive == 0 && + (_rawData == null || + _rawData[this.getSettingField("teamInactive").columnName]) + ) + return; + const parent = document.querySelector( + `#${this.teamNodeID( + _rawData[ + this.AB.definitionByID( + this.getSettingField("teamLink").settings.linkColumn + ).columnName + ] + )}` + ); + if (parent == null) return; + const hasChild = parent.parentNode.colSpan > 1; + const teamID = _rawData.id; + const newChild = { + name: _rawData[this.getSettingField("teamName").columnName], + filteredOut: false, + isInactive: _rawData[this.getSettingField("teamInactive").columnName], + id: this.teamNodeID(teamID), + relationship: hasChild ? "110" : "100", + className: `strategy-${ + _rawData[ + `${this.getSettingField("teamStrategy").columnName}__relation` + ]?.[this.getSettingField("strategyCode").columnName] + }`, + _rawData, + }; + newChild.filteredOut = this.filterTeam(newChild); + + // Need to add differently if the node already has child nodes + if (hasChild) + this.__orgchart.addSiblings( + // Sibling + this.closest(parent, (el) => el.nodeName === "TABLE") + .querySelector(".nodes") + .querySelector(".node"), + { siblings: [newChild] } + ); + else this.__orgchart.addChildren(parent, { children: [newChild] }); + await this.refresh(isServerSideUpdate); + + // TODO(Guy): Render assignment for specific node later. + // const contentDC = this._contentDC; + // const contentFieldLinkColumnName = teamObj.fieldByID( + // this.settings.contentField + // ).fieldLink.columnName; + // await this._callAfterRender(async () => { + // this._fnPageContentCallback( + // contentDC.getData((e) => e[contentFieldLinkColumnName] == teamID), + // true, + // contentDC, + // () => {} + // ); + // await Promise.all( + // children.map((child) => + // this.teamAddChild(child._rawData, false, child.children) + // ) + // ); + // }); + } + + teamCanInactivate(values) { + const isInactive = this.getSettingField("teamInactive").columnName; + if (values[isInactive]) return true; // Allow activating inactive teams + const canInactive = this.getSettingField("teamCanInactivate").columnName; + if (!values[canInactive]) return false; + const children = this.getSettingField("teamLink").columnName; + if ( + values[children].some( + (c) => + this.datacollection.getData((r) => r.id == c)[0]?.[isInactive] == + false + ) + ) + return false; + if ( + document + .getElementById(this.teamNodeID(values.id)) + .querySelectorAll(".team-group-record").length > 0 + ) + return false; + return true; + } + + teamCanDelete(values) { + return ( + values[this.getSettingField("teamCanInactivate").columnName] && + // TODO (Guy): Should not save "many" value in the future. Let's fix later. + values[this.getSettingField("teamLink").columnName].length === 0 && + values[this.getSettingField("contentField").columnName].length === 0 + ); + } + + teamDelete(values, isServerSideUpdate = true) { + if (!this.teamCanDelete(values)) { + this.AB.Webix.message({ + text: "This team cannot be deleted", + type: "error", + expire: 1001, + }); + return; + } + const nodeID = this.teamNodeID(values.id); + const $teamNode = document.querySelector(`#${nodeID}`); + if ($teamNode.querySelectorAll(".team-group-record").length > 0) + this.AB.Webix.alert({ + text: this.label( + "Since there are assignments or teams associated with this team, this action cannot be done until all of its assignments are made inactive" + ), + }); + else + this.AB.Webix.confirm({ + text: this.label( + "This will permanently remove this team. Click OK to continue or Cancel to not remove the team." + ), + }).then(() => { + isServerSideUpdate && this.datacollection.model.delete(values.id); + this.__orgchart.removeNodes(document.querySelector(`#${nodeID}`)); + }); + } + + async teamEdit(values, isServerSideUpdate = true) { + let _rawData = values; + const teamDC = this.datacollection; + const teamObj = teamDC.datasource; + if (isServerSideUpdate) { + this.busy(); + this._setUpdatedBy(teamObj, values); + try { + _rawData = await teamDC.model.update(values.id, values); + } catch (err) { + // TODO (Guy): the update error + console.error(err); + } + this.ready(); + } + const $node = document.querySelector(`#${this.teamNodeID(_rawData.id)}`); + + // Remove inactive node from display, unless the filter setting to show + // inctive nodes is on. + if ( + this.__filters?.inactive == 0 && + _rawData[this.getSettingField("teamInactive").columnName] + ) { + this.__orgchart.removeNodes($node); + return; + } + const oldChartData = JSON.parse($node.dataset.source); + const linkFieldColumnName = teamObj.fieldByID( + this.getSettingField("teamLink").settings.linkColumn + ).columnName; + if ( + oldChartData._rawData[linkFieldColumnName] != + _rawData[linkFieldColumnName] + ) { + // TODO (Guy): Fix refresh the only updated node and those children and assignments later. + await this.refresh(isServerSideUpdate); + // this.__orgchart.removeNodes($node); + // this.teamAddChild( + // _rawData, + // false, + // this.getChartData(_rawData.id)?.children + // ); + return; + } + const currentStrategy = $node.classList?.value?.match(/strategy-\S+/)[0]; + const strategyCode = + _rawData[ + `${this.getSettingField("teamStrategy").columnName}__relation` + ]?.[this.getSettingField("strategyCode").columnName]; + const newStrategy = + (strategyCode && `strategy-${strategyCode}`) || currentStrategy; + if (currentStrategy !== newStrategy) { + $node.classList?.remove(currentStrategy); + $node.classList?.add(newStrategy); + } + const teamName = _rawData[this.getSettingField("teamName").columnName]; + $node.querySelector(".title").innerHTML = teamName; + const newChartData = { + className: newStrategy, + filteredOut: false, + id: this.teamNodeID(_rawData.id), + isInactive: _rawData[this.getSettingField("teamInactive").columnName], + name: teamName, + relationship: oldChartData.relationship, + _rawData, + }; + newChartData.filteredOut = this.filterTeam(newChartData); + $node.dataset.source = JSON.stringify(newChartData); + isServerSideUpdate && (await this.refresh(isServerSideUpdate)); + } + + async teamForm(mode, values) { + const teamObj = this.datacollection.datasource; + const ids = this.ids; + const settings = this.settings; + const nameField = teamObj.fieldByID(settings.teamName); + const entityDC = this._entityDC; + const entityObjID = entityDC.datasource.id; + const entityDCCursorID = entityDC.getCursor().id; + const strategyField = teamObj.fieldByID(settings.teamStrategy); + const linkField = teamObj.fieldByID( + this.getSettingField("teamLink").settings.linkColumn + ); + const linkFieldColumnName = linkField.columnName; + const isEditMode = mode === "Edit"; + const teamCond = { + glue: "and", + rules: [ + { + key: teamObj.connectFields( + (f) => f.settings.linkObject === entityObjID + )[0].columnName, + value: entityDCCursorID, + rule: "equals", + }, + ], + }; + const self = this; + const labelWidth = 110; + const $teamFormPopup = webix.ui({ + view: "popup", + id: ids.teamFormPopup, + close: true, + position: "center", + width: 400, + css: { "border-radius": "10px" }, + body: { + rows: [ + { + view: "toolbar", + css: "webix_dark", + cols: [ + { width: 5 }, + { + view: "label", + label: `${this.label(mode)} Team`, + align: "left", + }, + { + view: "icon", + icon: "fa fa-times", + align: "right", + width: 60, + click: () => { + $teamFormPopup.blockEvent(); + $teamFormPopup.$view.remove(); + $teamFormPopup.destructor(); + }, + }, + ], + }, + { + view: "form", + id: ids.teamForm, + hidden: true, + borderless: true, + elements: [ + { + view: "text", + label: nameField.label, + labelWidth, + name: nameField.columnName, + required: true, + }, + { + view: "richselect", + label: this.label("Strategy"), + labelWidth, + id: this.ids.teamFormCode, + // no name because we don't actually save this, it's + // used to filter strategyField options + options: [], + required: true, + on: { + async onViewShow() { + webix.extend(this, webix.ProgressBar); + this.showProgress({ type: "icon" }); + try { + this.disable(); + this.define( + "options", + await self.strategyCodeOptions() + ); + this.refresh(); + this.enable(); + this.hideProgress(); + } catch { + // Close popup before response or possily response fail + } + }, + async onChange(code, previous) { + if (code === previous) return; + const opts = await self.strategyOptions(code); + const $strategyField = $$( + self.ids.teamFormStrategy + ); + $strategyField.define?.("options", opts); + $strategyField.refresh(); + $strategyField.enable(); + }, + }, + }, + { + view: "richselect", + label: this.label("Sub Strategy"), + labelWidth, + name: strategyField.columnName, + id: this.ids.teamFormStrategy, + options: [], + required: true, + on: { + async onViewShow() { + webix.extend(this, webix.ProgressBar); + this.showProgress({ type: "icon" }); + try { + this.disable(); + const value = this.getValue(); + if (value) { + const { code } = ( + await self.strategyOptions() + ).find((o) => o.id === value); + const options = self.strategyOptions(code); + $$(self.ids.teamFormCode).setValue(code); + this.define("options", options); + this.refresh(); + this.enable(); + } + this.hideProgress(); + } catch { + // Close popup before response or possily response fail + } + }, + }, + }, + { + view: "combo", + label: linkField.label, + labelWidth, + name: linkFieldColumnName, + options: [], + required: true, + on: { + async onViewShow() { + webix.extend(this, webix.ProgressBar); + this.showProgress({ type: "icon" }); + try { + this.disable(); + this.define( + "options", + (await linkField.getOptions(teamCond)).map( + (e) => ({ + id: e.id, + value: teamObj.displayData(e), + }) + ) + ); + this.refresh(); + isEditMode && this.enable(); + this.hideProgress(); + } catch { + // Close popup before response or possily response fail + } + }, + }, + }, + { + view: "switch", + disabled: !this.teamCanInactivate(values), + name: this.getSettingField("teamInactive").columnName, + label: "Inactive", + }, + { view: "text", name: "id", hidden: true }, + { + id: ids.teamFormSubmit, + view: "button", + value: this.label("Save"), + disabled: true, + css: "webix_primary", + click: async () => { + let newValues = $$(ids.teamForm).getValues(); + if (newValues.id) { + const $node = document.getElementById( + this.teamNodeID(newValues.id) + ); + const oldValues = JSON.parse( + $node.dataset.source + )._rawData; + newValues = this._parseFormValueByType( + teamObj, + oldValues, + newValues + ); + if ( + !this._checkDataIsChanged(oldValues, newValues) + ) + return; + this.teamEdit(newValues); + } else { + this.teamAddChild( + this._parseFormValueByType( + teamObj, + null, + newValues + ) + ); + } + $teamFormPopup.blockEvent(); + $teamFormPopup.$view.remove(); + $teamFormPopup.destructor(); + }, + }, + ], + on: { + onChange: () => { + const values = $$(ids.teamForm).getValues(); + let valid = + !!values[strategyField.columnName] && + !!values[nameField.columnName]; + if (isEditMode) + valid = valid && !!values[linkFieldColumnName]; + const $teamFormSubmit = $$(ids.teamFormSubmit); + if (valid) $teamFormSubmit.enable(); + else $teamFormSubmit.disable(); + }, + }, + }, + ], + }, + on: { + onShow() { + $$(ids.teamForm).show(); + }, + onHide() { + this.$view.remove(); + this.destructor(); + }, + }, + }); + if (values.__parentID) { + values[linkFieldColumnName] = values.__parentID; + delete values.__parentID; + } + $$(ids.teamForm).setValues(values); + $teamFormPopup.show(); + } + + // HELPERS + + /** + * generate a id for the assignment dom node based on it's record id + * @param {string} id record id + */ + contentNodeID(id) { + return `contentnode_${id}`; + } + + /** + * Get valid drop down option for strategyCode + * @returns {array} + */ + async strategyCodeOptions() { + // These shouldn't change often so cache them to prevent extra requests + // to NetSuite + if (!this._strategyCodeOpts) { + const strategyID = + this.getSettingField("teamStrategy").settings.linkObject; + const strategyObj = this.AB.objectByID(strategyID); + const strategyCodeFieldID = this.getSettingField("strategyCode").id; + const strategyCodeField = strategyObj.fields( + (f) => f.id === strategyCodeFieldID + )[0]; + + const opts = await strategyCodeField.getOptions(); + this._strategyCodeOpts = opts.map(fieldToOption).sort(); + } + return this._strategyCodeOpts; + } + + /** + * Get valid drop down option for teamStrategy based on strategyCode + * @param {string} code the id of a strategyCod + * @returns {array} + */ + async strategyOptions(code) { + // These shouldn't change often so cache instead of querying Netsuite each + // time + if (!this._strategyOpts) { + const teamObj = this.datacollection.datasource; + const strategyField = teamObj.fieldByID(this.settings.teamStrategy); + const subStrategyCol = this.getSettingField("subStrategy").columnName; + const strategyCodeCol = + this.getSettingField("strategyCode").columnName; + const opts = await strategyField.getOptions(null, null, null, null, [ + subStrategyCol, + ]); + this._strategyOpts = opts + .map((e) => ({ + id: e.id, + value: e[`${subStrategyCol}__relation`].name, + code: e[strategyCodeCol], + })) + .sort(); + } + return code + ? this._strategyOpts.filter((o) => o.code === code) + : this._strategyOpts; + } + + /** + * generate a id for the team dom node based on it's record id + * @param {string} id record id + */ + teamNodeID(id) { + return `teamnode_${id}`; + } + + /** + * extract the record id from the team dom node id + * @param {string} id dom node id + */ + teamRecordID(id) { + return id.split("_")[1]; + } + + /** + * Recursively finds the closest ancestor element that matches the provided function. + * @param {Element} el - The starting element. + * @param {Function} fn - The function to test against. + * @return {Element|null} The closest matching ancestor element or null if no match is found. + */ + closest(el, fn) { + return ( + el && + (fn(el) && el !== document.querySelector(`#${this.ids.chartContent}`) + ? el + : this.closest(el.parentNode, fn)) + ); + } + + busy() { + const $chartView = $$(this.ids.chartContent); + $chartView.disable(); + $chartView.showProgress({ type: "icon" }); + } + + ready() { + const $chartView = $$(this.ids.chartContent); + $chartView.enable(); + $chartView.hideProgress(); + } +}; + +/** + * Creates a new HTML element with the given type and classes + * @param {string} type - The type of the HTML element to create. + * @param {string} classes - A space-separated list of classes to add to the element. + * @returns {Element} The newly created HTML element. + */ +function element(type, classes) { + const elem = document.createElement(type); + elem.classList.add(...classes.split(" ")); + return elem; +} + +function fieldToOption(f) { + return { + id: f.id, + value: f.text, + }; +} + +function sort(a, b) { + return (a.lastName ?? a.name).toLowerCase() > + (b.lastName ?? b.name).toLowerCase() + ? 1 + : -1; +} diff --git a/styles/orgchart-webcomponents.css b/styles/orgchart-webcomponents.css index ad263773..d8cd1b19 100644 --- a/styles/orgchart-webcomponents.css +++ b/styles/orgchart-webcomponents.css @@ -32,7 +32,7 @@ org-chart { -moz-user-select: none; -ms-user-select: none; user-select: none; - background-image: linear-gradient(90deg, rgba(200, 0, 0, 0.15) 10%, rgba(0, 0, 0, 0) 10%), linear-gradient(rgba(200, 0, 0, 0.15) 10%, rgba(0, 0, 0, 0) 10%); + /* background-image: linear-gradient(90deg, rgba(200, 0, 0, 0.15) 10%, rgba(0, 0, 0, 0) 10%), linear-gradient(rgba(200, 0, 0, 0.15) 10%, rgba(0, 0, 0, 0) 10%); */ background-size: 10px 10px; border: 1px dashed transparent; padding: 20px; @@ -163,29 +163,29 @@ org-chart td { padding: 0; } -org-chart tr.lines .topLine { - border-top: 2px solid rgba(217, 83, 79, 0.8); -} - -org-chart tr.lines .rightLine { - border-right: 1px solid rgba(217, 83, 79, 0.8); - float: none; - border-radius: 0; -} - -org-chart tr.lines .leftLine { - border-left: 1px solid rgba(217, 83, 79, 0.8); - float: none; - border-radius: 0; -} - -org-chart tr.lines .downLine { - background-color: rgba(217, 83, 79, 0.8); - margin: 0 auto; - height: 20px; - width: 2px; - float: none; -} +/* org-chart tr.lines .topLine { */ +/* border-top: 2px solid rgba(217, 83, 79, 0.8); */ +/* } */ +/**/ +/* org-chart tr.lines .rightLine { */ +/* border-right: 1px solid rgba(217, 83, 79, 0.8); */ +/* float: none; */ +/* border-radius: 0; */ +/* } */ +/**/ +/* org-chart tr.lines .leftLine { */ +/* border-left: 1px solid rgba(217, 83, 79, 0.8); */ +/* float: none; */ +/* border-radius: 0; */ +/* } */ +/**/ +/* org-chart tr.lines .downLine { */ +/* background-color: rgba(217, 83, 79, 0.8); */ +/* margin: 0 auto; */ +/* height: 20px; */ +/* width: 2px; */ +/* float: none; */ +/* } */ /* node styling */ org-chart .node { @@ -242,19 +242,19 @@ org-chart .node.allowedDrop { border-color: rgba(68, 157, 68, 0.9); } -org-chart .node .title { - text-align: center; - font-size: 12px; - font-weight: bold; - height: 20px; - line-height: 20px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - background-color: rgba(217, 83, 79, 0.8); - color: #fff; - border-radius: 4px 4px 0 0; -} +/* org-chart .node .title { */ +/* text-align: center; */ +/* font-size: 12px; */ +/* font-weight: bold; */ +/* height: 20px; */ +/* line-height: 20px; */ +/* overflow: hidden; */ +/* text-overflow: ellipsis; */ +/* white-space: nowrap; */ +/* background-color: rgba(217, 83, 79, 0.8); */ +/* color: #fff; */ +/* border-radius: 4px 4px 0 0; */ +/* } */ org-chart.b2t .node .title { -ms-transform: rotate(-180deg); @@ -297,20 +297,20 @@ org-chart .node .title .symbol { margin-left: 2px; } -org-chart .node .content { - width: 100%; - height: 20px; - font-size: 11px; - line-height: 18px; - border: 1px solid rgba(217, 83, 79, 0.8); - border-radius: 0 0 4px 4px; - text-align: center; - background-color: #fff; - color: #333; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} +/* org-chart .node .content { */ +/* width: 100%; */ +/* height: 20px; */ +/* font-size: 11px; */ +/* line-height: 18px; */ +/* border: 1px solid rgba(217, 83, 79, 0.8); */ +/* border-radius: 0 0 4px 4px; */ +/* text-align: center; */ +/* background-color: #fff; */ +/* color: #333; */ +/* overflow: hidden; */ +/* text-overflow: ellipsis; */ +/* white-space: nowrap; */ +/* } */ org-chart.b2t .node .content { -ms-transform: rotate(180deg); @@ -515,3 +515,11 @@ org-chart .slide-left { org-chart.l2r .node.slide-left, org-chart.r2l .node.slide-left { left: -40px; } + +.orgchart-teams-edit-content-confirm-popup { + min-width: 450px; +} + +.orgchart-teams-edit-content-confirm-popup .webix_popup_button { + padding: 0px 10px; +} diff --git a/styles/team-widget.css b/styles/team-widget.css new file mode 100644 index 00000000..bd4caf75 --- /dev/null +++ b/styles/team-widget.css @@ -0,0 +1,410 @@ +@import url("https://fonts.googleapis.com/css2?family=Jomhuria&display=swap"); + +org-chart .node { + position: relative; + border: 2px dashed transparent; + text-align: center; + justify-content: center; + width: 450px !important; + font-family: "Jomhuria", sans-serif; + font-style: normal; + font-weight: 200; + font-size: 16px; + color: #000000; +} + +org-chart .node .spacer { + border: none; + width: 100%; + height: 15px; +} + +org-chart .node .title { + position: relative; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + background: #ef3340; + border-radius: 15px; + width: 100%; + font-weight: 400; + font-size: 32px; + color: #ffffff; + z-index: 1; +} + +org-chart .node .content { + position: relative; + top: -15px; + width: 100%; + min-height: 325px; + border-radius: 0 0 15px 15px; + display: flex; + flex-direction: column; + margin: 0 auto; + background: #dddddd; + box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.25); +} + +org-chart tr.lines .topLine { + border-top: 2px solid black; +} + +org-chart tr.lines .rightLine { + border-right: 1px solid black; + float: none; + border-radius: 0; +} + +org-chart tr.lines .leftLine { + border-left: 1px solid black; + float: none; + border-radius: 0; +} + +org-chart tr.lines .downLine { + background-color: black; + margin: 0 auto; + height: 20px; + width: 2px; + float: none; +} + +.team-group-section { + width: 100%; + padding: 8px; +} + +.team-group-title { + width: 100%; +} + +.team-group-content { + width: 100%; + display: flex; + flex-direction: column; + gap: 3px; +} + +.team-group-record { + padding: 5px; + width: 100%; + border-width: 4px; + border-style: solid; + border-radius: 12px; + border-color: #ef3340; + background: #ffffff; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 2%; +} + +.team-group-record:hover { + filter: brightness(0.75); +} + +.team-group-record-display { + padding: 5px; + height: 20px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; +} + +.team-group-record-display img { + border-radius: 50%; + width: 30px; + height: 30px; +} + +.team-group-record-display svg { + margin: 0px 3px; + width: 12px; + height: 12px; + padding: 2px 8px; + background-color: skyblue; + border-radius: 5px; + display: inline-block; +} + +/* TODO (Guy): Now we are hardcoding for each display. */ +.team-group-record-display-hardcode { + display: flex; + flex-direction: row; +} +.display-block { + display: inline-block; +} + +.display-block.display-block-right { + height: 30px; + width: 80px; + padding: 5px 0px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + background-color: #036; + border-radius: 10px; + color: #fff; +} + +.team-button-section { + display: flex; + flex-direction: row; + padding: 3px; + gap: 6px; + justify-content: flex-end; + margin-right: 5px; +} + +.team-button { + display: flex; + align-items: center; + justify-content: center; + gap: 2px; + width: 28px; + height: 10px; + padding: 2px 4px; + background: #1a3e72; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 8px; + color: white !important; +} + +.team-chart-toolbar { + display: flex; + flex-direction: row; + align-items: flex-start; + padding: 17px 24px; + gap: 10px; + + width: 254px; + background: #ffffff; + box-shadow: 0px -1px 4px rgba(0, 0, 0, 0.25); + border-radius: 20px; +} + +.filter-button { + border-radius: 10px; + background: #fff; + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25); + color: #2f27ce; + font-family: Roboto; + font-size: 20px; + font-style: normal; + font-weight: 400; + line-height: normal; + padding: 7px 15px; + position: absolute; +} + +.filter-button:hover { + background: #2f27ce; + color: #fff; +} + +.filter-popup { + border-radius: 15px; + background: #fff; + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25); +} + +.active-text { + color: #ffffff; + font-family: "Jomhuria", sans-serif; + height: 6.5px; + font-size: 8px; + font-weight: 400; + text-align: center; +} + +.is-active { + background: #4bc90f; + cursor: default; +} + +.is-inactive { + background: grey; + cursor: default; +} + +.team-form-button { + padding: 3px 10px; + text-align: center; + font-weight: 400; + font-size: 17px; + font-family: "Roboto" sans-serif; + color: white; + background: #1a3e72; + border-radius: 10px; +} + +.team-form-button:hover { + background: #0e2341; +} + +.team-form-button:disabled { + background: #AAA; +} + +.filter-apply > div > button > span { + color: #2f27ce !important; +} + +@keyframes skeleton { + 0% { background-color: #ddd; } + 50% { background-color: #eee; } + 100% { background-color: #ddd} +} + +/* -- DATA PANEL -- */ +.data-panel-button { + display: flex; + border-radius: 10px; + background: #fff; + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25); + float: right; + padding: 5px; + width: 230px; + margin-right: 25px; +} + +.data-panel-button .fa-users { + display: block; + color: #2F27CE; +} + +.data-panel-close { + background: #2F27CE; + color: #FFF; + border-radius: 5px; + padding: 4px 5px; + float: right; +} + +.data-panel-close:hover { + background: #FFF; + color: #2F27CE; + border: 1px solid #2F27CE; +} + +.data-panel-employee { + margin-top: 3px; + text-align: center; + border-radius: 8px; + border: 2px solid #868686; + height: 22px; + font-family: Jomhuria; + font-size: 18px; + line-height: 25px; +} + +.data-panel-employee-list .webix_list_item { + border-bottom: unset; + background: transparent; + overflow: auto; + max-height: 95%; +} + +.data-panel-open { + background: #FFF; + color: #2F27CE; + font-family: "Roboto"; + font-size: 17px; + font-weight: 900; + border-radius: 10px; + float: right !important; + width: 90%; + padding: 5px; + margin-left: 10px; +} + +.data-panel-open:hover { + background: #2F27CE; + color: #FFF !important; +} + +.data-panel-popup { + border-radius: 10px; + background: #fff; + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25); + padding: 10px; +} + +.data-panel-tabbar { + border: solid black; + border-width: 1px 0px 1px 0px !important; + padding: 5px; +} + +.data-panel-tabbar .webix_all_tabs { + float: left; + display: flex; + gap: 10px; + height: 25px; +} + +.data-panel-tabbar .webix_all_tabs .webix_item_tab { + color: #000; + border-radius: 7px; + background: #FFF; + font-family: "Jomhuria", sans-serif; + font-size:20px; + line-height: normal; + box-shadow: none; + border: none; + display: flex; + width: 54px !important; + height: 24px; + justify-content: center; + align-items: center; +} + +.data-panel-tabbar .webix_all_tabs .webix_selected { + color: #FFF; + background: #000 !important; +} + +.data-panel-tabbar .webix_all_tabs .webix_item_tab:hover { + color: #000; + background: rgba(0, 0, 0, 0.20) !important; + box-shadow: none; +} + +.data-panel-tabbar .webix_all_tabs .webix_item_tab:focus { + color: #FFF; + background: #222 !important; + box-shadow: none; +} + +.data-panel-tabview .webix_multiview { + margin-top: 0px !important; +} + +.data-panel-tabview .webix_multiview .webix_list { + height: 530px !important; +} + +/* following a comment here: https://docs.webix.com/api__refs__ui.popup.html + * so that the popup doesn't close. Not sure if we want to do it this way */ +.webix_modal { + width: 0; + height: 0; +} + +org-chart .team-group-record > .team-group-record-edit-icon { + cursor: pointer; + display: none; + padding: 4px; +} + +org-chart .team-group-record:hover > .team-group-record-edit-icon { + display: block; +} diff --git a/test/AppBuilder/platform/views/viewComponent/ABViewOrgChartTeams.test.js b/test/AppBuilder/platform/views/viewComponent/ABViewOrgChartTeams.test.js new file mode 100644 index 00000000..3bf1ba91 --- /dev/null +++ b/test/AppBuilder/platform/views/viewComponent/ABViewOrgChartTeams.test.js @@ -0,0 +1,294 @@ +import { assert } from "chai"; +import sinon from "sinon"; +import ABFactory from "../../../../../AppBuilder/ABFactory"; +import ABViewOrgChartTeams from "../../../../../AppBuilder/platform/views/ABViewOrgChartTeams"; +import ABViewOrgChartTeamsComponent from "../../../../../AppBuilder/platform/views/viewComponent/ABViewOrgChartTeamsComponent"; + +describe("ABViewDetailCheckboxComponent item widget", function () { + let sandbox; + let selectorStub; + let modelCreate; + let teamChart; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + const AB = new ABFactory(); + const application = AB.applicationNew({}); + sinon.stub(AB, "definitionByID").returns({}); + const settings = { strategyColors: { ops: "#111111", slm: "#222222" } }; + const view = new ABViewOrgChartTeams({ settings }, application); + teamChart = new ABViewOrgChartTeamsComponent(view); + modelCreate = sinon.fake.resolves({ id: "new" }); + teamChart.datacollection = { model: {} }; + teamChart.datacollection.model.create = modelCreate; + sinon.replace(teamChart, "getSettingField", (n) => ({ + columnName: n, + settings: {}, + })); + teamChart.__orgchart = { + addSiblings: sinon.fake(), + addChildren: sinon.fake(), + removeNodes: sinon.fake(), + }; + selectorStub = sinon.stub(document, "querySelector"); + }); + + afterEach(function () { + selectorStub.restore(); + sandbox.restore(); + }); + + it(".generateStrategyCss adds css rules", function () { + teamChart.generateStrategyCss(); + const css = document.getElementsByTagName("style")[0].innerHTML; + assert.include( + css, + "org-chart .strategy-external .title{background:#989898 !important;}" + ); + assert.include( + css, + "org-chart .strategy-ops .title{background:#111111 !important;}" + ); + assert.include( + css, + "org-chart .strategy-slm .title{background:#222222 !important;}" + ); + }); + + it(".pullData prepares data for org-chart", async function () { + const dc = {}; + sinon.stub(teamChart.view, "datacollection").get(() => dc); + assert.equal(teamChart.view.datacollection, dc); + dc.waitForDataCollectionToInitialize = sinon.fake.resolves(); + dc.getCursor = sinon.fake.returns({ + id: "1", + teamName: "One", + teamLink: ["2", "3", "7"], + teamStrategy__relation: { strategyCode: "test" }, + }); + const data = (id, teamName, teamLink = []) => [ + { + id, + teamName, + teamLink, + __rawData: { teamLink }, + teamInactive: false, + }, + ]; + dc.getData = sinon + .stub() + .onCall(0) + .returns(data("2", "Two")) + .onCall(1) + .returns(data("3", "Three", ["4", "6"])) + .onCall(2) + .returns(data("4", "Four", ["5"])) + .onCall(3) + .returns(data("5", "Five")) + .onCall(4) + .returns(data("6", "Six")) + .onCall(5) + .returns(data("7", "Seven")); + await teamChart.pullData(); + const expected = (i, n) => ({ id: `teamnode_${i}`, name: n }); + assert(dc.getData.callCount, 6); + // Check expected data strucutre, note: the calls will process children + // before siblings. Also siblings get sorted alphabetically by name so we + // expect: 1 ---- 7 + // \-- 3 --- 4 - 5 + // \- 2 \- 11 + assert.include(teamChart.chartData, expected(1, "One")); + assert.include(teamChart.chartData.children[2], expected(2, "Two")); + assert.include(teamChart.chartData.children[1], expected(3, "Three")); + assert.include( + teamChart.chartData.children[1].children[0], + expected(4, "Four") + ); + assert.include( + teamChart.chartData.children[1].children[0].children[0], + expected(5, "Five") + ); + assert.include( + teamChart.chartData.children[1].children[1], + expected(6, "Six") + ); + assert.include(teamChart.chartData.children[0], expected(7, "Seven")); + assert.equal(teamChart.chartData.className, "strategy-test"); + }); + + it(".filterTeam()", function () { + [ + { + filters: {}, + result: undefined, + }, + { + filters: { strategy: "OPS", teamName: "" }, + result: false, + }, + { + filters: { strategy: "SLM", teamName: "" }, + result: true, + }, + { + filters: { strategy: "SLM", teamName: "X" }, + result: false, + }, + { + filters: { teamName: "Y" }, + result: true, + }, + ].forEach((c) => { + assert.equal( + teamChart.filterTeam(c.filters, { name: "Team X" }, "OPS"), + c.result + ); + }); + }); + + describe(".teamAddChild", function () { + const values = { teamName: "Test" }; + + before(function () {}); + + beforeEach(function () { + sinon.stub(teamChart, "closest").returns({ + querySelector: function () { + return this; + }, + }); + }); + + it("adds a team node as child", async function () { + selectorStub.returns({ parentNode: { colSpan: 0 } }); + await teamChart.teamAddChild(values, { text: "1" }); + assert(modelCreate.calledOnce); + assert(teamChart.__orgchart.addChildren.calledOnce); + assert.deepEqual( + teamChart.__orgchart.addChildren.lastArg.children[0], + { + relationship: "100", + name: "Test", + id: "teamnode_new", + className: "strategy-1", + } + ); + }); + + it("adds a team node as sibling", async function () { + selectorStub.returns({ parentNode: { colSpan: 2 } }); + await teamChart.teamAddChild(values, { text: "1" }); + assert(modelCreate.calledOnce); + assert(teamChart.__orgchart.addSiblings.calledOnce); + assert.deepEqual( + teamChart.__orgchart.addSiblings.lastArg.siblings[0], + { + relationship: "110", + name: "Test", + id: "teamnode_new", + className: "strategy-1", + } + ); + }); + }); + + it(".teamCanInactivate", function () { + const tests = [ + { + values: { + teamInactive: false, + teamCanInactivate: true, + teamLink: [], + }, + expected: true, + }, + { + values: { + teamInactive: false, + teamCanInactivate: false, + teamLink: [], + }, + expected: false, + }, + { + values: { + teamInactive: true, + teamCanInactivate: false, + teamLink: [], + }, + expected: true, + }, + { + values: { + teamInactive: false, + teamCanInactivate: true, + teamLink: [1], + }, + expected: false, + }, + ]; + tests.forEach((t, i) => { + const result = teamChart.teamCanInactivate(t.values); + assert.equal(result, t.expected, `case ${i + 1}`); + }); + }); + + it(".teamCanDelete", function () { + const tests = [ + { + values: { + teamCanInactivate: true, + teamLink: [], + }, + expected: true, + }, + { + values: { + teamCanInactivate: false, + teamLink: [], + }, + expected: false, + }, + { + values: { + teamCanInactivate: true, + teamLink: [1], + }, + expected: false, + }, + ]; + tests.forEach((t, i) => { + const result = teamChart.teamCanDelete(t.values); + assert.equal(result, t.expected, `case ${i + 1}`); + }); + }); + + it(".teamDelete - calls model.delete & updates ui", async function () { + const canDeleteFake = sinon.fake.returns(true); + sinon.replace(teamChart, "teamCanDelete", canDeleteFake); + const values = { id: "delete" }; + teamChart.AB.Webix.confirm = sinon.fake.resolves(); + teamChart.datacollection.model.delete = sinon.fake.resolves(); + await teamChart.teamDelete(values); + assert(canDeleteFake.calledOnceWith(values)); + assert(teamChart.datacollection.model.delete.calledOnceWith(values.id)); + assert(teamChart.__orgchart.removeNodes.calledOnce); + }); + + it(".teamEdit - calls model.update", function () { + teamChart.datacollection.model.update = sinon.fake.resolves(); + selectorStub.returnsThis(); + + const values = { id: "update", teamName: "update" }; + teamChart.teamEdit(values, {}); + assert( + teamChart.datacollection.model.update.calledOnceWith("update", values) + ); + }); + + it(".teamNodeID/teamRecordID can insert & extract ID", function () { + const id = "c43f40d9-6d6a-40d8-adaf-7c61a54b439e"; + const result = teamChart.teamRecordID(teamChart.teamNodeID(id)); + assert.equal(id, result); + }); +});