diff --git a/testgen/common/models/user.py b/testgen/common/models/user.py index b4e1d575..187347a4 100644 --- a/testgen/common/models/user.py +++ b/testgen/common/models/user.py @@ -3,7 +3,7 @@ from uuid import UUID, uuid4 import streamlit as st -from sqlalchemy import Boolean, Column, String, asc, func, select, update +from sqlalchemy import Boolean, Column, String, asc, func, select, text, update from sqlalchemy.dialects import postgresql from testgen.common.models import get_current_session @@ -22,6 +22,7 @@ class User(Entity): password: str = Column(String) is_global_admin: bool = Column(Boolean, nullable=False, default=False) latest_login: datetime = Column(postgresql.TIMESTAMP) + preferences: dict = Column(postgresql.JSONB, nullable=False, server_default=text("'{}'")) _get_by = "username" _default_order_by = (asc(func.lower(username)),) @@ -41,6 +42,10 @@ def save(self, update_latest_login: bool = False) -> None: self.latest_login = datetime.now(UTC) super().save() + def update_preferences(self) -> None: + query = update(User).where(User.id == self.id).values(preferences=self.preferences) + get_current_session().execute(query) + @classmethod @st.cache_data(show_spinner=False) def get(cls, identifier: str) -> Self | None: diff --git a/testgen/settings.py b/testgen/settings.py index 8d2b4512..697ad9e6 100644 --- a/testgen/settings.py +++ b/testgen/settings.py @@ -461,6 +461,24 @@ Disables sending usage data when set to any value except "true" and "yes". Defaults to "yes" """ +DISABLE_FEEDBACK_POPUP: bool = os.getenv("TG_DISABLE_FEEDBACK_POPUP", "no").lower() in ("yes", "true") +""" +When set to "yes" or "true", suppresses the periodic feedback popup entirely. +Intended for enterprise customers who block outbound network calls. + +from env variable: `TG_DISABLE_FEEDBACK_POPUP` +defaults to: `no` +""" + +SLACK_FEEDBACK_WEBHOOK: str | None = os.getenv("TG_SLACK_FEEDBACK_WEBHOOK") +""" +Slack Incoming Webhook URL to post feedback submissions. +When set, each submitted feedback is posted to the configured Slack channel. + +from env variable: `TG_SLACK_FEEDBACK_WEBHOOK` +defaults to: `None` (no Slack notification) +""" + ANALYTICS_JOB_SOURCE: str = os.getenv("TG_JOB_SOURCE", "CLI") """ Identifies the job trigger for analytics purposes. diff --git a/testgen/template/dbsetup/030_initialize_new_schema_structure.sql b/testgen/template/dbsetup/030_initialize_new_schema_structure.sql index cd05e290..60f7fb41 100644 --- a/testgen/template/dbsetup/030_initialize_new_schema_structure.sql +++ b/testgen/template/dbsetup/030_initialize_new_schema_structure.sql @@ -640,7 +640,8 @@ CREATE TABLE auth_users ( name VARCHAR(256), password VARCHAR(120), is_global_admin BOOLEAN NOT NULL DEFAULT FALSE, - latest_login TIMESTAMP + latest_login TIMESTAMP, + preferences JSONB NOT NULL DEFAULT '{}' ); ALTER TABLE auth_users diff --git a/testgen/template/dbupgrade/0180_incremental_upgrade.sql b/testgen/template/dbupgrade/0180_incremental_upgrade.sql new file mode 100644 index 00000000..f3b05a94 --- /dev/null +++ b/testgen/template/dbupgrade/0180_incremental_upgrade.sql @@ -0,0 +1,15 @@ +SET SEARCH_PATH TO {SCHEMA_NAME}; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = '{SCHEMA_NAME}' + AND table_name = 'auth_users' + AND column_name = 'preferences' + ) THEN + ALTER TABLE auth_users + ADD COLUMN preferences JSONB NOT NULL DEFAULT '{}'; + END IF; +END $$; diff --git a/testgen/ui/components/frontend/js/components/feedback_widget.js b/testgen/ui/components/frontend/js/components/feedback_widget.js new file mode 100644 index 00000000..90bf7985 --- /dev/null +++ b/testgen/ui/components/frontend/js/components/feedback_widget.js @@ -0,0 +1,495 @@ +/** + * @typedef Properties + * @type {object} + * @property {import('../van.min.js').State} visible - Controls widget visibility + */ +import van from '../van.min.js'; +import { emitEvent, getValue, loadStylesheet, getRandomId } from '../utils.js'; +import { Streamlit } from '../streamlit.js'; + +const { button, div, i, input, label, span, textarea } = van.tags; + +const RATINGS = [ + { value: 1, emoji: '\u{1F620}', label: 'Frustrated' }, // 😠 + { value: 2, emoji: '\u{1F615}', label: 'Dissatisfied' }, // 😕 + { value: 3, emoji: '\u{1F610}', label: 'Neutral' }, // 😐 + { value: 4, emoji: '\u{1F642}', label: 'Satisfied' }, // 🙂 + { value: 5, emoji: '\u{1F929}', label: 'Love it!' }, // 🤩 +]; + +const FeedbackWidget = (/** @type Properties */ props) => { + loadStylesheet('feedback-widget', stylesheet); + + const domId = `feedback-widget-${getRandomId()}`; + + // Position the component's iframe itself as fixed at bottom-right. + // This avoids cross-window VanJS reactivity issues that arise from shouldRenderOutsideFrame. + const iframe = window.frameElement; + if (iframe) { + Object.assign(iframe.style, { + position: 'fixed', + bottom: '24px', + right: '24px', + width: '340px', + zIndex: '9999', + border: 'none', + background: 'transparent', + }); + } + + // Internal state + const visible = van.derive(() => getValue(props.visible) ?? false); + + // Control iframe height (and thus visibility) reactively. + // Use 1 instead of 0 when hidden: setFrameHeight(0) causes Streamlit to stop + // sending streamlit:render updates to the iframe, so prop changes (like visible→true + // triggered by the "Give Feedback" help menu button) would never reach the component. + van.derive(() => { + const isVisible = visible.val; + Streamlit.setFrameHeight(isVisible ? 400 : 1); + if (iframe) { + iframe.style.height = (isVisible ? 400 : 1) + 'px'; + iframe.style.pointerEvents = isVisible ? 'auto' : 'none'; + } + }); + const selectedRating = van.state(0); + const comment = van.state(''); + const email = van.state(''); + const expanded = van.state(false); + const showSuccess = van.state(false); + const submitting = van.state(false); + + // Reset form when widget becomes visible + van.derive(() => { + if (visible.val && !visible.oldVal) { + selectedRating.val = 0; + comment.val = ''; + email.val = ''; + expanded.val = false; + showSuccess.val = false; + } + }); + + const handleClose = () => { + emitEvent('FeedbackDismissed'); + }; + + const handleSubmit = () => { + if (selectedRating.val === 0 || submitting.val) return; + + submitting.val = true; + + emitEvent('FeedbackSubmitted', { + payload: { + rating: selectedRating.val, + comment: comment.val, + email: email.val, + }, + }); + + showSuccess.val = true; + + // Auto-close after showing success + setTimeout(() => { + submitting.val = false; + emitEvent('FeedbackDismissed'); + }, 2500); + }; + + const selectRating = (value) => { + selectedRating.val = value; + }; + + const toggleExpand = () => { + expanded.val = !expanded.val; + }; + + // Make iframe body a transparent positioning context + document.body.style.cssText = 'margin:0;padding:0;background:transparent;position:relative;height:400px;overflow:visible;'; + + return div( + { id: domId, class: () => `feedback-widget ${visible.val ? '' : 'hidden'}` }, + + // Form view + () => !showSuccess.val ? div( + { class: 'feedback-form' }, + + // Header + div( + { class: 'feedback-header' }, + div( + { class: 'feedback-header-text' }, + div({ class: 'feedback-title' }, "How's your experience?"), + div({ class: 'feedback-subtitle' }, 'Your feedback helps us improve TestGen'), + ), + button( + { + class: 'feedback-close', + onclick: handleClose, + title: 'Dismiss', + }, + i({ class: 'material-symbols-rounded', style: 'font-size: 18px;' }, 'close'), + ), + ), + + // Body + div( + { class: 'feedback-body' }, + + // Emoji rating row + div( + { class: 'rating-row' }, + ...RATINGS.map(rating => + div( + { + class: () => `rating-option ${selectedRating.val === rating.value ? 'selected' : ''}`, + onclick: () => selectRating(rating.value), + }, + span({ class: 'rating-emoji' }, rating.emoji), + span({ class: 'rating-label' }, rating.label), + ) + ), + ), + + // Expand toggle + button( + { + class: () => `expand-toggle ${expanded.val ? 'expanded' : ''}`, + onclick: toggleExpand, + }, + i({ class: 'material-symbols-rounded' }, 'expand_more'), + 'Add a comment (optional)', + ), + + // Expandable section + div( + { class: () => `expandable-section ${expanded.val ? 'expanded' : ''}` }, + + // Comment field + div( + { class: 'feedback-field' }, + label({ for: 'feedbackComment' }, 'Comment'), + textarea({ + id: 'feedbackComment', + placeholder: "What's on your mind?", + value: comment, + oninput: (e) => comment.val = e.target.value, + }), + ), + + // Email field + div( + { class: 'feedback-field' }, + label({ for: 'feedbackEmail' }, 'Email (optional)'), + input({ + id: 'feedbackEmail', + type: 'email', + placeholder: 'you@company.com', + value: email, + oninput: (e) => email.val = e.target.value, + }), + ), + ), + ), + + // Footer + div( + { class: 'feedback-footer' }, + button( + { + class: 'btn btn-primary', + disabled: () => selectedRating.val === 0 || submitting.val, + onclick: handleSubmit, + }, + i({ class: 'material-symbols-rounded' }, 'send'), + 'Submit', + ), + ), + ) : null, + + // Success view + () => showSuccess.val ? div( + { class: 'feedback-success' }, + i({ class: 'material-symbols-rounded success-icon' }, 'check_circle'), + div({ class: 'success-title' }, 'Thanks for your feedback!'), + div({ class: 'success-subtitle' }, 'We appreciate you taking the time.'), + ) : null, + ); +}; + +const stylesheet = new CSSStyleSheet(); +stylesheet.replace(` +/* Feedback Widget Container */ +.feedback-widget { + position: absolute; + bottom: 0; + right: 0; + width: 340px; + background: var(--dk-card-background, #fff); + border-radius: 12px; + box-shadow: rgba(0,0,0,0.12) 0 8px 32px, rgba(0,0,0,0.08) 0 2px 8px; + overflow: hidden; + transition: opacity 0.25s, transform 0.25s; + transform-origin: bottom right; +} + +.feedback-widget.hidden { + opacity: 0; + transform: scale(0.95) translateY(8px); + pointer-events: none; +} + +/* Header */ +.feedback-header { + padding: 16px 20px 12px; + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.feedback-header-text { + flex: 1; +} + +.feedback-title { + font-size: 15px; + font-weight: 600; + margin-bottom: 2px; + color: var(--primary-text-color); +} + +.feedback-subtitle { + font-size: 12px; + color: var(--secondary-text-color); +} + +.feedback-close { + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--secondary-text-color); + transition: 0.2s; + flex-shrink: 0; + margin: -4px -8px 0 0; +} + +.feedback-close:hover { + background: var(--select-hover-background, rgb(240, 242, 246)); + color: var(--primary-text-color); +} + +/* Body */ +.feedback-body { + padding: 0 20px; +} + +/* Emoji Rating Row */ +.rating-row { + display: flex; + justify-content: space-between; + gap: 4px; + margin-bottom: 4px; +} + +.rating-option { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 8px 4px; + border-radius: 8px; + cursor: pointer; + transition: 0.2s; + border: 2px solid transparent; +} + +.rating-option:hover { + background: var(--select-hover-background, rgb(240, 242, 246)); +} + +.rating-option.selected { + background: var(--select-hover-background, rgb(240, 242, 246)); + border-color: var(--primary-color); +} + +.rating-emoji { + font-size: 28px; + line-height: 1; + filter: saturate(0.8); + transition: 0.15s; +} + +.rating-option:hover .rating-emoji, +.rating-option.selected .rating-emoji { + transform: scale(1.15); + filter: saturate(1); +} + +.rating-label { + font-size: 10px; + color: var(--secondary-text-color); + text-align: center; + white-space: nowrap; +} + +.rating-option.selected .rating-label { + color: var(--primary-color); + font-weight: 500; +} + +/* Expand Toggle */ +.expand-toggle { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 0; + color: var(--secondary-text-color); + font-size: 12px; + cursor: pointer; + border: none; + background: none; + transition: 0.2s; + font-family: inherit; +} + +.expand-toggle:hover { + color: var(--primary-text-color); +} + +.expand-toggle .material-symbols-rounded { + font-size: 18px; + transition: 0.2s; +} + +.expand-toggle.expanded .material-symbols-rounded { + transform: rotate(180deg); +} + +/* Expandable Section */ +.expandable-section { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; +} + +.expandable-section.expanded { + max-height: 200px; +} + +/* Form Fields */ +.feedback-field { + margin-bottom: 12px; +} + +.feedback-field label { + display: block; + font-size: 12px; + color: var(--secondary-text-color); + margin-bottom: 4px; +} + +.feedback-field textarea, +.feedback-field input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-color, rgba(0,0,0,.12)); + border-radius: 6px; + font-family: inherit; + font-size: 13px; + background: var(--form-field-color, rgb(240, 242, 246)); + color: var(--primary-text-color); + transition: 0.2s; + outline: none; + box-sizing: border-box; +} + +.feedback-field textarea { + resize: vertical; + min-height: 64px; + max-height: 120px; +} + +.feedback-field textarea:focus, +.feedback-field input:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 1px var(--primary-color); +} + +.feedback-field textarea::placeholder, +.feedback-field input::placeholder { + color: var(--disabled-text-color); +} + +/* Footer */ +.feedback-footer { + padding: 12px 20px 16px; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.btn { + padding: 8px 20px; + border-radius: 6px; + font-family: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: 0.2s; + border: none; + display: flex; + align-items: center; + gap: 6px; +} + +.btn-primary { + background: var(--primary-color); + color: white; +} + +.btn-primary:hover:not(:disabled) { + filter: brightness(0.95); +} + +.btn-primary:disabled { + background: var(--disabled-text-color); + cursor: not-allowed; +} + +.btn-primary .material-symbols-rounded { + font-size: 16px; +} + +/* Success State */ +.feedback-success { + padding: 32px 20px; + text-align: center; +} + +.feedback-success .success-icon { + font-size: 48px; + color: var(--primary-color); + margin-bottom: 12px; +} + +.success-title { + font-size: 15px; + font-weight: 600; + margin-bottom: 4px; + color: var(--primary-text-color); +} + +.success-subtitle { + font-size: 13px; + color: var(--secondary-text-color); +} +`); + +export { FeedbackWidget }; diff --git a/testgen/ui/components/frontend/js/components/help_menu.js b/testgen/ui/components/frontend/js/components/help_menu.js index 45b2da24..b4148df1 100644 --- a/testgen/ui/components/frontend/js/components/help_menu.js +++ b/testgen/ui/components/frontend/js/components/help_menu.js @@ -58,6 +58,12 @@ const HelpMenu = (/** @type Properties */ props) => { ) : null, span({ class: 'help-divider' }), + div( + { class: 'help-item help-item--feedback', onclick: () => emitEvent('FeedbackClicked') }, + Icon({ classes: 'help-item-icon' }, 'rate_review'), + 'Give Feedback', + ), + span({ class: 'help-divider' }), HelpLink(slackUrl, 'Slack Community', 'group'), getValue(props.support_email) ? HelpLink( @@ -140,6 +146,15 @@ stylesheet.replace(` margin: 0 16px; } +.help-item--feedback { + color: var(--primary-color); + font-weight: 500; +} + +.help-item--feedback .help-item-icon { + color: var(--primary-color); +} + .help-version { padding: 16px 16px 8px; display: flex; diff --git a/testgen/ui/components/frontend/js/main.js b/testgen/ui/components/frontend/js/main.js index 24df64fa..507e454b 100644 --- a/testgen/ui/components/frontend/js/main.js +++ b/testgen/ui/components/frontend/js/main.js @@ -36,6 +36,7 @@ const componentLoaders = { connections: () => import('./pages/connections.js').then(m => m.Connections), table_group_wizard: () => import('./pages/table_group_wizard.js').then(m => m.TableGroupWizard), help_menu: () => import('./components/help_menu.js').then(m => m.HelpMenu), + feedback_widget: () => import('./components/feedback_widget.js').then(m => m.FeedbackWidget), table_group_list: () => import('./pages/table_group_list.js').then(m => m.TableGroupList), table_group_delete: () => import('./pages/table_group_delete_confirmation.js').then(m => m.TableGroupDeleteConfirmation), run_profiling_dialog: () => import('./pages/run_profiling_dialog.js').then(m => m.RunProfilingDialog), diff --git a/testgen/ui/components/widgets/page.py b/testgen/ui/components/widgets/page.py index b85c8fdf..746962e7 100644 --- a/testgen/ui/components/widgets/page.py +++ b/testgen/ui/components/widgets/page.py @@ -1,3 +1,5 @@ +import logging + import streamlit as st from streamlit.delta_generator import DeltaGenerator @@ -33,6 +35,9 @@ def page_header( st.html('
') + # Feedback widget (bottom-right) + feedback_widget() + def help_menu(help_topic: str | None = None) -> None: with st.container(key="tg-header--help"): @@ -53,7 +58,11 @@ def close_help(rerun: bool = False) -> None: def open_app_logs(): close_help() application_logs_dialog() - + + def open_feedback(): + close_help() + st.session_state.feedback_visible = True + with help_container.container(): flex_row_end() with st.popover("Help"): @@ -70,6 +79,7 @@ def open_app_logs(): }, on_change_handlers={ "AppLogsClicked": lambda _: open_app_logs(), + "FeedbackClicked": lambda _: open_feedback(), }, event_handlers={ "ExternalLinkClicked": lambda _: close_help(rerun=True), @@ -118,3 +128,66 @@ def _apply_html(html: str, container: DeltaGenerator | None = None): container.html(html) else: st.html(html) + + +LOG = logging.getLogger("testgen") + + +def feedback_widget(): + """Render the feedback popup widget in the bottom-right corner. + + Visibility is driven by two signals: + - session.show_feedback_popup: set by router on session start (30-day eligibility gate) + - st.session_state.feedback_visible: set when the user manually clicks "Give Feedback" + + Feedback submissions are sent to MixPanel and optionally to Slack via TG_SLACK_FEEDBACK_WEBHOOK. + """ + visible = bool(session.show_feedback_popup) or bool(st.session_state.get("feedback_visible", False)) + + def on_dismissed(_): + session.show_feedback_popup = False + st.session_state.feedback_visible = False + + def on_submitted(payload): + if payload: + try: + from testgen.common.mixpanel_service import MixpanelService + MixpanelService().send_event( + "feedback_submitted", + rating=int(payload.get("rating", 0)), + comment=payload.get("comment") or None, + email=payload.get("email") or None, + ) + except Exception: + LOG.exception("Error sending feedback to MixPanel") + + if settings.SLACK_FEEDBACK_WEBHOOK: + try: + import requests + rating = payload.get("rating", "N/A") + comment = payload.get("comment") or "" + email = payload.get("email") or "" + lines = [f"*New TestGen Feedback* ⭐ {rating}/5"] + if comment: + lines.append(f"*Comment:* {comment}") + if email: + lines.append(f"*Email:* {email}") + response = requests.post(settings.SLACK_FEEDBACK_WEBHOOK, json={"text": "\n".join(lines)}, timeout=5) + LOG.info("Slack feedback webhook response: %s %s", response.status_code, response.text) + except Exception: + LOG.exception("Error sending feedback to Slack") + else: + LOG.warning("Slack feedback webhook not configured (TG_SLACK_FEEDBACK_WEBHOOK is not set)") + + session.show_feedback_popup = False + st.session_state.feedback_visible = False + + testgen_component( + "feedback_widget", + props={"visible": visible}, + on_change_handlers={ + "FeedbackDismissed": on_dismissed, + "FeedbackSubmitted": on_submitted, + }, + ) + diff --git a/testgen/ui/components/widgets/testgen_component.py b/testgen/ui/components/widgets/testgen_component.py index 93dbe523..c12cf06f 100644 --- a/testgen/ui/components/widgets/testgen_component.py +++ b/testgen/ui/components/widgets/testgen_component.py @@ -23,6 +23,7 @@ "help_menu", "notification_settings", "import_metadata_dialog", + "feedback_widget", ] diff --git a/testgen/ui/navigation/router.py b/testgen/ui/navigation/router.py index eaa43a52..cf52aa2c 100644 --- a/testgen/ui/navigation/router.py +++ b/testgen/ui/navigation/router.py @@ -39,6 +39,35 @@ def _init_session(self, url: str): source = st.query_params.pop("source", None) MixpanelService().send_event(f"nav-{url}", page_load=True, source=source) + def _evaluate_feedback_popup(self) -> None: + from datetime import UTC, datetime, timedelta + + from testgen import settings + + if settings.DISABLE_FEEDBACK_POPUP: + session.show_feedback_popup = False + return + + user = session.auth.user + if not user: + session.show_feedback_popup = False + return + + last_popup_str = user.preferences.get("last_feedback_popup") + if last_popup_str: + try: + last_popup_dt = datetime.fromisoformat(last_popup_str) + if datetime.now(UTC) - last_popup_dt < timedelta(days=30): + session.show_feedback_popup = False + return + except (ValueError, TypeError): + pass # Corrupted value — treat as no prior popup + + # User is eligible: record the timestamp and show the popup + user.preferences["last_feedback_popup"] = datetime.now(UTC).isoformat() + user.update_preferences() + session.show_feedback_popup = True + def run(self) -> None: streamlit_pages = [route.streamlit_page for route in self._routes.values()] @@ -71,6 +100,13 @@ def run(self) -> None: st.query_params.from_dict(session.page_args_pending_router) session.page_args_pending_router = None + if session.show_feedback_popup is None and session.auth.is_logged_in: + try: + self._evaluate_feedback_popup() + except Exception: + LOG.exception("Error evaluating feedback popup eligibility") + session.show_feedback_popup = False + session.current_page = current_page.url_path current_page.run() else: diff --git a/testgen/ui/session.py b/testgen/ui/session.py index 9f50ed33..79d8d359 100644 --- a/testgen/ui/session.py +++ b/testgen/ui/session.py @@ -35,6 +35,8 @@ class TestgenSession(Singleton): add_project: bool version: Version | None + show_feedback_popup: bool + testgen_event_id: ClassVar[dict[str, str]] = {} sidebar_event_id: str | None link_event_id: str | None diff --git a/testgen/ui/static/js/components/help_menu.js b/testgen/ui/static/js/components/help_menu.js index 45b2da24..b4148df1 100644 --- a/testgen/ui/static/js/components/help_menu.js +++ b/testgen/ui/static/js/components/help_menu.js @@ -58,6 +58,12 @@ const HelpMenu = (/** @type Properties */ props) => { ) : null, span({ class: 'help-divider' }), + div( + { class: 'help-item help-item--feedback', onclick: () => emitEvent('FeedbackClicked') }, + Icon({ classes: 'help-item-icon' }, 'rate_review'), + 'Give Feedback', + ), + span({ class: 'help-divider' }), HelpLink(slackUrl, 'Slack Community', 'group'), getValue(props.support_email) ? HelpLink( @@ -140,6 +146,15 @@ stylesheet.replace(` margin: 0 16px; } +.help-item--feedback { + color: var(--primary-color); + font-weight: 500; +} + +.help-item--feedback .help-item-icon { + color: var(--primary-color); +} + .help-version { padding: 16px 16px 8px; display: flex;