nicedouble / StreamlitAntdComponents

A Streamlit component to display Antd-Design
https://nicedouble-streamlitantdcomponentsdemo-app-middmy.streamlit.app/
MIT License
401 stars 27 forks source link

How to programmatically change current step position #25

Open CHerSun opened 11 months ago

CHerSun commented 11 months ago

Could you advise on how to change current step programmatically?

I want to have steps on top (then a form with some inputs), and next, back buttons at the bottom.

Tried:

The only variant I've found is to add a counter to key and update counter to force a full redraw, but that looks ugly. I'd appreciate a more neat solution.

nicedouble commented 11 months ago

@CHerSun ,i also use index in sac.steps,but when button click,the sac.steps will blinking,in future version,maybe add previous and next button in sac.steps.

import streamlit as st
import streamlit_antd_components as sac

st.title("Issue #25")

if 'index' not in st.session_state:
    st.session_state['index'] = 0

def next_callback(max_index):
    if st.session_state['index'] < max_index:
        st.session_state['index'] += 1

def previous_callback():
    if st.session_state['index'] > 0:
        st.session_state['index'] -= 1

sac.steps(
    [sac.StepsItem('step 1'),
     sac.StepsItem('step 2'),
     sac.StepsItem('step 3')],
    index=st.session_state['index'],
)

st.button('previous', on_click=previous_callback)
st.button('next', on_click=next_callback, args=(2,))
st.write(st.session_state)
CHerSun commented 11 months ago

Yep, I use similar thing, but through key=f"some_fixed_key_{counter}" and updating counter. Will try with index only.

Do you think it might be possible to do this similarly to streamlit native components? I.e. use the same key for component:

...
sac.steps (...,
        key="some_fixed_key",
    )
...

and just update st.session_state["some_fixed_key"] with new value before rendering? And if component sees changed value - it updates that on JS side too?

in future version,maybe add previous and next button in sac.steps

not sure if that's needed tbh. At least for my case I want steps to be on top and buttons to be at the bottom of something like a form, so that user doesn't have to scroll up/down (thought even to use steps x2, but users are against that).

CHerSun commented 11 months ago

Full code in case you'll want to look into my part:

from collections.abc import Callable
from dataclasses import _MISSING_TYPE, MISSING
from functools import partial
from typing import Any, TypeVar, overload

import streamlit as st
from streamlit_antd_components import StepsItem, steps
from translations import no_translation

T_IN = TypeVar("T_IN")
T1 = TypeVar("T1")
T2 = TypeVar("T2")
T3 = TypeVar("T3")
T4 = TypeVar("T4")
T_OUT = TypeVar("T_OUT")

def __next_callback(key: str) -> None:
    st.session_state[key + "current_step"] += 1
    st.session_state[key + "counter"] += 1
def __prev_callback(key: str) -> None:
    st.session_state[key + "current_step"] -= 1
    st.session_state[key + "counter"] += 1
def __user_changed_step(key: str) -> None:
    st.session_state[key + "current_step"] = st.session_state[key + "steps" + str(st.session_state[key + "counter"])]

@overload
def stepper(  # noqa: PLR0913
    actions: tuple[Callable[[T_IN], T_OUT|None]],
    titles: list[str] | None = None,
    descriptions: list[str] | None = None,
    translations: Callable[[str], str] = no_translation,
    key: str = "",
    starting_value: T_IN|_MISSING_TYPE = MISSING,
) -> T_OUT|None:
    ...
@overload
def stepper(  # noqa: PLR0913
    actions: tuple[Callable[[T_IN], T1|None], Callable[[T1], T_OUT|None]],
    titles: list[str] | None = None,
    descriptions: list[str] | None = None,
    translations: Callable[[str], str] = no_translation,
    key: str = "",
    starting_value: T_IN|_MISSING_TYPE = MISSING,
) -> T_OUT|None:
    ...
@overload
def stepper(  # noqa: PLR0913
    actions: tuple[Callable[[T_IN], T1|None], Callable[[T1], T2|None], Callable[[T2], T_OUT|None]],
    titles: list[str] | None = None,
    descriptions: list[str] | None = None,
    translations: Callable[[str], str] = no_translation,
    key: str = "",
    starting_value: T_IN|_MISSING_TYPE = MISSING,
) -> T_OUT|None:
    ...
@overload
def stepper(  # noqa: PLR0913
    actions: tuple[Callable[[T_IN], T1|None], Callable[[T1], T2|None], Callable[[T2], T3|None], Callable[[T3], T_OUT|None]],
    titles: list[str] | None = None,
    descriptions: list[str] | None = None,
    translations: Callable[[str], str] = no_translation,
    key: str = "",
    starting_value: T_IN|_MISSING_TYPE = MISSING,
) -> T_OUT|None:
    ...
@overload
def stepper(  # noqa: PLR0913
    actions: tuple[Callable[[T_IN], T1|None], Callable[[T1], T2|None], Callable[[T2], T3|None], Callable[[T3], T4|None], Callable[[T4], T_OUT|None]],
    titles: list[str] | None = None,
    descriptions: list[str] | None = None,
    translations: Callable[[str], str] = no_translation,
    key: str = "",
    starting_value: T_IN|_MISSING_TYPE = MISSING,
) -> T_OUT|None:
    ...

def stepper( # noqa: PLR0913
    actions: tuple[Callable, ...],
    titles: list[str] | None = None,
    descriptions: list[str] | None = None,
    translations: Callable[[str], str] = no_translation,
    key: str = "",
    starting_value: Any = MISSING,
) -> Any|None:
    """Display a stepper, which activates next step only if current step gave result different than None

    Args:
        actions (tuple[Callable]): A collection of actions to be performed step-by-step
        titles (list[str] | None, optional): Step titles. Must be of the same length as actions or None. Defaults to None.
        descriptions (list[str] | None, optional): Step descriptions. Must be of the same length as actions. Defaults to None.
        translations (Callable[[str], str], optional): Translations function to use. Defaults to no_translation.
        key (str, optional): streamlit component key to use. Defaults to "".
        starting_value (Any, optional): Value to give to the first function step. If not given - no value will be given on 1st step. \
            Defaults to MISSING.

    Returns:
        Any: Result of the last step or None if not reached that yet.

    NOTE:
     * actions are performed step-by-step
     * results of previous step are given to the next step as input
     * first step either gets NO INPUT or given starting_value
    """
    key = key + ".stepper."

    current_step = st.session_state.setdefault(key + "current_step", 0)
    previous_current_step_result = st.session_state.setdefault(key + str(current_step), None)
    next_step_possible = previous_current_step_result is not None
    # TODO: a better way to allow Next/Prev buttons and stepper
    # Ugly workaround for inability to directly update selected step on SAC steps - a counter to force-refresh component
    counter = st.session_state.setdefault(key + "counter", 0)

    # Remove any results past this step:
    for i in range(current_step+1, len(actions)):
        st.session_state.pop(key + str(i), None)

    # Prepare stepper items list
    enabled_steps_till = current_step + int(next_step_possible)
    step_items = [StepsItem(
                        titles[i] if titles else "",
                        description=descriptions[i] if descriptions else "",
                        disabled=i>enabled_steps_till,
                        )
                  for i in range(len(actions))]
    # UI Render steps
    steps(step_items, index=current_step, key=key + "steps" + str(counter), return_index=True,
          on_change=partial(__user_changed_step, key), type="default")

    st.divider()

    # UI Render action and get its result
    if current_step==0 and starting_value is MISSING:
        current_step_result = actions[current_step]()
    else:
        previous_step_result = st.session_state[key + str(current_step - 1)]
        current_step_result = actions[current_step](previous_step_result)

    # Save current step result
    st.session_state[key + str(current_step)] = current_step_result
    if (current_step_result is None and previous_current_step_result is not None) or \
        (current_step_result is not None and previous_current_step_result is None):
        st.rerun()

    st.divider()

    # UI render buttons
    cols = st.columns(2)
    confirmed = False
    # Render Next or Confirm
    if current_step == len(actions)-1:
        confirmed = cols[0].button(translations("Confirm"), type="primary", disabled=current_step_result is None, key=key + "finish",
                                use_container_width=True)
    else:
        cols[0].button(translations("Next"), type="primary", disabled=not next_step_possible, key=key + "next",
                       on_click=partial(__next_callback, key), use_container_width=True)
    # Render back button
    if current_step>0:
        cols[1].button(translations("Back"),             disabled=current_step==0,        key=key + "prev",
                       on_click=partial(__prev_callback, key), use_container_width=True)

    # Return None always, until Finish button is clicked on the last step
    return current_step_result if confirmed else None

The idea is to take a collection of functions and chain their results from one to another, controlling the flow automatically (when to allow next step, cleaning data if user decided to go back, etc).

When user clicks on the stepper - everything looks to be working ok. But buttons make it to flicker, as you've written above.

nicedouble commented 9 months ago

@CHerSun ,hi, i update sac,now it support change value by streamlit session state,you can try it.

and just update st.session_state["some_fixed_key"] with new value before rendering? And if component sees changed value - it updates that on JS side too?

Btw, thanks your idea,i use react useEffect to achive this.


import streamlit as st
import streamlit_antd_components as sac

st.title("Issue #25")

def next_callback(max_index):
    if st.session_state['steps'] < max_index:
        st.session_state['steps'] += 1

def previous_callback():
    if st.session_state['steps'] > 0:
        st.session_state['steps'] -= 1

sac.steps(
    [sac.StepsItem('step 1'),
     sac.StepsItem('step 2'),
     sac.StepsItem('step 3')],
    key='steps', return_index=True
)

st.button('previous', on_click=previous_callback)
st.button('next', on_click=next_callback, args=(2,))
st.write(st.session_state)```
CHerSun commented 9 months ago

Cool! Thank you, you rock really. One of the best pieces for the Streamlit.