Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add validators for token input / adding tokens #43

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,15 @@ Small demo: [click here](https://sabieber.github.io/token-autocomplete/)
| suggestionsUri | An optional URI which when defined is called to provide suggestions for the text entered by the user | '' |
| suggestionsUriBuilder | A function which is called before sending the suggestions request so the URI can be altered/updated. | (query) -> return this.suggestionsUri + '?query=' + query |
| suggestionRenderer | Function which creates the DOM element for each displayed suggestion. | TokenAutocomplete.Autocomplete.defaultRenderer |
| allowDuplicates | Allow duplicate tokens in the list | true |
| searchWithin | Search typed input at start (false) or somewhere within the suggestion (true) | false |
| tokenInputValidator | Function that validates the input text - when the function returns true, the token can be added to the token list | |
| tokenAddValidator | Function that validates the input text - when the function returns true, the token will be added to the token list | |

The difference between ```tokenInputValidator``` and ```tokenAddValidator``` is, that
the input validator is called when the input is still visible in the suggestion
text field - on error the field is not cleared and an error message will be
displayed (e.g. validation of a specific format of input).
The add validator is called when the token should be added to the
list of tokens. When th validator returns false, the token will be
ignored without any further notice.
4 changes: 4 additions & 0 deletions dist/token-autocomplete.css
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
color: rgba(0, 0, 0, 0.6);
}

.token-autocomplete-container .token-autocomplete-error {
color: #c00;
}

.token-autocomplete-container .token-autocomplete-token {
font-size: 16px;
line-height: 32px;
Expand Down
120 changes: 114 additions & 6 deletions lib/token-autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ interface Suggestion {
completionDescription: string | null
}

interface ErrorMessages {
duplicateToken: string;
emptyInput: string
}

interface Options {
name: string,
selector: string,
Expand All @@ -25,12 +30,17 @@ interface Options {
suggestionsUri: string,
suggestionsUriBuilder: SuggestionUriBuilder,
suggestionRenderer: SuggestionRenderer,
tokenInputValidator: TokenInputValidator,
tokenAddValidator: TokenAddValidator,
minCharactersForSuggestion: number,
allowCustomEntries: boolean,
readonly: boolean,
optional: boolean,
allowDuplicates: boolean,
enableTabulator: boolean,
requestDelay: number
requestDelay: number,
searchWithin: boolean,
errorMessages: ErrorMessages
}

enum SelectModes {
Expand All @@ -53,6 +63,9 @@ interface SingleSelect extends SelectMode {
}

interface MultiSelect extends SelectMode {
parent: TokenAutocomplete;
options: Options;

removeToken(token: HTMLSpanElement): void;

removeLastToken(): void;
Expand Down Expand Up @@ -96,6 +109,22 @@ interface SuggestionUriBuilder {
(query: string): string;
}

interface TokenInputValidator {
(msel: MultiSelect, suggestion: string): TokenInputError|null;
}

class TokenInputError {
message: string;

constructor(message: string) {
this.message = message;
}
}

interface TokenAddValidator {
(msel: MultiSelect, newtoken: Token): TokenInputError|null;
}

class TokenAutocomplete {

KEY_BACKSPACE = 'Backspace';
Expand All @@ -111,6 +140,7 @@ class TokenAutocomplete {
container: any;
hiddenSelect: HTMLSelectElement;
textInput: HTMLSpanElement;
errorContainer: HTMLDivElement;

select: SelectMode;
autocomplete: Autocomplete;
Expand All @@ -129,12 +159,20 @@ class TokenAutocomplete {
return this.suggestionsUri + '?query=' + query
},
suggestionRenderer: TokenAutocomplete.Autocomplete.defaultRenderer,
tokenInputValidator: TokenAutocomplete.Autocomplete.defaultInputValidator,
tokenAddValidator: TokenAutocomplete.Autocomplete.defaultAddValidator,
minCharactersForSuggestion: 1,
allowCustomEntries: true,
readonly: false,
optional: false,
allowDuplicates: true,
enableTabulator: true,
requestDelay: 200
requestDelay: 200,
searchWithin: false,
errorMessages: {
emptyInput: 'Enter a value',
duplicateToken: 'This value is already in the list'
}
};
log: any;

Expand Down Expand Up @@ -183,6 +221,12 @@ class TokenAutocomplete {
}
this.container.appendChild(this.textInput);

if (!this.options.readonly) {
this.errorContainer = document.createElement('div');
this.errorContainer.id = this.container.id + '-error';
this.errorContainer.classList.add('token-autocomplete-error');
this.container.appendChild(this.errorContainer);
}

this.container.appendChild(this.hiddenSelect);
this.addHiddenEmptyOption();
Expand Down Expand Up @@ -410,6 +454,8 @@ class TokenAutocomplete {
} else if (parent.getCurrentInput() === '' && event.key == parent.KEY_BACKSPACE) {
event.preventDefault();
me.removeLastToken();
} else {
me.clearError();
}
if ((event.key == parent.KEY_DOWN || event.key == parent.KEY_UP) && parent.autocomplete.suggestions.childNodes.length > 0) {
event.preventDefault();
Expand All @@ -425,8 +471,15 @@ class TokenAutocomplete {
*/
handleInputAsValue(input: string): void {
if (this.parent.options.allowCustomEntries) {
this.clearCurrentInput();
this.addToken(input, input, null);
const check = this.options.tokenInputValidator(this, input);
if (check === null) {
// token is valid and can be added
this.clearCurrentInput();
this.addToken(input, input, null);
} else {
// show error
this.showError(check.message);
}
return;
}
if (this.parent.autocomplete.suggestions.childNodes.length === 1) {
Expand All @@ -449,14 +502,21 @@ class TokenAutocomplete {
return;
}

this.parent.addHiddenOption(tokenValue, tokenText, tokenType);

let addedToken = {
value: tokenValue,
text: tokenText,
type: tokenType
};

const tokenError = this.options.tokenAddValidator(this, addedToken);
if (tokenError !== null) {
// show error
this.showError(tokenError.message);
return;
}

this.parent.addHiddenOption(tokenValue, tokenText, tokenType);

let element = this.renderer(addedToken);

let me = this;
Expand Down Expand Up @@ -536,6 +596,13 @@ class TokenAutocomplete {
this.parent.log('removed token', token.textContent);
}

showError(message: string) {
this.parent.errorContainer.innerText = message;
}
clearError() {
this.parent.errorContainer.innerText = '';
}

removeTokenWithText(tokenText: string | null) {
if (tokenText === null) {
return;
Expand Down Expand Up @@ -897,6 +964,13 @@ class TokenAutocomplete {
} else if (value.localeCompare(text.slice(0, value.length), undefined, {sensitivity: 'base'}) === 0) {
// The suggestion starts with the query text the user entered and will be displayed.
me.addSuggestion(suggestion);
} else if (me.options.searchWithin) {
const localeValue = value.toLocaleLowerCase();
const localeText = text.toLocaleLowerCase();
if (localeText.indexOf(localeValue) >=0 ) {
// The suggestion contains the query text the user entered and will be displayed.
me.addSuggestion(suggestion);
}
}
});
if (me.suggestions.childNodes.length == 0 && me.parent.options.noMatchesText) {
Expand Down Expand Up @@ -1098,5 +1172,39 @@ class TokenAutocomplete {

return option;
}

static isDuplicate(msel: MultiSelect, input: string): boolean {
// check for duplcates and reject existing values
const options = msel.parent.hiddenSelect.options;
for (let i = 0; i < options.length; ++i) {
if (options[i].value === input) {
// duplicate value
return true;
}
}
return false;
}

static defaultInputValidator: TokenInputValidator = function (msel: MultiSelect, input: string) : TokenInputError|null {
if (input === '') {
return new TokenInputError(msel.options.errorMessages.emptyInput);
}
if (TokenAutocomplete.Autocomplete.isDuplicate(msel, input)) {
// duplicate value
return new TokenInputError(msel.options.errorMessages.duplicateToken);
}
return null;
}

static defaultAddValidator: TokenAddValidator = function(msel: MultiSelect, token: Token) : TokenInputError|null {
if (! msel.options.allowDuplicates) {
// check for duplcates and reject existing values
if (TokenAutocomplete.Autocomplete.isDuplicate(msel, token.value)) {
// duplicate value
return new TokenInputError(msel.options.errorMessages.duplicateToken);
}
}
return null;
}
}
}