Repository: streamlit/streamlit

Issue:
Specify max number of characters in text area
### Problem

Right now, there is no limit to specify number of characters in text area. This is very risky and an overkill if users use it to process too large textual data.

### Solution

It would be great to give users the option to specify the max number of characters allowed in text area. It should be up to the user to specify number of characters though. 



Discussion:
How about a `max_chars` kwarg? Default `==None` meaning infinite.
I think this should be fine. I hope the documentation will be updated accordingly once this is implemented.

Thanks a million @treuille for creating such an amazing framework. 

Sorry to bother you guys. Any progress on this one? I am trying to make an app available that uses spaCy, but increased number of words in the text area basically kills it. I want to limit to something like 2000 characters for example.
Hi @mzeidhassan we are prioritizing it on our backlog but in the meantime you could do something like this:

```python
import streamlit as st

text = st.text_area("Your text:")

if len(text) > 10:
    st.error("You can't use more than 10 characters")
else:
    st.success(text)

```

Resolving patch:
diff --git a/e2e/scripts/st_text_area.py b/e2e/scripts/st_text_area.py
--- a/e2e/scripts/st_text_area.py
+++ b/e2e/scripts/st_text_area.py
@@ -25,3 +25,6 @@
 
 i4 = st.text_area("text area 4", None)
 st.write('value 4: "', i4, '"')
+
+i5 = st.text_area("text area 5", max_chars=10)
+st.write('value 5: "', i5, '"')
diff --git a/lib/streamlit/DeltaGenerator.py b/lib/streamlit/DeltaGenerator.py
--- a/lib/streamlit/DeltaGenerator.py
+++ b/lib/streamlit/DeltaGenerator.py
@@ -1723,7 +1723,7 @@ def _check_and_convert_to_indices(options, default_values):
             if not isinstance(default_values, list):
                 # This if is done before others because calling if not x (done
                 # right below) when x is of type pd.Series() or np.array() throws a
-                # ValueError exception.                
+                # ValueError exception.
                 if is_type(default_values, "numpy.ndarray") or is_type(
                     default_values, "pandas.core.series.Series"
                 ):
@@ -2214,7 +2214,7 @@ def beta_color_picker(self, element, label, value=None, key=None):
         return str(current_value)
 
     @_with_element
-    def text_input(self, element, label, value="", key=None, type="default"):
+    def text_input(self, element, label, value="", max_chars=None, key=None, type="default"):
         """Display a single-line text input widget.
 
         Parameters
@@ -2224,6 +2224,8 @@ def text_input(self, element, label, value="", key=None, type="default"):
         value : any
             The text value of this widget when it first renders. This will be
             cast to str internally.
+        max_chars : int or None
+            Max number of characters allowed in text input.
         key : str
             An optional string to use as the unique key for the widget.
             If this is omitted, a key will be generated for the widget
@@ -2247,6 +2249,10 @@ def text_input(self, element, label, value="", key=None, type="default"):
         """
         element.text_input.label = label
         element.text_input.default = str(value)
+
+        if max_chars is not None:
+            element.text_input.max_chars = max_chars
+
         if type == "default":
             element.text_input.type = TextInput.DEFAULT
         elif type == "password":
@@ -2262,7 +2268,7 @@ def text_input(self, element, label, value="", key=None, type="default"):
         return str(current_value)
 
     @_with_element
-    def text_area(self, element, label, value="", height=None, key=None):
+    def text_area(self, element, label, value="", height=None, max_chars=None, key=None):
         """Display a multi-line text input widget.
 
         Parameters
@@ -2275,6 +2281,8 @@ def text_area(self, element, label, value="", height=None, key=None):
         height : int or None
             Desired height of the UI element expressed in pixels. If None, a
             default height is used.
+        max_chars : int or None
+            Maximum number of characters allowed in text area.
         key : str
             An optional string to use as the unique key for the widget.
             If this is omitted, a key will be generated for the widget
@@ -2301,9 +2309,12 @@ def text_area(self, element, label, value="", height=None, key=None):
         element.text_area.label = label
         element.text_area.default = str(value)
 
-        if  height is not None:
+        if height is not None:
             element.text_area.height = height
 
+        if max_chars is not None:
+            element.text_area.max_chars = max_chars
+
         ui_value = _get_widget_ui_value("text_area", element, user_key=key)
         current_value = ui_value if ui_value is not None else value
         return str(current_value)


Test patch:
diff --git a/frontend/src/components/shared/InputInstructions/InputInstructions.test.tsx b/frontend/src/components/shared/InputInstructions/InputInstructions.test.tsx
new file mode 100644
--- /dev/null
+++ b/frontend/src/components/shared/InputInstructions/InputInstructions.test.tsx
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * Copyright 2018-2020 Streamlit Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from "react"
+import { shallow } from "enzyme"
+
+import InputInstructions, { Props } from "./InputInstructions"
+
+const getProps = (props: Partial<Props> = {}): Props => ({
+  dirty: true,
+  value: "asd",
+  ...props,
+})
+
+describe("InputInstructions", () => {
+  const props = getProps()
+  const wrapper = shallow(<InputInstructions {...props} />)
+
+  it("renders without crashing", () => {
+    expect(wrapper.text()).toBeDefined()
+  })
+
+  it("should show Enter instructions", () => {
+    expect(wrapper.text()).toBe("Press Enter to apply")
+  })
+
+  describe("Multiline type", () => {
+    const props = getProps({
+      type: "multiline",
+    })
+    const wrapper = shallow(<InputInstructions {...props} />)
+
+    it("should show Ctrl+Enter instructions", () => {
+      expect(wrapper.text()).toBe("Press Ctrl+Enter to apply")
+    })
+
+    it("show ⌘+Enter instructions", () => {
+      Object.defineProperty(navigator, "platform", {
+        value: "MacIntel",
+        writable: true,
+      })
+
+      const props = getProps({
+        type: "multiline",
+      })
+      const wrapper = shallow(<InputInstructions {...props} />)
+
+      expect(wrapper.text()).toBe("Press ⌘+Enter to apply")
+    })
+
+    it("should show instructions for max length", () => {
+      const props = getProps({
+        type: "multiline",
+        maxLength: 3,
+      })
+      const wrapper = shallow(<InputInstructions {...props} />)
+
+      expect(wrapper.text()).toBe("Press ⌘+Enter to apply3/3")
+    })
+  })
+
+  it("should show instructions for max length", () => {
+    const props = getProps({
+      maxLength: 3,
+    })
+    const wrapper = shallow(<InputInstructions {...props} />)
+
+    expect(wrapper.text()).toBe("Press Enter to apply3/3")
+  })
+})
diff --git a/frontend/src/components/widgets/NumberInput/NumberInput.test.tsx b/frontend/src/components/widgets/NumberInput/NumberInput.test.tsx
--- a/frontend/src/components/widgets/NumberInput/NumberInput.test.tsx
+++ b/frontend/src/components/widgets/NumberInput/NumberInput.test.tsx
@@ -283,15 +283,4 @@ describe("NumberInput widget", () => {
       expect(preventDefault).toHaveBeenCalled()
     })
   })
-
-  it("should show a message when it's dirty", () => {
-    const props = getIntProps()
-    const wrapper = shallow(<NumberInput {...props} />)
-
-    wrapper.setState({
-      dirty: true,
-    })
-
-    expect(wrapper.find("div.instructions").length).toBe(1)
-  })
 })
diff --git a/frontend/src/components/widgets/TextArea/TextArea.test.tsx b/frontend/src/components/widgets/TextArea/TextArea.test.tsx
--- a/frontend/src/components/widgets/TextArea/TextArea.test.tsx
+++ b/frontend/src/components/widgets/TextArea/TextArea.test.tsx
@@ -84,19 +84,6 @@ describe("TextArea widget", () => {
     expect(wrapper.find(UITextArea).prop("disabled")).toBe(props.disabled)
   })
 
-  it("should show Ctrl+Enter instructions", () => {
-    // @ts-ignore
-    wrapper.find(UITextArea).prop("onChange")({
-      target: {
-        value: "testing",
-      },
-    } as React.ChangeEvent<HTMLTextAreaElement>)
-
-    expect(wrapper.find("div.instructions").text()).toBe(
-      "Press Ctrl+Enter to apply"
-    )
-  })
-
   it("should set widget value on blur", () => {
     const props = getProps()
     const wrapper = shallow(<TextArea {...props} />)
@@ -157,6 +144,32 @@ describe("TextArea widget", () => {
     expect(resize).toBe("vertical")
   })
 
+  it("should limit the length if max_chars is passed", () => {
+    const props = getProps({
+      height: 500,
+      maxChars: 10,
+    })
+    const wrapper = shallow(<TextArea {...props} />)
+
+    // @ts-ignore
+    wrapper.find(UITextArea).prop("onChange")({
+      target: {
+        value: "0123456789",
+      },
+    } as EventTarget)
+
+    expect(wrapper.find(UITextArea).prop("value")).toBe("0123456789")
+
+    // @ts-ignore
+    wrapper.find(UITextArea).prop("onChange")({
+      target: {
+        value: "0123456789a",
+      },
+    } as EventTarget)
+
+    expect(wrapper.find(UITextArea).prop("value")).toBe("0123456789")
+  })
+
   describe("On mac it should", () => {
     Object.defineProperty(navigator, "platform", {
       value: "MacIntel",
@@ -166,19 +179,6 @@ describe("TextArea widget", () => {
     const props = getProps()
     const wrapper = shallow(<TextArea {...props} />)
 
-    it("show ⌘+Enter instructions", () => {
-      // @ts-ignore
-      wrapper.find(UITextArea).prop("onChange")({
-        target: {
-          value: "testing",
-        },
-      } as React.ChangeEvent<HTMLTextAreaElement>)
-
-      expect(wrapper.find("div.instructions").text()).toBe(
-        "Press ⌘+Enter to apply"
-      )
-    })
-
     it("should set widget value when ⌘+enter is pressed", () => {
       // @ts-ignore
       wrapper.find(UITextArea).prop("onChange")({
diff --git a/frontend/src/components/widgets/TextInput/TextInput.test.tsx b/frontend/src/components/widgets/TextInput/TextInput.test.tsx
--- a/frontend/src/components/widgets/TextInput/TextInput.test.tsx
+++ b/frontend/src/components/widgets/TextInput/TextInput.test.tsx
@@ -89,19 +89,6 @@ describe("TextInput widget", () => {
     expect(wrapper.find(UIInput).prop("disabled")).toBe(props.disabled)
   })
 
-  it("should show Enter instructions", () => {
-    // @ts-ignore
-    wrapper.find(UIInput).prop("onChange")({
-      target: {
-        value: "testing",
-      },
-    } as React.ChangeEvent<HTMLTextAreaElement>)
-
-    expect(wrapper.find("div.instructions").text()).toBe(
-      "Press Enter to apply"
-    )
-  })
-
   it("should set widget value on blur", () => {
     const props = getProps()
     const wrapper = shallow(<TextInput {...props} />)
@@ -161,4 +148,29 @@ describe("TextInput widget", () => {
 
     expect(props.widgetMgr.setStringValue).toHaveBeenCalledTimes(1)
   })
+
+  it("should limit the length if max_chars is passed", () => {
+    const props = getProps({
+      maxChars: 10,
+    })
+    const wrapper = shallow(<TextInput {...props} />)
+
+    // @ts-ignore
+    wrapper.find(UIInput).prop("onChange")({
+      target: {
+        value: "0123456789",
+      },
+    } as EventTarget)
+
+    expect(wrapper.find(UIInput).prop("value")).toBe("0123456789")
+
+    // @ts-ignore
+    wrapper.find(UIInput).prop("onChange")({
+      target: {
+        value: "0123456789a",
+      },
+    } as EventTarget)
+
+    expect(wrapper.find(UIInput).prop("value")).toBe("0123456789")
+  })
 })