🚧 Feedback modal UI 🚧 (#3536)

Joseph T. Lyons created

[[PR Description]]

TODO: 
- [x] Add placeholder text to editor
- [x] Add external link icon to "Community repo" button
- [x] Show `not-allowed` cursor for disabled buttons
- [ ] Add `Headline` ui component
- [ ] Finish UI pass
- [ ] Fix `IconPosition` on button (should swap the icon side)
- [ ] Add conditional tooltip for disabled "Send feedback" button.
- [ ] Add common/top feedback link.
- [ ] Add `vw`/`vh` units to allow sizing the modal based on viewport
size.

Release Notes:

- N/A

Change summary

crates/feedback2/src/deploy_feedback_button.rs  |   2 
crates/feedback2/src/feedback_modal.rs          | 192 ++++++++++--------
crates/gpui2/src/styled.rs                      |  14 +
crates/ui2/src/components/button/button.rs      |  47 +++-
crates/ui2/src/components/button/button_like.rs |   8 
crates/ui2/src/components/icon.rs               |   3 
crates/ui2/src/prelude.rs                       |   2 
crates/workspace2/src/status_bar.rs             |  27 --
8 files changed, 165 insertions(+), 130 deletions(-)

Detailed changes

crates/feedback2/src/deploy_feedback_button.rs 🔗

@@ -32,7 +32,7 @@ impl Render for DeployFeedbackButton {
         IconButton::new("give-feedback", Icon::Envelope)
             .style(ui::ButtonStyle::Subtle)
             .selected(is_open)
-            .tooltip(|cx| Tooltip::text("Give Feedback", cx))
+            .tooltip(|cx| Tooltip::text("Share Feedback", cx))
             .on_click(|_, cx| {
                 cx.dispatch_action(Box::new(GiveFeedback));
             })

crates/feedback2/src/feedback_modal.rs 🔗

@@ -6,15 +6,15 @@ use db::kvp::KEY_VALUE_STORE;
 use editor::{Editor, EditorEvent};
 use futures::AsyncReadExt;
 use gpui::{
-    div, red, rems, serde_json, AppContext, DismissEvent, Div, EventEmitter, FocusHandle,
-    FocusableView, Model, PromptLevel, Render, Task, View, ViewContext,
+    div, rems, serde_json, AppContext, DismissEvent, Div, EventEmitter, FocusHandle, FocusableView,
+    Model, PromptLevel, Render, Task, View, ViewContext,
 };
 use isahc::Request;
 use language::Buffer;
 use project::Project;
 use regex::Regex;
 use serde_derive::Serialize;
-use ui::{prelude::*, Button, ButtonStyle, Label, Tooltip};
+use ui::{prelude::*, Button, ButtonStyle, IconPosition, Tooltip};
 use util::ResultExt;
 use workspace::Workspace;
 
@@ -104,6 +104,11 @@ impl FeedbackModal {
 
         let feedback_editor = cx.build_view(|cx| {
             let mut editor = Editor::for_buffer(buffer, Some(project.clone()), cx);
+            editor.set_placeholder_text(
+                "You can use markdown to add links or organize feedback.",
+                cx,
+            );
+            // editor.set_show_gutter(false, cx);
             editor.set_vertical_scroll_margin(5, cx);
             editor
         });
@@ -251,12 +256,6 @@ impl Render for FeedbackModal {
         };
 
         let valid_character_count = FEEDBACK_CHAR_LIMIT.contains(&self.character_count);
-        let characters_remaining =
-            if valid_character_count || self.character_count > *FEEDBACK_CHAR_LIMIT.end() {
-                *FEEDBACK_CHAR_LIMIT.end() as i32 - self.character_count as i32
-            } else {
-                self.character_count as i32 - *FEEDBACK_CHAR_LIMIT.start() as i32
-            };
 
         let allow_submission =
             valid_character_count && valid_email_address && !self.pending_submission;
@@ -264,9 +263,9 @@ impl Render for FeedbackModal {
         let has_feedback = self.feedback_editor.read(cx).text_option(cx).is_some();
 
         let submit_button_text = if self.pending_submission {
-            "Sending..."
+            "Submitting..."
         } else {
-            "Send Feedback"
+            "Submit"
         };
         let dismiss = cx.listener(|_, _, cx| {
             cx.emit(DismissEvent);
@@ -285,102 +284,121 @@ impl Render for FeedbackModal {
         let open_community_repo =
             cx.listener(|_, _, cx| cx.dispatch_action(Box::new(OpenZedCommunityRepo)));
 
-        // TODO: Nate UI pass
+        // Moved this here because providing it inline breaks rustfmt
+        let provide_an_email_address =
+            "Provide an email address if you want us to be able to reply.";
+
         v_stack()
             .elevation_3(cx)
             .key_context("GiveFeedback")
             .on_action(cx.listener(Self::cancel))
             .min_w(rems(40.))
             .max_w(rems(96.))
-            .border()
-            .border_color(red())
-            .h(rems(40.))
-            .p_2()
-            .gap_2()
+            .h(rems(32.))
+            .p_4()
+            .gap_4()
+            .child(v_stack().child(
+                // TODO: Add Headline component to `ui2`
+                div().text_xl().child("Share Feedback"),
+            ))
             .child(
-                v_stack().child(
-                    div()
-                        .size_full()
-                        .child(Label::new("Give Feedback").color(Color::Default))
-                        .child(Label::new("This editor supports markdown").color(Color::Muted)),
-                ),
+                Label::new(if self.character_count < *FEEDBACK_CHAR_LIMIT.start() {
+                    format!(
+                        "Feedback must be at least {} characters.",
+                        FEEDBACK_CHAR_LIMIT.start()
+                    )
+                } else if self.character_count > *FEEDBACK_CHAR_LIMIT.end() {
+                    format!(
+                        "Feedback must be less than {} characters.",
+                        FEEDBACK_CHAR_LIMIT.end()
+                    )
+                } else {
+                    format!(
+                        "Characters: {}",
+                        *FEEDBACK_CHAR_LIMIT.end() - self.character_count
+                    )
+                })
+                .color(if valid_character_count {
+                    Color::Success
+                } else {
+                    Color::Error
+                }),
             )
             .child(
                 div()
                     .flex_1()
                     .bg(cx.theme().colors().editor_background)
+                    .p_2()
                     .border()
+                    .rounded_md()
                     .border_color(cx.theme().colors().border)
                     .child(self.feedback_editor.clone()),
             )
-            .child(
-                div().child(
-                    Label::new(format!(
-                        "Characters: {}",
-                        characters_remaining
-                    ))
-                    .color(
-                        if valid_character_count {
-                            Color::Success
-                        } else {
-                            Color::Error
-                        }
-                    )
-                ),
-            )
             .child(
                 div()
-                .bg(cx.theme().colors().editor_background)
-                .border()
-                .border_color(cx.theme().colors().border)
-                .child(self.email_address_editor.clone())
-            )
-            .child(
-                h_stack()
-                    .justify_between()
-                    .gap_1()
-                    .child(Button::new("community_repo", "Community Repo")
-                        .style(ButtonStyle::Filled)
-                        .color(Color::Muted)
-                        .on_click(open_community_repo)
+                    .child(
+                        h_stack()
+                            .bg(cx.theme().colors().editor_background)
+                            .p_2()
+                            .border()
+                            .rounded_md()
+                            .border_color(cx.theme().colors().border)
+                            .child(self.email_address_editor.clone()),
                     )
-                    .child(h_stack().justify_between().gap_1()
-                        .child(
-                            Button::new("cancel_feedback", "Cancel")
-                                .style(ButtonStyle::Subtle)
-                                .color(Color::Muted)
-                                // TODO: replicate this logic when clicking outside the modal
-                                // TODO: Will require somehow overriding the modal dismal default behavior
-                                .map(|this| {
-                                    if has_feedback {
-                                        this.on_click(dismiss_prompt)
-                                    } else {
-                                        this.on_click(dismiss)
-                                    }
-                                })
-                        )
-                        .child(
-                            Button::new("send_feedback", submit_button_text)
-                                .color(Color::Accent)
-                                .style(ButtonStyle::Filled)
-                                // TODO: Ensure that while submitting, "Sending..." is shown and disable the button
-                                // TODO: If submit errors: show popup with error, don't close modal, set text back to "Send Feedback", and re-enable button
-                                // TODO: If submit is successful, close the modal
-                                .on_click(cx.listener(|this, _, cx| {
-                                    let _ = this.submit(cx);
-                                }))
-                                .tooltip(|cx| {
-                                    Tooltip::with_meta(
-                                        "Submit feedback to the Zed team.",
-                                        None,
-                                        "Provide an email address if you want us to be able to reply.",
-                                        cx,
+                    .child(
+                        h_stack()
+                            .justify_between()
+                            .gap_1()
+                            .child(
+                                Button::new("community_repo", "Community Repo")
+                                    .style(ButtonStyle::Transparent)
+                                    .icon(Icon::ExternalLink)
+                                    .icon_position(IconPosition::End)
+                                    .icon_size(IconSize::Small)
+                                    .on_click(open_community_repo),
+                            )
+                            .child(
+                                h_stack()
+                                    .gap_1()
+                                    .child(
+                                        Button::new("cancel_feedback", "Cancel")
+                                            .style(ButtonStyle::Subtle)
+                                            .color(Color::Muted)
+                                            // TODO: replicate this logic when clicking outside the modal
+                                            // TODO: Will require somehow overriding the modal dismal default behavior
+                                            .map(|this| {
+                                                if has_feedback {
+                                                    this.on_click(dismiss_prompt)
+                                                } else {
+                                                    this.on_click(dismiss)
+                                                }
+                                            }),
                                     )
-                                })
-                                .when(!allow_submission, |this| this.disabled(true))
-                        ),
-                    )
-
+                                    .child(
+                                        Button::new("send_feedback", submit_button_text)
+                                            .color(Color::Accent)
+                                            .style(ButtonStyle::Filled)
+                                            // TODO: Ensure that while submitting, "Sending..." is shown and disable the button
+                                            // TODO: If submit errors: show popup with error, don't close modal, set text back to "Submit", and re-enable button
+                                            // TODO: If submit is successful, close the modal
+                                            .on_click(cx.listener(|this, _, cx| {
+                                                let _ = this.submit(cx);
+                                            }))
+                                            .tooltip(move |cx| {
+                                                Tooltip::with_meta(
+                                                    "Submit feedback to the Zed team.",
+                                                    None,
+                                                    provide_an_email_address,
+                                                    cx,
+                                                )
+                                            })
+                                            .when(!allow_submission, |this| this.disabled(true)),
+                                    ),
+                            ),
+                    ),
             )
     }
 }
+
+// TODO: Add compilation flags to enable debug mode, where we can simulate sending feedback that both succeeds and fails, so we can test the UI
+// TODO: Maybe store email address whenever the modal is closed, versus just on submit, so users can remove it if they want without submitting

crates/gpui2/src/styled.rs 🔗

@@ -245,6 +245,13 @@ pub trait Styled: Sized {
         self
     }
 
+    /// Sets the flex direction of the element to `column-reverse`.
+    /// [Docs](https://tailwindcss.com/docs/flex-direction#column-reverse)
+    fn flex_col_reverse(mut self) -> Self {
+        self.style().flex_direction = Some(FlexDirection::ColumnReverse);
+        self
+    }
+
     /// Sets the flex direction of the element to `row`.
     /// [Docs](https://tailwindcss.com/docs/flex-direction#row)
     fn flex_row(mut self) -> Self {
@@ -252,6 +259,13 @@ pub trait Styled: Sized {
         self
     }
 
+    /// Sets the flex direction of the element to `row-reverse`.
+    /// [Docs](https://tailwindcss.com/docs/flex-direction#row-reverse)
+    fn flex_row_reverse(mut self) -> Self {
+        self.style().flex_direction = Some(FlexDirection::RowReverse);
+        self
+    }
+
     /// Sets the element to allow a flex item to grow and shrink as needed, ignoring its initial size.
     /// [Docs](https://tailwindcss.com/docs/flex#flex-1)
     fn flex_1(mut self) -> Self {

crates/ui2/src/components/button/button.rs 🔗

@@ -1,6 +1,6 @@
 use gpui::{AnyView, DefiniteLength};
 
-use crate::prelude::*;
+use crate::{prelude::*, IconPosition};
 use crate::{
     ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, IconSize, Label, LineHeightStyle,
 };
@@ -14,6 +14,7 @@ pub struct Button {
     label_color: Option<Color>,
     selected_label: Option<SharedString>,
     icon: Option<Icon>,
+    icon_position: Option<IconPosition>,
     icon_size: Option<IconSize>,
     icon_color: Option<Color>,
     selected_icon: Option<Icon>,
@@ -27,6 +28,7 @@ impl Button {
             label_color: None,
             selected_label: None,
             icon: None,
+            icon_position: None,
             icon_size: None,
             icon_color: None,
             selected_icon: None,
@@ -48,6 +50,11 @@ impl Button {
         self
     }
 
+    pub fn icon_position(mut self, icon_position: impl Into<Option<IconPosition>>) -> Self {
+        self.icon_position = icon_position.into();
+        self
+    }
+
     pub fn icon_size(mut self, icon_size: impl Into<Option<IconSize>>) -> Self {
         self.icon_size = icon_size.into();
         self
@@ -141,19 +148,29 @@ impl RenderOnce for Button {
             self.label_color.unwrap_or_default()
         };
 
-        self.base
-            .children(self.icon.map(|icon| {
-                ButtonIcon::new(icon)
-                    .disabled(is_disabled)
-                    .selected(is_selected)
-                    .selected_icon(self.selected_icon)
-                    .size(self.icon_size)
-                    .color(self.icon_color)
-            }))
-            .child(
-                Label::new(label)
-                    .color(label_color)
-                    .line_height_style(LineHeightStyle::UILabel),
-            )
+        self.base.child(
+            h_stack()
+                .gap_1()
+                .map(|this| {
+                    if self.icon_position == Some(IconPosition::End) {
+                        this.flex_row_reverse()
+                    } else {
+                        this
+                    }
+                })
+                .child(
+                    Label::new(label)
+                        .color(label_color)
+                        .line_height_style(LineHeightStyle::UILabel),
+                )
+                .children(self.icon.map(|icon| {
+                    ButtonIcon::new(icon)
+                        .disabled(is_disabled)
+                        .selected(is_selected)
+                        .selected_icon(self.selected_icon)
+                        .size(self.icon_size)
+                        .color(self.icon_color)
+                })),
+        )
     }
 }

crates/ui2/src/components/button/button_like.rs 🔗

@@ -30,6 +30,13 @@ pub trait ButtonCommon: Clickable + Disableable {
     fn tooltip(self, tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self;
 }
 
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
+pub enum IconPosition {
+    #[default]
+    Start,
+    End,
+}
+
 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
 pub enum ButtonStyle {
     /// A filled button with a solid background color. Provides emphasis versus
@@ -347,6 +354,7 @@ impl RenderOnce for ButtonLike {
                 ButtonSize::None => this,
             })
             .bg(self.style.enabled(cx).background)
+            .when(self.disabled, |this| this.cursor_not_allowed())
             .when(!self.disabled, |this| {
                 this.cursor_pointer()
                     .hover(|hover| hover.bg(self.style.hovered(cx).background))

crates/ui2/src/components/icon.rs 🔗

@@ -51,6 +51,7 @@ pub enum Icon {
     CopilotDisabled,
     Dash,
     Envelope,
+    ExternalLink,
     ExclamationTriangle,
     Exit,
     File,
@@ -123,13 +124,13 @@ impl Icon {
             Icon::Close => "icons/x.svg",
             Icon::Collab => "icons/user_group_16.svg",
             Icon::Copilot => "icons/copilot.svg",
-
             Icon::CopilotInit => "icons/copilot_init.svg",
             Icon::CopilotError => "icons/copilot_error.svg",
             Icon::CopilotDisabled => "icons/copilot_disabled.svg",
             Icon::Dash => "icons/dash.svg",
             Icon::Envelope => "icons/feedback.svg",
             Icon::ExclamationTriangle => "icons/warning.svg",
+            Icon::ExternalLink => "icons/external_link.svg",
             Icon::Exit => "icons/exit.svg",
             Icon::File => "icons/file.svg",
             Icon::FileDoc => "icons/file_icons/book.svg",

crates/ui2/src/prelude.rs 🔗

@@ -12,6 +12,6 @@ pub use crate::selectable::*;
 pub use crate::{h_stack, v_stack};
 pub use crate::{Button, ButtonSize, ButtonStyle, IconButton};
 pub use crate::{ButtonCommon, Color, StyledExt};
-pub use crate::{Icon, IconElement, IconSize};
+pub use crate::{Icon, IconElement, IconPosition, IconSize};
 pub use crate::{Label, LabelCommon, LabelSize, LineHeightStyle};
 pub use theme::ActiveTheme;

crates/workspace2/src/status_bar.rs 🔗

@@ -5,8 +5,8 @@ use gpui::{
     div, AnyView, Div, IntoElement, ParentElement, Render, Styled, Subscription, View, ViewContext,
     WindowContext,
 };
+use ui::h_stack;
 use ui::prelude::*;
-use ui::{h_stack, Icon, IconButton};
 use util::ResultExt;
 
 pub trait StatusItemView: Render {
@@ -48,30 +48,7 @@ impl Render for StatusBar {
             .h_8()
             .bg(cx.theme().colors().status_bar_background)
             .child(h_stack().gap_1().child(self.render_left_tools(cx)))
-            .child(
-                h_stack()
-                    .gap_4()
-                    .child(
-                        h_stack().gap_1().child(
-                            // Feedback Tool
-                            div()
-                                .border()
-                                .border_color(gpui::red())
-                                .child(IconButton::new("status-feedback", Icon::Envelope)),
-                        ),
-                    )
-                    .child(
-                        // Right Dock
-                        h_stack().gap_1().child(
-                            // Terminal
-                            div()
-                                .border()
-                                .border_color(gpui::red())
-                                .child(IconButton::new("status-chat", Icon::MessageBubbles)),
-                        ),
-                    )
-                    .child(self.render_right_tools(cx)),
-            )
+            .child(h_stack().gap_4().child(self.render_right_tools(cx)))
     }
 }