Skip to content

Commit

Permalink
Add st.status container (streamlit#7140)
Browse files Browse the repository at this point in the history
* Add icon to expander

* Add implementation for st.status

* Fix

* Add top comment

* Add docstring

* Fixes

* Improve implementation

* Some refactoring

* Minor fixes

* Add auto collapse mode

* Add auto collapse mode

* Various fixes and improvements

* Add empty expander test

* Add empty expander test

* Disallow empty for normal expander

* Apply various updates

* Some refactoring

* Update status container

* Update callback handler

* Change empty state for expander

* Move icon implementation to expander

* Add e2e tests

* Add unit tests

* Add empty expander e2e test

* Update comment

* Cleanups

* Cleanup

* Add metrics

* Update docstring

* Update snapshot

* Add test for empty status

* Use smaller block

* Update docstrings

* Don't change color on hover if empty

* Update docstrings based on feedback

* Minor change

* Update playback tests

* Integrated PR feedback

* Fix test

* Use descriptive names for screenshot tests

* Update status snapshots

* Update st.status docstring

---------

Co-authored-by: Debbie Matthews <debbie.matthews@snowflake.com>
  • Loading branch information
2 people authored and Your Name committed Mar 22, 2024
1 parent 7b5880a commit 4926765
Show file tree
Hide file tree
Showing 71 changed files with 802 additions and 337 deletions.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions e2e/playwright/st_expander.py
Expand Up @@ -24,3 +24,5 @@

sidebar = st.sidebar.expander("Expand me!")
sidebar.write("I am in the sidebar")

st.expander("Empty expander")
5 changes: 5 additions & 0 deletions e2e/playwright/st_expander_test.py
Expand Up @@ -70,3 +70,8 @@ def test_expander_collapses_and_expands(app: Page):
expander_header.click()
toggle = expander_header.locator("svg").first
expect(toggle).to_be_visible()


def test_empty_expander_not_rendered(app: Page):
"""Test that an empty expander is not rendered."""
expect(app.get_by_text("Empty expander")).not_to_be_attached()
48 changes: 48 additions & 0 deletions e2e/playwright/st_status.py
@@ -0,0 +1,48 @@
# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
#
# 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 streamlit as st

running_status = st.status("Running status", expanded=True)
running_status.write("Doing some work...")

with st.status("Completed status", expanded=True, state="complete"):
st.write("Hello world")

with st.status("Error status", expanded=True, state="error"):
st.error("Oh no, something went wrong!")

with st.status("Collapsed", state="complete"):
st.write("Hello world")

with st.status("About to change label...", state="complete") as status:
st.write("Hello world")
status.update(label="Changed label")

status = st.status("Without context manager", state="complete")
status.write("Hello world")
status.update(state="error", expanded=True)

with st.status("Collapse via update...", state="complete", expanded=True) as status:
st.write("Hello world")
status.update(label="Collapsed", expanded=False)

st.status("Empty state...", state="complete")

try:
with st.status("Uncaught exception"):
st.write("Hello world")
raise Exception("Error!")
except Exception:
pass
61 changes: 61 additions & 0 deletions e2e/playwright/st_status_test.py
@@ -0,0 +1,61 @@
# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022)
#
# 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.


from playwright.sync_api import Page, expect

from conftest import ImageCompareFunction


def test_status_container_rendering(
themed_app: Page, assert_snapshot: ImageCompareFunction
):
"""Test that st.status renders correctly via screenshots."""
status_containers = themed_app.get_by_test_id("stExpander")
expect(status_containers).to_have_count(9)

# Don't check screenshot for first element,
# since we cannot reliably screenshot test the spinner icon.

assert_snapshot(status_containers.nth(1), name="st_status-complete_state")
assert_snapshot(status_containers.nth(2), name="st_status-error_state")
assert_snapshot(status_containers.nth(3), name="st_status-collapsed")
assert_snapshot(status_containers.nth(4), name="st_status-changed_label")
assert_snapshot(status_containers.nth(5), name="st_status-without_cm")
assert_snapshot(status_containers.nth(6), name="st_status-collapsed_via_update")
assert_snapshot(status_containers.nth(7), name="st_status-empty_state")
assert_snapshot(status_containers.nth(8), name="st_status-uncaught_exception")


def test_running_state(app: Page):
"""Test that st.status renders a spinner when in running state."""
running_status = app.get_by_test_id("stExpander").nth(0)
# Check if it has a spinner icon:
expect(running_status.get_by_test_id("stExpanderIconSpinner")).to_be_visible()


def test_status_collapses_and_expands(app: Page):
"""Test that a status collapses and expands."""
expander_content = "Doing some work..."
running_status = app.get_by_test_id("stExpander").nth(0)
# Starts expanded:
expect(running_status.get_by_text(expander_content)).to_be_visible()

expander_header = running_status.locator(".streamlit-expanderHeader")
# Collapse:
expander_header.click()
expect(running_status.get_by_text(expander_content)).not_to_be_attached()
# Expand:
expander_header.click()
expect(running_status.get_by_text(expander_content)).to_be_visible()
8 changes: 1 addition & 7 deletions frontend/lib/src/components/core/Block/Block.tsx
Expand Up @@ -54,13 +54,7 @@ interface BlockPropsWithWidth extends BaseBlockProps {
const BlockNodeRenderer = (props: BlockPropsWithWidth): ReactElement => {
const { node } = props

// Allow columns and chat messages to create the specified space regardless of empty state
// TODO: Maybe we can simplify this to: node.isEmpty && !node.deltaBlock.allowEmpty?
if (
node.isEmpty &&
!node.deltaBlock.column &&
!node.deltaBlock.chatMessage
) {
if (node.isEmpty && !node.deltaBlock.allowEmpty) {
return <></>
}

Expand Down
38 changes: 38 additions & 0 deletions frontend/lib/src/components/elements/Expander/Expander.test.tsx
Expand Up @@ -61,6 +61,44 @@ describe("Expander container", () => {
expect(screen.getByText(props.element.label)).toBeInTheDocument()
})

it("renders no collapse/expand icon if empty", () => {
const props = getProps({}, { empty: true })
render(<Expander {...props}></Expander>)
expect(
screen.queryByTestId("stExpanderToggleIcon")
).not.toBeInTheDocument()
})

it("renders expander with a spinner icon", () => {
const props = getProps({ icon: "spinner" })
render(
<Expander {...props}>
<div>test</div>
</Expander>
)
expect(screen.getByTestId("stExpanderIconSpinner")).toBeInTheDocument()
})

it("renders expander with a check icon", () => {
const props = getProps({ icon: "check" })
render(
<Expander {...props}>
<div>test</div>
</Expander>
)
expect(screen.getByTestId("stExpanderIconCheck")).toBeInTheDocument()
})

it("renders expander with a error icon", () => {
const props = getProps({ icon: "error" })
render(
<Expander {...props}>
<div>test</div>
</Expander>
)
expect(screen.getByTestId("stExpanderIconError")).toBeInTheDocument()
})

it("should render a expanded component", () => {
const props = getProps()
render(
Expand Down
128 changes: 111 additions & 17 deletions frontend/lib/src/components/elements/Expander/Expander.tsx
Expand Up @@ -22,13 +22,85 @@ import {
SharedStylePropsArg,
} from "baseui/accordion"
import { useTheme } from "@emotion/react"
import { ExpandMore, ExpandLess } from "@emotion-icons/material-outlined"
import { Block as BlockProto } from "@streamlit/lib/src/proto"
import {
ExpandMore,
ExpandLess,
Check,
ErrorOutline,
} from "@emotion-icons/material-outlined"

import Icon from "@streamlit/lib/src/components/shared/Icon"
import { Block as BlockProto } from "@streamlit/lib/src/proto"
import {
StyledSpinnerIcon,
StyledIcon,
} from "@streamlit/lib/src/components/shared/Icon"
import StreamlitMarkdown from "@streamlit/lib/src/components/shared/StreamlitMarkdown"
import { notNullOrUndefined } from "@streamlit/lib/src/util/utils"
import { LibContext } from "@streamlit/lib/src/components/core/LibContext"
import { IconSize, isPresetTheme } from "@streamlit/lib/src/theme"

import {
StyledExpandableContainer,
StyledIconContainer,
} from "./styled-components"

export interface ExpanderIconProps {
icon: string
}

import { StyledExpandableContainer } from "./styled-components"
/**
* Renders an icon for the expander.
*
* If the icon is "spinner", it will render a spinner icon.
* If the icon is "check", it will render a check icon.
* If the icon is "error", it will render an error icon.
* Otherwise, it will render nothing.
*
* @param {string} icon - The icon to render.
* @returns {ReactElement}
*/
export const ExpanderIcon = (props: ExpanderIconProps): ReactElement => {
const { icon } = props
const { activeTheme } = React.useContext(LibContext)

const iconProps = {
size: "lg" as IconSize,
margin: "",
padding: "",
}
if (icon === "spinner") {
const usingCustomTheme = !isPresetTheme(activeTheme)
return (
<StyledSpinnerIcon
usingCustomTheme={usingCustomTheme}
data-testid="stExpanderIconSpinner"
{...iconProps}
/>
)
} else if (icon === "check") {
return (
<StyledIcon
as={Check}
color={"inherit"}
aria-hidden="true"
data-testid="stExpanderIconCheck"
{...iconProps}
/>
)
} else if (icon === "error") {
return (
<StyledIcon
as={ErrorOutline}
color={"inherit"}
aria-hidden="true"
data-testid="stExpanderIconError"
{...iconProps}
/>
)
}

return <></>
}

export interface ExpanderProps {
element: BlockProto.Expandable
Expand All @@ -45,10 +117,12 @@ const Expander: React.FC<ExpanderProps> = ({
children,
}): ReactElement => {
const { label, expanded: initialExpanded } = element

const [expanded, setExpanded] = useState<boolean>(initialExpanded)
const [expanded, setExpanded] = useState<boolean>(initialExpanded || false)
useEffect(() => {
setExpanded(initialExpanded)
// Only apply the expanded state if it was actually set in the proto.
if (notNullOrUndefined(initialExpanded)) {
setExpanded(initialExpanded)
}
// Having `label` in the dependency array here is necessary because
// sometimes two distinct expanders look so similar that even the react
// diffing algorithm decides that they're the same element with updated
Expand All @@ -59,14 +133,22 @@ const Expander: React.FC<ExpanderProps> = ({
// expander's `expanded` state in this edge case.
}, [label, initialExpanded])

const toggle = (): void => setExpanded(!expanded)
const toggle = (): void => {
if (!empty) {
setExpanded(!expanded)
}
}
const { colors, radii, spacing, fontSizes } = useTheme()

return (
<StyledExpandableContainer data-testid="stExpander">
<StyledExpandableContainer
data-testid="stExpander"
empty={empty}
disabled={widgetsDisabled}
>
<Accordion
onChange={toggle}
expanded={expanded ? ["panel"] : []}
expanded={expanded && !empty ? ["panel"] : []}
disabled={widgetsDisabled}
overrides={{
Content: {
Expand Down Expand Up @@ -136,7 +218,6 @@ const Expander: React.FC<ExpanderProps> = ({
}),
props: {
className: "streamlit-expanderHeader",
isStale,
},
},
ToggleIcon: {
Expand All @@ -145,16 +226,26 @@ const Expander: React.FC<ExpanderProps> = ({
}),
// eslint-disable-next-line react/display-name
component: () => {
if (expanded) {
return <Icon content={ExpandLess} size="lg" />
if (empty) {
// Don't show then expand/collapse icon if there's no content.
return <></>
}
return <Icon content={ExpandMore} size="lg" />
return (
<StyledIcon
as={expanded ? ExpandLess : ExpandMore}
color={"inherit"}
aria-hidden="true"
data-testid="stExpanderToggleIcon"
size="lg"
margin=""
padding=""
/>
)
},
},
Root: {
props: {
className: classNames("streamlit-expander", { empty }),
isStale,
className: classNames("streamlit-expander"),
},
style: {
borderStyle: "solid",
Expand All @@ -173,7 +264,10 @@ const Expander: React.FC<ExpanderProps> = ({
>
<Panel
title={
<StreamlitMarkdown source={label} allowHTML={false} isLabel />
<StyledIconContainer>
{element.icon && <ExpanderIcon icon={element.icon} />}
<StreamlitMarkdown source={label} allowHTML={false} isLabel />
</StyledIconContainer>
}
key="panel"
>
Expand Down

0 comments on commit 4926765

Please sign in to comment.