Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add a feedback widget #450

Merged
merged 13 commits into from
May 31, 2023
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "QFeedback" (
"id" SERIAL NOT NULL,
"feedback" TEXT NOT NULL,
"created_date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"qUserId" INTEGER NOT NULL,

CONSTRAINT "QFeedback_pkey" PRIMARY KEY ("id")
);
7 changes: 7 additions & 0 deletions quadratic-api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,10 @@ model QFile {
times_updated Int @default(1)
version String?
}

model QFeedback {
id Int @id @default(autoincrement())
feedback String
created_date DateTime @default(now())
qUserId Int
}
davidkircos marked this conversation as resolved.
Show resolved Hide resolved
2 changes: 2 additions & 0 deletions quadratic-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import cors from 'cors';
import ai_chat_router from './routes/ai_chat';
import helmet from 'helmet';
import files_router from './routes/files';
import feedback_router from './routes/feedback';

const app = express();

Expand Down Expand Up @@ -54,6 +55,7 @@ app.use((req, res, next) => {
// Routes
app.use('/ai', ai_chat_router);
app.use('/v0/files', files_router);
app.use('/v0/feedback', feedback_router);

if (SENTRY_DSN) {
// test route
Expand Down
31 changes: 31 additions & 0 deletions quadratic-api/src/routes/feedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import express from 'express';
import { z } from 'zod';
import { Request as JWTRequest } from 'express-jwt';
import { PrismaClient } from '@prisma/client';
import { validateAccessToken } from '../middleware/auth';
import { get_user } from '../helpers/get_user';

const files_router = express.Router();
const prisma = new PrismaClient();

const RequestBodySchema = z.object({
feedback: z.string(),
});
type RequestBody = z.infer<typeof RequestBodySchema>;

files_router.post('/', validateAccessToken, async (request: JWTRequest, response) => {
const { feedback }: RequestBody = RequestBodySchema.parse(request.body);
const user = await get_user(request);

await prisma.qFeedback.create({
data: {
feedback,
davidkircos marked this conversation as resolved.
Show resolved Hide resolved
qUserId: user.id,
created_date: new Date(),
},
});

response.status(200).end();
});

export default files_router;
25 changes: 25 additions & 0 deletions src/api-client/apiClientSingleton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,31 @@ class APIClientSingleton {
});
}
}

async postFeedback(feedback: string): Promise<boolean> {
try {
const url = `${this.getAPIURL()}/v0/feedback`;
const body = JSON.stringify({ feedback });
const token = await this.getAuth();
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body,
});
if (!response.ok) {
throw new Error(`Unexpected response: ${response.status} ${response.statusText}`);
}
return true;
} catch (error) {
Sentry.captureException({
message: `API Error Catch \`/v0/feedback\`: ${error}`,
});
return false;
}
}
}

export default APIClientSingleton.getInstance();
2 changes: 2 additions & 0 deletions src/atoms/editorInteractionStateAtom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface EditorInteractionState {
showCommandPalette: boolean;
showGoToMenu: boolean;
showFileMenu: boolean;
showFeedbackMenu: boolean;
selectedCell: Coordinate;
mode: CellType;
}
Expand All @@ -17,6 +18,7 @@ export const editorInteractionStateDefault: EditorInteractionState = {
showCodeEditor: false,
showCommandPalette: false,
showGoToMenu: false,
showFeedbackMenu: false,
showFileMenu: false,
selectedCell: { x: 0, y: 0 },
mode: 'TEXT',
Expand Down
2 changes: 2 additions & 0 deletions src/constants/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ export const DOCUMENTATION_PYTHON_URL = `${DOCUMENTATION_URL}/python`;
export const DOCUMENTATION_FORMULAS_URL = `${DOCUMENTATION_URL}/formulas`;
export const DOCUMENTATION_FILES_URL = `${DOCUMENTATION_URL}/files`;
export const BUG_REPORT_URL = 'https://github.com/quadratichq/quadratic/issues';
export const DISCORD = 'https://discord.gg/quadratic';
export const TWITTER = 'https://twitter.com/quadratichq';
2 changes: 2 additions & 0 deletions src/gridGL/interaction/keyboard/keyboardViewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export function keyboardViewport(options: {
if ((event.metaKey || event.ctrlKey) && (event.key === 'p' || event.key === 'k' || event.key === '/')) {
setEditorInteractionState({
...editorInteractionState,
showFeedbackMenu: false,
showCellTypeMenu: false,
showGoToMenu: false,
showCommandPalette: !editorInteractionState.showCommandPalette,
Expand Down Expand Up @@ -107,6 +108,7 @@ export function keyboardViewport(options: {
if ((event.metaKey || event.ctrlKey) && (event.key === 'g' || event.key === 'j')) {
setEditorInteractionState({
...editorInteractionState,
showFeedbackMenu: false,
showCellTypeMenu: false,
showCommandPalette: false,
showGoToMenu: !editorInteractionState.showGoToMenu,
Expand Down
2 changes: 2 additions & 0 deletions src/ui/QuadraticUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { SheetController } from '../grid/controller/sheetController';
import ReadOnlyDialog from './components/ReadOnlyDialog';
import { IS_READONLY_MODE } from '../constants/app';
import { useLocalFiles } from './contexts/LocalFiles';
import FeedbackMenu from './menus/FeedbackMenu';

export default function QuadraticUI({ app, sheetController }: { app: PixiApp; sheetController: SheetController }) {
const editorInteractionState = useRecoilValue(editorInteractionStateAtom);
Expand Down Expand Up @@ -70,6 +71,7 @@ export default function QuadraticUI({ app, sheetController }: { app: PixiApp; sh
</div>

{!presentationMode && <BottomBar sheet={sheetController.sheet} />}
{editorInteractionState.showFeedbackMenu && <FeedbackMenu />}
{presentationMode && <PresentationModeHint />}
{hasInitialPageLoadError && <InitialPageLoadError />}

Expand Down
36 changes: 36 additions & 0 deletions src/ui/icons/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,39 @@ export const CopyAsPNG = (props: SvgIconProps) => (
<path d="M15.425 11.575C15.7083 11.8583 16.0667 12 16.5 12C16.9333 12 17.2917 11.8583 17.575 11.575C17.8583 11.2917 18 10.9333 18 10.5C18 10.0667 17.8583 9.70833 17.575 9.425C17.2917 9.14167 16.9333 9 16.5 9C16.0667 9 15.7083 9.14167 15.425 9.425C15.1417 9.70833 15 10.0667 15 10.5C15 10.9333 15.1417 11.2917 15.425 11.575Z" />
</SvgIcon>
);

export const SocialDiscord = (props: SvgIconProps) => (
<SvgIcon {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M17.7123 20C22.2534 19.85 24 16.7429 24 16.7429C24 9.84296 21.0411 4.25016 21.0411 4.25016C18.0822 1.93589 15.2671 2.00018 15.2671 2.00018L14.9795 2.34303C18.4726 3.45731 20.0959 5.06443 20.0959 5.06443C17.9589 3.84302 15.863 3.24302 13.911 3.00731C12.4315 2.83588 11.0137 2.87874 9.76027 3.05017L9.41096 3.09302C8.69178 3.15731 6.94521 3.43588 4.74658 4.44301C3.9863 4.80729 3.53425 5.06443 3.53425 5.06443C3.53425 5.06443 5.23973 3.37159 8.93836 2.25732L8.73288 2.00018C8.73288 2.00018 5.91781 1.93589 2.9589 4.25016C2.9589 4.25016 0 9.84296 0 16.7429C0 16.7429 1.72603 19.85 6.26712 20C6.26712 20 7.0274 19.0357 7.64384 18.2214C5.03425 17.4072 4.04794 15.6929 4.04794 15.6929L4.62329 16.0572L4.70548 16.1215L4.78596 16.1697L4.80993 16.1804L4.89041 16.2286C5.40411 16.5286 5.91781 16.7643 6.39041 16.9572C7.23288 17.3 8.23973 17.6429 9.41096 17.8786C10.9521 18.1786 12.7603 18.2857 14.7329 17.9C15.6986 17.7286 16.6849 17.4286 17.7123 16.9786C18.4315 16.7 19.2329 16.2929 20.0753 15.7143C20.0753 15.7143 19.0479 17.4715 16.3562 18.2643C16.9726 19.0786 17.7123 20 17.7123 20ZM8.15753 9.99296C6.9863 9.99296 6.06164 11.0644 6.06164 12.3715C6.06164 13.6786 7.00685 14.7501 8.15753 14.7501C9.32877 14.7501 10.2534 13.6786 10.2534 12.3715C10.274 11.0644 9.32877 9.99296 8.15753 9.99296ZM15.6575 9.99296C14.4863 9.99296 13.5616 11.0644 13.5616 12.3715C13.5616 13.6786 14.5068 14.7501 15.6575 14.7501C16.8288 14.7501 17.7534 13.6786 17.7534 12.3715C17.7534 11.0644 16.8288 9.99296 15.6575 9.99296Z"
/>
</SvgIcon>
);

export const SocialTwitter = (props: SvgIconProps) => (
<SvgIcon {...props}>
<g clipPath="url(#clip0_310_5)">
<path d="M24 4.55699C23.117 4.94899 22.168 5.21299 21.172 5.33199C22.189 4.72299 22.97 3.75799 23.337 2.60799C22.386 3.17199 21.332 3.58199 20.21 3.80299C19.313 2.84599 18.032 2.24799 16.616 2.24799C13.437 2.24799 11.101 5.21399 11.819 8.29299C7.728 8.08799 4.1 6.12799 1.671 3.14899C0.381 5.36199 1.002 8.25699 3.194 9.72299C2.388 9.69699 1.628 9.47599 0.965 9.10699C0.911 11.388 2.546 13.522 4.914 13.997C4.221 14.185 3.462 14.229 2.69 14.081C3.316 16.037 5.134 17.46 7.29 17.5C5.22 19.123 2.612 19.848 0 19.54C2.179 20.937 4.768 21.752 7.548 21.752C16.69 21.752 21.855 14.031 21.543 7.10599C22.505 6.41099 23.34 5.54399 24 4.55699Z" />
</g>
<defs>
<clipPath id="clip0_310_5">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</SvgIcon>
);

export const SocialGithub = (props: SvgIconProps) => (
<SvgIcon {...props}>
<g clipPath="url(#clip0_310_3)">
<path d="M12 0C5.374 0 0 5.373 0 12C0 17.302 3.438 21.8 8.207 23.387C8.806 23.498 9 23.126 9 22.81V20.576C5.662 21.302 4.967 19.16 4.967 19.16C4.421 17.773 3.634 17.404 3.634 17.404C2.545 16.659 3.717 16.675 3.717 16.675C4.922 16.759 5.556 17.912 5.556 17.912C6.626 19.746 8.363 19.216 9.048 18.909C9.155 18.134 9.466 17.604 9.81 17.305C7.145 17 4.343 15.971 4.343 11.374C4.343 10.063 4.812 8.993 5.579 8.153C5.455 7.85 5.044 6.629 5.696 4.977C5.696 4.977 6.704 4.655 8.997 6.207C9.954 5.941 10.98 5.808 12 5.803C13.02 5.808 14.047 5.941 15.006 6.207C17.297 4.655 18.303 4.977 18.303 4.977C18.956 6.63 18.545 7.851 18.421 8.153C19.191 8.993 19.656 10.064 19.656 11.374C19.656 15.983 16.849 16.998 14.177 17.295C14.607 17.667 15 18.397 15 19.517V22.81C15 23.129 15.192 23.504 15.801 23.386C20.566 21.797 24 17.3 24 12C24 5.373 18.627 0 12 0Z" />
</g>
<defs>
<clipPath id="clip0_310_3">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</SvgIcon>
);
16 changes: 15 additions & 1 deletion src/ui/menus/BottomBar/BottomBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { isMobileOnly } from 'react-device-detect';
import { debugShowCacheFlag, debugShowFPS, debugShowRenderer, debugShowCacheCount } from '../../../debugFlags';
import { Sheet } from '../../../grid/sheet/Sheet';
import { editorInteractionStateAtom } from '../../../atoms/editorInteractionStateAtom';
import { ChatBubbleOutline } from '@mui/icons-material';

interface Props {
sheet: Sheet;
Expand Down Expand Up @@ -125,7 +126,20 @@ export const BottomBar = (props: Props) => {
gap: '1rem',
}}
>
{!isMobileOnly && <span>✓ Python 3.9.5</span>}
{!isMobileOnly && (
<>
<span
style={{ display: 'flex', alignItems: 'center', gap: '.25rem' }}
onClick={() => {
setEditorInteractionState((prevState) => ({ ...prevState, showFeedbackMenu: true }));
}}
>
<ChatBubbleOutline fontSize="inherit" />
Feedback
</span>
<span>✓ Python 3.9.5</span>
</>
)}
<span>✓ Quadratic {process.env.REACT_APP_VERSION}</span>
<span
style={{
Expand Down
29 changes: 17 additions & 12 deletions src/ui/menus/CommandPalette/ListItems/Help.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { CommandPaletteListItem } from '../CommandPaletteListItem';
import { OpenInNew } from '@mui/icons-material';
import { DOCUMENTATION_URL, BUG_REPORT_URL } from '../../../../constants/urls';
import { ChatBubbleOutline, OpenInNew } from '@mui/icons-material';
import { DOCUMENTATION_URL } from '../../../../constants/urls';
import { CommandPaletteListItemSharedProps } from '../CommandPaletteListItem';
import { editorInteractionStateAtom } from '../../../../atoms/editorInteractionStateAtom';
import { useSetRecoilState } from 'recoil';

const ListItems = [
{
Expand All @@ -17,16 +19,19 @@ const ListItems = [
),
},
{
label: 'Help: Report a problem',
Component: (props: CommandPaletteListItemSharedProps) => (
<CommandPaletteListItem
{...props}
icon={<OpenInNew />}
action={() => {
window.open(BUG_REPORT_URL, '_blank')?.focus();
}}
/>
),
label: 'Help: Provide feedback',
Component: (props: CommandPaletteListItemSharedProps) => {
const setEditorInteractionState = useSetRecoilState(editorInteractionStateAtom);
return (
<CommandPaletteListItem
{...props}
icon={<ChatBubbleOutline />}
action={() => {
setEditorInteractionState((prevState) => ({ ...prevState, showFeedbackMenu: true }));
}}
/>
);
},
},
];

Expand Down
127 changes: 127 additions & 0 deletions src/ui/menus/FeedbackMenu/FeedbackMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { useState } from 'react';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
TextField,
} from '@mui/material';
import { useTheme } from '@mui/system';
import { useRecoilState } from 'recoil';
import { editorInteractionStateAtom } from '../../../atoms/editorInteractionStateAtom';
import { BUG_REPORT_URL, DISCORD, TWITTER } from '../../../constants/urls';
import useLocalStorage from '../../../hooks/useLocalStorage';
import { useGlobalSnackbar } from '../../contexts/GlobalSnackbar';
import apiClientSingleton from '../../../api-client/apiClientSingleton';
import { SocialDiscord, SocialGithub, SocialTwitter } from '../../icons';

export const FeedbackMenu = () => {
const [editorInteractionState, setEditorInteractionState] = useRecoilState(editorInteractionStateAtom);
const { showFeedbackMenu } = editorInteractionState;
// We'll keep the user's state around unless they explicitly cancel or get a successful submit
const [value, setValue] = useLocalStorage('feedback-message', '');
const [loadState, setLoadState] = useState<'INITIAL' | 'LOADING' | 'LOAD_ERROR'>('INITIAL');
const theme = useTheme();
const { addGlobalSnackbar } = useGlobalSnackbar();

const closeMenu = () => {
setEditorInteractionState((state) => ({
...state,
showFeedbackMenu: false,
}));
};

const onSubmit = async () => {
setLoadState('LOADING');
const success = await apiClientSingleton.postFeedback(value);
if (success) {
setValue('');
closeMenu();
addGlobalSnackbar('Feedback submitted! Thank you.');
} else {
setLoadState('LOAD_ERROR');
}
};

const isLoading = loadState === 'LOADING';
const hasError = loadState === 'LOAD_ERROR';

return (
<Dialog open={showFeedbackMenu} onClose={closeMenu} fullWidth maxWidth={'sm'} BackdropProps={{ invisible: true }}>
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Provide feedback</span>
<span style={{ display: 'flex', alignItems: 'center', gap: theme.spacing(1) }}>
<IconButton href={BUG_REPORT_URL} target="_blank" color="inherit">
<SocialGithub />
</IconButton>
<IconButton href={TWITTER} target="_blank" color="inherit">
<SocialTwitter />
</IconButton>
<IconButton href={DISCORD} target="_blank" color="inherit">
<SocialDiscord />
</IconButton>
</span>
</DialogTitle>
<DialogContent>
<DialogContentText>How can we make Quadratic better? Reach out, or let us know below:</DialogContentText>

<TextField
InputLabelProps={{ shrink: true }}
id="feedback"
variant="outlined"
disabled={isLoading}
fullWidth
minRows={4}
multiline
sx={{ mt: theme.spacing(2) }}
autoFocus
value={value}
onFocus={(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
// If an error exists, reset
if (loadState === 'LOAD_ERROR') {
setLoadState('INITIAL');
}
// Ensure cursor position to the end on focus
if (value.length > 0) {
event.target.setSelectionRange(value.length, value.length);
}
}}
// Allow submit via keyboard CMD + Enter
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
onSubmit();
}
}}
onChange={(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setValue(event.target.value);
}}
{...(hasError ? { error: true, helperText: 'Failed to send. Try again.' } : {})}
/>
</DialogContent>
<DialogActions>
<Button
variant="text"
color="inherit"
onClick={() => {
setValue('');
closeMenu();
}}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="text"
sx={{ mr: theme.spacing(1) }}
onClick={onSubmit}
disabled={value.length === 0 || isLoading}
>
{isLoading ? 'Submitting…' : 'Submit'}
</Button>
</DialogActions>
</Dialog>
);
};
2 changes: 2 additions & 0 deletions src/ui/menus/FeedbackMenu/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { FeedbackMenu } from './FeedbackMenu';
export default FeedbackMenu;
Loading
Loading