Skip to content

Commit

Permalink
POL-114 First version of typeahead (#87)
Browse files Browse the repository at this point in the history
Currently only showing the facts keys on the typeahead
  • Loading branch information
josejulio authored and theute committed Apr 20, 2020
1 parent 6f417ae commit 023665a
Show file tree
Hide file tree
Showing 28 changed files with 2,253 additions and 184 deletions.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
src/demoData/*
/**/*.d.ts
src/utils/Expression/*
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@
"@redhat-cloud-services/frontend-components-notifications": "1.0.2",
"@redhat-cloud-services/frontend-components-utilities": "1.0.0",
"@redhat-cloud-services/rbac-client": "^1.0.51",
"antlr4ts": "^0.5.0-alpha.3",
"axios": "^0.19.0",
"classnames": "^2.2.6",
"csstips": "^1.2.0",
"date-fns": "^2.9.0",
"formik": "^2.0.6",
"formik": "2.1.4",
"http-status-codes": "^1.4.0",
"react": "^16.11.0",
"react-content-loader": "^3.4.2",
Expand Down Expand Up @@ -53,6 +54,7 @@
"@types/yup": "^0.26.26",
"@typescript-eslint/eslint-plugin": "^2.9.0",
"@typescript-eslint/parser": "^2.9.0",
"antlr4ts-cli": "^0.5.0-alpha.3",
"babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0",
Expand Down
106 changes: 106 additions & 0 deletions src/components/Condition/ConditionField.tsx
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>
);
};
37 changes: 37 additions & 0 deletions src/components/Condition/ConditionFieldWithFormik.tsx
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>
);
};
115 changes: 115 additions & 0 deletions src/components/Condition/ConditionVisitor.ts
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 src/components/Condition/__tests__/ConditionVisitor.test.ts
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);
});

});
28 changes: 0 additions & 28 deletions src/components/ConditionEditor/AndBlock.tsx

This file was deleted.

Loading

0 comments on commit 023665a

Please sign in to comment.