diff --git a/custom-recipes/pi-system-af-tree/recipe.py b/custom-recipes/pi-system-af-tree/recipe.py index 101ec8c6..570d65fe 100644 --- a/custom-recipes/pi-system-af-tree/recipe.py +++ b/custom-recipes/pi-system-af-tree/recipe.py @@ -62,6 +62,8 @@ def next_tree_item(tree_data): {'name': 'record_boundary_type', 'type': 'string'}, {'name': 'summary_duration', 'type': 'string'}, {'name': 'max_count', 'type': 'int'}, + {'name': 'interval', 'type': 'string'}, + {'name': 'sync_time', 'type': 'string'}, ] output_dataset.write_schema(schema) diff --git a/js/pi-system_treecontroller.js b/js/pi-system_treecontroller.js index 6ae49016..c69692ac 100644 --- a/js/pi-system_treecontroller.js +++ b/js/pi-system_treecontroller.js @@ -15,10 +15,25 @@ const aggregateDataTypeFields = Object.freeze({ ] }, aggregates: { + interval: { + label: 'Interval', + type: 'text', + defaultValue: '', + isVisible: function(attribute) { + return attribute.data_type === 'InterpolatedData'; + }, + }, + sync_time: { + label: 'Sync time', + type: 'text', + defaultValue: '', + isVisible: function(attribute) { + return attribute.data_type === 'InterpolatedData'; + }, + }, summary_type: { label: 'Summary type', type: 'multiselect', - dependsOn: ['data_type'], defaultValue: [], isVisible: function(attribute) { return attribute.data_type === 'SummaryData'; @@ -41,7 +56,6 @@ const aggregateDataTypeFields = Object.freeze({ boundary_type: { label: 'Boundary type', type: 'select', - dependsOn: ['data_type'], defaultValue: 'Inside', isVisible: function(attribute) { return attribute.data_type === 'InterpolatedData'; @@ -54,7 +68,6 @@ const aggregateDataTypeFields = Object.freeze({ record_boundary_type: { label: 'Boundary type', type: 'select', - dependsOn: ['data_type'], defaultValue: 'Inside', isVisible: function(attribute) { return attribute.data_type === 'RecordedData'; @@ -68,21 +81,11 @@ const aggregateDataTypeFields = Object.freeze({ summary_duration: { label: 'Summary duration', type: 'text', - dependsOn: ['data_type'], defaultValue: '', isVisible: function(attribute) { return attribute.data_type === 'SummaryData'; }, }, - max_count: { - label: 'Max count', - type: 'number', - dependsOn: ['data_type'], - defaultValue: 10000, - isVisible: function(attribute) { - return ['PlotData', 'InterpolatedData', 'RecordedData'].includes(attribute.data_type); - }, - }, } }); @@ -120,9 +123,8 @@ app.controller('AfExplorerFormCtrl', [ '$scope', '$stateParams', '$q', - 'CodeMirrorSettingService', 'TreeDataService', - function($scope, $stateParams, $q, CodeMirrorSettingService, TreeDataService) { + function($scope, $stateParams, $q, TreeDataService) { $scope.paramDesc = { 'parameterSetId': 'basic-auth', @@ -132,21 +134,30 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.attributeList = $scope.config.attributeList || []; // la liste des attributs qui sont affichés sur le main panel à droite $scope.config.outputSelectedAttributes = $scope.config.outputSelectedAttributes || []; // la liste des attributs qui sont séléctionnés pour être dans l'output dataset $scope.config.searchMatchedElementPaths = $scope.config.searchMatchedElementPaths || []; // la liste pour highlighter les elements de la recherche - $scope.config.lastSearchedElementName = $scope.config.lastSearchedElementName || ""; - $scope.config.pendingTabContextReset = $scope.config.pendingTabContextReset || false; // indique le changement de tab template/element $scope.config.selectedTemplateNames = $scope.config.selectedTemplateNames || []; // la liste des templates sélectionnés (checkbox cochée) parmi ceux affichés + $scope.config.attributeSearch = $scope.config.attributeSearch || ""; + $scope.config.displayPath = $scope.config.displayPath || false; + $scope.config.elementsByTemplate = $scope.config.elementsByTemplate || {}; + $scope.config.searchInProgress = $scope.config.searchInProgress || false; + + // TODO: get categories from backend for attributes + // $scope.config.attributeCategoryFilter = $scope.config.attributeCategoryFilter || "" $scope.aggregateDataTypeFields = aggregateDataTypeFields; $scope.attributeGroupSections = [ { + type: 'element', key: 'attributesWithoutTemplate', title: 'Elements', - emptyMessage: 'No attributes without template' + emptyMessage: 'No attributes without template matched your selection', + shouldDisplay: () => $scope.config.activeTab === "element" }, { + type: 'template', key: 'attributesGroupedByTemplate', title: 'Templates', - emptyMessage: 'No templated attributes' + emptyMessage: 'No templated attributes matched your selection', + shouldDisplay: () => true } ]; @@ -189,7 +200,6 @@ app.controller('AfExplorerFormCtrl', [ }).error(setErrorInScope.bind($scope.errorScope)); if ($scope.authConfigured() === true) { const hasTreeData = Array.isArray($scope.config.treeData) && $scope.config.treeData.length > 0; - const hasTemplateTreeData = Array.isArray($scope.config.templateTreeData) && $scope.config.templateTreeData.length > 0; $scope.authSectionVisible = !hasTreeData; $scope.showTreeData = hasTreeData; } @@ -263,8 +273,6 @@ app.controller('AfExplorerFormCtrl', [ $scope.config.attributeList = []; $scope.config.outputSelectedAttributes = []; $scope.config.searchMatchedElementPaths = []; - $scope.config.lastSearchedElementName = ""; - $scope.config.pendingTabContextReset = false; $scope.config.selectedTemplateNames = []; } @@ -364,6 +372,9 @@ app.controller('AfExplorerFormCtrl', [ } $scope.getChildrenFromDB = function(item) { + if (item.type === "template") { + return getAttributesForTemplate(item); + } console.log("ALX:gcfd:" + JSON.stringify(item)); return $scope.callPythonDo({ method: "get_children_from_db", parent: item }) .then(function(data) { @@ -388,11 +399,11 @@ app.controller('AfExplorerFormCtrl', [ } function resetRightPanelForCurrentTabContext() { - $scope.config.attribute_name = ""; $scope.config.clickedNodes = []; $scope.config.attributeList = []; $scope.config.searchMatchedElementPaths = []; $scope.config.selectedTemplateNames = []; + $scope.config.attributeSearch = ""; if ($scope.config.activeTab === "element") { $scope.config.template = "-- Any --"; } else if ($scope.config.activeTab === "template") { @@ -400,18 +411,10 @@ app.controller('AfExplorerFormCtrl', [ } } - function consumePendingTabContextReset() { // reset la main view après changement de tab + action sur le new tab - if (!$scope.config.pendingTabContextReset) { - return; - } - resetRightPanelForCurrentTabContext(); - $scope.config.pendingTabContextReset = false; - } - $scope.setTab = function(tab) { const previousTab = $scope.config.activeTab; if (tab !== previousTab) { - $scope.config.pendingTabContextReset = true; + resetRightPanelForCurrentTabContext(); } $scope.config.activeTab = tab; }; @@ -430,48 +433,10 @@ app.controller('AfExplorerFormCtrl', [ return $q.all([attributeCategoriesPromise, elementCategoriesPromise]); } - $scope.doSearch = function(element_name, attribute_name) { - consumePendingTabContextReset(); - - const hasElementFilter = !!(element_name?.trim()); - const hadPreviousElementFilter = !!($scope.config.lastSearchedElementName?.trim()); - - // If user clears element filter after a scoped search, release previous click-based scope. - if (!hasElementFilter && hadPreviousElementFilter) { - $scope.config.clickedNodes = []; - } - - const hasClickedNodes = Array.isArray($scope.config.clickedNodes) && $scope.config.clickedNodes.length > 0; - const hasAttributeFilter = !!(attribute_name?.trim()); - const isRestrictedAttributeSearch = hasClickedNodes && hasAttributeFilter && !hasElementFilter; - const hasTemplateFilter = !!( - $scope.config.template && - $scope.config.template !== "-- Any --" - ); - const isTemplateScopedSearch = - hasTemplateFilter && - ($scope.config.activeTab === "template"); - const shouldDisplaySearchAttributesDirectly = - hasAttributeFilter || isTemplateScopedSearch; - $scope.config.lastSearchedElementName = element_name || ""; - if ($scope.config.activeTab === "template") { - $scope.config.selectedTemplateNames = getSelectedTemplateNamesFromClickedNodes(); - } else { - $scope.config.selectedTemplateNames = []; - } - const hasSelectedTemplateNodes = ( - $scope.config.activeTab === "template" && - Array.isArray($scope.config.selectedTemplateNames) && - $scope.config.selectedTemplateNames.length > 0 - ); - const shouldShowTemplateSelectionAttributes = hasSelectedTemplateNodes; - - if (!isRestrictedAttributeSearch) { - $scope.config.attributeList = []; - } + $scope.doSearch = function(element_name) { + $scope.config.searchInProgress = true; $scope.config.searchMatchedElementPaths = []; - // TODO: understand what this does - $scope.callPythonDo({ method: "do_search", element_name: element_name, attribute_name: attribute_name, root_tree: $scope.config.treeData }).then( + $scope.callPythonDo({ method: "do_search", element_name: element_name, root_tree: $scope.config.treeData }).then( function(data) { TreeDataService.setTreeData(data.choices); $scope.config.treeData = TreeDataService.getTreeData(); @@ -479,31 +444,155 @@ app.controller('AfExplorerFormCtrl', [ const matchedElementPaths = getMatchedElementPaths(matchedAttributes); $scope.config.searchMatchedElementPaths = matchedElementPaths; markSearchResults($scope.config.treeData, matchedElementPaths); - if ( - isRestrictedAttributeSearch || - shouldDisplaySearchAttributesDirectly || - shouldShowTemplateSelectionAttributes - ) { - applySearchAttributesToList(matchedAttributes); - } } ); }; - function applySearchAttributesToList(attributes) { - const seen = new Set(); - const deduped = []; + function clearSearchHighlights(nodes) { + nodes.forEach(node => { + node.searchHighlighted = false; + if (node?.children?.length > 0) { + clearSearchHighlights(node.children); + } + }); + } - attributes.forEach(attribute => { - if (!attribute?.path || seen.has(attribute.path)) { + function getElementNameFromPath(elementPath) { + const splitPath = elementPath.split('\\'); + return splitPath?.[splitPath.length - 1]; + } + + function getElementPathFromAttributePath(attributePath) { + return attributePath.split('|')?.[0]; + } + + function getAttributesForTemplate(node) { + return $scope.callPythonDo({ method: "get_attribute_for_template", template_name: node.title}).then( + function(data) { + node.children = data.attributes; + node.children.forEach(child => { + // TODO: do the same for the normal getChild + const elementPath = getElementPathFromAttributePath(child.path); + child.expanded = false; + child.parent_element = getElementNameFromPath(elementPath); + child.parent_element_path = elementPath; + }); + return node; + } + ); + } + + $scope.isTemplateAssociatedElementSelected = function(element) { + return $scope.config.clickedNodes.includes(element.url); + } + + $scope.getElementsForTemplate = function (templateName) { + return $scope.callPythonDo({ method: "get_elements_for_template", template_name: templateName}).then( + function(data) { + $scope.config.elementsByTemplate[templateName] = data.elements; + } + ); + } + + // Called whenever the element selection change + function refreshSelectedElementsByTemplatesAllTemplates() { + Object.keys($scope.selectedElementsByTemplate).forEach(templateName => { + if (!$scope.config.elementsByTemplate[templateName]) { return; } - seen.add(attribute.path); - const attrCopy = { ...attribute }; - deduped.push(enrichAttribute(attrCopy)); + setSelectedElementsByTemplate(templateName); }); + } + + function setSelectedElementsByTemplate(templateName) { + if ($scope.config.activeTab === 'element') { + // Sets selected elements for the elements dropdown to all visualized elements + $scope.selectedElementsByTemplate[templateName] = $scope.config.elementsByTemplate[templateName].filter(element => { + return $scope.config.clickedNodes.includes(element.url); + } + ).map(element => element.url); + } else if ($scope.config.activeTab === 'template') { + $scope.selectedElementsByTemplate[templateName] = $scope.config.elementsByTemplate[templateName].map(element => element.url); + } + $scope.selectedElementsByTemplateUI[templateName] = $scope.selectedElementsByTemplate[templateName] + } + + $scope.selectedElementsByTemplateUI = {}; + $scope.selectedElementsByTemplate = {}; + $scope.templateModeExcludedAttributes = {}; + + $scope.setupElementsDropdown = function($element, templateName) { + let initialized = false; + const dropdown = $element.next(); + + // if not initialized but elements by template is already populated + // refresh the state, and set to initialized + if (!initialized && $scope.config.elementsByTemplate[templateName]?.length > 0) { + setSelectedElementsByTemplate(templateName); + initialized = true; + } + + // TODO: maybe unhook this + dropdown.on('click', function() { + // console.log("triggered click") + if (initialized) { + return; + } - $scope.config.attributeList = deduped; + $scope.$applyAsync(() => { + $scope.getElementsForTemplate(templateName).then(() => { + setSelectedElementsByTemplate(templateName); + initialized = true; + $scope.$broadcast('selectPickerRefresh'); + } + ) + }); + }); + + $element.on('change', function() { + + if (!initialized) { + return; + } + + $scope.$applyAsync(() => { + // TODO: redo everything by templateID + const options = $scope.config.elementsByTemplate[templateName]; + const previouslySelected = new Set($scope.selectedElementsByTemplate[templateName]); + const currentlySelected = new Set($scope.selectedElementsByTemplateUI[templateName]); + + options.forEach(element => { + const elementKey = String(element.url); + const wasSelected = previouslySelected.has(elementKey); + const isSelected = currentlySelected.has(elementKey); + + if (wasSelected !== isSelected) { + if ($scope.config.activeTab === 'element') { + $scope.toggleNodeVisualization(element); + } else if ($scope.config.activeTab === 'template') { + // TODO: if selection becomes empty because of this do something + // like unselecting the template altogether + if (wasSelected && !isSelected) { + if (!$scope.templateModeExcludedAttributes[templateName]) { + $scope.templateModeExcludedAttributes[templateName] = {} + } + $scope.templateModeExcludedAttributes[templateName][element.path] = $scope.config.attributeList.filter(attribute => { + return attribute.parent_element_path === element.path; + }); + $scope.config.attributeList = $scope.config.attributeList.filter(attribute => { + return attribute.parent_element_path !== element.path; + }); + } else if (!wasSelected && isSelected) { + const attributesToAdd = $scope.templateModeExcludedAttributes[templateName]?.[element.path]; + $scope.config.attributeList.push(...attributesToAdd) + } + } + } + }); + + $scope.selectedElementsByTemplate = angular.copy($scope.selectedElementsByTemplateUI); + }); + }); } function getMatchedElementPaths(attributes) { @@ -519,46 +608,34 @@ app.controller('AfExplorerFormCtrl', [ return Array.from(matchedPathSet); } + $scope.toggleNodeVisualization = function(node) { + console.log("clicked on ", node) - function collectTemplateTitlesByClickedUrls(nodes, clickedUrlSet, outputSet) { - if (!Array.isArray(nodes) || !clickedUrlSet || !outputSet) { - return; + // Keep right-side attribute search when active so multi-node clicks can + // enrich results with the same filter (ex: "Load" on California + Fresno). + // TODO: understand why we need a reset if the attribute search is empty + if (node?.type === "element") { + // TODO: factorize this reset + $scope.config.template = "-- Any --"; } - nodes.forEach(function(node) { - if (!node) { - return; - } - if ( - clickedUrlSet.has(node.url) && - node.type === "template" && - node.title && - node.title !== "-- Any --" - ) { - outputSet.add(node.title); - } - if (Array.isArray(node.children) && node.children.length > 0) { - collectTemplateTitlesByClickedUrls(node.children, clickedUrlSet, outputSet); - } - }); - } - - function getSelectedTemplateNamesFromClickedNodes() { - const clickedUrls = Array.isArray($scope.config.clickedNodes) - ? $scope.config.clickedNodes - : []; - if (!clickedUrls.length) { - return []; + const indexClickedNode = $scope.config.clickedNodes.indexOf(node.url); + const nodeAlreadySelected = indexClickedNode > -1; + // If the node is already clicked, remove it from clicked nodes - else add it + if (nodeAlreadySelected) { + $scope.config.clickedNodes.splice(indexClickedNode, 1); + } else { + $scope.config.clickedNodes.push(node.url); } - const selectedTemplateNames = new Set(); - collectTemplateTitlesByClickedUrls( - $scope.config.templateTreeData, - new Set(clickedUrls), - selectedTemplateNames - ); - return Array.from(selectedTemplateNames); - } + $scope.toggleDisplayAttributes(node, !nodeAlreadySelected); + + // In element node, the visualized nodes are reflected on the elements dropdown + if ($scope.config.activeTab === 'element') { + refreshSelectedElementsByTemplatesAllTemplates(); + } + console.log("clickedNodes: " + JSON.stringify($scope.config.clickedNodes)); + }; function markSearchResults(nodes, matchedElementPaths) { if (!Array.isArray(nodes)) { @@ -579,48 +656,41 @@ app.controller('AfExplorerFormCtrl', [ }); } + // TODO understand why both $scope.onSearchInputKeydown = function($event) { if ($event && ($event.key === "Enter" || $event.keyCode === 13)) { $event.preventDefault(); - const targetId = $event.target?.id || ""; - if (targetId === "ReturnsName") { - $scope.searchFromElement(); - return; - } - $scope.doSearch($scope.config.element_name, $scope.config.attribute_name); + // const targetId = $event.target?.id || ""; + // // TODO: understand + // if (targetId === "ReturnsName") { + // $scope.searchFromElement(); + // return; + // } + $scope.searchFromElement(); } }; $scope.searchFromElement = function() { - if (!$scope.config) { - return; - } - - // Left search always resets right-side filter/template search. - $scope.config.clickedNodes = []; - $scope.config.selectedTemplateNames = []; - $scope.config.attribute_name = ""; - $scope.doSearch($scope.config.element_name, $scope.config.attribute_name); + $scope.doSearch($scope.config.element_name); }; - function setAttributesChecked(attributes, isChecked) { - if (!Array.isArray(attributes)) { - return; - } - attributes.forEach(attribute => { - attribute.checked = !!isChecked; - if (isChecked) { - $scope.addAttributeToSelection(attribute); - } else { - $scope.removeAttributeFromSelection(attribute); - } - }); - } + $scope.clearSearch = function() { + $scope.config.searchInProgress = false; + $scope.config.element_name = ""; + $scope.config.searchMatchedElementPaths = []; + clearSearchHighlights($scope.config.treeData); + }; $scope.toggleSelectAllGroupedAttributes = function(groupedAttributes) { const shouldRemove = groupedAttributes.checked === CheckboxStatus.CHECKED; groupedAttributes.groups.forEach((group) => { + if (group.isDisplayed) { + return; + } group.attributes.forEach((aggregatedAttribute) => { + if (!aggregatedAttribute.isDisplayed) { + return; + } aggregatedAttribute.attributes.forEach((underlyingAttribute) => { if (shouldRemove) { $scope.removeAttributeFromSelection(underlyingAttribute); @@ -644,9 +714,12 @@ app.controller('AfExplorerFormCtrl', [ ) }; - $scope.checkTemplate = function(template) { - const shouldRemove = template.checked === CheckboxStatus.CHECKED; - template.attributes.forEach((aggregatedAttribute) => { + $scope.toggleGroupedAttributes = function(group) { + const shouldRemove = group.checked === CheckboxStatus.CHECKED; + group.attributes.forEach((aggregatedAttribute) => { + if (!aggregatedAttribute.isDisplayed) { + return; + } aggregatedAttribute.attributes.forEach((underlyingAttribute) => { if (shouldRemove) { $scope.removeAttributeFromSelection(underlyingAttribute); @@ -695,31 +768,13 @@ app.controller('AfExplorerFormCtrl', [ stopDisplayingAttributes(node); return; } - // TODO: refacto - if (node.type === "element" && !hasAttributeChildren(node)) { - $scope.config.template = "-- Any --"; + if (!hasAttributeChildren(node)) { $scope.getChildrenFromDB(node).then(newNode => { - processNode(newNode); + addChildrenToAttributeList(newNode); }); - } else if (node.type === "template") { - const selectedTemplateNames = getSelectedTemplateNamesFromClickedNodes(); - if (!selectedTemplateNames.length) { - $scope.config.template = "-- Any --"; - $scope.config.attributeList = []; - $scope.config.searchMatchedElementPaths = []; - return; - } - - // Keep previous single-template behavior in config when only one is selected. - // For multi-select, backend will use selectedTemplateNames. - $scope.config.template = selectedTemplateNames.length === 1 - ? selectedTemplateNames[0] - : "-- Any --"; - $scope.config.element_name = "*"; - $scope.doSearch($scope.config.element_name, $scope.config.attribute_name); - } else { - processNode(node); + return; } + addChildrenToAttributeList(node); } // Merge frontend data and saved output with loaded attributes @@ -727,7 +782,11 @@ app.controller('AfExplorerFormCtrl', [ // TODO: check this makes sense, since selectedOutput is persisted and so newly loaded attributes should not be found in it const selectedAttribute = $scope.config.outputSelectedAttributes.find(attr => attr.path === attribute.path); attribute.checked = !!(selectedAttribute); - attribute.parent_element = parentNode.title; + if (parentNode.type === "element") { + attribute.parent_element = parentNode?.title; + } else if (parentNode.type === "template") { + attribute.template_name = parentNode?.title; + } attribute.data_type = selectedAttribute?.data_type ? selectedAttribute.data_type : $scope.aggregateDataTypeFields.data_type.defaultValue; Object.entries($scope.aggregateDataTypeFields.aggregates).forEach(([aggregateName, aggregate]) => { if ((selectedAttribute?.[aggregateName] === undefined || selectedAttribute?.[aggregateName] === null) && aggregate.isVisible(attribute)) { @@ -741,18 +800,15 @@ app.controller('AfExplorerFormCtrl', [ return attribute; } - function processNode(node) { - const hasAttributeFilter = !!($scope.config.attribute_name?.trim()); - const parentTemplateName = node?.template_name ? node.template_name : null; + // Put node children in the displayed attribute list + function addChildrenToAttributeList(node) { + const parentTemplateName = node?.template_name; node.children.forEach(child => { if (child.type === "attribute") { if (!child.parent_template_name && parentTemplateName) { child.parent_template_name = parentTemplateName; } - if (hasAttributeFilter && !attributeMatchesCurrentSearch(child)) { - return; - } const isAlreadyPresent = $scope.config.attributeList.some(attr => attr.path === child.path); if (!isAlreadyPresent) { $scope.config.attributeList.push(enrichAttribute(child, node)); @@ -814,7 +870,24 @@ app.controller('AfExplorerFormCtrl', [ }); }; - function groupDuplicatedAttributesAcrossGroup(groupKey) { + function attributeMatchesSearch(attribute_name, template_name, attribute_description="") { + if ($scope.config.attributeSearch === "") { + return true; + } + const lowercasedSearch = $scope.config.attributeSearch.toLowerCase(); + const templateNameMatches = template_name.toLowerCase().includes(lowercasedSearch); + const attributeNameMatches = attribute_name.toLowerCase().includes(lowercasedSearch); + let attributeDescriptionMatches = false; + if (attribute_description) { + attributeDescriptionMatches = attribute_description.toLowerCase().includes(lowercasedSearch); + } + return (templateNameMatches || attributeNameMatches || attributeDescriptionMatches) + } + + // Attributes are shared between templates + // Meaning all elements with the same template will share the attributes in this template + // If multiple elements with the same template are selected, we only show the attribute once + function conflateAttributes(groupKey) { return (acc, attr) => { // TODO: switch to id const key = attr[groupKey] + "::" + attr.title; @@ -823,6 +896,7 @@ app.controller('AfExplorerFormCtrl', [ acc[key] = { title: attr.title, description: attr.description, + groupKey: groupKey, group: attr[groupKey], template_names: [], parent_elements: [], @@ -833,6 +907,7 @@ app.controller('AfExplorerFormCtrl', [ paths: [], data_type: attr.data_type, data_types: [], + isDisplayed: attributeMatchesSearch(attr.title, attr[groupKey], attr.description), }; getAggregateNames().forEach(aggregateName => { @@ -871,7 +946,7 @@ app.controller('AfExplorerFormCtrl', [ } } - function groupAttributes() { + function groupAttributesIntoSections() { return (acc, attr) => { const key = attr.group; if (!acc[key]) { @@ -880,21 +955,27 @@ app.controller('AfExplorerFormCtrl', [ allChecked: attr.checked, checked: CheckboxStatus.UNCHECKED, // Used to determine UI checkbox state attributes: [], - checkStates: [] + checkStates: [], + isDisplayed: true, + nbSearchMatches: 0 } } - acc[key].checkStates.push(...attr.checkStates) + if (attr.isDisplayed) { + acc[key].checkStates.push(...attr.checkStates); + acc[key].allChecked = acc[key].allChecked && attr.allChecked; + } acc[key].checked = getCheckboxStatus(acc[key].checkStates); - acc[key].allChecked = acc[key].allChecked && attr.allChecked; acc[key].attributes.push(attr); + acc[key].isDisplayed = acc[key].isDisplayed && !attr.isDisplayed; + acc[key].nbSearchMatches += +attr.isDisplayed; return acc; } } function buildAggregatedAttributes(attributes, groupKey) { - const deduplicatedAttributes = Object.values(attributes.reduce(groupDuplicatedAttributesAcrossGroup(groupKey), {})); - return Object.values(deduplicatedAttributes.reduce(groupAttributes(), {})); + const deduplicatedAttributes = Object.values(attributes.reduce(conflateAttributes(groupKey), {})); + return Object.values(deduplicatedAttributes.reduce(groupAttributesIntoSections(), {})); } function splitAttributesByTemplatePresence(attributes) { @@ -913,9 +994,13 @@ app.controller('AfExplorerFormCtrl', [ function buildGroupedAttributesResult(attributes, groupKey) { const groups = buildAggregatedAttributes(attributes, groupKey); + const displayedGroups = groups.filter(group => !group.isDisplayed); + // TODO: probably turn this into a reduce return { - allChecked: groups.length > 0 && groups.every(group => group.allChecked), + allChecked: displayedGroups.length > 0 && displayedGroups.every(group => group.allChecked), checked: getCheckboxStatus(groups.reduce((acc, group) => acc.concat(group.checkStates), [])), + // a table can be empty because all it's attributes have been filtered out OR there are no elements to show + empty: groups.length === 0 || groups.every(group => group.isDisplayed), groups: groups } } @@ -951,26 +1036,8 @@ app.controller('AfExplorerFormCtrl', [ $scope.groupedAttributes = $scope.buildGroupedAttributes(); }, true); - - function attributeMatchesCurrentSearch(attribute) { - const rawFilter = ($scope.config.attribute_name || "").trim(); - if (!rawFilter) { - return true; - } - - const attributeTitle = (attribute?.title ? attribute.title : "").toLowerCase(); - const filter = rawFilter.toLowerCase(); - - if (filter.includes("*")) { - const regexPattern = "^" + escapeRegex(filter).replace(/\\\*/g, ".*") + "$"; - return new RegExp(regexPattern).test(attributeTitle); - } - - return attributeTitle.includes(filter); - } - - function escapeRegex(input) { - return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + $scope.refreshAttributeSection = function() { + $scope.groupedAttributes = $scope.buildGroupedAttributes(); } $scope.addAttributeToSelection = function(attribute) { @@ -1013,6 +1080,7 @@ app.component('treeNode', { getChildrenFromDb: '<', toggleDisplayAttributes: '<', config: '<', + toggleNodeVisualization: '&', }, controllerAs: 'ctrl', @@ -1020,69 +1088,6 @@ app.component('treeNode', { controller: function() { const ctrl = this; - function consumePendingTabContextReset() { - if (!ctrl.config?.pendingTabContextReset) { - return; - } - - ctrl.config.attribute_name = ""; - ctrl.config.clickedNodes = []; - ctrl.config.attributeList = []; - ctrl.config.searchMatchedElementPaths = []; - - if (ctrl.config.activeTab === "element") { - ctrl.config.template = "-- Any --"; - } else if (ctrl.config.activeTab === "template") { - ctrl.config.element_name = ""; - } - - ctrl.config.pendingTabContextReset = false; - } - - function findNodeByUrl(nodes, targetUrl) { - if (!Array.isArray(nodes) || !targetUrl) { - return null; - } - - for (let i = 0; i < nodes.length; i += 1) { - const node = nodes[i]; - if (!node) { - continue; - } - if (node.url === targetUrl) { - return node; - } - const childMatch = findNodeByUrl(node.children, targetUrl); - if (childMatch) { - return childMatch; - } - } - - return null; - } - - // TODO: understand why the logic is different from toggleDisplayAttributes (merge them if possible) - function rebuildAttributesFromClickedNodes() { - const clickedUrls = Array.isArray(ctrl.config?.clickedNodes) - ? ctrl.config.clickedNodes - : []; - - ctrl.config.attributeList = []; - - if (!clickedUrls.length) { - return; - } - - clickedUrls.forEach(function(url) { - const node = - findNodeByUrl(ctrl.config.treeData, url) || - findNodeByUrl(ctrl.config.templateTreeData, url); - if (node) { - ctrl.toggleDisplayAttributes(node); - } - }); - } - ctrl.hasRenderableChildren = function(node) { if (!node || !Array.isArray(node.children) || !node.children.length) { return false; @@ -1106,52 +1111,6 @@ app.component('treeNode', { node.expanded = !node.expanded; }; - ctrl.onNodeClick = function(node) { - consumePendingTabContextReset(); - - // TODO: factorize this check - const hasActiveAttributeSearch = !!( - ctrl.config?.attribute_name?.trim() - ); - - // Keep right-side attribute search when active so multi-node clicks can - // enrich results with the same filter (ex: "Load" on California + Fresno). - // TODO: understand why we need a reset if the attribute search is empty - if (!hasActiveAttributeSearch) { - ctrl.config.attribute_name = ""; - } - if (node?.type === "element") { - // TODO: factorize this reset - ctrl.config.template = "-- Any --"; - } - - const indexClickedNode = ctrl.config.clickedNodes.indexOf(node.url); - const nodeAlreadySelected = indexClickedNode > -1; - // If the node is already clicked, remove it from clicked nodes - else add it - if (nodeAlreadySelected) { - ctrl.config.clickedNodes.splice(indexClickedNode, 1); - } else { - ctrl.config.clickedNodes.push(node.url); - } - - // TODO: split element/template logic - if (node?.type === "template") { - // Template clicks should always rebuild right-side content from the full template selection. - ctrl.toggleDisplayAttributes(node); - console.log("ctrl.config.clickedNodes: " + JSON.stringify(ctrl.config.clickedNodes)); - return; - } - - // TODO: understand why this is mutually exclusive - if (hasActiveAttributeSearch) { - rebuildAttributesFromClickedNodes(); - } else { - ctrl.toggleDisplayAttributes(node, !nodeAlreadySelected); - } - - console.log("ctrl.config.clickedNodes: " + JSON.stringify(ctrl.config.clickedNodes)); - }; - ctrl.isNodeClicked = function(node) { // the click is entirely based on node.url return ctrl.config.clickedNodes.includes(node.url); @@ -1170,6 +1129,7 @@ app.directive('attributeTableRow', function() { restrict: 'A', scope: { mergedAttribute: '=', + displayPath: '<', aggregateDataTypeFields: '<', onCheckAttribute: '&', onUpdateDataType: '&', diff --git a/python-connectors/pi-system_hierarchy/connector.py b/python-connectors/pi-system_hierarchy/connector.py index fac8d137..326b9beb 100644 --- a/python-connectors/pi-system_hierarchy/connector.py +++ b/python-connectors/pi-system_hierarchy/connector.py @@ -73,10 +73,10 @@ def recurse_next_item(self, next_url, parent=None, type=None): return for item in items: parent_path = item.get("Path") - link_to_attributes = self.client.extract_link_with_key(item, "Attributes") - if link_to_attributes: - for attribute in self.recurse_next_item(link_to_attributes, parent=parent_path, type="Attribute"): - yield attribute + # link_to_attributes = self.client.extract_link_with_key(item, "Attributes") + # if link_to_attributes: + # for attribute in self.recurse_next_item(link_to_attributes, parent=parent_path, type="Attribute"): + # yield attribute link_to_elements = self.client.extract_link_with_key(item, "Elements") if link_to_elements: for element in self.recurse_next_item(link_to_elements, parent=parent_path, type="Element"): @@ -134,7 +134,7 @@ def batch_next_item(self, next_item, parent=None, type=None): for retrieved_item in retrieved_items: retrieved_item_path = retrieved_item.get("Path") elements_url = self.client.extract_link_with_key(retrieved_item, "Elements") - attributes_url = self.client.extract_link_with_key(retrieved_item, "Attributes") + # attributes_url = self.client.extract_link_with_key(retrieved_item, "Attributes") if elements_url: todo_list.append( { @@ -143,14 +143,14 @@ def batch_next_item(self, next_item, parent=None, type=None): "parent": parent_of_batched_item + "\\" + retrieved_item.get("Name") } ) - if attributes_url: - todo_list.append( - { - "url": attributes_url, - "type": "Attribute", - "parent": parent_of_batched_item + "\\" + retrieved_item.get("Name") - } - ) + # if attributes_url: + # todo_list.append( + # { + # "url": attributes_url, + # "type": "Attribute", + # "parent": parent_of_batched_item + "\\" + retrieved_item.get("Name") + # } + # ) yield { "ItemType": type, "Name": retrieved_item.get("Name"), diff --git a/python-lib/osisoft_client.py b/python-lib/osisoft_client.py index 1f06024a..d43438cc 100644 --- a/python-lib/osisoft_client.py +++ b/python-lib/osisoft_client.py @@ -758,14 +758,15 @@ def search_attributes(self, database_webid, **kwargs): else: json_response = None - def search_elements(self, database_webid, name=None, description=None, category=None, template=None, full_search=True): + def search_elements(self, database, name=None, description=None, category=None, template=None, full_search=True): headers = self.get_requests_headers() tempo_maxcount = OSIsoftConstants.DEFAULT_MAXCOUNT params = { "maxCount": tempo_maxcount, "associations": "Paths", } - url = self.endpoint.get_base_url() + "/assetdatabases/{}/elements".format(database_webid) + # url = self.endpoint.get_base_url() + "/assetdatabases/{}/elements".format(database_webid) + url = "{}/elements".format(database) if name: params["nameFilter"] = name if description: diff --git a/python-lib/osisoft_plugin_common.py b/python-lib/osisoft_plugin_common.py index 5a8570ab..251d4afa 100644 --- a/python-lib/osisoft_plugin_common.py +++ b/python-lib/osisoft_plugin_common.py @@ -748,8 +748,9 @@ def recursive_tree_rebuild(dictionary, records, counter=None): children = [] # context["id"] = str(counter) context["title"] = key - context["expanded"] = True + # context["expanded"] = True # context["checked"] = False context["children"] = children + # logger.info("context post" + str(context)) output.append(context) return output diff --git a/resource/attribute-table-row.html b/resource/attribute-table-row.html index c480ff30..9aeae594 100644 --- a/resource/attribute-table-row.html +++ b/resource/attribute-table-row.html @@ -5,7 +5,7 @@ {{ctrl.mergedAttribute.title}} {{ctrl.mergedAttribute.description}} - + {{path}}
diff --git a/resource/browse_af_tree.py b/resource/browse_af_tree.py index bea164a1..f0d368db 100644 --- a/resource/browse_af_tree.py +++ b/resource/browse_af_tree.py @@ -1,3 +1,4 @@ +import copy from osisoft_client import OSIsoftClient from safe_logger import SafeLogger from osisoft_plugin_common import get_credentials, build_select_choices, check_debug_mode @@ -77,6 +78,7 @@ def do(payload, config, plugin_config, inputs): ) method = payload.get("method") + logger.info("Running do for method '{}'".format(method)) if method == "get_query_catalogs": return get_query_catalogs(None, config) if method == "get_children_from_db": @@ -107,6 +109,34 @@ def do(payload, config, plugin_config, inputs): "get_element_categories_from_db", lambda: get_items_from_db(client, parent, "ElementCategories", database_name=database_name) ) + if method == "get_elements_for_template": + database_name = config.get("database_name") + template_name = payload.get("template_name", None) + elements = [] + for element in client.search_elements(database_name, name=None, description=None, category=None, template=template_name, full_search=True): + elements.append(get_item_details(element)) + return {"choices": [], "elements": elements} + if method=="get_attribute_for_template": + database_name = config.get("database_name") + template_name = payload.get("template_name", None) + if template_name is None: + return {"choices": [], "attributes": []} + # when searching for template : attribute_name=None, element_category=None, attribute_category=None + elements_max_count, attributes_max_count = get_max_counts(config) + attributes = [] + for result in client.batched_search( + database_name, None, None, + None, None, template_name, [], + elements_max_count=elements_max_count, attributes_max_count=attributes_max_count + ): + attributes.append(result) + attributes = split_real_from_linked_paths(attributes) + items = [] + for attribute in attributes: + item = get_item_details(attribute) + items.append(item) + items = expand_items_by_paths(items) + return {"choices": [], "attributes": items} if method == "do_search": template_name = config.get("template", None) category_name = config.get("element_category", None) @@ -133,6 +163,7 @@ def do(payload, config, plugin_config, inputs): attribute_category = None database_name = config.get("database_name") element_name = config.get("element_name") + # TODO: remove, stale attribute_name = config.get("attribute_name") if isinstance(element_name, str): element_name = element_name.strip() @@ -144,6 +175,7 @@ def do(payload, config, plugin_config, inputs): attribute_name = None has_attribute_filter = attribute_name is not None + # TODO: remove, never true anymore is_template_tab = active_tab == "template" has_clicked_element_nodes = len(clicked_nodes) > 0 # clicked_nodes scope is only for element-node URLs (batched_search restrict_to_elements). @@ -177,7 +209,7 @@ def do(payload, config, plugin_config, inputs): clicked_nodes = [] # root_tree = payload.get("root_tree") root_tree = config.get("treeData", []) - root_tree = shorten_tree(root_tree) + root_tree_before_search = copy.deepcopy(root_tree) attributes = [] # https://dku-qa-osi.francecentral.cloudapp.azure.com/piwebapi/assetdatabases/F1RD3VEt1yTvt0ip6-a5yeEVsgbMcrwu_Je0qg9btcZIvPswT1NJU09GVC1QSS1TRVJWXFdFTEw database_webid = database_name.split("/")[-1] @@ -208,6 +240,7 @@ def do(payload, config, plugin_config, inputs): items = expand_items_by_paths(items) attributesCopy = [dict(item) for item in items] rebuilt_tree = rebuild_tree(client, items.copy(), root_tree) + expand_nodes_for_matched_paths(client, rebuilt_tree, items, root_tree_before_search) logger.info("Search network timer:{}".format(network_timer.get_report())) return {"choices": rebuilt_tree, "attributes": attributesCopy} @@ -273,14 +306,6 @@ def get_children_from_db(client, parent_node, database_name=None): attributes_url = links.get("Attributes") elements_url = links.get("Elements") children = [] - if elements_url: - elements = client.get_next_item_from_url(elements_url, params={"associations": "Paths"}) - for element in elements: - child = get_item_details(element) - # child["title"] = "🧩{}".format(child.get("title")) - child["type"] = "element" - child["children"] = [] - children.append(child) if attributes_url: attributes = client.get_next_item_from_url(attributes_url, params={"associations": "Paths"}) templates_urls = [] @@ -298,6 +323,14 @@ def get_children_from_db(client, parent_node, database_name=None): for child, template_name in zip(children, templates_names): if template_name: child["template_name"] = template_name + if elements_url: + elements = client.get_next_item_from_url(elements_url, params={"associations": "Paths"}) + for element in elements: + child = get_item_details(element) + # child["title"] = "🧩{}".format(child.get("title")) + child["type"] = "element" + child["children"] = [] + children.append(child) return {"choices": children} @@ -346,18 +379,79 @@ def nest_children(items): def rebuild_tree(client, items, root_tree=None): # builds an active tree containing all the items and their parent up to the root tree = Tree(root_tree=root_tree) - # tree.print() while items: item = items.pop() if item is None: - break - find_all_ancestors(client, item, tree) - update_item(item, tree) + continue + find_missing_element_ancestors(client, item, tree) + if is_attribute_item(item): + continue + insert_missing_element(item, tree) result = recursive_tree_rebuild(tree.get_tree(), tree.get_records()) result = drop_first_levels(result) return result +def expand_nodes_for_matched_paths(client, tree, items, root_tree): + if not isinstance(tree, list) or not isinstance(items, list): + return + + for item in items: + item_path = item.get("path") + element_tokens, attribute_tokens = path_to_list(item_path) + if not element_tokens or not attribute_tokens: + continue + mark_expanded_path(client, tree, element_tokens[2:], root_tree) + + +def mark_expanded_path(client, nodes, path_tokens, root_tree): + if not isinstance(nodes, list) or not path_tokens: + return + + current_nodes = nodes + traversed_tokens = [] + for token in path_tokens: + matching_node = None + for node in current_nodes: + if node.get("title") == token: + matching_node = node + break + if matching_node is None: + return + traversed_tokens.append(token) + was_expanded = bool(matching_node.get("expanded")) + matching_node["expanded"] = True + previous_node = find_node_by_path_tokens(root_tree, traversed_tokens) + if ( + not was_expanded and + ( + previous_node is None or + not isinstance(previous_node.get("children"), list) or + len(previous_node.get("children")) == 0 + ) + ): + matching_node["children"] = get_children_from_db(client, matching_node).get("choices", []) + current_nodes = matching_node.get("children", []) + + +def find_node_by_path_tokens(nodes, path_tokens): + if not isinstance(nodes, list) or not path_tokens: + return None + + current_nodes = nodes + current_node = None + for token in path_tokens: + current_node = None + for node in current_nodes: + if node.get("title") == token: + current_node = node + break + if current_node is None: + return None + current_nodes = current_node.get("children", []) + return current_node + + def drop_first_levels(result): # recursively removes the 2 first levels of the returned tree # (server and DB) @@ -373,10 +467,12 @@ def drop_first_levels(result): return output_result -def find_all_ancestors(client, item, tree): - # Find all the ancestors of an item +def find_missing_element_ancestors(client, item, tree): + # Find the missing element ancestors of an item without loading attributes. elements_paths_tokens, attributes_paths_tokens = path_to_list(item.get("path")) - client.traverse_and_cache(elements_paths_tokens, attributes_paths_tokens, tree) + if not elements_paths_tokens: + return + client.traverse_and_cache(elements_paths_tokens, [], tree) def combine_trees(final_tree, all_item_s_ancestors): @@ -448,11 +544,21 @@ def set_as_selected(items): return items -def update_item(item, tree): +def is_attribute_item(item): + if not isinstance(item, dict): + return False + if item.get("type") == "attribute": + return True + return bool(item.get("path")) and "|" in item.get("path", "") + + +def insert_missing_element(item, tree): elements_paths_tokens, attributes_paths_tokens = path_to_list(item.get("path")) - if not elements_paths_tokens and not attributes_paths_tokens: + if not elements_paths_tokens or attributes_paths_tokens: + return + if tree.exists(elements_paths_tokens): return - tree.put(elements_paths_tokens + attributes_paths_tokens, item) + tree.put(elements_paths_tokens, item) def get_max_counts(config): diff --git a/resource/pi-system_af-explorer.css b/resource/pi-system_af-explorer.css index d950cdc0..66205efe 100644 --- a/resource/pi-system_af-explorer.css +++ b/resource/pi-system_af-explorer.css @@ -165,39 +165,70 @@ margin-bottom: 6px; } -.tree-view__tabs{ +.tree-view__tabs { display: flex; align-items: center; + flex-direction: row; align-self: stretch; + padding: 0; + width: 228px; + height: 42px; + box-sizing: border-box; border: 1px solid #BBB; } .tree-view__tab { + box-sizing: border-box; display: flex; + flex-direction: row; align-items: flex-start; - flex: 1 0 0; - border-top: 1px solid #BBB; - border-right: 0.5px solid #BBB; - border-bottom: 1px solid #BBB; - border-left: 1px solid #BBB; - &:hover, &:active { - background-color: #E7F3FF; - } + flex: 1 1 0; + height: 42px; + min-width: 0; + background: #FFF; + cursor: pointer; + transition: background-color 0.16s ease; +} + +.tree-view__tab:first-child { + border-width: 1px 0.5px 1px 1px; + border-style: solid; + border-color: #BBB; +} + +.tree-view__tab:last-child { + border-width: 1px 1px 1px 0.5px; + border-style: solid; + border-color: #BBB; +} + +.tree-view__tab:hover { + background: #F2F8FF; +} + +.tree-view__tab.active:hover { + background: #E7F3FF; +} + +.tree-view__tab.active { + background: #E7F3FF; } .tree-view__tab-content { display: flex; - padding: 12px 23px; + flex-direction: row; justify-content: center; align-items: center; + padding: 12px 23px; gap: 4px; - flex: 1 0 0; + flex: 1 1 0; + height: 42px; + box-sizing: border-box; color: #000; - font-family: "Source Sans Pro"; + font: inherit; font-size: 14px; - font-style: normal; font-weight: 600; - line-height: normal; + line-height: 18px; } .tree-view__content { diff --git a/resource/pi-system_af-explorer.html b/resource/pi-system_af-explorer.html index b01977b0..997c5da7 100644 --- a/resource/pi-system_af-explorer.html +++ b/resource/pi-system_af-explorer.html @@ -74,29 +74,32 @@ +