-
Notifications
You must be signed in to change notification settings - Fork 29
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
POL-114 First version of typeahead (#87)
Currently only showing the facts keys on the typeahead
- Loading branch information
Showing
28 changed files
with
2,253 additions
and
184 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,3 @@ | ||
src/demoData/* | ||
/**/*.d.ts | ||
src/utils/Expression/* |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
import * as React from 'react'; | ||
import { ChangeEvent } from 'react'; | ||
import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; | ||
import { Fact } from '../../types/Fact'; | ||
import { CharStreams, CommonTokenStream } from 'antlr4ts'; | ||
import { ExpressionLexer } from '../../utils/Expression/ExpressionLexer'; | ||
import { ExpressionParser } from '../../utils/Expression/ExpressionParser'; | ||
import { ConditionVisitor, SuggestionType } from './ConditionVisitor'; | ||
import { style } from 'typestyle'; | ||
|
||
const selectOptionClassName = style({ | ||
whiteSpace: 'nowrap', | ||
overflow: 'hidden', | ||
textOverflow: 'ellipsis', | ||
direction: 'rtl' | ||
}); | ||
|
||
const factToOptions = (base: string, facts: Fact[]): JSX.Element[] => { | ||
return facts.map(o => ( | ||
<SelectOption | ||
className={ selectOptionClassName } | ||
key={ base + o.id } | ||
value={ base + o.name } | ||
>{ base } <b> { o.name } </b> </SelectOption> | ||
)); | ||
}; | ||
|
||
export interface ConditionFieldProps { | ||
label: string; | ||
id: string; | ||
name: string; | ||
facts: Fact[]; | ||
selected: string; | ||
onSelect: (selected: string) => void; | ||
} | ||
|
||
export const ConditionField: React.FunctionComponent<ConditionFieldProps> = (props) => { | ||
|
||
const { facts, onSelect, selected } = props; | ||
const [ isOpen, setOpen ] = React.useState<boolean>(false); | ||
const [ options, setOptions ] = React.useState<JSX.Element[] | undefined>( | ||
factToOptions('', facts.slice(0, 10)) | ||
); | ||
|
||
const onFilter = React.useCallback((event: ChangeEvent<HTMLInputElement>) => { | ||
const localSelection = event.target.value; | ||
onSelect(localSelection); | ||
|
||
const inputStream = CharStreams.fromString(localSelection); | ||
const lexer = new ExpressionLexer(inputStream); | ||
const tokenStream = new CommonTokenStream(lexer); | ||
const parser = new ExpressionParser(tokenStream); | ||
const tree = parser.expression(); | ||
|
||
const visitor = new ConditionVisitor(); | ||
const result = visitor.visit(tree); | ||
|
||
if (result && result.suggestion.type === SuggestionType.FACT) { | ||
const resultValue = result.value; | ||
if (resultValue) { | ||
setOpen(true); | ||
const updatedSelection = localSelection.slice(0, localSelection.lastIndexOf(resultValue)).trim() + ' '; | ||
setOptions(factToOptions(updatedSelection, facts.filter(f => f.name && f.name.includes(resultValue)).slice(0, 10))); | ||
} else { | ||
setOptions(factToOptions(localSelection.trim() + ' ', facts.slice(0, 10))); | ||
} | ||
|
||
setOpen(true); | ||
} else { | ||
setOptions([]); | ||
setOpen(false); | ||
} | ||
|
||
return []; | ||
}, [ facts, onSelect ]); | ||
|
||
const onSelectCallback = React.useCallback((event, selected) => { | ||
onSelect(selected.toString()); | ||
setOpen(false); | ||
}, [ setOpen, onSelect ]); | ||
|
||
const onClear = React.useCallback(() => { | ||
onSelect(''); | ||
}, [ onSelect ]); | ||
|
||
return ( | ||
<Select | ||
label={ props.label } | ||
id={ props.id } | ||
name={ props.name } | ||
onToggle={ () => setOpen(() => !isOpen) } | ||
isExpanded={ isOpen } | ||
selections={ selected } | ||
variant={ SelectVariant.typeahead } | ||
onSelect={ onSelectCallback } | ||
onFilter={ onFilter } | ||
onClear={ onClear } | ||
ariaLabelTypeAhead="Condition writer" | ||
style={ { | ||
maxWidth: '100%' | ||
} } | ||
> | ||
{ options } | ||
</Select> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
import * as React from 'react'; | ||
import { ConditionField, ConditionFieldProps } from './ConditionField'; | ||
import { useField } from 'formik'; | ||
import { FormGroup, Text, TextVariants } from '@patternfly/react-core'; | ||
|
||
type ConditionFieldWithFormikProp = Omit<ConditionFieldProps, 'onSelect' | 'selected'> & { | ||
isRequired?: boolean; | ||
hint?: string; | ||
}; | ||
|
||
export const ConditionFieldWithForkmik: React.FunctionComponent<ConditionFieldWithFormikProp> = (props) => { | ||
const { hint, ...otherProps } = props; | ||
const [ field, meta, { setValue }] = useField({ ...otherProps }); | ||
const isValid = !meta.error || !meta.touched; | ||
|
||
const onSelect = React.useCallback((selected) => { | ||
setValue(selected); | ||
}, [ setValue ]); | ||
|
||
return ( | ||
<FormGroup | ||
fieldId={ props.id } | ||
helperTextInvalid={ meta.error } | ||
isRequired={ props.isRequired } | ||
isValid={ isValid } | ||
label={ props.label } | ||
> | ||
<ConditionField | ||
{ ...otherProps } | ||
{ ...field } | ||
selected={ field.value ? field.value.toString() : field.value } | ||
onSelect={ onSelect } | ||
/> | ||
{ hint && <Text component={ TextVariants.small }>{ hint }</Text> } | ||
</FormGroup> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
import { AbstractParseTreeVisitor, ErrorNode, TerminalNode } from 'antlr4ts/tree'; | ||
|
||
import { ExpressionVisitor } from '../../utils/Expression/ExpressionVisitor'; | ||
import { | ||
ArrayContext, ExprContext, | ||
// eslint-disable-next-line @typescript-eslint/camelcase | ||
KeyContext, Logical_operatorContext, | ||
ValueContext | ||
} from '../../utils/Expression/ExpressionParser'; | ||
|
||
export enum SuggestionType { | ||
FACT = 'FACT', | ||
VALUE = 'VALUE', | ||
LOGICAL_OPERATOR = 'LOGICAL_OPERATOR', | ||
BOOLEAN_OPERATOR = 'BOOLEAN_OPERATOR', | ||
ARRAY_OPERATOR = 'ARRAY_OPERATOR', | ||
NONE = 'NO_SUGGESTION' | ||
} | ||
|
||
interface SuggestionFact { | ||
type: SuggestionType.FACT; | ||
} | ||
|
||
interface SuggestionValue { | ||
type: SuggestionType.VALUE; | ||
fact: string; | ||
} | ||
|
||
interface SuggestionLogicalOperator { | ||
type: SuggestionType.LOGICAL_OPERATOR; | ||
} | ||
|
||
interface SuggestionNone { | ||
type: SuggestionType.NONE; | ||
} | ||
|
||
type Suggestion = SuggestionFact | SuggestionValue | SuggestionLogicalOperator | SuggestionNone; | ||
|
||
const makeSuggestionFact = (): SuggestionFact => ({ type: SuggestionType.FACT }); | ||
const makeSuggestionValue = (fact: string): SuggestionValue => ({ type: SuggestionType.VALUE, fact }); | ||
const makeSuggestionLogicalOperator = (): SuggestionLogicalOperator => ({ type: SuggestionType.LOGICAL_OPERATOR }); | ||
const makeSuggestionNone = (): SuggestionNone => ({ type: SuggestionType.NONE }); | ||
|
||
export class ConditionVisitorResult { | ||
readonly suggestion: Suggestion; | ||
readonly value: string | undefined; | ||
|
||
constructor(suggestion: Suggestion, value?: string) { | ||
this.suggestion = suggestion; | ||
this.value = value; | ||
} | ||
|
||
} | ||
|
||
type ReturnValue = ConditionVisitorResult | undefined; | ||
|
||
/** | ||
* Condition visitors returns a list of suggestions based on where we currently are. | ||
*/ | ||
export class ConditionVisitor extends AbstractParseTreeVisitor<ReturnValue> implements ExpressionVisitor<ReturnValue> { | ||
|
||
protected defaultResult() { | ||
return new ConditionVisitorResult(makeSuggestionFact()); | ||
} | ||
|
||
protected aggregateResult(aggregate, nextResult) { | ||
if (nextResult) { | ||
return nextResult; | ||
} | ||
|
||
return aggregate; | ||
} | ||
|
||
visitTerminal(_node: TerminalNode) { | ||
return undefined; | ||
} | ||
|
||
visitErrorNode(_node: ErrorNode): ReturnValue { | ||
return undefined; | ||
} | ||
|
||
// eslint-disable-next-line @typescript-eslint/camelcase | ||
visitLogical_operator(ctx: Logical_operatorContext) { | ||
// eslint-disable-next-line new-cap | ||
const operator = ctx.AND() || ctx.OR(); | ||
if (!operator) { | ||
new ConditionVisitorResult(makeSuggestionLogicalOperator()); | ||
} | ||
|
||
return new ConditionVisitorResult(makeSuggestionFact()); | ||
} | ||
|
||
visitKey(ctx: KeyContext) { | ||
// eslint-disable-next-line new-cap | ||
return new ConditionVisitorResult(makeSuggestionFact(), ctx.SIMPLETEXT().text); | ||
} | ||
|
||
visitValue(ctx: ValueContext) { | ||
// eslint-disable-next-line new-cap | ||
const nodeValue = ctx.NUMBER() || ctx.STRING(); | ||
|
||
if (!nodeValue) { | ||
if (ctx.parent instanceof ExprContext) { | ||
// Todo: Expected value inside ExprContext | ||
} else if (ctx.parent instanceof ArrayContext) { | ||
// Todo: Expected value inside ArrayContext | ||
} | ||
|
||
return new ConditionVisitorResult(makeSuggestionNone()); | ||
} | ||
|
||
return new ConditionVisitorResult(makeSuggestionValue(''), nodeValue ? nodeValue.text : ctx.text); | ||
} | ||
|
||
} |
65 changes: 65 additions & 0 deletions
65
src/components/Condition/__tests__/ConditionVisitor.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
import { ExpressionContext, ExpressionParser } from '../../../utils/Expression/ExpressionParser'; | ||
import { CharStreams, CommonTokenStream } from 'antlr4ts'; | ||
import { ExpressionLexer } from '../../../utils/Expression/ExpressionLexer'; | ||
import { ConditionVisitor, ConditionVisitorResult, SuggestionType } from '../ConditionVisitor'; | ||
|
||
describe('src/components/Condition/ConditionVisitor', () => { | ||
|
||
const treeForCondition = (condition: string): ExpressionContext => { | ||
const lexer = new ExpressionLexer( | ||
CharStreams.fromString(condition) | ||
); | ||
const tokenStream = new CommonTokenStream(lexer); | ||
lexer.removeErrorListeners(); | ||
const parser = new ExpressionParser(tokenStream); | ||
parser.removeErrorListeners(); | ||
return parser.expression(); | ||
}; | ||
|
||
it('Provides facts on empty condition', () => { | ||
const conditionVisitor = new ConditionVisitor(); | ||
const result = conditionVisitor.visit(treeForCondition('')); | ||
const expectedResult = new ConditionVisitorResult({ type: SuggestionType.FACT }); | ||
expect(result).toEqual(expectedResult); | ||
}); | ||
|
||
it('Provides facts on initial param', () => { | ||
const conditionVisitor = new ConditionVisitor(); | ||
const result = conditionVisitor.visit(treeForCondition('ar')); | ||
const expectedResult = new ConditionVisitorResult({ type: SuggestionType.FACT }, 'ar'); | ||
expect(result).toEqual(expectedResult); | ||
}); | ||
|
||
it('Detects last fact on condition', () => { | ||
const conditionVisitor = new ConditionVisitor(); | ||
const condition = 'facts.arch = "x86_64" and cpu'; | ||
const result = conditionVisitor.visit(treeForCondition(condition)); | ||
const expectedResult = new ConditionVisitorResult({ type: SuggestionType.FACT }, 'cpu'); | ||
expect(result).toEqual(expectedResult); | ||
}); | ||
|
||
it('Detects when a value is being written using String', () => { | ||
const conditionVisitor = new ConditionVisitor(); | ||
const condition = 'facts.arch = "my-value" '; | ||
const result = conditionVisitor.visit(treeForCondition(condition)); | ||
const expectedResult = new ConditionVisitorResult({ type: SuggestionType.VALUE, fact: '' }, '"my-value"'); | ||
expect(result).toEqual(expectedResult); | ||
}); | ||
|
||
it('Detects when a value is being written using number', () => { | ||
const conditionVisitor = new ConditionVisitor(); | ||
const condition = 'facts.arch = 5'; | ||
const result = conditionVisitor.visit(treeForCondition(condition)); | ||
const expectedResult = new ConditionVisitorResult({ type: SuggestionType.VALUE, fact: '' }, '5'); | ||
expect(result).toEqual(expectedResult); | ||
}); | ||
|
||
it('Detects a fact is after a logical operator', () => { | ||
const conditionVisitor = new ConditionVisitor(); | ||
const condition = 'facts.arch = 5 and '; | ||
const result = conditionVisitor.visit(treeForCondition(condition)); | ||
const expectedResult = new ConditionVisitorResult({ type: SuggestionType.FACT }); | ||
expect(result).toEqual(expectedResult); | ||
}); | ||
|
||
}); |
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.