diff --git a/docs_website/docs/integrations/add_surveys.md b/docs_website/docs/integrations/add_surveys.md new file mode 100644 index 000000000..58a642e05 --- /dev/null +++ b/docs_website/docs/integrations/add_surveys.md @@ -0,0 +1,44 @@ +--- +id: add_surveys +title: Add Surveys +sidebar_label: Add Surveys +--- + +Product surveys serve as an excellent tool to gather user feedback. Querybook supports several kinds of surveys out-of-the-box, including: + +1. **Table search**: Users can indicate if the table search results matched their expectations. +2. **Table trust**: Users can rate their trust in the provided table metadata. +3. **Text to SQL**: Users can evaluate the quality of AI-generated SQL code. +4. **Query authoring**: Users can rate their experience of writing queries on Querybook. + +Each of these surveys follows the same 1-5 rating format, complemented by an optional text field for additional comments. +By default, the surveys are disabled. If you wish to enable them, override the `querybook/config/querybook_public_config.yaml` file. + +Below is an example of a setting that enables all surveys: + +```yaml +survey: + global_response_cooldown: 2592000 # 30 days + global_trigger_cooldown: 600 # 10 minutes + global_max_per_week: 6 + global_max_per_day: 3 + + surfaces: + - surface: table_search + max_per_week: 5 + - surface: table_view + max_per_day: 4 + - surface: text_to_sql + response_cooldown: 24000 + - surface: query_authoring +``` + +To activate a survey for a specific surface, you need to include the relevant surface key under `surfaces`. +You can find out the list of all support surfaces in `SurveyTypeToQuestion` located under `querybook/webapp/const/survey.ts`. + +There are 4 variables that you can configure either for eaceh individual surface or globally for surveys, they are: + +- **response_cooldown**: Time (in seconds) the system waits before showing the same survey to a user who has already responded. +- **trigger_cooldown**: Waiting period before the same survey is shown to the same user. +- **max_per_week**: Maximum number of surveys shown to a user per week (per surface type). +- **max_per_day**: Daily limit for the number of surveys shown to a user (per surface type). diff --git a/docs_website/sidebars.json b/docs_website/sidebars.json index 5c36e240a..35a826618 100755 --- a/docs_website/sidebars.json +++ b/docs_website/sidebars.json @@ -38,6 +38,7 @@ "integrations/add_table_upload", "integrations/add_event_logger", "integrations/add_stats_logger", + "integrations/add_surveys", "integrations/add_ai_assistant", "integrations/customize_html", "integrations/embedded_iframe" diff --git a/package.json b/package.json index 3d9e4aeb7..244cf17f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "querybook", - "version": "3.28.3", + "version": "3.28.4", "description": "A Big Data Webapp", "private": true, "scripts": { diff --git a/querybook/config/querybook_public_config.yaml b/querybook/config/querybook_public_config.yaml index a9ac13daa..2fe739c52 100644 --- a/querybook/config/querybook_public_config.yaml +++ b/querybook/config/querybook_public_config.yaml @@ -1,3 +1,4 @@ +# Public Configs are shared between backend and frontend and are not sensitive # --------------- AI Assistant --------------- ai_assistant: enabled: false @@ -13,3 +14,18 @@ ai_assistant: table_vector_search: enabled: false + +survey: + global_response_cooldown: 2592000 # 30 days + global_trigger_cooldown: 600 # 10 minutes + global_max_per_week: 6 + global_max_per_day: 3 + + surfaces: [] + + # Uncomment to enable survey on all surfaces + # surfaces: + # - surface: table_search + # - surface: table_view + # - surface: text_to_sql + # - surface: query_authoring diff --git a/querybook/migrations/versions/c00f08f16065_add_surveys.py b/querybook/migrations/versions/c00f08f16065_add_surveys.py new file mode 100644 index 000000000..86446abf8 --- /dev/null +++ b/querybook/migrations/versions/c00f08f16065_add_surveys.py @@ -0,0 +1,41 @@ +"""Add surveys + +Revision ID: c00f08f16065 +Revises: 4c70dae378f2 +Create Date: 2023-11-20 22:40:36.139101 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = "c00f08f16065" +down_revision = "4c70dae378f2" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "survey", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.Column("uid", sa.Integer(), nullable=True), + sa.Column("rating", sa.Integer(), nullable=False), + sa.Column("comment", sa.String(length=5000), nullable=True), + sa.Column("surface", sa.String(length=255), nullable=False), + sa.Column("surface_metadata", sa.JSON(), nullable=False), + sa.ForeignKeyConstraint(["uid"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("survey") + # ### end Alembic commands ### diff --git a/querybook/server/datasources/__init__.py b/querybook/server/datasources/__init__.py index 8701b5054..885b30537 100644 --- a/querybook/server/datasources/__init__.py +++ b/querybook/server/datasources/__init__.py @@ -16,6 +16,7 @@ from . import event_log from . import data_element from . import comment +from . import survey # Keep this at the end of imports to make sure the plugin APIs override the default ones try: @@ -42,4 +43,5 @@ event_log data_element comment +survey api_plugin diff --git a/querybook/server/datasources/survey.py b/querybook/server/datasources/survey.py new file mode 100644 index 000000000..27a4c1c39 --- /dev/null +++ b/querybook/server/datasources/survey.py @@ -0,0 +1,27 @@ +from flask_login import current_user + +from app.datasource import register +from logic import survey as logic + + +@register("/survey/", methods=["POST"]) +def create_survey( + rating: int, surface: str, surface_metadata: dict[str, str], comment: str = None +): + return logic.create_survey( + uid=current_user.id, + rating=rating, + surface=surface, + surface_metadata=surface_metadata, + comment=comment, + ) + + +@register("/survey//", methods=["PUT"]) +def update_survey(survey_id: int, rating: int = None, comment: str = None): + return logic.update_survey( + uid=current_user.id, + survey_id=survey_id, + rating=rating, + comment=comment, + ) diff --git a/querybook/server/logic/survey.py b/querybook/server/logic/survey.py new file mode 100644 index 000000000..9e61f75ae --- /dev/null +++ b/querybook/server/logic/survey.py @@ -0,0 +1,52 @@ +import datetime + +from app.db import with_session +from models.survey import Survey + + +@with_session +def create_survey( + uid: int, + rating: int, + surface: str, + surface_metadata: dict[str, str] = {}, + comment: str = None, + commit: bool = True, + session=None, +): + return Survey.create( + { + "uid": uid, + "rating": rating, + "surface": surface, + "surface_metadata": surface_metadata, + "comment": comment, + }, + commit=commit, + session=session, + ) + + +@with_session +def update_survey( + uid: int, + survey_id: int, + rating: int = None, + comment: str = None, + commit: bool = True, + session=None, +): + survey = Survey.get(id=survey_id, session=session) + assert survey.uid == uid, "User does not own this survey" + + return Survey.update( + id=survey_id, + fields={ + "rating": rating, + "comment": comment, + "updated_at": datetime.datetime.now(), + }, + skip_if_value_none=True, + commit=commit, + session=session, + ) diff --git a/querybook/server/models/__init__.py b/querybook/server/models/__init__.py index 8588efb1f..cf3dce9f2 100644 --- a/querybook/server/models/__init__.py +++ b/querybook/server/models/__init__.py @@ -14,3 +14,4 @@ from .event_log import * from .data_element import * from .comment import * +from .survey import * diff --git a/querybook/server/models/survey.py b/querybook/server/models/survey.py new file mode 100644 index 000000000..332cf7ce4 --- /dev/null +++ b/querybook/server/models/survey.py @@ -0,0 +1,27 @@ +import sqlalchemy as sql + +from app import db +from const.db import ( + description_length, + name_length, + now, +) +from lib.sqlalchemy import CRUDMixin + +Base = db.Base + + +class Survey(CRUDMixin, Base): + __tablename__ = "survey" + + id = sql.Column(sql.Integer, primary_key=True) + created_at = sql.Column(sql.DateTime, default=now) + updated_at = sql.Column(sql.DateTime, default=now) + + uid = sql.Column(sql.Integer, sql.ForeignKey("user.id", ondelete="CASCADE")) + + rating = sql.Column(sql.Integer, nullable=False) + comment = sql.Column(sql.String(length=description_length), nullable=True) + + surface = sql.Column(sql.String(length=name_length), nullable=False) + surface_metadata = sql.Column(sql.JSON, default={}, nullable=False) diff --git a/querybook/webapp/components/AIAssistant/QueryGenerationModal.tsx b/querybook/webapp/components/AIAssistant/QueryGenerationModal.tsx index bd6b0c3d5..052d35b27 100644 --- a/querybook/webapp/components/AIAssistant/QueryGenerationModal.tsx +++ b/querybook/webapp/components/AIAssistant/QueryGenerationModal.tsx @@ -6,6 +6,8 @@ import { QueryComparison } from 'components/TranspileQueryModal/QueryComparison' import { AICommandType } from 'const/aiAssistant'; import { ComponentType, ElementType } from 'const/analytics'; import { IQueryEngine } from 'const/queryEngine'; +import { SurveySurfaceType } from 'const/survey'; +import { useSurveyTrigger } from 'hooks/ui/useSurveyTrigger'; import { useAISocket } from 'hooks/useAISocket'; import { trackClick } from 'lib/analytics'; import { TableToken } from 'lib/sql-helper/sql-lexer'; @@ -115,7 +117,7 @@ export const QueryGenerationModal = ({ useEffect(() => { if (!generating) { - setTables(uniq([...tablesInQuery, ...tables])); + setTables((tables) => uniq([...tablesInQuery, ...tables])); } }, [tablesInQuery, generating]); @@ -125,12 +127,25 @@ export const QueryGenerationModal = ({ setNewQuery(trimSQLQuery(rawNewQuery)); }, [rawNewQuery]); + const triggerSurvey = useSurveyTrigger(); + useEffect(() => { + if (!newQuery || generating) { + return; + } + triggerSurvey(SurveySurfaceType.TEXT_TO_SQL, { + question, + tables, + query: newQuery, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [newQuery, triggerSurvey, generating]); + const onGenerate = useCallback(() => { setFoundTables([]); generateSQL({ query_engine_id: engineId, - tables: tables, - question: question, + tables, + question, original_query: query, }); trackClick({ diff --git a/querybook/webapp/components/DataDocQueryCell/DataDocQueryCell.tsx b/querybook/webapp/components/DataDocQueryCell/DataDocQueryCell.tsx index fc62358c6..85607ab05 100644 --- a/querybook/webapp/components/DataDocQueryCell/DataDocQueryCell.tsx +++ b/querybook/webapp/components/DataDocQueryCell/DataDocQueryCell.tsx @@ -26,6 +26,8 @@ import { UDFForm } from 'components/UDFForm/UDFForm'; import { ComponentType, ElementType } from 'const/analytics'; import { IDataQueryCellMeta, TDataDocMetaVariables } from 'const/datadoc'; import type { IQueryEngine, IQueryTranspiler } from 'const/queryEngine'; +import { SurveySurfaceType } from 'const/survey'; +import { triggerSurvey } from 'hooks/ui/useSurveyTrigger'; import { trackClick } from 'lib/analytics'; import CodeMirror from 'lib/codemirror'; import { createSQLLinter } from 'lib/codemirror/codemirror-lint'; @@ -418,14 +420,22 @@ class DataDocQueryCellComponent extends React.PureComponent { return runQuery( await this.getTransformedQuery(), this.engineId, - async (query, engineId) => - ( + async (query, engineId) => { + const queryId = ( await this.props.createQueryExecution( query, engineId, this.props.cellId ) - ).id + ).id; + + triggerSurvey(SurveySurfaceType.QUERY_AUTHORING, { + query_execution_id: queryId, + cell_id: this.props.cellId, + }); + + return queryId; + } ); } diff --git a/querybook/webapp/components/DataDocScheduleList/DataDocBoardsSelect.tsx b/querybook/webapp/components/DataDocScheduleList/DataDocBoardsSelect.tsx index 6eddb5f19..2bdbeea6c 100644 --- a/querybook/webapp/components/DataDocScheduleList/DataDocBoardsSelect.tsx +++ b/querybook/webapp/components/DataDocScheduleList/DataDocBoardsSelect.tsx @@ -8,7 +8,7 @@ import { OptionTypeBase } from 'react-select'; export interface IDataDocBoardsSelectProps { onChange: (params: OptionTypeBase[]) => void; - value: IOption[]; + value: Array>; label?: string; name: string; } @@ -23,14 +23,16 @@ export const DataDocBoardsSelect: React.FC = ({ (state: IStoreState) => state.board.boardById ); - const boardOptions: IOption[] = useMemo(() => { - return Object.values(boardById).map((board) => ({ - value: board.id, - label: board.name, - })); - }, [boardById]); + const boardOptions: Array> = useMemo( + () => + Object.values(boardById).map((board) => ({ + value: board.id, + label: board.name, + })), + [boardById] + ); - const selectedBoards: IOption[] = useMemo( + const selectedBoards: Array> = useMemo( () => boardOptions.filter((board) => value.map((v) => v.value).includes(board.value) diff --git a/querybook/webapp/components/DataDocScheduleList/DataDocSchedsFilters.tsx b/querybook/webapp/components/DataDocScheduleList/DataDocSchedsFilters.tsx index 4fdfdbfcb..6caa30421 100644 --- a/querybook/webapp/components/DataDocScheduleList/DataDocSchedsFilters.tsx +++ b/querybook/webapp/components/DataDocScheduleList/DataDocSchedsFilters.tsx @@ -59,26 +59,24 @@ export const DataDocSchedsFilters: React.FC<{ }} onSubmit={() => undefined} // Just for fixing ts > - {({}) => { - return ( - <> - - - - ); - }} + {({}) => ( + <> + + + + )} diff --git a/querybook/webapp/components/DataTableNavigator/DataTableNavigator.tsx b/querybook/webapp/components/DataTableNavigator/DataTableNavigator.tsx index f3c26e00f..dfe4a9039 100644 --- a/querybook/webapp/components/DataTableNavigator/DataTableNavigator.tsx +++ b/querybook/webapp/components/DataTableNavigator/DataTableNavigator.tsx @@ -9,7 +9,9 @@ import { tableNameDataTransferName, tableNameDraggableType, } from 'const/metastore'; +import { SurveySurfaceType } from 'const/survey'; import { useShallowSelector } from 'hooks/redux/useShallowSelector'; +import { useSurveyTrigger } from 'hooks/ui/useSurveyTrigger'; import { queryMetastoresSelector } from 'redux/dataSources/selector'; import * as dataTableSearchActions from 'redux/dataTableSearch/action'; import { @@ -140,6 +142,7 @@ export const DataTableNavigator: React.FC = ({ if (noMetastore) { resetSearch(); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [noMetastore]); const queryMetastore = useMemo( @@ -185,6 +188,19 @@ export const DataTableNavigator: React.FC = ({ [updateSearchString] ); + const triggerSurvey = useSurveyTrigger(); + useEffect(() => { + if (searchString === '') { + return; + } + + triggerSurvey(SurveySurfaceType.TABLE_SEARCH, { + search_query: searchString, + search_filter: Object.keys(searchFilters), + is_modal: false, + }); + }, [searchString, searchFilters, triggerSurvey]); + const tableRowRenderer = useCallback( (table: ITableResultWithSelection) => ( = ({ tableId }) => { }); useTrackView(ComponentType.TABLE_DETAIL_VIEW); + const triggerSurvey = useSurveyTrigger(true); + useEffect(() => { + if (!tableId || !tableName) { + return; + } + triggerSurvey(SurveySurfaceType.TABLE_TRUST, { + table_id: tableId, + table_name: tableName, + }); + }, [tableId, tableName, triggerSurvey]); + useEffect(() => { getTable(tableId); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/querybook/webapp/components/QueryComposer/QueryComposer.tsx b/querybook/webapp/components/QueryComposer/QueryComposer.tsx index 10b43e9f6..8d731487e 100644 --- a/querybook/webapp/components/QueryComposer/QueryComposer.tsx +++ b/querybook/webapp/components/QueryComposer/QueryComposer.tsx @@ -32,7 +32,9 @@ import { IDataDocMetaVariable } from 'const/datadoc'; import KeyMap from 'const/keyMap'; import { IQueryEngine } from 'const/queryEngine'; import { ISearchOptions, ISearchResult } from 'const/searchAndReplace'; +import { SurveySurfaceType } from 'const/survey'; import { useDebounceState } from 'hooks/redux/useDebounceState'; +import { useSurveyTrigger } from 'hooks/ui/useSurveyTrigger'; import { useBrowserTitle } from 'hooks/useBrowserTitle'; import { useTrackView } from 'hooks/useTrackView'; import { trackClick } from 'lib/analytics'; @@ -467,6 +469,7 @@ const QueryComposer: React.FC = () => { return getSelectedQuery(query, selectedRange); }, [query, queryEditorRef]); + const triggerSurvey = useSurveyTrigger(); const handleRunQuery = React.useCallback(async () => { trackClick({ component: ComponentType.ADHOC_QUERY, @@ -494,6 +497,9 @@ const QueryComposer: React.FC = () => { return data.id; } ); + triggerSurvey(SurveySurfaceType.QUERY_AUTHORING, { + query_execution_id: queryId, + }); if (queryId != null) { setExecutionId(queryId); setResultsCollapsed(false); @@ -506,6 +512,7 @@ const QueryComposer: React.FC = () => { getCurrentSelectedQuery, setExecutionId, hasLintErrors, + triggerSurvey, ]); const keyMap = useKeyMap(clickOnRunButton, queryEngines, setEngineId); diff --git a/querybook/webapp/components/Search/SearchOverview.tsx b/querybook/webapp/components/Search/SearchOverview.tsx index ee2842d9c..082f59553 100644 --- a/querybook/webapp/components/Search/SearchOverview.tsx +++ b/querybook/webapp/components/Search/SearchOverview.tsx @@ -14,7 +14,9 @@ import { IQueryPreview, ITablePreview, } from 'const/search'; +import { SurveySurfaceType } from 'const/survey'; import { useShallowSelector } from 'hooks/redux/useShallowSelector'; +import { useSurveyTrigger } from 'hooks/ui/useSurveyTrigger'; import { useTrackView } from 'hooks/useTrackView'; import { trackClick, trackView } from 'lib/analytics'; import { titleize } from 'lib/utils'; @@ -116,9 +118,27 @@ export const SearchOverview: React.FC = ({ })); const metastoreId = _metastoreId ?? queryMetastores?.[0]?.id; - const results = resultByPage[currentPage] || []; + const results = useMemo( + () => resultByPage[currentPage] || [], + [resultByPage, currentPage] + ); const isLoading = !!searchRequest; + const triggerSurvey = useSurveyTrigger(); + useEffect(() => { + if ( + !isLoading && + searchString.length > 0 && + searchType === SearchType.Table + ) { + triggerSurvey(SurveySurfaceType.TABLE_SEARCH, { + search_query: searchString, + search_filter: Object.keys(searchFilters), + is_modal: true, + }); + } + }, [searchString, searchType, isLoading, searchFilters, triggerSurvey]); + // Log search results useEffect(() => { if (!isLoading && searchString.length > 0 && results.length > 0) { @@ -129,7 +149,7 @@ export const SearchOverview: React.FC = ({ page: currentPage, }); } - }, [isLoading, searchString, results]); + }, [isLoading, searchString, results, searchType, currentPage]); const dispatch = useDispatch(); const handleUpdateSearchString = React.useCallback( diff --git a/querybook/webapp/components/Survey/StarRating.tsx b/querybook/webapp/components/Survey/StarRating.tsx new file mode 100644 index 000000000..0c06e4e68 --- /dev/null +++ b/querybook/webapp/components/Survey/StarRating.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import styled, { css } from 'styled-components'; + +import { IconButton } from 'ui/Button/IconButton'; + +export interface IStarRatingProps { + rating?: number; + onChange: (rating: number) => any; + size?: number; +} + +const StyledRatingEmpty = css` + color: var(--text-lightest); + .Icon { + svg { + fill: transparent; + } + } +`; +const StyledRatingFilled = css` + color: var(--color-yellow); + .Icon { + svg { + fill: var(--color-yellow); + } + } +`; + +const StyledRating = styled.div` + display: flex; + justify-content: center; + align-items: center; + + .IconButton { + ${StyledRatingEmpty} + + &.star-selected { + ${StyledRatingFilled} + } + margin-right: 2px; + + transition: scale 0.2s ease-in-out; + + &:hover { + scale: 1.2; + + & ~ .IconButton { + ${StyledRatingEmpty} + } + } + + &:has(~ .IconButton:hover), + &:hover { + ${StyledRatingFilled} + } + } +`; + +const STARS = [1, 2, 3, 4, 5]; + +export const StarRating: React.FC = ({ + rating, + onChange, + size, +}) => { + const uptoStar = rating || 0; + return ( + + {STARS.map((star) => ( + onChange(star)} + className={star <= uptoStar ? 'star-selected' : ''} + /> + ))} + + ); +}; diff --git a/querybook/webapp/components/Survey/Survey.scss b/querybook/webapp/components/Survey/Survey.scss new file mode 100644 index 000000000..bca49679e --- /dev/null +++ b/querybook/webapp/components/Survey/Survey.scss @@ -0,0 +1,66 @@ +.Survey { + min-width: 240px; + max-width: 360px; + padding: 16px 16px 16px 16px; + + background-color: var(--bg-lightest); + border-radius: var(--border-radius); + + position: relative; + + &.Survey-enter { + animation: fadeIn 0.2s ease-in-out; + } + &.Survey-leave { + animation: fadeOut 0.2s ease-in-out; + opacity: 0; + } + + .Survey-rating { + margin-bottom: 16px; + margin-top: 16px; + width: 100%; + } + + .Survey-text { + margin-bottom: 8px; + textarea { + background-color: var(--bg-light); + } + } + + .Survey-actions { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + } + + .Survey-close { + position: absolute; + top: 4px; + left: 4px; + } +} + +@keyframes fadeIn { + 0% { + opacity: 0; + scale: 0.1; + } + 100% { + opacity: 1; + transform: none; + } +} + +@keyframes fadeOut { + 0% { + opacity: 1; + transform: none; + } + 100% { + opacity: 0; + scale: 0.1; + } +} diff --git a/querybook/webapp/components/Survey/Survey.tsx b/querybook/webapp/components/Survey/Survey.tsx new file mode 100644 index 000000000..1a0e6165b --- /dev/null +++ b/querybook/webapp/components/Survey/Survey.tsx @@ -0,0 +1,191 @@ +import clsx from 'clsx'; +import React from 'react'; +import toast, { Toast } from 'react-hot-toast'; + +import { + ISurvey, + IUpdateSurveyFormData, + SurveySurfaceType, + SurveyTypeToQuestion, +} from 'const/survey'; +import { saveSurveyRespondRecord } from 'lib/survey/triggerLogic'; +import { SurveyResource } from 'resource/survey'; +import { TextButton } from 'ui/Button/Button'; +import { ResizableTextArea } from 'ui/ResizableTextArea/ResizableTextArea'; + +import { StarRating } from './StarRating'; + +import './Survey.scss'; + +export interface ISurveyProps { + surface: SurveySurfaceType; + surfaceMeta?: Record; + toastProps: Toast; +} + +export const Survey: React.FC = ({ + surface, + surfaceMeta = {}, + toastProps, +}) => { + const [survey, setSurvey] = React.useState(null); + const handleDismiss = React.useCallback(() => { + toast.dismiss(toastProps.id); + }, [toastProps.id]); + + const formDOM = !survey ? ( + + ) : ( + + ); + + return ( +
+ {formDOM} +
+ ); +}; + +/** + * Render the question with templated vars in surfaceMeta + * Note that templated variable must have format `{varName}` + * + * for example "Do you trust {table}" rendered with surfaceMeta = { "table": "foo.bar" } + * becomes "Do you trust foo.bar" + * + * @param surface must be in SurveyTypeToQuestion + * @param surfaceMeta + * @returns + */ +function getSurveyQuestion( + surface: SurveySurfaceType, + surfaceMeta: Record +) { + return SurveyTypeToQuestion[surface].replace( + /\{(\w+)\}/g, + (_, key) => surfaceMeta[key] ?? '' + ); +} + +const SurveyCreationForm: React.FC<{ + surface: SurveySurfaceType; + surfaceMeta: Record; + onSurveyCreation: (survey: ISurvey) => any; + onDismiss: () => any; +}> = ({ surface, surfaceMeta, onSurveyCreation, onDismiss }) => { + const surveyQuestion = React.useMemo( + () => getSurveyQuestion(surface, surfaceMeta), + [surface, surfaceMeta] + ); + + const handleSurveyCreation = React.useCallback( + async (rating: number) => { + const createSurveyPromise = SurveyResource.createSurvey({ + surface, + surface_metadata: surfaceMeta, + rating, + }); + toast.promise(createSurveyPromise, { + loading: 'Submitting response...', + success: 'Survey recorded. Thanks for your feedback!', + error: 'Failed to submit survey', + }); + const { data } = await createSurveyPromise; + saveSurveyRespondRecord(surface); + onSurveyCreation(data); + }, + [onSurveyCreation, surface, surfaceMeta] + ); + + return ( + <> +
{surveyQuestion}
+
+ +
+
+ Dismiss +
+ + ); +}; + +const SurveyUpdateForm: React.FC<{ + survey: ISurvey; + onSurveyUpdate: (survey: ISurvey) => any; + surface: SurveySurfaceType; + surfaceMeta: Record; + onDismiss: () => any; +}> = ({ surface, onSurveyUpdate, survey, surfaceMeta, onDismiss }) => { + const [comment, setComment] = React.useState(''); + + const surveyQuestion = React.useMemo( + () => getSurveyQuestion(surface, surfaceMeta), + [surface, surfaceMeta] + ); + + const handleSurveyUpdate = React.useCallback( + async (updateForm: IUpdateSurveyFormData) => { + const updateSurveyPromise = SurveyResource.updateSurvey( + survey.id, + updateForm + ); + toast.promise(updateSurveyPromise, { + loading: 'Updating survey...', + success: 'Survey updated. Thanks for your feedback!', + error: 'Failed to update survey', + }); + + const { data } = await updateSurveyPromise; + onSurveyUpdate(data); + }, + [onSurveyUpdate, survey] + ); + + return ( + <> +
{surveyQuestion}
+
+ handleSurveyUpdate({ rating })} + rating={survey.rating} + /> +
+ +
+ +
+
+ Dismiss + handleSurveyUpdate({ comment })} + disabled={comment.length === 0} + > + Submit + +
+ + ); +}; diff --git a/querybook/webapp/config.d.ts b/querybook/webapp/config.d.ts index 14d372007..8ee560df5 100644 --- a/querybook/webapp/config.d.ts +++ b/querybook/webapp/config.d.ts @@ -103,6 +103,20 @@ declare module 'config/querybook_public_config.yaml' { enabled: boolean; }; }; + survey: { + global_response_cooldown: number; + global_trigger_cooldown: number; + global_max_per_week: number; + global_max_per_day: number; + + surfaces: Array<{ + surface: string; + response_cooldown?: number; + trigger_cooldown?: number; + max_per_week?: number; + max_per_day?: number; + }>; + }; }; export default data; } diff --git a/querybook/webapp/const/survey.ts b/querybook/webapp/const/survey.ts new file mode 100644 index 000000000..3bcb1bff4 --- /dev/null +++ b/querybook/webapp/const/survey.ts @@ -0,0 +1,40 @@ +export interface ISurvey { + id: number; + created_at: number; + updated_at: number; + + surface: SurveySurfaceType; + surface_metadata: Record; + rating: number; + comment: string; + + uid: number; +} + +export interface ICreateSurveyFormData { + surface: SurveySurfaceType; + surface_metadata: Record; + rating: number; + comment?: string | null; +} + +export interface IUpdateSurveyFormData { + rating?: number; + comment?: string | null; +} + +export enum SurveySurfaceType { + TABLE_SEARCH = 'table_search', + TABLE_TRUST = 'table_view', + TEXT_TO_SQL = 'text_to_sql', + QUERY_AUTHORING = 'query_authoring', +} + +export const SurveyTypeToQuestion: Record = { + [SurveySurfaceType.TABLE_SEARCH]: + 'Did this search help you find the right table?', + [SurveySurfaceType.TABLE_TRUST]: 'Do you trust {table_name}?', + [SurveySurfaceType.TEXT_TO_SQL]: 'Was Text2SQL helpful with your task?', + [SurveySurfaceType.QUERY_AUTHORING]: + 'Were you able to write this query efficiently?', +}; diff --git a/querybook/webapp/hooks/ui/useSurveyTrigger.tsx b/querybook/webapp/hooks/ui/useSurveyTrigger.tsx new file mode 100644 index 000000000..12142ee52 --- /dev/null +++ b/querybook/webapp/hooks/ui/useSurveyTrigger.tsx @@ -0,0 +1,68 @@ +import React, { useRef } from 'react'; +import toast from 'react-hot-toast'; + +import { Survey } from 'components/Survey/Survey'; +import { SurveySurfaceType } from 'const/survey'; +import { + saveSurveyTriggerRecord, + shouldTriggerSurvey, +} from 'lib/survey/triggerLogic'; + +export async function triggerSurvey( + surface: SurveySurfaceType, + surfaceMeta: Record +) { + if (!(await shouldTriggerSurvey(surface))) { + return; + } + + await saveSurveyTriggerRecord(surface); + + const toastId = toast.custom( + (toastProps) => ( + + ), + { + duration: 1 * 60 * 1000, // 1 minute + } + ); + + return toastId; +} + +export function useSurveyTrigger(endSurveyOnUnmount: boolean = false) { + const toastId = useRef(null); + + const triggerSurveyHook = React.useCallback( + (surface: SurveySurfaceType, surfaceMeta: Record) => { + if (toastId.current) { + toast.dismiss(toastId.current); + toastId.current = null; + } + + triggerSurvey(surface, surfaceMeta).then( + (id: string | undefined) => { + toastId.current = id; + } + ); + }, + [] + ); + + // eslint-disable-next-line arrow-body-style + React.useEffect(() => { + return () => { + if (endSurveyOnUnmount && toastId.current) { + toast.dismiss(toastId.current); + toastId.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return triggerSurveyHook; +} diff --git a/querybook/webapp/lib/codemirror/custom-commands.ts b/querybook/webapp/lib/codemirror/custom-commands.ts index b6d263c12..89565b14f 100644 --- a/querybook/webapp/lib/codemirror/custom-commands.ts +++ b/querybook/webapp/lib/codemirror/custom-commands.ts @@ -102,8 +102,8 @@ export function attachCustomCommand(commands: CommandActions) { // https://github.com/codemirror/codemirror5/blob/bd1b7d2976d768ae4e3b8cf209ec59ad73c0305a/keymap/sublime.js#L169 const addCursorToSelection = (cm, dir) => { - const ranges = cm.listSelections(), - newRanges = []; + const ranges = cm.listSelections(); + const newRanges = []; // eslint-disable-next-line @typescript-eslint/prefer-for-of for (let i = 0; i < ranges.length; i++) { const range = ranges[i]; diff --git a/querybook/webapp/lib/datasource.ts b/querybook/webapp/lib/datasource.ts index b94575c90..354e85bb0 100644 --- a/querybook/webapp/lib/datasource.ts +++ b/querybook/webapp/lib/datasource.ts @@ -1,13 +1,9 @@ -import aiAssistantSocket from './ai-assistant/ai-assistant-socketio'; import axios, { AxiosRequestConfig, Canceler, Method } from 'axios'; import toast from 'react-hot-toast'; -import { AICommandType } from 'const/aiAssistant'; import { setSessionExpired } from 'lib/querybookUI'; import { formatError } from 'lib/utils/error'; -import { DeltaStreamParser } from './stream'; - export interface ICancelablePromise extends Promise { cancel?: Canceler; } diff --git a/querybook/webapp/lib/local-store/const.ts b/querybook/webapp/lib/local-store/const.ts index e19185f89..753b8ec85 100644 --- a/querybook/webapp/lib/local-store/const.ts +++ b/querybook/webapp/lib/local-store/const.ts @@ -1,5 +1,7 @@ import type { Entity } from 'components/EnvironmentAppSidebar/types'; import type { IAdhocQuery } from 'const/adhocQuery'; +import type { ISurveyLocalRecord } from 'lib/survey/types'; +import type { SurveySurfaceType } from 'const/survey'; export const DISMISSED_ANNOUNCEMENT_KEY = 'dismissed_announcement_ids'; export type DismissedAnnouncementValue = number[]; @@ -18,3 +20,6 @@ export type AdhocQueryValue = IAdhocQuery; export const SIDEBAR_ENTITY = 'sidebar_entity'; export type TSidebarEntity = Entity; + +export const SURVEY_RECORD_KEY = 'survey'; +export type TSurveyRecord = Record; diff --git a/querybook/webapp/lib/survey/config.ts b/querybook/webapp/lib/survey/config.ts new file mode 100644 index 000000000..4405cda2a --- /dev/null +++ b/querybook/webapp/lib/survey/config.ts @@ -0,0 +1,17 @@ +import PublicConfig from 'config/querybook_public_config.yaml'; +import type { ISurveyConfig } from './types'; + +const surveyConfig = PublicConfig.survey; + +export const SURVEY_CONFIG: Record = {}; +surveyConfig?.surfaces.forEach((surface) => { + SURVEY_CONFIG[surface.surface] = { + surface: surface.surface, + responseCooldown: + surface.response_cooldown ?? surveyConfig.global_response_cooldown, + triggerCooldown: + surface.trigger_cooldown ?? surveyConfig.global_trigger_cooldown, + maxPerWeek: surface.max_per_week ?? surveyConfig.global_max_per_week, + maxPerDay: surface.max_per_day ?? surveyConfig.global_max_per_day, + }; +}); diff --git a/querybook/webapp/lib/survey/triggerLogic.ts b/querybook/webapp/lib/survey/triggerLogic.ts new file mode 100644 index 000000000..e06a6f542 --- /dev/null +++ b/querybook/webapp/lib/survey/triggerLogic.ts @@ -0,0 +1,126 @@ +import { SURVEY_CONFIG } from './config'; +import type { ISurveyLocalRecord, SurveyTime } from './types'; +import localStore from 'lib/local-store'; +import { SURVEY_RECORD_KEY, TSurveyRecord } from 'lib/local-store/const'; +import { SurveySurfaceType } from 'const/survey'; + +/** + * + * @param now current time in seconds + * @param surface name of surface, assumed to be in survey config + * @returns boolean indicating whether survey should be triggered + */ +export async function shouldTriggerSurvey( + surface: SurveySurfaceType +): Promise { + if (!SURVEY_CONFIG[surface]) { + return false; + } + + const now = getSurveyTime(); + const record = await getLocalRecord(surface); + const surveyConfig = SURVEY_CONFIG[surface]; + + // check if triggered too recently + if (now.nowSeconds - record.lastTriggered < surveyConfig.triggerCooldown) { + return false; + } + + // check if responded too recently + if (now.nowSeconds - record.lastResponded < surveyConfig.responseCooldown) { + return false; + } + + // check if triggered too many times this week + if ( + record.weekTriggered[0] === now.weekOfYear && + record.weekTriggered[1] >= surveyConfig.maxPerWeek + ) { + return false; + } + + // check if triggered too many times this day + if ( + record.dayTriggered[0] === now.dayOfYear && + record.dayTriggered[1] >= surveyConfig.maxPerDay + ) { + return false; + } + + return true; +} + +async function retrieveAndUpdateRecord( + surface: SurveySurfaceType, + callback: (record: ISurveyLocalRecord) => ISurveyLocalRecord +) { + const record = await getLocalRecord(surface); + const updatedRecord = callback(record); + + await localStore.set(SURVEY_RECORD_KEY, { + ...(await localStore.get(SURVEY_RECORD_KEY)), + [surface]: updatedRecord, + }); +} + +export async function saveSurveyTriggerRecord(surface: SurveySurfaceType) { + const now = getSurveyTime(); + await retrieveAndUpdateRecord(surface, (record) => { + // update last triggered + record.lastTriggered = now.nowSeconds; + + // update triggered count for this week + if (record.weekTriggered[0] === now.weekOfYear) { + record.weekTriggered[1] += 1; + } else { + record.weekTriggered = [now.weekOfYear, 1]; + } + + // update triggered count for this day + if (record.dayTriggered[0] === now.dayOfYear) { + record.dayTriggered[1] += 1; + } else { + record.dayTriggered = [now.dayOfYear, 1]; + } + + return record; + }); +} + +export async function saveSurveyRespondRecord(surface: SurveySurfaceType) { + const now = getSurveyTime(); + await retrieveAndUpdateRecord(surface, (record) => { + // update last responded + record.lastResponded = now.nowSeconds; + + return record; + }); +} + +async function getLocalRecord( + surface: SurveySurfaceType +): Promise { + const localRecord = await localStore.get(SURVEY_RECORD_KEY); + return { + lastTriggered: 0, + lastResponded: 0, + weekTriggered: [0, 0], + dayTriggered: [0, 0], + + ...(localRecord?.[surface] ?? {}), + }; +} + +function getSurveyTime(): SurveyTime { + const now = new Date(); + const startOfYear = new Date(now.getFullYear(), 0, 0); + const dayOfYear = Math.floor( + (now.getTime() - startOfYear.getTime()) / (1000 * 60 * 60 * 24) + ); + const weekOfYear = Math.floor(dayOfYear / 7); + return { + nowSeconds: Math.floor(now.getTime() / 1000), + weekOfYear, + dayOfYear, + }; +} diff --git a/querybook/webapp/lib/survey/types.ts b/querybook/webapp/lib/survey/types.ts new file mode 100644 index 000000000..57d760a90 --- /dev/null +++ b/querybook/webapp/lib/survey/types.ts @@ -0,0 +1,20 @@ +export interface ISurveyConfig { + surface: string; + responseCooldown: number; + triggerCooldown: number; + maxPerWeek: number; + maxPerDay: number; +} + +export interface ISurveyLocalRecord { + lastTriggered: number; + lastResponded: number; + weekTriggered: [weekNum: number, count: number]; + dayTriggered: [dayNum: number, count: number]; +} + +export interface SurveyTime { + nowSeconds: number; + weekOfYear: number; + dayOfYear: number; +} diff --git a/querybook/webapp/redux/scheduledDataDoc/action.ts b/querybook/webapp/redux/scheduledDataDoc/action.ts index f2e285c4e..8f3457e20 100644 --- a/querybook/webapp/redux/scheduledDataDoc/action.ts +++ b/querybook/webapp/redux/scheduledDataDoc/action.ts @@ -4,7 +4,7 @@ import { IOption } from 'lib/utils/react-select'; import { IScheduledDoc, IScheduledDocFilters, ThunkResult } from './types'; import { StatusType } from 'const/schedFiltersType'; -function reformatBoardIds(boardIds: IOption[]): number[] | null { +function reformatBoardIds(boardIds: Array>): number[] | null { if (boardIds.length) { return boardIds.map((board) => board.value); } diff --git a/querybook/webapp/redux/scheduledDataDoc/types.ts b/querybook/webapp/redux/scheduledDataDoc/types.ts index 9adfcd327..681f221e3 100644 --- a/querybook/webapp/redux/scheduledDataDoc/types.ts +++ b/querybook/webapp/redux/scheduledDataDoc/types.ts @@ -18,7 +18,7 @@ interface IBasicScheduledDocFilters { export interface IScheduledDocFilters extends IBasicScheduledDocFilters { status?: StatusType; - board_ids?: IOption[]; + board_ids?: Array>; } export interface ITransformedScheduledDocFilters diff --git a/querybook/webapp/resource/survey.ts b/querybook/webapp/resource/survey.ts new file mode 100644 index 000000000..7eb06747b --- /dev/null +++ b/querybook/webapp/resource/survey.ts @@ -0,0 +1,17 @@ +import type { + ISurvey, + IUpdateSurveyFormData, + ICreateSurveyFormData, +} from 'const/survey'; +import ds from 'lib/datasource'; + +export const SurveyResource = { + createSurvey: (surveyData: ICreateSurveyFormData) => + ds.save('/survey/', surveyData as Record), + + updateSurvey: (surveyId: number, surveyData: IUpdateSurveyFormData) => + ds.update( + `/survey/${surveyId}/`, + surveyData as Record + ), +};