1
0
Эх сурвалжийг харах

Merge branch 'main' into feature/dependencies

Falko Schindler 1 жил өмнө
parent
commit
fbbb3b341b

+ 1 - 1
examples/authentication/main.py

@@ -35,7 +35,7 @@ def login() -> None:
         return RedirectResponse('/')
     with ui.card().classes('absolute-center'):
         username = ui.input('Username').on('keydown.enter', try_login)
-        password = ui.input('Password').on('keydown.enter', try_login).props('type=password')
+        password = ui.input('Password', password=True, password_toggle_button=True).on('keydown.enter', try_login)
         ui.button('Log in', on_click=try_login)
 
 

+ 0 - 4
main.py

@@ -331,10 +331,6 @@ def documentation_page() -> None:
     ui.add_head_html('<style>html {scroll-behavior: auto;}</style>')
     with ui.column().classes('w-full p-8 lg:p-16 max-w-[1250px] mx-auto'):
         section_heading('Reference, Demos and more', '*NiceGUI* Documentation')
-        ui.markdown('''
-            This is the documentation for NiceGUI >= 1.0.
-            Documentation for older versions can be found at [https://0.9.nicegui.io/](https://0.9.nicegui.io/reference).
-        ''').classes('bold-links arrow-links')
         documentation.create_full()
 
 

+ 0 - 2
mypy.ini

@@ -1,2 +0,0 @@
-[mypy]
-ignore_missing_imports = True

+ 17 - 1
nicegui/elements/audio.js

@@ -1,9 +1,25 @@
 export default {
-  template: `<audio :controls="controls" :autoplay="autoplay" :muted="muted" :src="src" />`,
+  template: `<audio :controls="controls" :autoplay="autoplay" :muted="muted" :src="computed_src" />`,
   props: {
     controls: Boolean,
     autoplay: Boolean,
     muted: Boolean,
     src: String,
   },
+  data: function () {
+    return {
+      computed_src: undefined,
+    };
+  },
+  mounted() {
+    setTimeout(() => this.compute_src(), 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  updated() {
+    this.compute_src();
+  },
+  methods: {
+    compute_src() {
+      this.computed_src = (this.src.startsWith("/") ? window.path_prefix : "") + this.src;
+    },
+  },
 };

+ 28 - 0
nicegui/elements/image.js

@@ -0,0 +1,28 @@
+export default {
+  template: `
+    <q-img v-bind="$attrs" :src="computed_src">
+      <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
+        <slot :name="slot" v-bind="slotProps || {}" />
+      </template>
+    </q-img>
+  `,
+  props: {
+    src: String,
+  },
+  data: function () {
+    return {
+      computed_src: undefined,
+    };
+  },
+  mounted() {
+    setTimeout(() => this.compute_src(), 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  updated() {
+    this.compute_src();
+  },
+  methods: {
+    compute_src() {
+      this.computed_src = (this.src.startsWith("/") ? window.path_prefix : "") + this.src;
+    },
+  },
+};

+ 5 - 1
nicegui/elements/image.py

@@ -1,8 +1,12 @@
 from pathlib import Path
 from typing import Union
 
+from nicegui.dependencies import register_component
+
 from .mixins.source_element import SourceElement
 
+register_component('image', __file__, 'image.js')
+
 
 class Image(SourceElement):
 
@@ -13,4 +17,4 @@ class Image(SourceElement):
 
         :param source: the source of the image; can be a URL, local file path or a base64 string
         """
-        super().__init__(tag='q-img', source=source)
+        super().__init__(tag='image', source=source)

+ 5 - 16
nicegui/elements/input.py

@@ -2,10 +2,10 @@ from typing import Any, Callable, Dict, List, Optional
 
 from .icon import Icon
 from .mixins.disableable_element import DisableableElement
-from .mixins.value_element import ValueElement
+from .mixins.validation_element import ValidationElement
 
 
-class Input(ValueElement, DisableableElement):
+class Input(ValidationElement, DisableableElement):
     LOOPBACK = False
 
     def __init__(self,
@@ -35,9 +35,9 @@ class Input(ValueElement, DisableableElement):
         :param password_toggle_button: whether to show a button to toggle the password visibility (default: False)
         :param on_change: callback to execute when the value changes
         :param autocomplete: optional list of strings for autocompletion
-        :param validation: dictionary of validation rules, e.g. ``{'Too short!': lambda value: len(value) < 3}``
+        :param validation: dictionary of validation rules, e.g. ``{'Too long!': lambda value: len(value) < 3}``
         """
-        super().__init__(tag='q-input', value=value, on_value_change=on_change)
+        super().__init__(tag='q-input', value=value, on_value_change=on_change, validation=validation)
         if label is not None:
             self._props['label'] = label
         if placeholder is not None:
@@ -52,8 +52,6 @@ class Input(ValueElement, DisableableElement):
                     self.props(f'type={"text" if is_hidden else "password"}')
                 icon = Icon('visibility_off').classes('cursor-pointer').on('click', toggle_type)
 
-        self.validation = validation
-
         if autocomplete:
             def find_autocompletion() -> Optional[str]:
                 if self.value:
@@ -71,16 +69,7 @@ class Input(ValueElement, DisableableElement):
                 match = find_autocompletion()
                 if match:
                     self.set_value(match)
-                self.props(f'shadow-text=""')
+                self.props('shadow-text=""')
 
             self.on('keyup', autocomplete_input)
             self.on('keydown.tab', complete_input)
-
-    def on_value_change(self, value: Any) -> None:
-        super().on_value_change(value)
-        for message, check in self.validation.items():
-            if not check(value):
-                self.props(f'error error-message="{message}"')
-                break
-        else:
-            self.props(remove='error')

+ 11 - 1
nicegui/elements/interactive_image.js

@@ -1,7 +1,7 @@
 export default {
   template: `
     <div style="position:relative">
-      <img :src="src" style="width:100%; height:100%;" v-on="onEvents" draggable="false" />
+      <img :src="computed_src" style="width:100%; height:100%;" v-on="onEvents" draggable="false" />
       <svg style="position:absolute;top:0;left:0;pointer-events:none" :viewBox="viewBox">
         <g v-if="cross" :style="{ display: cssDisplay }">
           <line :x1="x" y1="0" :x2="x" y2="100%" stroke="black" />
@@ -18,9 +18,19 @@ export default {
       x: 100,
       y: 100,
       cssDisplay: "none",
+      computed_src: undefined,
     };
   },
+  mounted() {
+    setTimeout(() => this.compute_src(), 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  updated() {
+    this.compute_src();
+  },
   methods: {
+    compute_src() {
+      this.computed_src = (this.src.startsWith("/") ? window.path_prefix : "") + this.src;
+    },
     updateCrossHair(e) {
       this.x = (e.offsetX * e.target.naturalWidth) / e.target.clientWidth;
       this.y = (e.offsetY * e.target.naturalHeight) / e.target.clientHeight;

+ 27 - 0
nicegui/elements/mixins/validation_element.py

@@ -0,0 +1,27 @@
+from typing import Any, Callable, Dict, Optional
+
+from .value_element import ValueElement
+
+
+class ValidationElement(ValueElement):
+
+    def __init__(self, validation: Dict[str, Callable[..., bool]], **kwargs: Any) -> None:
+        super().__init__(**kwargs)
+        self.validation = validation
+        self._error: Optional[str] = None
+
+    @property
+    def error(self) -> Optional[str]:
+        """The latest error message from the validation functions."""
+        return self._error
+
+    def on_value_change(self, value: Any) -> None:
+        super().on_value_change(value)
+        for message, check in self.validation.items():
+            if not check(value):
+                self._error = message
+                self.props(f'error error-message="{message}"')
+                break
+        else:
+            self._error = None
+            self.props(remove='error')

+ 31 - 16
nicegui/elements/number.py

@@ -1,10 +1,10 @@
 from typing import Any, Callable, Dict, Optional
 
 from .mixins.disableable_element import DisableableElement
-from .mixins.value_element import ValueElement
+from .mixins.validation_element import ValidationElement
 
 
-class Number(ValueElement, DisableableElement):
+class Number(ValidationElement, DisableableElement):
     LOOPBACK = False
 
     def __init__(self,
@@ -37,10 +37,10 @@ class Number(ValueElement, DisableableElement):
         :param suffix: a suffix to append to the displayed value
         :param format: a string like "%.2f" to format the displayed value
         :param on_change: callback to execute when the value changes
-        :param validation: dictionary of validation rules, e.g. ``{'Too small!': lambda value: value < 3}``
+        :param validation: dictionary of validation rules, e.g. ``{'Too large!': lambda value: value < 3}``
         """
         self.format = format
-        super().__init__(tag='q-input', value=value, on_value_change=on_change)
+        super().__init__(tag='q-input', value=value, on_value_change=on_change, validation=validation)
         self._props['type'] = 'number'
         if label is not None:
             self._props['label'] = label
@@ -56,24 +56,39 @@ class Number(ValueElement, DisableableElement):
             self._props['prefix'] = prefix
         if suffix is not None:
             self._props['suffix'] = suffix
-        self.validation = validation
         self.on('blur', self.sanitize)
 
+    @property
+    def min(self) -> float:
+        """The minimum value allowed."""
+        return self._props.get('min', -float('inf'))
+
+    @min.setter
+    def min(self, value: float) -> None:
+        self._props['min'] = value
+        self.sanitize()
+
+    @property
+    def max(self) -> float:
+        """The maximum value allowed."""
+        return self._props.get('max', float('inf'))
+
+    @max.setter
+    def max(self, value: float) -> None:
+        self._props['max'] = value
+        self.sanitize()
+
+    @property
+    def out_of_limits(self) -> bool:
+        """Whether the current value is out of the allowed limits."""
+        return not self.min <= self.value <= self.max
+
     def sanitize(self) -> None:
         value = float(self.value or 0)
-        value = max(value, self._props.get('min', -float('inf')))
-        value = min(value, self._props.get('max', float('inf')))
+        value = max(value, self.min)
+        value = min(value, self.max)
         self.set_value(float(self.format % value) if self.format else value)
 
-    def on_value_change(self, value: Any) -> None:
-        super().on_value_change(value)
-        for message, check in self.validation.items():
-            if not check(value):
-                self.props(f'error error-message="{message}"')
-                break
-        else:
-            self.props(remove='error')
-
     def _msg_to_value(self, msg: Dict) -> Any:
         return float(msg['args']) if msg['args'] else None
 

+ 1 - 1
nicegui/elements/select.py

@@ -46,7 +46,7 @@ class Select(ChoiceElement, DisableableElement):
         if with_input:
             self.original_options = deepcopy(options)
             self._props['use-input'] = True
-            self._props['hide-selected'] = True
+            self._props['hide-selected'] = not multiple
             self._props['fill-input'] = True
             self._props['input-debounce'] = 0
         self._props['multiple'] = multiple

+ 4 - 1
nicegui/elements/textarea.py

@@ -17,11 +17,14 @@ class Textarea(Input):
         This element is based on Quasar's `QInput <https://quasar.dev/vue-components/input>`_ component.
         The ``type`` is set to ``textarea`` to create a multi-line text input.
 
+        You can use the `validation` parameter to define a dictionary of validation rules.
+        The key of the first rule that fails will be displayed as an error message.
+
         :param label: displayed name for the textarea
         :param placeholder: text to show if no value is entered
         :param value: the initial value of the field
         :param on_change: callback to execute when the value changes
-        :param validation: dictionary of validation rules, e.g. ``{'Too short!': lambda value: len(value) < 3}``
+        :param validation: dictionary of validation rules, e.g. ``{'Too long!': lambda value: len(value) < 3}``
         """
         super().__init__(label, placeholder=placeholder, value=value, on_change=on_change, validation=validation)
         self._props['type'] = 'textarea'

+ 7 - 3
nicegui/elements/upload.js

@@ -7,11 +7,15 @@ export default {
     </q-uploader>
   `,
   mounted() {
-    setTimeout(() => {
-      this.computed_url = (window.path_prefix || "") + this.url;
-    }, 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+    setTimeout(() => compute_url, 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  updated() {
+    this.compute_url();
   },
   methods: {
+    compute_url() {
+      this.computed_url = (this.url.startsWith("/") ? window.path_prefix : "") + this.url;
+    },
     reset() {
       this.$refs.uploader.reset();
     },

+ 20 - 6
nicegui/elements/video.js

@@ -1,14 +1,28 @@
 export default {
-  template: `<video :controls="controls" :autoplay="autoplay" :muted="muted" :src="src" />`,
-  methods: {
-    seek(seconds) {
-      this.$el.currentTime = seconds;
-    },
-  },
+  template: `<video :controls="controls" :autoplay="autoplay" :muted="muted" :src="computed_src" />`,
   props: {
     controls: Boolean,
     autoplay: Boolean,
     muted: Boolean,
     src: String,
   },
+  data: function () {
+    return {
+      computed_src: undefined,
+    };
+  },
+  mounted() {
+    setTimeout(() => this.compute_src(), 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  updated() {
+    this.compute_src();
+  },
+  methods: {
+    compute_src() {
+      this.computed_src = (this.src.startsWith("/") ? window.path_prefix : "") + this.src;
+    },
+    seek(seconds) {
+      this.$el.currentTime = seconds;
+    },
+  },
 };

+ 11 - 3
nicegui/functions/timer.py

@@ -40,6 +40,14 @@ class Timer:
         else:
             globals.app.on_startup(coroutine)
 
+    def activate(self) -> None:
+        """Activate the timer."""
+        self.active = True
+
+    def deactivate(self) -> None:
+        """Deactivate the timer."""
+        self.active = False
+
     async def _run_once(self) -> None:
         try:
             if not await self._connected():
@@ -50,7 +58,7 @@ class Timer:
                 if globals.state not in {globals.State.STOPPING, globals.State.STOPPED}:
                     await self._invoke_callback()
         finally:
-            self.cleanup()
+            self._cleanup()
 
     async def _run_in_loop(self) -> None:
         try:
@@ -75,7 +83,7 @@ class Timer:
                         globals.handle_exception(e)
                         await asyncio.sleep(self.interval)
         finally:
-            self.cleanup()
+            self._cleanup()
 
     async def _invoke_callback(self) -> None:
         try:
@@ -104,6 +112,6 @@ class Timer:
                 globals.log.error(f'Timer cancelled because client is not connected after {timeout} seconds')
                 return False
 
-    def cleanup(self) -> None:
+    def _cleanup(self) -> None:
         self.slot = None
         self.callback = None

+ 4 - 1
nicegui/helpers.py

@@ -44,7 +44,10 @@ def is_file(path: Optional[Union[str, Path]]) -> bool:
         return False
     elif isinstance(path, str) and path.strip().startswith('data:'):
         return False  # NOTE: avoid passing data URLs to Path
-    return Path(path).is_file()
+    try:
+        return Path(path).is_file()
+    except OSError:
+        return False
 
 
 def safe_invoke(func: Union[Callable[..., Any], Awaitable], client: Optional['Client'] = None) -> None:

+ 3 - 1
nicegui/run.py

@@ -16,6 +16,8 @@ from . import native as native_module
 from . import native_mode
 from .language import Language
 
+APP_IMPORT_STRING = 'nicegui:app'
+
 
 class Server(uvicorn.Server):
 
@@ -124,7 +126,7 @@ def run(*,
     # NOTE: The following lines are basically a copy of `uvicorn.run`, but keep a reference to the `server`.
 
     config = uvicorn.Config(
-        'nicegui:app' if reload else globals.app,
+        APP_IMPORT_STRING if reload else globals.app,
         host=host,
         port=port,
         reload=reload,

+ 7 - 0
pyproject.toml

@@ -53,3 +53,10 @@ requires = [
     "poetry-core>=1.0.0"
 ]
 build-backend = "poetry.core.masonry.api"
+
+[tool.pytest.ini_options]
+addopts = "--driver Chrome"
+asyncio_mode = "auto"
+
+[tool.mypy]
+ignore_missing_imports = true

+ 0 - 3
pytest.ini

@@ -1,3 +0,0 @@
-[pytest]
-addopts = --driver Chrome
-asyncio_mode = auto

+ 11 - 0
tests/test_helpers.py

@@ -79,3 +79,14 @@ def test_canceling_schedule_browser(monkeypatch):
     time.sleep(0.2)
     assert not thread.is_alive()
     assert called_with_url is None
+
+
+def test_is_file():
+    assert helpers.is_file(TEST_DIR / 'test_helpers.py')
+    assert helpers.is_file(str(TEST_DIR / 'test_helpers.py'))
+    assert not helpers.is_file(TEST_DIR / 'nonexistent_file')
+    assert not helpers.is_file(str(TEST_DIR / 'nonexistent_file'))
+    assert not helpers.is_file('data:image/png;base64,...')
+    assert not helpers.is_file(None)
+    assert not helpers.is_file('x' * 100_000), 'a very long filepath should not lead to OSError 63'
+    assert not helpers.is_file('http://nicegui.io/logo.png')

+ 3 - 1
tests/test_input.py

@@ -55,7 +55,7 @@ def test_toggle_button(screen: Screen):
 
 
 def test_input_validation(screen: Screen):
-    ui.input('Name', validation={'Too short': lambda value: len(value) >= 5})
+    input = ui.input('Name', validation={'Too short': lambda value: len(value) >= 5})
 
     screen.open('/')
     screen.should_contain('Name')
@@ -63,10 +63,12 @@ def test_input_validation(screen: Screen):
     element = screen.selenium.find_element(By.XPATH, '//*[@aria-label="Name"]')
     element.send_keys('John')
     screen.should_contain('Too short')
+    assert input.error == 'Too short'
 
     element.send_keys(' Doe')
     screen.wait(0.5)
     screen.should_not_contain('Too short')
+    assert input.error is None
 
 
 def test_input_with_multi_word_error_message(screen: Screen):

+ 14 - 0
tests/test_number.py

@@ -39,3 +39,17 @@ def test_clearable_number(screen: Screen):
     screen.should_contain('value: 42')
     screen.click('cancel')
     screen.should_contain('value: None')
+
+
+def test_out_of_limits(screen: Screen):
+    number = ui.number('Number', min=0, max=10, value=5)
+    ui.label().bind_text_from(number, 'out_of_limits', lambda value: f'out_of_limits: {value}')
+
+    screen.open('/')
+    screen.should_contain('out_of_limits: False')
+
+    number.value = 11
+    screen.should_contain('out_of_limits: True')
+
+    number.max = 15
+    screen.should_contain('out_of_limits: False')

+ 19 - 2
tests/test_storage.py

@@ -1,9 +1,11 @@
 import asyncio
 from pathlib import Path
 
+import httpx
+
 from nicegui import Client, app, background_tasks, ui
 
-from .screen import Screen
+from .screen import PORT, Screen
 
 
 def test_browser_data_is_stored_in_the_browser(screen: Screen):
@@ -75,7 +77,22 @@ def test_user_storage_modifications(screen: Screen):
     screen.should_contain('3')
 
 
-async def test_access_user_storage_on_interaction(screen: Screen):
+async def test_access_user_storage_from_fastapi(screen: Screen):
+    @app.get('/api')
+    def api():
+        app.storage.user['msg'] = 'yes'
+        return 'OK'
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    async with httpx.AsyncClient() as http_client:
+        response = await http_client.get(f'http://localhost:{PORT}/api')
+        assert response.status_code == 200
+        assert response.text == '"OK"'
+        assert next(Path('.nicegui').glob('storage_user_*.json')).read_text() == '{"msg": "yes"}'
+
+
+def test_access_user_storage_on_interaction(screen: Screen):
     @ui.page('/')
     async def page():
         if 'test_switch' not in app.storage.user:

+ 15 - 0
website/more_documentation/aggrid_documentation.py

@@ -138,3 +138,18 @@ def more() -> None:
 
         df = pd.DataFrame(data={'col1': [1, 2], 'col2': [3, 4]})
         ui.aggrid.from_pandas(df).classes('max-h-40')
+
+    @text_demo('Render columns as HTML', '''
+        You can render columns as HTML by passing a list of column indices to the `html_columns` argument.
+    ''')
+    def aggrid_with_html_columns():
+        ui.aggrid({
+            'columnDefs': [
+                {'headerName': 'Name', 'field': 'name'},
+                {'headerName': 'URL', 'field': 'url'},
+            ],
+            'rowData': [
+                {'name': 'Google', 'url': '<a href="https://google.com">https://google.com</a>'},
+                {'name': 'Facebook', 'url': '<a href="https://facebook.com">https://facebook.com</a>'},
+            ],
+        }, html_columns=[1])

+ 12 - 9
website/search.py

@@ -1,4 +1,4 @@
-from nicegui import events, ui
+from nicegui import background_tasks, events, ui
 
 
 class Search:
@@ -48,14 +48,17 @@ class Search:
         if e.key == 'k' and (e.modifiers.ctrl or e.modifiers.meta):
             self.dialog.open()
 
-    async def handle_input(self, e: events.ValueChangeEventArguments) -> None:
-        self.results.clear()
-        with self.results:
-            for result in await ui.run_javascript(f'return window.fuse.search("{e.value}").slice(0, 50)'):
-                href: str = result['item']['url']
-                with ui.element('q-item').props(f'clickable').on('click', lambda href=href: self.open_url(href)):
-                    with ui.element('q-item-section'):
-                        ui.label(result['item']['title'])
+    def handle_input(self, e: events.ValueChangeEventArguments) -> None:
+        async def handle_input():
+            with self.results:
+                results = await ui.run_javascript(f'return window.fuse.search("{e.value}").slice(0, 50)')
+                self.results.clear()
+                for result in results:
+                    href: str = result['item']['url']
+                    with ui.element('q-item').props(f'clickable').on('click', lambda href=href: self.open_url(href)):
+                        with ui.element('q-item-section'):
+                            ui.label(result['item']['title'])
+        background_tasks.create_lazy(handle_input(), name='handle_search_input')
 
     async def open_url(self, url: str) -> None:
         await ui.run_javascript(f'''

+ 21 - 1
website/static/search_index.json

@@ -366,7 +366,7 @@
   },
   {
     "title": "Button",
-    "content": "This element is based on Quasar's QBtn <https://quasar.dev/vue-components/button>_ component.  The `color parameter accepts a Quasar color, a Tailwind color, or a CSS color. If a Quasar color is used, the button will be styled according to the Quasar theme including the color of the text. Note that there are colors like \"red\" being both a Quasar color and a CSS color. In such cases the Quasar color will be used.  :param text: the label of the button :param on_click: callback which is invoked when button is pressed :param color: the color of the button (either a Quasar, Tailwind, or CSS color or None`, default: 'primary')",
+    "content": "This element is based on Quasar's QBtn <https://quasar.dev/vue-components/button>_ component.  The `color parameter accepts a Quasar color, a Tailwind color, or a CSS color. If a Quasar color is used, the button will be styled according to the Quasar theme including the color of the text. Note that there are colors like \"red\" being both a Quasar color and a CSS color. In such cases the Quasar color will be used.  :param text: the label of the button :param on_click: callback which is invoked when button is pressed :param color: the color of the button (either a Quasar, Tailwind, or CSS color or None, default: 'primary') :param icon: the name of an icon to be displayed on the button (default: None`)",
     "url": "/documentation/button"
   },
   {
@@ -559,6 +559,11 @@
     "content": "Adds global keyboard event tracking.  :param on_key: callback to be executed when keyboard events occur. :param active: boolean flag indicating whether the callback should be executed or not (default: True) :param repeating: boolean flag indicating whether held keys should be sent repeatedly (default: True) :param ignore: ignore keys when one of these element types is focussed (default: ['input', 'select', 'button', 'textarea'])",
     "url": "/documentation/keyboard"
   },
+  {
+    "title": "Stepper",
+    "content": "This element represents Quasar's QStepper <https://quasar.dev/vue-components/stepper#qstepper-api>_ component. It contains individual steps.  :param value: ui.step or name of the step to be initially selected (default: None meaning the first step) :param on_value_change: callback to be executed when the selected step changes",
+    "url": "/documentation/stepper"
+  },
   {
     "title": "Row Element",
     "content": "Provides a container which arranges its child in a row.",
@@ -689,6 +694,11 @@
     "content": "The @ui.refreshable decorator allows you to create functions that have a refresh method. This method will automatically delete all elements created by the function and recreate them.",
     "url": "/documentation/refreshable"
   },
+  {
+    "title": "Refreshable: Refreshable UI for input validation",
+    "content": "Here is a demo of how to use the refreshable decorator to give feedback about the validity of user input.",
+    "url": "/documentation/refreshable#refreshable_ui_for_input_validation"
+  },
   {
     "title": "Color Input",
     "content": ":param label: displayed label for the color input :param placeholder: text to show if no color is selected :param value: the current color value :param on_change: callback to execute when the value changes",
@@ -779,6 +789,16 @@
     "content": "Displays an image.  :param source: the source of the image; can be a URL, local file path or a base64 string",
     "url": "/documentation/image"
   },
+  {
+    "title": "Image: Local files",
+    "content": "You can use local images as well by passing a path to the image file.",
+    "url": "/documentation/image#local_files"
+  },
+  {
+    "title": "Image: Base64 string",
+    "content": "You can also use a Base64 string as image source.",
+    "url": "/documentation/image#base64_string"
+  },
   {
     "title": "Image: Lottie files",
     "content": "You can also use Lottie files with animations.",