Skip to content

Commit

Permalink
feat(web): optimize playground (pingcap#1019)
Browse files Browse the repository at this point in the history
* add info card

* fix

* fix

* refine code

* refactor ui
  • Loading branch information
634750802 committed Dec 9, 2022
1 parent e76c4a9 commit f540616
Show file tree
Hide file tree
Showing 8 changed files with 412 additions and 284 deletions.
240 changes: 9 additions & 231 deletions web/src/dynamic-pages/analyze/playground/Playground.tsx
Original file line number Diff line number Diff line change
@@ -1,100 +1,12 @@
import * as React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { format } from 'sql-formatter';
import { Button, Drawer, Tooltip, useEventCallback } from '@mui/material';
import { isNonemptyString, isNullish, notNullish } from '@site/src/utils/value';
import LoadingButton from '@mui/lab/LoadingButton';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import { useAnalyzeContext } from '../charts/context';
import SQLEditor from './SQLEditor';
import { PredefinedQuestion } from './predefined';
import PredefinedGroups from './PredefinedGroups';
import { Gap, PlaygroundBody, PlaygroundButton, PlaygroundContainer, PlaygroundDescription, PlaygroundHeadline, PlaygroundMain, PlaygroundSide, QuestionFieldTitle } from './styled';
import { Experimental } from '@site/src/components/Experimental';
import { aiQuestion } from '@site/src/api/core';
import ResultBlock from './ResultBlock';
import QuestionField from './QuestionField';
import { useAsyncOperation } from '@site/src/hooks/operation';
import { core } from '@site/src/api';
import { LoginRequired } from '@site/src/components/LoginRequired';
import { HelpOutline } from '@mui/icons-material';
import { useMemo } from 'react';
import { Drawer } from '@mui/material';
import useUrlSearchState, { booleanParam } from '@site/src/hooks/url-search-state';
import { useWhenMounted } from '@site/src/hooks/mounted';

const DEFAULT_QUESTION = 'Who closed the last issue in this repo?';
const QUESTION_MAX_LENGTH = 200;
import PlaygroundContent from './PlaygroundContent';
import PlaygroundButton from './PlaygroundButton';

function Playground ({ open, onClose }: { open: boolean, onClose: () => void }) {
const { repoName, repoId } = useAnalyzeContext();

const [inputValue, setInputValue] = useState('');
const [currentQuestion, setCurrentQuestion] = useState<PredefinedQuestion>();
const [customQuestion, setCustomQuestion] = useState('');

const setCustomQuestionWithMaxLength = useEventCallback((value: string) => {
setCustomQuestion(oldValue => value.length <= QUESTION_MAX_LENGTH ? value : oldValue);
});

const { data, loading, error, run } = useAsyncOperation({ sql: inputValue, type: 'repo', id: `${repoId ?? 'undefined'}` }, core.postPlaygroundSQL);
const { data: questionSql, loading: questionLoading, error: questionError, run: runQuestion } = useAsyncOperation({ question: customQuestion || DEFAULT_QUESTION, context: { repo_id: repoId, repo_name: repoName } }, aiQuestion);

const onChange = (newValue: string) => {
setInputValue(newValue);
setCurrentQuestion(undefined);
};

const handleFormatSQLClick = () => {
const formattedSQL = format(inputValue, {
language: 'mysql',
uppercase: true,
linesBetweenQueries: 2,
});
setInputValue(formattedSQL);
};

const handleSelectQuestion = useEventCallback((question: PredefinedQuestion) => {
const trueSql = [
{ match: 'repoId', value: `${repoId ?? 'undefined'}` },
{ match: 'repoName', value: repoName ?? 'undefined' },
].reduce((sql, { match, value }) => sql.replaceAll(`{{${match}}}`, value), question.sql);
setInputValue(trueSql);
setCurrentQuestion(question);
});

useEffect(() => {
if (isNonemptyString(questionSql)) {
setInputValue(format(questionSql, {
language: 'mysql',
uppercase: true,
linesBetweenQueries: 2,
}));
}
}, [questionSql]);

const defaultInput = useMemo(() => {
return `
/* ⚠️
Playground uses LIMITED resource(cpu/mem), so SQL should add:
WHERE repo_id = ${repoId ?? 'undefined'}
to use index as much as possible, or it will be terminated.
Example:
SELECT
*
FROM
github_events
WHERE
repo_id = ${repoId ?? '{{repoId}}'}
LIMIT
1;
*/
`;
}, [repoId]);

return (
<Drawer
anchor="bottom"
Expand All @@ -104,153 +16,19 @@ LIMIT
keepMounted: true,
}}
>
<PlaygroundContainer id="sql-playground-container">
<PlaygroundBody>
<PlaygroundSide>
<PlaygroundHeadline>
Playground: Customize your queries with SQL
<Experimental feature="ai-playground">
<> or AI<span className="opaque">🤖</span></>
</Experimental>
!
</PlaygroundHeadline>
<PlaygroundDescription>
<li>Choose a question<Experimental feature="ai-playground"><> or create a new one</>
</Experimental> below
</li>
<li>Check or edit the generated SQL(Optional)</li>
<li>Run your SQL and enjoy your results</li>
</PlaygroundDescription>
<Experimental feature="ai-playground">
<>
<QuestionFieldTitle>
Your Question
<Tooltip title="The result SQL will be generated by AI.">
<HelpOutline sx={{ ml: 1 }} fontSize="inherit" />
</Tooltip>
</QuestionFieldTitle>
<LoginRequired promote="Log in to write question" sx={{ mt: 1 }}>
<QuestionField
loading={questionLoading}
error={questionError}
value={customQuestion}
onChange={setCustomQuestionWithMaxLength}
onAction={runQuestion}
defaultQuestion={DEFAULT_QUESTION}
maxLength={QUESTION_MAX_LENGTH}
/>
</LoginRequired>
</>
</Experimental>
<PredefinedGroups onSelectQuestion={handleSelectQuestion} question={currentQuestion} />
</PlaygroundSide>
<PlaygroundMain>
<SQLEditor
loading={questionLoading || loading}
mode="sql"
theme="twilight"
onChange={onChange}
name="SQL_PLAYGROUND"
showPrintMargin={false}
value={inputValue || defaultInput}
fontSize={16}
setOptions={{
enableLiveAutocompletion: true,
}}
extra={
<>
<Button
variant="contained"
size="small"
disabled={!inputValue || isNullish(repoId)}
onClick={handleFormatSQLClick}
>
Format
</Button>
<LoadingButton
variant="contained"
size="small"
disabled={!inputValue || isNullish(repoId)}
onClick={run}
endIcon={<PlayArrowIcon fontSize="inherit" />}
loading={loading}
>
Run
</LoadingButton>
</>
}
/>
<Gap />
<ResultBlock data={data} loading={loading} error={error} />
</PlaygroundMain>
</PlaygroundBody>
</PlaygroundContainer>
<PlaygroundContent />
</Drawer>
);
}

export function usePlayground () {
const [open, setOpen] = useUrlSearchState('playground', booleanParam('enabled'), false);
const ref = useRef<HTMLButtonElement>(null);
const whenMounted = useWhenMounted();

const handleClose = useEventCallback(() => {
setOpen(false);
});

const handleClickTerminalBtn = useEventCallback((event: React.MouseEvent<HTMLElement>) => {
setOpen(open => !open);
});

useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key.toUpperCase() === 'K' && (event.ctrlKey || event.metaKey)) {
// it was Ctrl + K (Cmd + K)
setOpen(true);
}
return useMemo(() => {
return {
button: <PlaygroundButton open={open} onToggleOpen={whenMounted(() => setOpen(() => !open))} />,
drawer: <Playground open={open} onClose={whenMounted(() => setOpen(false))} />,
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);

const onAnimationEnd = useEventCallback(() => {
if (notNullish(ref.current)) {
// https://stackoverflow.com/questions/4797675/how-do-i-re-trigger-a-webkit-css-animation-via-javascript
ref.current.classList.remove('tada');
setTimeout(whenMounted(() => {
if (notNullish(ref.current) && ref.current.classList.contains('animated')) {
ref.current.classList.add('tada');
}
}), 6000);
}
});

const button = useMemo(() => {
return (
<PlaygroundButton
ref={ref}
className={`${open ? ' opened' : 'tada animated'}`}
onAnimationEnd={onAnimationEnd}
aria-label="Open SQL Playground"
onClick={handleClickTerminalBtn}
disableRipple
disableTouchRipple
sx={{
display: {
xs: 'none',
// Remove next line to show terminal button on desktop
md: 'inline-flex',
},
}}
>
<img src={require('./icon.png').default} width="66" height="73" alt="Playground icon" />
</PlaygroundButton>
);
}, [open]);

const drawer = <Playground open={open} onClose={handleClose} />;

return { button, drawer };
}
87 changes: 87 additions & 0 deletions web/src/dynamic-pages/analyze/playground/PlaygroundButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import * as React from 'react';
import { MouseEventHandler, useEffect, useState } from 'react';
import { Button, createTheme, Grow, useEventCallback } from '@mui/material';
import { useTimeout } from 'ahooks';
import { PlaygroundButton as StyledPlaygroundButton, PlaygroundButtonContainer, PlaygroundPopoverContent } from '@site/src/dynamic-pages/analyze/playground/styled';
import { Experimental } from '@site/src/components/Experimental';
import ThemeProvider from '@mui/system/ThemeProvider';
import { useWhenMounted } from '@site/src/hooks/mounted';

const DISPLAY_POPPER_TIMEOUT = 10000;

interface PlaygroundButtonProps {
open: boolean;
onToggleOpen: () => void;
}

export default function PlaygroundButton ({ open, onToggleOpen }: PlaygroundButtonProps) {
const whenMounted = useWhenMounted();

const [popoverIn, setPopoverIn] = useState(() => localStorage.getItem('ossinsight.playground.tooltip-closed') !== 'true');

const handleClickTerminalBtn = useEventCallback(whenMounted((event: React.MouseEvent<HTMLElement>) => {
onToggleOpen();
setPopoverIn(false);
localStorage.setItem('ossinsight.playground.tooltip-closed', 'true');
}));

useTimeout(whenMounted(() => {
setPopoverIn(false);
}), DISPLAY_POPPER_TIMEOUT);

useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key.toUpperCase() === 'K' && (event.ctrlKey || event.metaKey)) {
// it was Ctrl + K (Cmd + K)
onToggleOpen();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);

return (
<PlaygroundButtonContainer className={open ? 'opened' : ''}>
<Experimental feature="ai-playground">
<Grow in={popoverIn}>
<div>
<PlaygroundPopover onClickButton={handleClickTerminalBtn} />
</div>
</Grow>
</Experimental>
<StyledPlaygroundButton
className={`${open ? '' : 'tada animated'}`}
aria-label="Open SQL Playground"
onClick={handleClickTerminalBtn}
disableRipple
disableTouchRipple
>
<img src={require('./icon.png').default} width="66" height="73" alt="Playground icon" />
</StyledPlaygroundButton>
</PlaygroundButtonContainer>
);
}

const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#FFE895',
contrastText: '#1C1E21',
},
},
});

function PlaygroundPopover ({ onClickButton }: { onClickButton: MouseEventHandler }) {
return (
<ThemeProvider theme={theme}>
<PlaygroundPopoverContent>
<h2>👀 Want to know more about this repo?</h2>
<p>Chat with GitHub data directly and gain your own insights here!</p>
<Button fullWidth size="small" variant="contained" onClick={onClickButton}>Ask me a question</Button>
</PlaygroundPopoverContent>
</ThemeProvider>
);
}
Loading

0 comments on commit f540616

Please sign in to comment.