Skip to content

Commit

Permalink
Add feedback widget (streamlit#8915)
Browse files Browse the repository at this point in the history
## Describe your changes

Add a native `st.feedback` widget that shows one of the following
options: `thumbs`, `faces`, and `stars`.
It is built on top of a more generic `button_group` widget that can be
used in the future for other projects; in follow-up work we should start
to share more code with `multiselect` and `selectbox` (mainly web app
and Python unit tests).

The widget returns a sentiment value (positive integer), starting at `0`
for the worst sentiment up to `n-1` where `n` is the number of feedback
options (`n[thumbs] = 2`, `n[faces | stars] = 5`.

---

**Contribution License Agreement**

By submitting this pull request you agree that all contributions to this
project are made under the Apache 2.0 license.
  • Loading branch information
raethlein authored Jul 11, 2024
1 parent 5ff4a7c commit 668f762
Show file tree
Hide file tree
Showing 48 changed files with 2,175 additions and 155 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
62 changes: 62 additions & 0 deletions e2e_playwright/st_feedback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2024)
#
# 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 time

import streamlit as st

st.feedback()
st.feedback(
"faces",
key="faces_feedback",
on_change=lambda: st.write(f"Faces sentiment: {st.session_state.faces_feedback}"),
)
sentiment = st.feedback("stars")
st.write(f"Star sentiment: {sentiment}")


sentiment = st.feedback("stars", disabled=True, key="disabled_feedback")
st.write("feedback-disabled:", str(sentiment))

with st.form(key="my_form", clear_on_submit=True):
sentiment = st.feedback()
st.form_submit_button("Submit")

st.write("feedback-in-form:", str(sentiment))


@st.experimental_fragment()
def test_fragment():
sentiment = st.feedback(key="fragment_feedback")
st.write("feedback-in-fragment:", str(sentiment))


test_fragment()


if st.button("Create some elements to unmount component"):
for _ in range(3):
# The sleep here is needed, because it won't unmount the
# component if this is too fast.
time.sleep(1)
st.write("Another element")

sentiment = st.feedback(key="after_sleep_feedback")
st.write("feedback-after-sleep:", str(sentiment))


if "runs" not in st.session_state:
st.session_state.runs = 0
st.session_state.runs += 1
st.write("Runs:", st.session_state.runs)
123 changes: 123 additions & 0 deletions e2e_playwright/st_feedback_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2024)
#
# 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 re

from playwright.sync_api import Locator, Page, expect

from e2e_playwright.conftest import ImageCompareFunction, wait_for_app_run
from e2e_playwright.shared.app_utils import click_button, get_markdown


def get_button_group(app: Page, index: int) -> Locator:
return app.get_by_test_id("stButtonGroup").nth(index)


def get_feedback_icon_buttons(locator: Locator, type: str) -> Locator:
return locator.get_by_test_id(
re.compile("baseButton-borderlessIcon(Active)?")
).filter(has_text=type)


def get_feedback_icon_button(locator: Locator, type: str, index: int = 0) -> Locator:
return get_feedback_icon_buttons(locator, type).nth(index)


def test_click_thumbsup_and_take_snapshot(
themed_app: Page, assert_snapshot: ImageCompareFunction
):
thumbs = get_button_group(themed_app, 0)
get_feedback_icon_button(thumbs, "thumb_up").click()
wait_for_app_run(themed_app)
assert_snapshot(thumbs, name="st_feedback-thumbs")


def test_clicking_on_faces_shows_sentiment_via_on_change_callback_and_take_snapshot(
themed_app: Page, assert_snapshot: ImageCompareFunction
):
faces = get_button_group(themed_app, 1)
get_feedback_icon_button(faces, "sentiment_satisfied").click()
wait_for_app_run(themed_app)
text = get_markdown(themed_app, "Faces sentiment: 3")
expect(text).to_be_attached()
assert_snapshot(faces, name="st_feedback-faces")


def test_clicking_on_stars_shows_sentiment_and_take_snapshot(
themed_app: Page, assert_snapshot: ImageCompareFunction
):
stars = get_button_group(themed_app, 2)
get_feedback_icon_button(stars, "star", 3).click()
wait_for_app_run(themed_app)
text = get_markdown(themed_app, "Star sentiment: 3")
expect(text).to_be_attached()
assert_snapshot(stars, name="st_feedback-stars")


def test_feedback_buttons_are_disabled(app: Page):
"""Test that feedback buttons are disabled when `disabled=True` and that
they cannot be interacted with."""

stars = get_button_group(app, 3)
star_buttons = get_feedback_icon_buttons(stars, "star")
for star_button in star_buttons.all():
expect(star_button).to_have_js_property("disabled", True)
selected_button = star_buttons.nth(4)
selected_button.click(force=True)
expect(selected_button).not_to_have_css(
"background-color", re.compile("rgb\\(\\d+, \\d+, \\d+\\)")
)
text = get_markdown(app, "feedback-disabled: None")
expect(text).to_be_attached()


def test_feedback_works_in_forms(app: Page):
expect(app.get_by_text("feedback-in-form: None")).to_be_visible()
thumbs = get_button_group(app, 4)
get_feedback_icon_button(thumbs, "thumb_up").click()
expect(app.get_by_text("feedback-in-form: None")).to_be_visible()
app.get_by_test_id("baseButton-secondaryFormSubmit").click()
wait_for_app_run(app)

text = get_markdown(app, "feedback-in-form: 1")
expect(text).to_be_attached()


def test_feedback_works_with_fragments(app: Page):
expect(app.get_by_text("Runs: 1")).to_be_visible()
expect(app.get_by_text("feedback-in-fragment: None")).to_be_visible()
thumbs = get_button_group(app, 5)
get_feedback_icon_button(thumbs, "thumb_up").click()
wait_for_app_run(app)
expect(app.get_by_text("feedback-in-fragment: 1")).to_be_visible()
expect(app.get_by_text("Runs: 1")).to_be_visible()


def test_feedback_remount_keep_value(app: Page):
"""Test that `st.feedback` remounts correctly without resetting value."""
expect(app.get_by_text("feedback-after-sleep: None")).to_be_visible()

thumbs = get_button_group(app, 6)
selected_button = get_feedback_icon_button(thumbs, "thumb_up")
selected_button.click()
wait_for_app_run(app)
expect(app.get_by_text("feedback-after-sleep: 1")).to_be_visible()
expect(selected_button).to_have_css(
"background-color", re.compile("rgb\\(\\d+, \\d+, \\d+\\)")
)
click_button(app, "Create some elements to unmount component")
expect(selected_button).to_have_css(
"background-color", re.compile("rgb\\(\\d+, \\d+, \\d+\\)")
)
expect(app.get_by_text("feedback-after-sleep: 1")).to_be_visible()
16 changes: 16 additions & 0 deletions frontend/lib/src/components/core/Block/ElementNodeRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
Audio as AudioProto,
BokehChart as BokehChartProto,
Button as ButtonProto,
ButtonGroup as ButtonGroupProto,
DownloadButton as DownloadButtonProto,
CameraInput as CameraInputProto,
ChatInput as ChatInputProto,
Expand Down Expand Up @@ -155,6 +156,9 @@ const Video = React.lazy(
const Button = React.lazy(
() => import("@streamlit/lib/src/components/widgets/Button")
)
const ButtonGroup = React.lazy(
() => import("@streamlit/lib/src/components/widgets/ButtonGroup")
)
const DownloadButton = React.lazy(
() => import("@streamlit/lib/src/components/widgets/DownloadButton")
)
Expand Down Expand Up @@ -508,6 +512,18 @@ const RawElementNodeRenderer = (
return <Button element={buttonProto} {...widgetProps} />
}

case "buttonGroup": {
const buttonGroupProto = node.element.buttonGroup as ButtonGroupProto
widgetProps.disabled = widgetProps.disabled || buttonGroupProto.disabled
return (
<ButtonGroup
key={buttonGroupProto.id}
element={buttonGroupProto}
{...widgetProps}
/>
)
}

case "downloadButton": {
const downloadButtonProto = node.element
.downloadButton as DownloadButtonProto
Expand Down
7 changes: 5 additions & 2 deletions frontend/lib/src/components/shared/BaseButton/BaseButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
BaseButtonKind,
BaseButtonSize,
StyledBorderlessIconButton,
StyledBorderlessIconButtonActive,
StyledIconButton,
StyledLinkButton,
StyledMinimalButton,
Expand All @@ -41,7 +42,7 @@ function BaseButton({
fluidWidth,
children,
autoFocus,
}: BaseButtonPropsT): ReactElement {
}: Readonly<BaseButtonPropsT>): ReactElement {
let ComponentType = StyledPrimaryButton

if (kind === BaseButtonKind.SECONDARY) {
Expand All @@ -54,6 +55,8 @@ function BaseButton({
ComponentType = StyledIconButton
} else if (kind === BaseButtonKind.BORDERLESS_ICON) {
ComponentType = StyledBorderlessIconButton
} else if (kind === BaseButtonKind.BORDERLESS_ICON_ACTIVE) {
ComponentType = StyledBorderlessIconButtonActive
} else if (kind === BaseButtonKind.MINIMAL) {
ComponentType = StyledMinimalButton
} else if (kind === BaseButtonKind.PRIMARY_FORM_SUBMIT) {
Expand All @@ -71,7 +74,7 @@ function BaseButton({
return (
<ComponentType
kind={kind}
size={size || BaseButtonSize.MEDIUM}
size={size ?? BaseButtonSize.MEDIUM}
fluidWidth={fluidWidth || false}
disabled={disabled || false}
onClick={onClick || (() => {})}
Expand Down
24 changes: 21 additions & 3 deletions frontend/lib/src/components/shared/BaseButton/styled-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export enum BaseButtonKind {
LINK = "link",
ICON = "icon",
BORDERLESS_ICON = "borderlessIcon",
BORDERLESS_ICON_ACTIVE = "borderlessIconActive",
MINIMAL = "minimal",
PRIMARY_FORM_SUBMIT = "primaryFormSubmit",
SECONDARY_FORM_SUBMIT = "secondaryFormSubmit",
Expand Down Expand Up @@ -307,21 +308,38 @@ export const StyledBorderlessIconButton = styled(

return {
backgroundColor: theme.colors.transparent,
border: `${theme.sizes.borderWidth} solid ${theme.colors.transparent}`,
padding: iconPadding[size],
marginLeft: theme.spacing.none,
marginRight: theme.spacing.none,

border: "none",
display: "flex",
minHeight: "unset",

"&:focus": {
boxShadow: "none",
outline: "none",
},
"&:disabled, &:disabled:hover, &:disabled:active": {
"&:hover": {
backgroundColor: theme.colors.lightGray,
},
"&:disabled, &:disabled:hover, &:disabled:active": {
backgroundColor: theme.colors.transparent,
borderColor: theme.colors.transparent,
color: theme.colors.gray,
color: theme.colors.gray50,
},
}
})

export const StyledBorderlessIconButtonActive = styled(
StyledBorderlessIconButton
)<RequiredBaseButtonProps>(({ theme }) => {
return {
backgroundColor: theme.colors.lightGray,
color: theme.colors.black,
}
})

export const StyledTooltipNormal = styled.div(({ theme }) => ({
display: "block",
[`@media (max-width: ${theme.breakpoints.sm})`]: {
Expand Down
12 changes: 11 additions & 1 deletion frontend/lib/src/components/shared/Icon/DynamicIcon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { render } from "@streamlit/lib/src/test_util"
import { screen } from "@testing-library/react"
import "@testing-library/jest-dom"

import { DynamicIcon, DynamicIconProps } from "./DynamicIcon"
import { DynamicIcon, DynamicIconProps, isMaterialIcon } from "./DynamicIcon"

const getProps = (
props: Partial<DynamicIconProps> = {}
Expand Down Expand Up @@ -50,4 +50,14 @@ describe("Dynamic icon", () => {
expect(icon).toBeInTheDocument()
expect(testId.textContent).toEqual(icon.textContent)
})

it("isMaterialIcon returns correct results", () => {
expect(isMaterialIcon(":material/test:")).toBeTruthy()
expect(isMaterialIcon(":material/test-hyphen:")).toBeTruthy()
expect(isMaterialIcon(":material/test_underscore:")).toBeTruthy()
expect(isMaterialIcon(":material/test")).toBeFalsy()
expect(isMaterialIcon("material/test:")).toBeFalsy()
expect(isMaterialIcon("material/test")).toBeFalsy()
expect(isMaterialIcon(":materialtest:")).toBeFalsy()
})
})
6 changes: 6 additions & 0 deletions frontend/lib/src/components/shared/Icon/DynamicIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ function parseIconPackEntry(iconName: string): IconPackEntry {
return { pack: iconPack, icon: iconNameInPack }
}

export function isMaterialIcon(option: string): boolean {
const materialIconRegexp = /^:material\/(.+):$/
const materialIconMatch = materialIconRegexp.exec(option)
return materialIconMatch !== null
}

export interface DynamicIconProps {
iconValue: string
size?: IconSize
Expand Down
2 changes: 1 addition & 1 deletion frontend/lib/src/components/shared/Icon/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@
*/

export { default, EmojiIcon } from "./Icon"
export { DynamicIcon } from "./DynamicIcon"
export { DynamicIcon, isMaterialIcon } from "./DynamicIcon"
export { StyledIcon, StyledSpinnerIcon } from "./styled-components"
Loading

0 comments on commit 668f762

Please sign in to comment.