Skip to content

Commit

Permalink
feat: use Fuse.js as default search functionality on client (#5)
Browse files Browse the repository at this point in the history
  • Loading branch information
voznik committed Oct 16, 2024
1 parent aae2fdc commit 1f757c0
Show file tree
Hide file tree
Showing 9 changed files with 172 additions and 76 deletions.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "streamlit-textcomplete"
version = "0.1.2"
version = "0.2.0"
description = "Streamlit autocomplete Textcomplete editor for HTMLTextAreaElement"
authors = ["voznik <[email protected]>"]
readme = "README.md"
Expand Down
27 changes: 16 additions & 11 deletions textcomplete/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
List,
Literal,
Optional,
Tuple,
TypedDict,
TypeVar,
Tuple,
Union,
)

Expand Down Expand Up @@ -62,30 +62,33 @@ class StrategyProps:
```typescript
type ReplaceResult = [string, string] | string | null;
export interface StrategyProps<T = any> {
id: string;
match: RegExp | ((regexp: string | RegExp) => RegExpMatchArray | null);
search: (term: string, callback: SearchCallback<T>, match: RegExpMatchArray) => void;
template: (data: T, term: string) => string;
replace: (data: T) => ReplaceResult;
search: (term: string, callback: SearchCallback<T>, match: RegExpMatchArray) => void;
cache?: boolean;
context?: (text: string) => string | boolean;
template?: (data: T, term: string) => string;
index?: number;
id?: string;
data?: Arrray<T> | DataFrame;
fuse_options?: FuseOptions;
}
```
"""

def __init__(
self,
match: str = None,
id: str,
match: str,
template: str,
replace: str,
search: str = None,
replace: str = None,
cache: bool = False,
context: str = None,
template: str = None,
index: str = None,
id: str = None,
data: List[Dict[str, Any]] | pd.DataFrame = None,
comparator_keys: List[str] = None,
fuse_options: Dict[str, Any] = None,
# comparator_keys: List[str] = None,
) -> None:
self.match = match
self.search = search
Expand All @@ -96,7 +99,8 @@ def __init__(
self.index = index
self.id = id
self.data = data
self.comparator_keys = comparator_keys if comparator_keys is not None else []
self.fuse_options = fuse_options
# self.comparator_keys = comparator_keys if comparator_keys is not None else []

def to_dict(self) -> Dict[str, Any]:
result = {
Expand All @@ -108,7 +112,8 @@ def to_dict(self) -> Dict[str, Any]:
"template": self.template,
"index": self.index,
"id": self.id,
"comparatorKeys": self.comparator_keys,
# "comparatorKeys": self.comparator_keys,
"fuseOptions": self.fuse_options,
}

if isinstance(self.data, pd.DataFrame):
Expand Down
54 changes: 46 additions & 8 deletions textcomplete/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
textcomplete,
)

# Example of strategy with async search function JS string
# Type `@` to see the users list
username_strategy = StrategyProps(
id="userFullName",
match="\\B@(\\w*)$",
Expand All @@ -24,18 +26,53 @@
template="""([fullName]) => `🧑🏻 ${fullName}`""",
)

company_data = [
{
"name": "Amazon",
"products": [
{"name": "Echo Dot"},
{"name": "Kindle Paperwhite"},
{"name": "Amazon Fire TV Stick"},
],
},
{
"name": "Netflix",
"products": [{"name": "Stranger Things"}, {"name": "The Crown"}, {"name": "Squid Game"}],
},
{
"name": "Google",
"products": [
{"name": "Google Pixel 7"},
{"name": "Nest Thermostat"},
{"name": "Chromecast"},
],
},
]

# Example of strategy with default search function is provided by the component
# Type `#` to see the company list
company_name_strategy = StrategyProps(
id="companyName",
match="\\B#(\\w*)$",
template="""(company) => company.name""",
replace="""(company) => `${company.name}`""",
data=company_data,
fuse_options={"keys": ["name", "products.name"], "shouldSort": False, "threshold": 0.5},
)

emoji_data = [
{"name": properties["en"], "value": unicode_repr}
for unicode_repr, properties in EMOJI_DATA.items()
]
# Example of strategy with default search & replace functions provided by the component
# if search_data list is provided

# Example of strategy with default search function is provided by the component
# Type `:` to see the emoji list
emoji_strategy = StrategyProps(
id="emoji",
match="\\B:(\\w*)$",
template="""(emoji) => `${emoji.value} :${emoji.name}`""",
replace="""emoji => emoji.value""",
data=emoji_data,
comparator_keys=["name", "value"],
template="""(emoji) => `${emoji['value']} :${emoji['name']}`""",
)

col1, col2 = st.columns(2, gap="medium")
Expand Down Expand Up @@ -70,12 +107,13 @@ def on_select(textcomplete_result: TextcompleteResult):
st.caption(f"You wrote {len(txt)} characters.")
st.write(
""":orange[⚠️ IMPORTANT: Always type a space after autocomplete.
There's no way to update streamlit react component state event though textarea value is updated :( ]"""
There's no way to update streamlit react component state event
even though textarea value is updated :( ]"""
)

textcomplete(
area_label=original_label,
strategies=[username_strategy, emoji_strategy],
strategies=[username_strategy, company_name_strategy, emoji_strategy],
on_select=on_select,
max_count=5,
stop_enter_propagation=True,
Expand All @@ -95,7 +133,7 @@ def on_select(textcomplete_result: TextcompleteResult):
# data-testid="stChatInputTextArea"
textcomplete(
area_label=chat_input_label,
strategies=[username_strategy, emoji_strategy],
strategies=[username_strategy, company_name_strategy, emoji_strategy],
on_select=on_select,
max_count=10,
stop_enter_propagation=True,
Expand All @@ -109,7 +147,7 @@ def on_select(textcomplete_result: TextcompleteResult):
## Usage
To use textcomplete, you have to create a Textcomplete object with an editor:
# noqa: F401,E501
## How it works
(An input event is triggered to the underlying HTML element.)
The editor emits a change event.
Expand Down
1 change: 1 addition & 0 deletions textcomplete/frontend/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@

/dist
/build
streamlit*
2 changes: 1 addition & 1 deletion textcomplete/frontend/build/index.js

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion textcomplete/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions textcomplete/frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
{
"name": "streamlit-textcomplete",
"version": "0.0.1",
"version": "0.2.0",
"description": "Streamlit autocomplete Textcomplete editor for HTMLTextAreaElement",
"main": "index.js",
"scripts": {
"postinstall": "patch-package",
"rspack": "rspack",
"build": "rspack build",
"dev": "rspack dev",
"test": "jest"
"test": "jest",
"lint": "eslint src",
"format": "prettier src --write"
},
"keywords": [
"streamlit",
Expand All @@ -20,6 +22,7 @@
"dependencies": {
"@textcomplete/core": "^0.1.13",
"@textcomplete/textarea": "^0.1.13",
"fuse.js": "^7.0.0",
"streamlit-component-lib": "^2.0.0"
},
"devDependencies": {
Expand Down
106 changes: 65 additions & 41 deletions textcomplete/frontend/src/helpers.js
Original file line number Diff line number Diff line change
@@ -1,76 +1,100 @@
import Fuse from 'fuse.js/basic';

const validateAndConvertFunction = (fnString, fnName) => {
if (fnString && typeof fnString === 'string' && fnString.trim() !== '') {
try {
const fn = new Function('return ' + fnString)();
if (typeof fn !== 'function') {
throw new Error(`${fnName} is not a valid function`);
}
return fn;
} catch (error) {
throw new Error(`Invalid ${fnName} function: ` + error.message);
}
} else {
throw new Error(`${fnName} is not a valid string or is empty`);
}
};

const DEFAULT_FUSE_OPTIONS = { keys: ['name'] };
const DEFAULT_NOOP_FN = `() => []`;

/**
* Convert stringified functions back into functions
* @param {import('@textcomplete/core').StrategyProps} props
* @param {any[]} data
* @param {string} key
*/
export const convertStrategyProps = (props, data = [], [labelKey, valueKey] = []) => {
let searchFn = new Function('return ' + props.search)();
let replaceFn = new Function('return ' + props.replace)();
let templateFn = props.template && new Function('return ' + props.template)();
let contextFn = props.context && new Function('return ' + props.context)();
// If data is provided, create a default search function that filters the data by key
if (Array.isArray(data) && data.length && labelKey && valueKey) {
export const convertStrategyProps = ({
id,
index,
cache,
match,
search,
replace,
template,
context,
data = [],
fuseOptions,
}) => {
let searchFn = validateAndConvertFunction(search || DEFAULT_NOOP_FN, 'search');
let replaceFn = validateAndConvertFunction(replace, 'replace');
let templateFn = validateAndConvertFunction(template, 'template');
let contextFn = context && new Function('return ' + context)();
// If data is provided, create a default search function that uses Fuse.js to search the data
if (Array.isArray(data) && data.length) {
const fuse = new Fuse(data, fuseOptions || DEFAULT_FUSE_OPTIONS);
// (Required) When the current input matches the "match" regexp above, this
// function is called. The first argument is the captured substring.
// You can callback only once for each search.
searchFn = (term, callback, match) => {
const filteredData = data.filter(item =>
`${item[labelKey]}`.toLowerCase().includes(term.toLowerCase())
);
callback(filteredData);
searchFn = (term, callback) => {
const result = fuse.search(term).map(result => result.item);
callback(result);
};
// (Required) Specify how to update the editor value. The whole substring
// matched in the match phase will be replaced by the returned value.
// Note that it can return a string or an array of two strings. If it returns
// an array, the matched substring will be replaced by the concatenated string
// and the cursor will be set between first and second strings.
replaceFn = item => `${item[valueKey]}`;
}
return {
id: props.id,
index: props.index,
cache: props.cache,
match: new RegExp(props.match),
id: id,
index: index,
cache: cache,
match: new RegExp(match),
search: searchFn,
replace: replaceFn,
template: templateFn,
context: contextFn,
};
};

/**
* Parse the Textcomplete args
* @param {any} args
* @param {any} theme
* */
// @returns {import('@textcomplete/core').TextcompleteOption}
export const parseTextcompleteArgs = (args, theme) => {
if (!args.area_label) {
throw new Error('Textcomplete: No label provided.');
}
const label = args.area_label;
const stopEnterPropagation = args.stop_enter_propagation || false;
export const parseTextcompleteStrategies = args => {
if (!args.strategies || !Array.isArray(args.strategies)) {
throw new Error('Textcomplete: No strategies provided.');
}
const strategies = args.strategies.map(s =>
convertStrategyProps(s, s.data, s.comparatorKeys)
);
const strategies = args.strategies.map(s => convertStrategyProps(s));
if (!strategies.length) {
console.warn('Textcomplete: No strategies provided. There will be no autocomplete.');
}
const option = {
return strategies;
};

export const parseTextcompleteOption = args => {
return {
dropdown: Object.assign({}, args.dropdown_option),
};
const variables = `
};

export const parseTextcompleteLabel = args => {
if (!args.area_label) {
throw new Error('Textcomplete: No label provided.');
}
return args.area_label;
};

export const parseTextcompleteCss = theme => {
return `
:root {
--background-color: ${theme.backgroundColor};
--secondary-background-color: ${theme.secondaryBackgroundColor};
--text-color: ${theme.textColor};
--primary-color: ${theme.primaryColor};
};
`;
const css = variables;
return { label, strategies, option, stopEnterPropagation, css };
};
Loading

0 comments on commit 1f757c0

Please sign in to comment.