diff --git a/CHANGELOG.md b/CHANGELOG.md index 72ab3e95..b421f6e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Adjusted `package.json`, `.gitignore`, `.vscode/launch.js` and GitHub workflow f - [#125](https://github.com/equinor/webviz-core-components/pull/125) - Introduced `DefaultPropsHelper.ts` in order to account for coexistence of TypeScript restrictions and `React`'s `defaultProps`. - [#125](https://github.com/equinor/webviz-core-components/pull/125) - `setup.py` is now reading package data from `package.json` file inside `webviz_core_components`. - [#121](https://github.com/equinor/webviz-core-components/pull/121) - Changed rendering of `SmartNodeSelector` component when only one node can be selected. +- [#136](https://github.com/equinor/webviz-core-components/pull/136) - Changes to selected tags in `SmartNodeSelector` are now always sent. ### Added - [#125](https://github.com/equinor/webviz-core-components/pull/125) - Added `Storybook` for demo of components. @@ -26,6 +27,8 @@ Adjusted `package.json`, `.gitignore`, `.vscode/launch.js` and GitHub workflow f - [#130](https://github.com/equinor/webviz-core-components/pull/130) - Added feedback button to `WebvizPluginPlaceholder`. Added `href` and `target` properties to `WebvizToolbarButton`. ### Fixed +- [#136](https://github.com/equinor/webviz-core-components/pull/136) - Several bug fixes in `SmartNodeSelector` (exception on entering invalid node name when no metadata given, exception on using several wildcards, +new tag when pressing enter with single node selection and invalid data, node selected several times when its name is partly contained in other nodes, exception on holding backspace pressed). - [#125](https://github.com/equinor/webviz-core-components/pull/125) - Removed `selectedNodes` attribute from `SmartNodeSelector` arguments in `usage.py`. - [#124](https://github.com/equinor/webviz-core-components/pull/124) - `SmartNodeSelector` now returns all selected tags (also invalid and duplicate ones) to parent. - [#123](https://github.com/equinor/webviz-core-components/pull/123) - Removed unused variables and added types to `SmartNodeSelector` and its tests. diff --git a/react/src/demo/App.tsx b/react/src/demo/App.tsx index fe534c12..19bd63fc 100644 --- a/react/src/demo/App.tsx +++ b/react/src/demo/App.tsx @@ -210,6 +210,13 @@ class App extends Component { {this.state.selectedNodes.length == 0 && ( None )} +
Selected tags:
+ {this.state.selectedTags.length > 0 && this.state.selectedTags.map((tag, index) => ( +
{tag}
+ ))} + {this.state.selectedTags.length === 0 && ( + None + )} ); } diff --git a/react/src/lib/components/SmartNodeSelector/SmartNodeSelector.jsx b/react/src/lib/components/SmartNodeSelector/SmartNodeSelector.jsx index d85e5c7d..f80bc97f 100644 --- a/react/src/lib/components/SmartNodeSelector/SmartNodeSelector.jsx +++ b/react/src/lib/components/SmartNodeSelector/SmartNodeSelector.jsx @@ -76,7 +76,7 @@ SmartNodeSelector.propTypes = { /** * A label that will be printed when this component is rendered. */ - label: PropTypes.string.isRequired, + label: PropTypes.string, /** * Stating of suggestions should be shown or not. diff --git a/react/src/lib/components/SmartNodeSelector/components/SmartNodeSelectorComponent.tsx b/react/src/lib/components/SmartNodeSelector/components/SmartNodeSelectorComponent.tsx index 11af7bb9..9b784af6 100644 --- a/react/src/lib/components/SmartNodeSelector/components/SmartNodeSelectorComponent.tsx +++ b/react/src/lib/components/SmartNodeSelector/components/SmartNodeSelectorComponent.tsx @@ -5,42 +5,42 @@ * LICENSE file in the root directory of this source tree. */ -import React, { Component, MouseEvent } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import './SmartNodeSelector.css'; -import TreeNodeSelection from '../utils/TreeNodeSelection'; -import TreeData from '../utils/TreeData'; +import React, { Component, MouseEvent } from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import "./SmartNodeSelector.css"; +import TreeNodeSelection from "../utils/TreeNodeSelection"; +import TreeData from "../utils/TreeData"; import { TreeDataNode } from "../utils/TreeDataNodeTypes"; -import Suggestions from './Suggestions'; -import Tag from './Tag'; +import Suggestions from "./Suggestions"; +import Tag from "./Tag"; enum Direction { Left = 0, - Right + Right, } type ParentProps = { - selectedTags: string[], - selectedNodes: string[], - selectedIds: string[] + selectedTags: string[]; + selectedNodes: string[]; + selectedIds: string[]; }; export type SmartNodeSelectorPropsType = { - id: string, - maxNumSelectedNodes: number, - delimiter: string, - numMetaNodes: number, - data: TreeDataNode[], - label?: string, - showSuggestions: boolean, - setProps: (props: ParentProps) => void, - selectedTags?: string[], - placeholder?: string, - numSecondsUntilSuggestionsAreShown: number, - persistence: boolean | string | number, - persisted_props: ("selectedTags")[], - persistence_type: "local" | "session" | "memory" + id: string; + maxNumSelectedNodes: number; + delimiter: string; + numMetaNodes: number; + data: TreeDataNode[]; + label?: string; + showSuggestions: boolean; + setProps: (props: ParentProps) => void; + selectedTags?: string[]; + placeholder?: string; + numSecondsUntilSuggestionsAreShown: number; + persistence: boolean | string | number; + persisted_props: "selectedTags"[]; + persistence_type: "local" | "session" | "memory"; }; type SmartNodeSelectorStateType = { @@ -98,8 +98,8 @@ export default class SmartNodeSelectorComponent extends Component this.handleClickOutside(e), true); - document.addEventListener('mouseup', (e) => this.handleMouseUp(e), true); - document.addEventListener('mousemove', (e) => this.handleMouseMove(e), true); - document.addEventListener('keydown', (e) => this.handleGlobalKeyDown(e), true); + document.addEventListener( + "click", + (e) => this.handleClickOutside(e), + true + ); + document.addEventListener( + "mouseup", + (e) => this.handleMouseUp(e), + true + ); + document.addEventListener( + "mousemove", + (e) => this.handleMouseMove(e), + true + ); + document.addEventListener( + "keydown", + (e) => this.handleGlobalKeyDown(e), + true + ); } componentWillUnmount(): void { this.componentIsMounted = false; if (this.suggestionTimer) clearTimeout(this.suggestionTimer); - document.removeEventListener('click', (e) => this.handleClickOutside(e), true); - document.removeEventListener('mouseup', (e) => this.handleMouseUp(e), true); - document.removeEventListener('mousemove', (e) => this.handleMouseMove(e), true); - document.removeEventListener('keydown', (e) => this.handleGlobalKeyDown(e), true); + document.removeEventListener( + "click", + (e) => this.handleClickOutside(e), + true + ); + document.removeEventListener( + "mouseup", + (e) => this.handleMouseUp(e), + true + ); + document.removeEventListener( + "mousemove", + (e) => this.handleMouseMove(e), + true + ); + document.removeEventListener( + "keydown", + (e) => this.handleGlobalKeyDown(e), + true + ); } componentDidUpdate(prevProps: SmartNodeSelectorPropsType): void { - const selectedTags = this.state.nodeSelections.filter( - nodeSelection => nodeSelection.isValid() - ).map( - nodeSelection => nodeSelection.getCompleteNodePathAsString() - ); + const selectedTags = this.state.nodeSelections + .filter((nodeSelection) => nodeSelection.isValid()) + .map((nodeSelection) => + nodeSelection.getCompleteNodePathAsString() + ); if ( - this.props.selectedTags - && JSON.stringify(this.props.selectedTags) !== JSON.stringify(selectedTags) - && JSON.stringify(prevProps.selectedTags) !== JSON.stringify(this.props.selectedTags) + this.props.selectedTags && + JSON.stringify(this.props.selectedTags) !== + JSON.stringify(selectedTags) && + JSON.stringify(prevProps.selectedTags) !== + JSON.stringify(this.props.selectedTags) ) { const nodeSelections: TreeNodeSelection[] = []; if (this.props.selectedTags !== undefined) { @@ -197,7 +232,10 @@ export default class SmartNodeSelectorComponent extends Component): void { + selectLastInput( + e: React.MouseEvent + ): void { if (!this.selectionHasStarted && this.countSelectedTags() == 0) { this.setFocusOnTagInput(this.countTags() - 1); e.preventDefault(); @@ -235,11 +275,10 @@ export default class SmartNodeSelectorComponent extends Component= 0 && index < this.countTags()) { if (this.state.nodeSelections.length > index && index >= 0) { - ( - ( - this.state.nodeSelections[index].getRef() as React.RefObject - ).current as HTMLInputElement - ).focus(); + ((this.state.nodeSelections[ + index + ].getRef() as React.RefObject) + .current as HTMLInputElement).focus(); } this.maybeShowSuggestions(); } @@ -250,21 +289,37 @@ export default class SmartNodeSelectorComponent extends Component void = () => { return undefined }): boolean { + incrementCurrentTagIndex( + callback: () => void = () => { + return undefined; + } + ): boolean { if (this.currentTagIndex() < this.countTags() - 1) { - this.updateState({ currentTagIndex: this.currentTagIndex() + 1, callback: callback }); + this.updateState({ + currentTagIndex: this.currentTagIndex() + 1, + callback: callback, + }); return true; } return false; } - decrementCurrentTagIndex(callback: () => void = () => { return undefined }): boolean { + decrementCurrentTagIndex( + callback: () => void = () => { + return undefined; + } + ): boolean { if (this.currentTagIndex() > 0) { - this.updateState({ currentTagIndex: this.currentTagIndex() - 1, callback: callback }); + this.updateState({ + currentTagIndex: this.currentTagIndex() - 1, + callback: callback, + }); return true; } return false; @@ -281,7 +336,9 @@ export default class SmartNodeSelectorComponent extends Component !v.trulyEquals(this.state.nodeSelections[i])); + check = + check || + nodeSelections.some( + (v, i) => !v.trulyEquals(this.state.nodeSelections[i]) + ); } check = check || currentTagIndex != this.currentTagIndex(); check = check || suggestionsVisible != this.state.suggestionsVisible; @@ -308,68 +369,73 @@ export default class SmartNodeSelectorComponent extends Component { return undefined }, - forceUpdate = false + callback = () => { + return undefined; + }, + forceUpdate = false, }: SmartNodeSelectorUpdateStateType): void { if (!this.componentIsMounted) return; if ( - this.currentTagIndex() > 0 - && currentTagIndex !== undefined - && this.currentNodeSelection() !== undefined - && currentTagIndex != this.currentTagIndex() + this.currentTagIndex() > 0 && + currentTagIndex !== undefined && + this.currentNodeSelection() !== undefined && + currentTagIndex != this.currentTagIndex() ) { - this.nodeSelection(this.currentTagIndex()) - .setFocussedLevel(this.nodeSelection(this.currentTagIndex()).countLevel() - 1); + this.nodeSelection(this.currentTagIndex()).setFocussedLevel( + this.nodeSelection(this.currentTagIndex()).countLevel() - 1 + ); } - const newNodeSelections = nodeSelections === undefined ? this.state.nodeSelections : nodeSelections; - const currentNodeSelections = this.state.nodeSelections; - const newTagIndex = currentTagIndex === undefined ? this.currentTagIndex() : currentTagIndex; - const newSuggestionsVisible = ( - suggestionsVisible === undefined ? - this.state.suggestionsVisible - : suggestionsVisible - ); + const newNodeSelections = + nodeSelections === undefined + ? this.state.nodeSelections + : nodeSelections; + const newTagIndex = + currentTagIndex === undefined + ? this.currentTagIndex() + : currentTagIndex; + const newSuggestionsVisible = + suggestionsVisible === undefined + ? this.state.suggestionsVisible + : suggestionsVisible; - if (forceUpdate || this.doesStateChange({ - nodeSelections: newNodeSelections, - currentTagIndex: newTagIndex, - suggestionsVisible: newSuggestionsVisible - })) { - this.setState({ + if ( + forceUpdate || + this.doesStateChange({ nodeSelections: newNodeSelections, currentTagIndex: newTagIndex, suggestionsVisible: newSuggestionsVisible, - hasError: this.state.hasError, - error: this.state.error - }, () => { - callback(); - if ( - newNodeSelections !== currentNodeSelections - || this.countValidSelections() !== this.numValidSelections - ) { + }) + ) { + this.setState( + { + nodeSelections: newNodeSelections, + currentTagIndex: newTagIndex, + suggestionsVisible: newSuggestionsVisible, + hasError: this.state.hasError, + error: this.state.error, + }, + () => { + callback(); this.updateSelectedTagsAndNodes(); } - }); - } - else { + ); + } else { callback(); - if ( - newNodeSelections !== currentNodeSelections - || this.countValidSelections() !== this.numValidSelections - ) { - this.updateSelectedTagsAndNodes(); - } + this.updateSelectedTagsAndNodes(); } } maybeShowSuggestions(): void { const { numSecondsUntilSuggestionsAreShown } = this.props; - if (this.suggestionTimer) - clearTimeout(this.suggestionTimer); - if (this.currentNodeSelection() !== undefined && !this.currentNodeSelection().isValid()) { - this.suggestionTimer = setTimeout(() => - this.showSuggestions(), numSecondsUntilSuggestionsAreShown * 1000 + if (this.suggestionTimer) clearTimeout(this.suggestionTimer); + if ( + this.currentNodeSelection() !== undefined && + !this.currentNodeSelection().isValid() + ) { + this.suggestionTimer = setTimeout( + () => this.showSuggestions(), + numSecondsUntilSuggestionsAreShown * 1000 ); } } @@ -377,11 +443,9 @@ export default class SmartNodeSelectorComponent extends Component - ).current === document.activeElement + (this.currentNodeSelection().getRef() as React.RefObject) + .current === document.activeElement ) { - this.updateState({ suggestionsVisible: true }); } } @@ -391,7 +455,10 @@ export default class SmartNodeSelectorComponent extends Component, suggestion: string): void { + useSuggestion( + e: globalThis.KeyboardEvent | MouseEvent, + suggestion: string + ): void { const nodeSelection = this.currentNodeSelection(); this.noUserInputSelect = true; @@ -399,14 +466,20 @@ export default class SmartNodeSelectorComponent extends Component this.focusCurrentTag() + callback: () => this.focusCurrentTag(), }; - } - else { + } else { this.focusCurrentTag(); } struct.suggestionsVisible = false; @@ -415,30 +488,43 @@ export default class SmartNodeSelectorComponent extends Component).current as HTMLDivElement); - const blinkTimer = setInterval(() => { - numBlinks++; - if (numBlinks % 2 == 0) { - numberOfTagsDiv.classList.add("SmartNodeSelector__Warning"); - } else { - numberOfTagsDiv.classList.remove("SmartNodeSelector__Warning"); - } - if (numBlinks === 7) { - clearInterval(blinkTimer); - } - }, 200); + if (this.props.maxNumSelectedNodes !== 1) { + let numBlinks = 0; + const numberOfTagsDiv = (this + .refNumberOfTags as React.RefObject) + .current as HTMLDivElement; + const blinkTimer = setInterval(() => { + numBlinks++; + if (numBlinks % 2 == 0) { + numberOfTagsDiv.classList.add("SmartNodeSelector__Warning"); + } else { + numberOfTagsDiv.classList.remove( + "SmartNodeSelector__Warning" + ); + } + if (numBlinks === 7) { + clearInterval(blinkTimer); + } + }, 200); + } } - checkIfSelectionIsDuplicate(nodeSelection: TreeNodeSelection, index: number): boolean { - const duplicateSelections = this.state.nodeSelections.filter((entry, i) => - (i < index && entry.containsOrIsContainedBy(nodeSelection)) + checkIfSelectionIsDuplicate( + nodeSelection: TreeNodeSelection, + index: number + ): boolean { + const duplicateSelections = this.state.nodeSelections.filter( + (entry, i) => + i < index && entry.containsOrIsContainedBy(nodeSelection) ); return duplicateSelections.length > 0; } blurActiveElement(): void { - if (document.activeElement && document.activeElement instanceof HTMLElement) { + if ( + document.activeElement && + document.activeElement instanceof HTMLElement + ) { (document.activeElement as HTMLElement).blur(); } } @@ -447,12 +533,15 @@ export default class SmartNodeSelectorComponent extends Component).current as HTMLUListElement; - const suggestions = (this.suggestionsRef as React.RefObject).current as HTMLDivElement; - const eventTarget = (event.target as Element); + const domNode = (this.tagFieldRef as React.RefObject) + .current as HTMLUListElement; + const suggestions = (this + .suggestionsRef as React.RefObject) + .current as HTMLDivElement; + const eventTarget = event.target as Element; if ( - (!domNode || !domNode.contains(eventTarget)) - && (!suggestions || !suggestions.contains(eventTarget)) + (!domNode || !domNode.contains(eventTarget)) && + (!suggestions || !suggestions.contains(eventTarget)) ) { this.hideSuggestions(); if (!this.selectionHasStarted) { @@ -469,10 +558,12 @@ export default class SmartNodeSelectorComponent extends Component 0) { + if ( + (e.key === "Backspace" || e.key === "Delete") && + this.countSelectedTags() > 0 + ) { this.removeSelectedTags(); - } - else if (e.key === "c" && e.ctrlKey) { + } else if (e.key === "c" && e.ctrlKey) { this.copyAllSelectedTags(); } } @@ -488,20 +579,20 @@ export default class SmartNodeSelectorComponent extends Component): void { + handleMouseDown( + e: React.MouseEvent + ): void { if (this.state.hasError) { return; } if (e.target instanceof HTMLElement) this.mouseDownElement = e.target as HTMLElement; - else - this.mouseDownElement = null; + else this.mouseDownElement = null; this.mouseDownPosition = [e.clientX, e.clientY]; if (this.countSelectedTags() > 0) { this.unselectAllTags({}); e.stopPropagation(); - } - else { + } else { this.mouseButtonDown = true; } } @@ -509,18 +600,17 @@ export default class SmartNodeSelectorComponent extends Component { if (index >= startIndex && index <= endIndex) { nodeSelection.setSelected(true); - } - else { + } else { nodeSelection.setSelected(false); } }); @@ -623,26 +718,31 @@ export default class SmartNodeSelectorComponent extends Component selection.setSelected(false)); + this.state.nodeSelections.forEach((selection) => + selection.setSelected(false) + ); this.updateState({ - currentTagIndex: newCurrentTagIndex === undefined ? this.countTags() - 1 : newCurrentTagIndex, + currentTagIndex: + newCurrentTagIndex === undefined + ? this.countTags() - 1 + : newCurrentTagIndex, callback: () => { - if (showSuggestions) - this.maybeShowSuggestions(); - if (focusInput) - this.focusCurrentTag(); - } + if (showSuggestions) this.maybeShowSuggestions(); + if (focusInput) this.focusCurrentTag(); + }, }); } removeSelectedTags(): void { - let newSelections = this.state.nodeSelections.filter((tag) => !tag.isSelected()); + let newSelections = this.state.nodeSelections.filter( + (tag) => !tag.isSelected() + ); const numRemovedTags = this.countTags() - newSelections.length; let newTagIndex = this.currentTagIndex(); if (newTagIndex >= this.firstSelectedTagIndex) { @@ -655,48 +755,52 @@ export default class SmartNodeSelectorComponent extends Component this.focusCurrentTag() + callback: () => this.focusCurrentTag(), }); } - removeTag(e: React.MouseEvent, index: number): void { + removeTag( + e: React.MouseEvent, + index: number + ): void { let newSelections = [...this.state.nodeSelections]; let newTagIndex = - this.currentTagIndex() === index ? - Math.max(this.countTags() - 2, 0) - : this.currentTagIndex() - (index < this.currentTagIndex() ? 1 : 0); + this.currentTagIndex() === index + ? Math.max(this.countTags() - 2, 0) + : this.currentTagIndex() - + (index < this.currentTagIndex() ? 1 : 0); newSelections.splice(index, 1); if (newSelections.length == 0) { newSelections = [this.createNewNodeSelection()]; } else if (index === this.countTags() - 1) { if (!this.hasLastEmptyTag()) { - newSelections = [...newSelections, this.createNewNodeSelection()]; + newSelections = [ + ...newSelections, + this.createNewNodeSelection(), + ]; } newTagIndex = this.countTags() - 1; } this.updateState({ nodeSelections: newSelections, currentTagIndex: newTagIndex, - callback: () => this.setFocusOnTagInput(newTagIndex) + callback: () => this.setFocusOnTagInput(newTagIndex), }); e.stopPropagation(); } - clearAllTags(e: React.MouseEvent): void { + clearAllTags( + e: React.MouseEvent + ): void { this.updateState({ - nodeSelections: [ - this.createNewNodeSelection(), - ], + nodeSelections: [this.createNewNodeSelection()], currentTagIndex: 0, suggestionsVisible: false, callback: () => { - ( - ( - this.state.nodeSelections[0].getRef() as React.RefObject - ).current as HTMLInputElement - ).focus(); - } + ((this.state.nodeSelections[0].getRef() as React.RefObject) + .current as HTMLInputElement).focus(); + }, }); e.stopPropagation(); e.preventDefault(); @@ -708,22 +812,32 @@ export default class SmartNodeSelectorComponent extends Component this.lastSelectedTagIndex) { this.focusCurrentTag(); @@ -746,11 +860,17 @@ export default class SmartNodeSelectorComponent extends Component { this.unselectAllTags({ focusInput: true }); - } + }, }); } e.preventDefault(); } canAddSelection(): boolean { - return this.countValidSelections() < this.props.maxNumSelectedNodes || this.props.maxNumSelectedNodes == -1; + return ( + (this.countValidSelections() < this.props.maxNumSelectedNodes || + this.props.maxNumSelectedNodes == -1) && + this.props.maxNumSelectedNodes !== 1 + ); } updateSelectedTagsAndNodes(): void { @@ -774,16 +898,21 @@ export default class SmartNodeSelectorComponent extends Component= maxNumSelectedNodes && maxNumSelectedNodes > 0) { + if ( + selectedNodes.length >= maxNumSelectedNodes && + maxNumSelectedNodes > 0 + ) { break loop1; } selectedNodes.push(matchedNodePaths[j]); @@ -794,7 +923,7 @@ export default class SmartNodeSelectorComponent extends Component - - - + + + ); } else { @@ -813,180 +952,191 @@ export default class SmartNodeSelectorComponent extends Component, index: number): void { + handleInputSelect( + e: React.SyntheticEvent, + index: number + ): void { if (this.noUserInputSelect) { this.noUserInputSelect = false; return; } - const eventTarget = (e.target as HTMLInputElement); + const eventTarget = e.target as HTMLInputElement; const val = eventTarget.value; const tag = this.nodeSelection(index); const previouslyFocussedLevel = tag.getFocussedLevel(); - if (eventTarget.selectionStart != null && eventTarget.selectionEnd != null) { + if ( + eventTarget.selectionStart != null && + eventTarget.selectionEnd != null + ) { if (!tag.isFocusOnMetaData()) { tag.setFocussedLevel( - val.slice( - 0, - eventTarget.selectionStart - ).split(this.props.delimiter).length - 1, + val + .slice(0, eventTarget.selectionStart) + .split(this.props.delimiter).length - 1, false ); } - const selection = eventTarget.value.substring(eventTarget.selectionStart, eventTarget.selectionEnd); + const selection = eventTarget.value.substring( + eventTarget.selectionStart, + eventTarget.selectionEnd + ); if (selection.includes(this.props.delimiter)) { if (eventTarget.selectionDirection === "backward") { eventTarget.setSelectionRange( - eventTarget.selectionStart + selection.indexOf(this.props.delimiter) + 1, + eventTarget.selectionStart + + selection.indexOf(this.props.delimiter) + + 1, eventTarget.selectionEnd ); - } - else { + } else { eventTarget.setSelectionRange( eventTarget.selectionStart, - eventTarget.selectionStart + selection.indexOf(this.props.delimiter) + eventTarget.selectionStart + + selection.indexOf(this.props.delimiter) ); } } - this.state.nodeSelections.forEach(v => v.setSelected(false)); + this.state.nodeSelections.forEach((v) => v.setSelected(false)); this.updateState({ currentTagIndex: index, callback: () => { this.maybeShowSuggestions(); }, - forceUpdate: tag.getFocussedLevel() !== previouslyFocussedLevel + forceUpdate: tag.getFocussedLevel() !== previouslyFocussedLevel, }); } e.stopPropagation(); } handleInputKeyUp(e: React.KeyboardEvent): void { - const eventTarget = (e.target as HTMLInputElement); + const eventTarget = e.target as HTMLInputElement; const val = eventTarget.value; if (e.key === "Enter" && val) { - if (this.currentTagIndex() == this.countTags() - 1) { this.focusCurrentTag(); } else { this.incrementCurrentTagIndex(); this.setFocusOnTagInput(this.currentTagIndex() + 1); } - - } - else if (e.key === "ArrowRight" && val) { + } else if (e.key === "ArrowRight" && val) { if (eventTarget.selectionStart == eventTarget.value.length) { this.focusCurrentTag(); } - } - else if (e.key === "ArrowLeft") { + } else if (e.key === "ArrowLeft") { if (eventTarget.selectionStart == 0) { this.focusCurrentTag(); } - } - else if (e.key === "ArrowUp" || e.key === "ArrowDown") { + } else if (e.key === "ArrowUp" || e.key === "ArrowDown") { e.preventDefault(); - } - else if (e.key === this.props.delimiter && val) { + } else if (e.key === this.props.delimiter && val) { if (this.currentNodeSelection().isFocusOnMetaData()) { this.currentNodeSelection().setNodeName(val.slice(0, -1)); this.currentNodeSelection().incrementFocussedLevel(); this.updateState({ forceUpdate: true }); - } - else if (!this.currentNodeSelection().isValid()) { - this.currentNodeSelection().setNodeName(val); + } else if (!this.currentNodeSelection().isValid()) { + this.currentNodeSelection().setNodeName( + val.split(this.props.delimiter)[ + this.currentNodeSelection().getFocussedLevel() - this.currentNodeSelection().getNumMetaNodes() + ] + ); this.currentNodeSelection().incrementFocussedLevel(); this.updateState({ forceUpdate: true }); - } - else { + } else { e.preventDefault(); } } } handleInputKeyDown(e: React.KeyboardEvent): void { - const eventTarget = (e.target as HTMLInputElement); + const eventTarget = e.target as HTMLInputElement; const val = eventTarget.value; - if (e.key === "Enter" && val && !this.hasLastEmptyTag() && this.currentTagIndex() == this.countTags() - 1) { + if ( + e.key === "Enter" && + val && + !this.hasLastEmptyTag() && + this.currentTagIndex() == this.countTags() - 1 + ) { if (this.canAddSelection()) { this.updateState({ - nodeSelections: [...this.state.nodeSelections, this.createNewNodeSelection()], - currentTagIndex: this.currentTagIndex() + 1 + nodeSelections: [ + ...this.state.nodeSelections, + this.createNewNodeSelection(), + ], + currentTagIndex: this.currentTagIndex() + 1, }); - } - else { + } else { this.letMaxNumValuesBlink(); } - } - else if ( - ( - e.key === "ArrowRight" - && eventTarget.selectionEnd == eventTarget.value.length - && !e.repeat - ) - && val + } else if ( + e.key === "ArrowRight" && + eventTarget.selectionEnd == eventTarget.value.length && + !e.repeat && + val ) { if (e.shiftKey) { if (this.currentTagIndex() < this.countTags() - 1) { this.selectTag(this.currentTagIndex()); this.currentSelectionDirection = Direction.Right; } - } else { - if (this.currentTagIndex() == this.countTags() - 1 - && !this.hasLastEmptyTag() - && this.canAddSelection()) { + if ( + this.currentTagIndex() == this.countTags() - 1 && + !this.hasLastEmptyTag() && + this.canAddSelection() + ) { this.updateState({ - nodeSelections: [...this.state.nodeSelections, this.createNewNodeSelection()], - currentTagIndex: this.currentTagIndex() + 1 + nodeSelections: [ + ...this.state.nodeSelections, + this.createNewNodeSelection(), + ], + currentTagIndex: this.currentTagIndex() + 1, }); - } - else { - this.incrementCurrentTagIndex(() => - this.focusCurrentTag() - ); + } else { + this.incrementCurrentTagIndex(() => this.focusCurrentTag()); e.preventDefault(); } } - } - else if ( - e.key === "ArrowLeft" - && eventTarget.selectionStart == 0 - && eventTarget.selectionEnd == 0 - && this.currentTagIndex() > 0 - && !e.repeat + } else if ( + e.key === "ArrowLeft" && + eventTarget.selectionStart == 0 && + eventTarget.selectionEnd == 0 && + this.currentTagIndex() > 0 && + !e.repeat ) { if (e.shiftKey) { if (!this.currentNodeSelection().displayAsTag()) { this.selectTag(this.currentTagIndex() - 1); - } - else { + } else { this.selectTag(this.currentTagIndex()); } this.currentSelectionDirection = Direction.Left; - } - else { + } else { this.decrementCurrentTagIndex(() => { this.focusCurrentTag(); }); e.preventDefault(); } - } - else if (e.key === "ArrowUp" || e.key === "ArrowDown") { + } else if (e.key === "ArrowUp" || e.key === "ArrowDown") { e.preventDefault(); - } - else if (e.key === "Backspace" - && this.currentNodeSelection().getFocussedLevel() > 0 - && ( - eventTarget.value == "" - || ( - !this.currentNodeSelection().isFocusOnMetaData() - && val.slice(-1) == this.props.delimiter) - ) + } else if ( + e.key === "Backspace" && + this.currentNodeSelection().getFocussedLevel() > 0 && + (eventTarget.value === "" || + (!this.currentNodeSelection().isFocusOnMetaData() && + val.slice(-1) === this.props.delimiter)) ) { + if (e.repeat) { + e.preventDefault(); + return; + } this.currentNodeSelection().decrementFocussedLevel(); this.updateState({ forceUpdate: true }); e.preventDefault(); - } - else if (e.key === "v" && e.ctrlKey && this.currentTagIndex() == this.countTags() - 1) { + } else if ( + e.key === "v" && + e.ctrlKey && + this.currentTagIndex() == this.countTags() - 1 + ) { this.pasteTags(e); } } @@ -997,8 +1147,7 @@ export default class SmartNodeSelectorComponent extends Component - SmartNodeSelector
- { - error.split("\n").map((item) => ( - <>{item}
- )) - } +
+ SmartNodeSelector +
+ {error.split("\n").map((item) => ( + <> + {item} +
+ + ))}
- ) + ); } const frameless = maxNumSelectedNodes === 1; @@ -1031,13 +1197,15 @@ export default class SmartNodeSelectorComponent extends Component {label && } -
0 && this.countValidSelections() > maxNumSelectedNodes) - })} +
0 && + this.countValidSelections() > maxNumSelectedNodes, + })} onClick={(e) => this.selectLastInput(e)} onMouseDown={(e) => this.handleMouseDown(e)} > @@ -1051,20 +1219,33 @@ export default class SmartNodeSelectorComponent extends Component this.checkIfSelectionIsDuplicate(nodeSelection, index) + checkIfDuplicate={(nodeSelection, index) => + this.checkIfSelectionIsDuplicate( + nodeSelection, + index + ) } inputKeyDown={(e) => this.handleInputKeyDown(e)} inputKeyUp={(e) => this.handleInputKeyUp(e)} inputChange={(e) => this.handleInputChange(e)} - inputSelect={(e, index) => this.handleInputSelect(e, index)} - hideSuggestions={(cb) => this.hideSuggestions(cb)} - removeTag={(e, index) => this.removeTag(e, index)} - updateSelectedTagsAndNodes={() => this.updateSelectedTagsAndNodes()} + inputSelect={(e, index) => + this.handleInputSelect(e, index) + } + hideSuggestions={(cb) => + this.hideSuggestions(cb) + } + removeTag={(e, index) => + this.removeTag(e, index) + } + updateSelectedTagsAndNodes={() => + this.updateSelectedTagsAndNodes() + } /> ))} @@ -1073,24 +1254,41 @@ export default class SmartNodeSelectorComponent extends Component this.clearAllTags(e)} - disabled={(this.countTags() <= 1 && this.hasLastEmptyTag())} + disabled={ + this.countTags() <= 1 && this.hasLastEmptyTag() + } />
- {showSuggestions && + {showSuggestions && ( = 0} - useSuggestion={(e, suggestion) => this.useSuggestion(e, suggestion)} + visible={ + suggestionsVisible && + this.currentTagIndex() >= 0 + } + useSuggestion={(e, suggestion) => + this.useSuggestion(e, suggestion) + } treeNodeSelection={this.currentNodeSelection()} /> - } + )}
- {maxNumSelectedNodes > 1 &&
maxNumSelectedNodes - })} ref={this.refNumberOfTags}>Selected {this.countValidSelections()} of {maxNumSelectedNodes}
} - + {maxNumSelectedNodes > 1 && ( +
+ maxNumSelectedNodes, + })} + ref={this.refNumberOfTags} + > + Selected {this.countValidSelections()} of{" "} + {maxNumSelectedNodes} +
+ )} + ); } } @@ -1161,16 +1359,18 @@ SmartNodeSelectorComponent.propTypes = { * the new `value` also matches what was given originally. * Used in conjunction with `persistence_type`. */ - persistence: PropTypes.oneOfType( - [PropTypes.bool, PropTypes.string, PropTypes.number] - ), + persistence: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.string, + PropTypes.number, + ]), /** * Properties whose user interactions will persist after refreshing the * component or the page. Since only `value` is allowed this prop can * normally be ignored. */ - persisted_props: PropTypes.arrayOf(PropTypes.oneOf(['selectedTags'])), + persisted_props: PropTypes.arrayOf(PropTypes.oneOf(["selectedTags"])), /** * Where persisted user changes will be stored: @@ -1178,5 +1378,5 @@ SmartNodeSelectorComponent.propTypes = { * local: window.localStorage, data is kept after the browser quit. * session: window.sessionStorage, data is cleared once the browser quit. */ - persistence_type: PropTypes.oneOf(['local', 'session', 'memory']), + persistence_type: PropTypes.oneOf(["local", "session", "memory"]), }; diff --git a/react/src/lib/components/SmartNodeSelector/components/Tag.tsx b/react/src/lib/components/SmartNodeSelector/components/Tag.tsx index 7a81127e..78ffd30d 100644 --- a/react/src/lib/components/SmartNodeSelector/components/Tag.tsx +++ b/react/src/lib/components/SmartNodeSelector/components/Tag.tsx @@ -59,7 +59,7 @@ export default class Tag extends Component { return ( treeNodeSelection.displayAsTag() || (!invalid && !currentTag) || - (invalid && !currentTag && treeNodeSelection.getNodeName(0) != "") + (invalid && !currentTag && treeNodeSelection.getNodeName(0) !== "") ); } @@ -281,7 +281,7 @@ export default class Tag extends Component { return ( treeNodeSelection.displayAsTag() || (treeNodeSelection.isValid() && currentTag) || - (treeNodeSelection.getNodeName(0) != "" && !currentTag) + (treeNodeSelection.getNodeName(0) !== "" && !currentTag) ); } @@ -309,11 +309,14 @@ export default class Tag extends Component { treeNodeSelection.getNumMetaNodes() ]; width = this.calculateTextWidth(currentText, 0, 0); - const splitByCurrentText = value.split(currentText); + const splitByCurrentText = [ + ...splitByDelimiter.filter( + (_, index) => index < treeNodeSelection.getFocussedLevel() - treeNodeSelection.getNumMetaNodes() + ), ""].join(treeNodeSelection.getDelimiter()); if (splitByCurrentText[0] !== undefined) { distanceLeft = this.calculateTextWidth( - splitByCurrentText[0], + splitByCurrentText, 0, 0 ); diff --git a/react/src/lib/components/SmartNodeSelector/utils/TreeData.ts b/react/src/lib/components/SmartNodeSelector/utils/TreeData.ts index 65b14222..407a46f8 100644 --- a/react/src/lib/components/SmartNodeSelector/utils/TreeData.ts +++ b/react/src/lib/components/SmartNodeSelector/utils/TreeData.ts @@ -5,8 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import { TreeDataNode, TreeDataNodeMetaData } from './TreeDataNodeTypes'; - +import { TreeDataNode, TreeDataNodeMetaData } from "./TreeDataNodeTypes"; export default class TreeData { private treeData: TreeDataNode[]; @@ -16,7 +15,7 @@ export default class TreeData { constructor({ treeData, - delimiter + delimiter, }: { treeData: TreeDataNode[]; delimiter: string; @@ -34,75 +33,89 @@ export default class TreeData { let stringifiedData = ""; const nodeData: TreeDataNodeMetaData[] = []; const delimiter = this.delimiter; - const populateNode = (indices: number[] = [], nodePath: string): void => { + const populateNode = ( + indices: number[] = [], + nodePath: string + ): void => { if (indices.length == 0) { throw "Indices array must at least have one element"; } let node: TreeDataNode = this.treeData[indices[0]]; for (let i = 1; i < indices.length; i++) { - if (node.children) - node = node.children[indices[i]]; - else - throw "Implementation error"; + if (node.children) node = node.children[indices[i]]; + else throw "Implementation error"; } - if (node.name === "" || node.name === undefined || node.name === null) { - const path = nodePath.replace(/\{[0-9]+\}/g, "") + (nodePath !== "" ? delimiter : "") + node.name; + if ( + node.name === "" || + node.name === undefined || + node.name === null + ) { + const path = + nodePath.replace(/\{[0-9]+\}/g, "") + + (nodePath !== "" ? delimiter : "") + + node.name; throw ` Empty/invalid strings are not allowed as names of nodes: "${path}" ${Array(path.length + 2).join("\u00A0")}^`; - } nodeData.push({ id: node.id, description: node.description, color: node.color, icon: node.icon, - numChildren: (node.children ? node.children.length : 0) + numChildren: node.children ? node.children.length : 0, }); const index = indexCount++; if (node.children && node.children.length > 0) { for (let i = 0; i < node.children.length; i++) { populateNode( [...indices, i], - `${nodePath}${nodePath != "" ? delimiter : ""}{${index}}${node.name}` + `${nodePath}${ + nodePath != "" ? delimiter : "" + }{${index}}${node.name}` ); } + } else { + stringifiedData += `"${nodePath}${ + nodePath !== "" ? delimiter : "" + }{${index}}${node.name}" `; } - else { - stringifiedData += `"${nodePath}${nodePath !== "" ? delimiter : ""}{${index}}${node.name}" ` - } - - } + }; for (let i = 0; i < this.treeData.length; i++) { populateNode([i], ""); } this.stringifiedData = stringifiedData; this.nodeData = nodeData; if (nodeData.length != indexCount) { - throw "implementation error" + throw "implementation error"; } } countMatchedNodes(nodePath: string[]): number { let nodePathString = ""; for (let i = 0; i < nodePath.length - 1; i++) { - nodePathString += `\\{(\\d+)\\}${this.adjustNodeName(nodePath[i])}${this.delimiter}`; + nodePathString += `\\{(\\d+)\\}${this.adjustNodeName(nodePath[i])}${ + this.delimiter + }`; } - const re = RegExp(`"${nodePathString}\\{(\\d+)\\}([^${this.delimiter}"]*)"`, 'g'); + const re = RegExp( + `"${nodePathString}\\{(\\d+)\\}([^${this.delimiter}"]*)"`, + "g" + ); let count = 0; // Can be replaced with matchAll as soon as ECMAScript 2021 is declared standard in this project. // see: https://tc39.es/ecma262/#sec-string.prototype.matchall - while ((re.exec(this.stringifiedData)) !== null) { + while (re.exec(this.stringifiedData) !== null) { count++; } return count; } private escapeRegExp(string: string): string { - return string.replace(/[.+^${}()|[]\\]/g, '\\$&'); + return string.replace(/[.+^${}()|[]\\]/g, "\\$&"); } private replaceAll(str: string, find: string, replace: string): string { @@ -110,10 +123,20 @@ export default class TreeData { } private adjustNodeName(nodeName: string): string { - return this.replaceAll(this.replaceAll(this.escapeRegExp(nodeName), "*", "[^:\"]*"), "?", "."); + if (nodeName === undefined) { + console.log(nodeName); + } + return this.replaceAll( + this.replaceAll(this.escapeRegExp(nodeName), "*", '[^:"]*'), + "?", + "." + ); } - findFirstNode(nodePath: string[], completeNodePath = true): TreeDataNodeMetaData[] | null { + findFirstNode( + nodePath: string[], + completeNodePath = true + ): TreeDataNodeMetaData[] | null { let nodePathString = ""; for (let i = 0; i < nodePath.length; i++) { if (i > 0) { @@ -133,16 +156,26 @@ export default class TreeData { return result; } - findSuggestions(nodePath: string[]): { nodeName: string; metaData: TreeDataNodeMetaData }[] { + findSuggestions( + nodePath: string[] + ): { nodeName: string; metaData: TreeDataNodeMetaData }[] { const searchTerm = this.adjustNodeName(nodePath[nodePath.length - 1]); let nodePathString = ""; for (let i = 0; i < nodePath.length - 1; i++) { - nodePathString += `\\{(\\d+)\\}${this.adjustNodeName(nodePath[i])}${this.delimiter}`; + nodePathString += `\\{(\\d+)\\}${this.adjustNodeName(nodePath[i])}${ + this.delimiter + }`; } - const re = RegExp(`"${nodePathString}\\{(\\d+)\\}(${searchTerm}[^${this.delimiter}"]*)`, 'g'); + const re = RegExp( + `"${nodePathString}\\{(\\d+)\\}(${searchTerm}[^${this.delimiter}"]*)`, + "g" + ); - const suggestions: { nodeName: string; metaData: TreeDataNodeMetaData }[] = []; + const suggestions: { + nodeName: string; + metaData: TreeDataNodeMetaData; + }[] = []; const nodeNames: Set = new Set(); // Can be replaced with matchAll as soon as ECMAScript 2021 is declared standard in this project. @@ -157,23 +190,31 @@ export default class TreeData { suggestions.push({ nodeName: match[nodePath.length + 1], - metaData: this.nodeData[parseInt(match[nodePath.length + 0])] + metaData: this.nodeData[parseInt(match[nodePath.length + 0])], }); } return suggestions; } - findChildNodes(nodePath: string[]): { nodeName: string; metaData: TreeDataNodeMetaData }[] { + findChildNodes( + nodePath: string[] + ): { nodeName: string; metaData: TreeDataNodeMetaData }[] { let nodePathString = ""; for (let i = 0; i < nodePath.length; i++) { - nodePathString += `\\{(\\d+)\\}${this.adjustNodeName(nodePath[i])}${this.delimiter}`; + nodePathString += `\\{(\\d+)\\}${this.adjustNodeName(nodePath[i])}${ + this.delimiter + }`; } const re = RegExp( - `"${nodePathString}\\{(\\d+)\\}([^${this.delimiter}"]*)`, 'g' + `"${nodePathString}\\{(\\d+)\\}([^${this.delimiter}"]*)`, + "g" ); - const childNodes: { nodeName: string; metaData: TreeDataNodeMetaData }[] = []; + const childNodes: { + nodeName: string; + metaData: TreeDataNodeMetaData; + }[] = []; const nodeNames: Set = new Set(); // Can be replaced with matchAll as soon as ECMAScript 2021 is declared standard in this project. @@ -188,17 +229,20 @@ export default class TreeData { childNodes.push({ nodeName: match[nodePath.length + 2], - metaData: this.nodeData[parseInt(match[nodePath.length + 1])] + metaData: this.nodeData[parseInt(match[nodePath.length + 1])], }); } return childNodes; } cleanNodeName(nodeName: string): string { - return nodeName.replace(new RegExp(`\\{\\d+\\}`, 'g'), ""); + return nodeName.replace(new RegExp(`\\{\\d+\\}`, "g"), ""); } - findNodes(nodePath: string[]): { nodePaths: string[]; metaData: TreeDataNodeMetaData[][] } { + findNodes( + nodePath: string[], + exactMatch = false + ): { nodePaths: string[]; metaData: TreeDataNodeMetaData[][] } { let nodePathString = ""; for (let i = 0; i < nodePath.length; i++) { if (i > 0) { @@ -206,7 +250,9 @@ export default class TreeData { } nodePathString += `\\{(\\d+)\\}${this.adjustNodeName(nodePath[i])}`; } - const re = RegExp(`"(${nodePathString})`, 'g'); + const re = exactMatch + ? RegExp(`"(${nodePathString})"`, "g") + : RegExp(`"(${nodePathString})`, "g"); const metaData: TreeDataNodeMetaData[][] = []; const nodePaths: string[] = []; @@ -224,7 +270,7 @@ export default class TreeData { } return { nodePaths: nodePaths, - metaData: metaData + metaData: metaData, }; } } diff --git a/react/src/lib/components/SmartNodeSelector/utils/TreeNodeSelection.ts b/react/src/lib/components/SmartNodeSelector/utils/TreeNodeSelection.ts index 08369556..afc4b824 100644 --- a/react/src/lib/components/SmartNodeSelector/utils/TreeNodeSelection.ts +++ b/react/src/lib/components/SmartNodeSelector/utils/TreeNodeSelection.ts @@ -63,8 +63,7 @@ export default class TreeNodeSelection { } return nodePath; } - else - throw "The given index is out of bounds"; + return []; } getFocussedNodeName(): string { @@ -79,6 +78,9 @@ export default class TreeNodeSelection { } setNodeName(data: string, index?: number): void { + if (data === undefined) { + console.log(data); + } if (index !== undefined) { this.nodePath[index] = data; } @@ -242,13 +244,13 @@ export default class TreeNodeSelection { let text = ""; for (let i = 0; i < this.countLevel(); i++) { const el = this.getNodeName(i); - if (this.getFocussedLevel() == i && i < this.numMetaNodes && typeof el === "string") { + if (this.getFocussedLevel() === i && i < this.numMetaNodes && typeof el === "string") { text = el break; } else if (i >= this.numMetaNodes) { if (el === "" && this.getFocussedLevel() < i) break; - text += text == "" ? el : this.delimiter + el; + text += text === "" ? el : this.delimiter + el; } } return text; @@ -273,7 +275,7 @@ export default class TreeNodeSelection { isValidUpToFocussedNode(): boolean { return ( - this.getNodeName(this.focussedLevel) != "" + this.getNodeName(this.focussedLevel) !== "" && this.treeData.findFirstNode(this.getNodePath(this.focussedLevel), false) !== null ); } @@ -290,6 +292,9 @@ export default class TreeNodeSelection { } isValid(): boolean { + if (this.nodePath.length === 0) { + return false; + } return this.treeData.findFirstNode(this.nodePath) !== null; } @@ -298,7 +303,7 @@ export default class TreeNodeSelection { } exactlyMatchedNodePaths(): Array { - return this.treeData.findNodes(this.nodePath).nodePaths; + return this.treeData.findNodes(this.nodePath, true).nodePaths; } countExactlyMatchedNodePaths(): number {