feat(wren-ui): add Prompt UI to home & home/[id] page (#20)

* feat(wren-ui): move ask components to pages/home folder

* feat(wren-ui): add home page to integrate prompt component & demo prompt result

* feat(wren-ui): add prompt to home/[id] page, rename iterated component

* fix(wren-ui): block hover style, typo, ask flow

---------

Co-authored-by: shimin <shimin@shimins-MacBook-Pro.local>
This commit is contained in:
Shimin
2024-03-20 10:42:25 +08:00
committed by GitHub
co-authored by shimin
parent 79235623d2
commit 6a76da858b
22 changed files with 676 additions and 24 deletions
+25 -3
View File
@@ -1,12 +1,29 @@
import { ReactNode, useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
interface Props {
text?: string;
children?: ReactNode;
multipleLine?: number;
}
const Wrapper = styled.div<{ multipleLine?: number }>`
overflow: hidden;
text-overflow: ellipsis;
${(props) =>
props.multipleLine
? `
display: -webkit-box;
-webkit-line-clamp: ${props.multipleLine};
-webkit-box-orient: vertical;
`
: `
white-space: nowrap;
`}
`;
export default function EllipsisWrapper(props: Props) {
const { text, children } = props;
const { text, multipleLine, children } = props;
const ref = useRef<HTMLDivElement | null>(null);
const [width, setWidth] = useState(undefined);
const hasWidth = width !== undefined;
@@ -28,8 +45,13 @@ export default function EllipsisWrapper(props: Props) {
const title = Array.isArray(text) ? text.join('') : text;
return (
<div ref={ref} className="text-truncate" title={title} style={{ width }}>
<Wrapper
ref={ref}
title={title}
multipleLine={multipleLine}
style={{ width }}
>
{hasWidth ? renderContent() : null}
</div>
</Wrapper>
);
}
@@ -2,13 +2,13 @@ import { Button, Skeleton, Typography } from 'antd';
import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined';
import SaveOutlined from '@ant-design/icons/SaveOutlined';
import QuestionCircleOutlined from '@ant-design/icons/QuestionCircleOutlined';
import StepContent from '@/components/ask/StepContent';
import StepContent from '@/components/pages/home/StepContent';
const { Title } = Typography;
interface Props {
loading: boolean;
onOpenSaveAsViewModal: ({ sql: string }) => void;
onOpenSaveAsViewModal: (data: { sql: string }) => void;
question: string;
description: string;
answerResultSteps: Array<{
@@ -2,7 +2,7 @@ import dynamic from 'next/dynamic';
import { Button, Typography } from 'antd';
import CopyOutlined from '@ant-design/icons/lib/icons/CopyOutlined';
import UpCircleOutlined from '@ant-design/icons/UpCircleOutlined';
import PreviewData from '@/components/ask/PreviewData';
import PreviewData from '@/components/pages/home/PreviewData';
const CodeBlock = dynamic(() => import('@/components/editor/CodeBlock'), {
ssr: false,
@@ -3,7 +3,7 @@ import FunctionOutlined from '@ant-design/icons/FunctionOutlined';
import { BinocularsIcon } from '@/utils/icons';
import CollapseContent, {
Props as CollapseContentProps,
} from '@/components/ask/CollapseContent';
} from '@/components/pages/home/CollapseContent';
import useAnswerStepContent from '@/hooks/useAnswerStepContent';
const { Title, Paragraph } = Typography;
@@ -0,0 +1,46 @@
import { Row, Col } from 'antd';
import styled from 'styled-components';
import { makeIterable } from '@/utils/iteration';
import EllipsisWrapper from '@/components/EllipsisWrapper';
const DemoBlock = styled.div`
&:hover {
border-color: var(--geekblue-6) !important;
transition: border-color ease 0.2s;
}
`;
interface Props {
demo: any[];
onSelect: (data: any) => void;
}
const DemoTemplate = ({ title, summary, onSelect }) => {
return (
<Col span={8}>
<DemoBlock
className="border border-gray-5 rounded px-3 pt-3 pb-4 cursor-pointer"
onClick={() => onSelect({ title, summary })}
>
<div className="d-flex justify-space-between align-center text-sm mb-3">
<div className="border border-gray-5 px-2 rounded-pill">{title}</div>
</div>
<EllipsisWrapper multipleLine={3} text={summary} />
</DemoBlock>
</Col>
);
};
const DemoColumnIterator = makeIterable(DemoTemplate);
export default function DemoPrompt(props: Props) {
const { demo, onSelect } = props;
return (
<div style={{ width: 580 }}>
<div className="text-center mt-3 mb-2">Try asking...</div>
<Row gutter={16}>
<DemoColumnIterator data={demo} onSelect={onSelect} />
</Row>
</div>
);
}
@@ -0,0 +1,228 @@
import { ReactNode } from 'react';
import { Row, Col, Button, Collapse } from 'antd';
import styled from 'styled-components';
import { PROCESS_STATE } from '@/utils/enum';
import { makeIterable } from '@/utils/iteration';
import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined';
import FunctionOutlined from '@ant-design/icons/FunctionOutlined';
import CloseOutlined from '@ant-design/icons/CloseOutlined';
import StopOutlined from '@ant-design/icons/StopFilled';
import LoadingOutlined from '@ant-design/icons/LoadingOutlined';
import CloseCircleFilled from '@ant-design/icons/CloseCircleFilled';
import WarningOutlined from '@ant-design/icons/WarningOutlined';
import CaretRightOutlined from '@ant-design/icons/CaretRightOutlined';
import ViewSQLModal from '@/components/pages/home/prompt/ViewSQLModal';
import EllipsisWrapper from '@/components/EllipsisWrapper';
import useModalAction from '@/hooks/useModalAction';
import useAskProcessState from '@/hooks/useAskProcessState';
const ResultStyle = styled.div`
position: absolute;
bottom: calc(100% + 12px);
left: 0;
width: 100%;
background: white;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
`;
const StyledCollapse = styled(Collapse)`
.ant-collapse-item {
> .ant-collapse-header {
color: var(--gray-7);
padding-left: 0;
padding-right: 0;
.ant-collapse-arrow {
margin-right: 8px;
}
}
> .ant-collapse-content .ant-collapse-content-box {
color: var(--gray-7);
padding: 0;
}
}
`;
const ResultBlock = styled.div`
&:hover {
border-color: var(--geekblue-6) !important;
transition: border-color ease 0.2s;
}
`;
interface Props {
processState: ReturnType<typeof useAskProcessState>;
data: any[];
error?: any;
onClose: () => void;
onStop: () => void;
}
const ResultTemplate = ({ index, summary, sql, onShowSQL }) => {
return (
<Col span={8}>
<ResultBlock className="border border-gray-5 rounded px-3 pt-3 pb-4 cursor-pointer">
<div className="d-flex justify-space-between align-center text-sm mb-3">
<div className="border border-gray-5 px-2 rounded-pill">
Result {index + 1}
</div>
<Button
className="adm-btn-no-style gray-6 text-sm px-1"
type="text"
onClick={() => onShowSQL({ sql, summary })}
>
<FunctionOutlined className="-mr-1" />
View SQL
</Button>
</div>
<EllipsisWrapper multipleLine={3} text={summary} />
</ResultBlock>
</Col>
);
};
const ResultColumnIterator = makeIterable(ResultTemplate);
const makeProcessing = (text: string) => (props: Props) => {
const { onStop } = props;
return (
<div className="d-flex justify-space-between">
<span>
<LoadingOutlined className="mr-2 geekblue-6 text-lg" spin />
{text}
</span>
<Button
className="adm-btn-no-style gray-7 bg-gray-3 text-sm px-2"
type="text"
size="small"
onClick={onStop}
>
<StopOutlined className="-mr-1" />
Stop
</Button>
</div>
);
};
const makeProcessingError =
(config: { icon: ReactNode; title: string; description: string }) =>
(props: Props) => {
const { onClose, error } = props;
const { message } = error || {};
return (
<div>
<div className="d-flex justify-space-between text-medium mb-2">
<div className="d-flex align-center">
{config.icon}
{config.title}
</div>
<Button
className="adm-btn-no-style gray-7 bg-gray-3 text-sm px-2"
type="text"
size="small"
onClick={onClose}
>
<CloseOutlined className="-mr-1" />
Close
</Button>
</div>
<div className="gray-7">{config.description}</div>
{message && (
<StyledCollapse
ghost
expandIcon={({ isActive }) => (
<CaretRightOutlined rotate={isActive ? 90 : 0} />
)}
>
<Collapse.Panel key="1" header="Show error messages">
<pre className="mb-0">{message}</pre>
</Collapse.Panel>
</StyledCollapse>
)}
</div>
);
};
const ErrorIcon = () => <CloseCircleFilled className="mr-2 red-5 text-lg" />;
const UnderstandingFailed = makeProcessingError({
icon: <ErrorIcon />,
title: 'Failed to understand',
description:
'Sorry, I cannot understand your question. Could you please rephrase your question or provide more context?',
});
const SearchingFailed = makeProcessingError({
icon: <ErrorIcon />,
title: 'Something went wrong',
description:
"Sorry, we encountered an error when we're processing your request.",
});
const NoResult = makeProcessingError({
icon: <WarningOutlined className="mr-2 text-lg" />,
title: 'Please try again',
description: 'No results found. Try providing more details in your question.',
});
const Understanding = makeProcessing('Understanding question');
const Searching = makeProcessing('Searching data');
const Finished = (props: Props) => {
const { data, onClose } = props;
const viewSQLModal = useModalAction();
const showSQL = (payload: { sql: string; summary: string }) => {
viewSQLModal.openModal(payload);
};
return (
<div>
<div className="d-flex justify-space-between mb-3">
<div className="d-flex align-center text-medium">
<CheckCircleOutlined className="mr-2 green-7 text-lg" /> {data.length}{' '}
result(s) found
</div>
<Button
className="adm-btn-no-style gray-7 bg-gray-3 text-sm px-2"
type="text"
size="small"
onClick={onClose}
>
<CloseOutlined className="-mr-1" />
Close
</Button>
</div>
<Row gutter={12}>
<ResultColumnIterator data={data} onShowSQL={showSQL} />
</Row>
<ViewSQLModal {...viewSQLModal.state} onClose={viewSQLModal.closeModal} />
</div>
);
};
const getProcessStateComponent = (state: PROCESS_STATE) => {
return (
{
[PROCESS_STATE.UNDERSTANDING]: Understanding,
[PROCESS_STATE.SEARCHING]: Searching,
[PROCESS_STATE.FINISHED]: Finished,
[PROCESS_STATE.UNDERSTANDING_FAILED]: UnderstandingFailed,
[PROCESS_STATE.SEARCHING_FAILED]: SearchingFailed,
[PROCESS_STATE.NO_RESULT]: NoResult,
}[state] || null
);
};
export default function PromptResult(props: Props) {
const { processState } = props;
const StateComponent = getProcessStateComponent(processState.currentState);
if (StateComponent === null) return null;
return (
<ResultStyle className="border border-gray-3 rounded p-4">
<StateComponent {...props} />
</ResultStyle>
);
}
@@ -0,0 +1,21 @@
import { Modal } from 'antd';
import CodeBlock from '@/components/editor/CodeBlock';
export default function ShowSQLModal(props) {
const { visible, onClose, defaultValue } = props;
const { sql, summary } = defaultValue || {};
return (
<Modal
title="SQL statement"
width={560}
visible={visible}
onCancel={onClose}
destroyOnClose
centered
footer={null}
>
<div className="mb-3">{summary}</div>
<CodeBlock code={sql} showLineNumbers />
</Modal>
);
}
@@ -0,0 +1,128 @@
import { useEffect, useMemo, useState } from 'react';
import { Input, Button } from 'antd';
import styled from 'styled-components';
import { PROCESS_STATE } from '@/utils/enum';
import PromptResult from '@/components/pages/home/prompt/Result';
import useAskProcessState from '@/hooks/useAskProcessState';
interface Props {
onStop: () => void;
onSubmit: (value: string) => void;
data?: { status: string; result: { sql: string; summary: string }[] };
error?: any;
}
const PromptStyle = styled.div`
position: fixed;
width: 768px;
left: 50%;
margin-left: calc(-384px + 140px);
bottom: 12px;
`;
const PromptButton = styled(Button)`
min-width: 72px;
`;
const convertToProcessState = (status: string) => {
return {
understanding: PROCESS_STATE.UNDERSTANDING,
searching: PROCESS_STATE.SEARCHING,
finished: PROCESS_STATE.FINISHED,
}[status];
};
const convertToErrorState = (status: string) => {
return {
understanding: PROCESS_STATE.UNDERSTANDING_FAILED,
searching: PROCESS_STATE.SEARCHING_FAILED,
finished: PROCESS_STATE.NO_RESULT,
}[status];
};
export default function Prompt(props: Props) {
const { data, error, onSubmit, onStop } = props;
const [inputValue, setInputValue] = useState('');
const askProcessState = useAskProcessState();
const results = useMemo(() => data.result || [], [data]);
const question = useMemo(() => inputValue.trim(), [inputValue]);
const isProcessing = useMemo(
() =>
[PROCESS_STATE.UNDERSTANDING, PROCESS_STATE.SEARCHING].includes(
askProcessState.currentState,
),
[askProcessState.currentState],
);
useEffect(() => {
if (data) {
const processState = convertToProcessState(data.status);
askProcessState.setState(processState);
}
}, [data]);
useEffect(() => {
if (error) {
// TODO: confirm error state
const errorState = convertToErrorState(error.status);
askProcessState.setState(errorState);
}
}, [error]);
const closeResult = () => {
askProcessState.resetState();
};
const stopProcess = () => {
askProcessState.resetState();
onStop && onStop();
};
const syncInputValue = (event) => {
setInputValue(event.target.value);
};
const inputEnter = (event) => {
if (event.shiftKey) return;
event.preventDefault();
if (!isProcessing) submitAsk();
};
const submitAsk = async () => {
if (question) {
onSubmit && (await onSubmit(question));
}
};
return (
<PromptStyle className="d-flex align-end bg-gray-2 p-3 border border-gray-3 rounded">
<Input.TextArea
size="large"
autoSize
placeholder="Ask to explore your data"
value={inputValue}
onInput={syncInputValue}
onPressEnter={inputEnter}
/>
<PromptButton
type="primary"
size="large"
className="ml-3"
onClick={submitAsk}
disabled={isProcessing}
>
Ask
</PromptButton>
<PromptResult
data={results}
error={error}
processState={askProcessState}
onClose={closeResult}
onStop={stopProcess}
/>
</PromptStyle>
);
}
@@ -63,6 +63,8 @@ const SelectModelTemplate: IterableComponent = ({ index, name, fields }) => (
</Form.Item>
);
const SelectModelIterator = makeIterable(SelectModelTemplate);
export default function CreateModels(props: Props) {
const { onBack, onNext, selectedModels, tables } = props;
@@ -78,8 +80,6 @@ export default function CreateModels(props: Props) {
});
}, [selectedModels, tables]);
const SelectModelIterator = makeIterable(SelectModelTemplate);
const submit = () => {
form
.validateFields()
@@ -75,12 +75,12 @@ const SelectRelationTemplate: IterableComponent = ({
</Form.Item>
);
const SelectRelationIterator = makeIterable(SelectRelationTemplate);
export default function RecommendRelations(props: Props) {
const { recommendRelations, onBack, onNext } = props;
const [form] = Form.useForm();
const SelectRelationIterator = makeIterable(SelectRelationTemplate);
const submit = () => {
form
.validateFields()
@@ -68,14 +68,14 @@ const ButtonTemplate = (
);
};
const DataSourceIterator = makeIterable(ButtonTemplate);
const TemplatesIterator = makeIterable(ButtonTemplate);
export default function Starter(props) {
const { onNext } = props;
const dataSources = getDataSources();
const templates = getTemplates();
const DataSourceIterator = makeIterable(ButtonTemplate);
const TemplatesIterator = makeIterable(ButtonTemplate);
const onSelectDataSource = (value: DATA_SOURCES) => {
onNext && onNext({ dataSource: value });
};
+28
View File
@@ -0,0 +1,28 @@
import { useState } from 'react';
import { PROCESS_STATE } from '@/utils/enum';
export default function useAskProcessState() {
const [currentState, setCurrentState] = useState<PROCESS_STATE>(
PROCESS_STATE.IDLE,
);
const resetState = () => {
setCurrentState(PROCESS_STATE.IDLE);
};
const nextState = () => {
setCurrentState(currentState + 1);
console.log(currentState);
};
const setState = (state: PROCESS_STATE) => {
setCurrentState(state);
};
return {
currentState,
resetState,
nextState,
setState,
};
}
@@ -1,12 +1,14 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/router';
import styled from 'styled-components';
import { Divider } from 'antd';
import { Path } from '@/utils/enum';
import { nextTick } from '@/utils/time';
import useModalAction from '@/hooks/useModalAction';
import SiderLayout from '@/components/layouts/SiderLayout';
import AnswerResult from '@/components/ask/AnswerResult';
import AnswerResult from '@/components/pages/home/AnswerResult';
import SaveAsViewModal from '@/components/modals/SaveAsViewModal';
import Prompt from '@/components/pages/home/prompt';
const AnswerResultsBlock = styled.div`
width: 768px;
@@ -31,10 +33,30 @@ const AnswerResultsBlock = styled.div`
}
`;
const testData = {
status: '',
result: [
{
summary: 'Top 10 customer with most order from global customer in 2024',
sql: 'SELECT * FROM customer',
},
{
summary: 'Top 10 customer with most order from global customer in 2024',
sql: 'SELECT * FROM customer',
},
{
summary: 'Top 10 customer with most order from global customer in 2024',
sql: 'SELECT * FROM customer',
},
],
};
export default function AnswerBlock() {
const divRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const saveAsViewModal = useModalAction();
// TODO: adjust when intergrating with API
const [simulateData, setSimulateData] = useState(testData);
// TODO: implement scroll when has new answer result
useEffect(() => {
@@ -58,7 +80,7 @@ export default function AnswerBlock() {
const data = [];
const onSelect = (selectKeys: string[]) => {
router.push(`${Path.ASK}/${selectKeys[0]}`);
router.push(`${Path.Home}/${selectKeys[0]}`);
};
// TODO: call API to get real answer results
@@ -90,6 +112,23 @@ export default function AnswerBlock() {
},
];
const onStop = () => {
// TODO: send stop asking API
};
const simulateProcess = async () => {
setSimulateData({ ...simulateData, status: 'understanding' });
await nextTick(3000);
setSimulateData({ ...simulateData, status: 'searching' });
await nextTick(3000);
setSimulateData({ ...simulateData, status: 'finished' });
};
const onSubmit = async (value) => {
console.log(value);
await simulateProcess();
};
return (
<SiderLayout loading={false} sidebar={{ data, onSelect }}>
<AnswerResultsBlock className="mt-12 mb-15" ref={divRef}>
@@ -114,6 +153,7 @@ export default function AnswerBlock() {
console.log('save as view', values);
}}
/>
<Prompt data={simulateData} onSubmit={onSubmit} onStop={onStop} />
</SiderLayout>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { useState } from 'react';
import { useRouter } from 'next/router';
import { Path } from '@/utils/enum';
import { nextTick } from '@/utils/time';
import SiderLayout from '@/components/layouts/SiderLayout';
import Prompt from '@/components/pages/home/prompt';
import DemoPrompt from '@/components/pages/home/prompt/DemoPrompt';
const testData = {
status: 'searching',
result: [
{
summary: 'Top 10 customer with most order from global customer in 2024',
sql: 'SELECT * FROM customer',
},
{
summary: 'Top 10 customer with most order from global customer in 2024',
sql: 'SELECT * FROM customer',
},
{
summary: 'Top 10 customer with most order from global customer in 2024',
sql: 'SELECT * FROM customer',
},
],
};
const errorData = undefined;
// {
// message: '',
// extensions: {
// code: '000',
// data: {
// status: 'finished',
// // message: `Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 10\n at Main.main(Main.java:4)`,
// },
// },
// };
const demoData = [
{
title: 'General question',
summary: 'Show me top 10 best-selling product last month.',
},
{
title: 'Drill into metrics',
summary:
'List the name of the users who successfully convert to paying customers last week.',
},
{
title: 'Aggregate data',
summary:
"Help me categorize customers' ages into groups segmented by every 10 years.",
},
];
export default function Ask() {
const router = useRouter();
// TODO: adjust when intergrating with API
const [simulateData, setSimulateData] = useState(testData);
const data = [];
const isDemo = true;
const onSelect = (selectKeys: string[]) => {
router.push(`${Path.Home}/${selectKeys[0]}`);
};
const onDemoSelect = () => {};
const onStop = () => {
// TODO: send stop asking API
};
const simulateProcess = async () => {
setSimulateData({ ...simulateData, status: 'understanding' });
await nextTick(3000);
setSimulateData({ ...simulateData, status: 'searching' });
await nextTick(3000);
setSimulateData({ ...simulateData, status: 'finished' });
};
const onSubmit = async (value) => {
console.log(value);
await simulateProcess();
};
return (
<SiderLayout loading={false} sidebar={{ data, onSelect }}>
<div
className="d-flex align-center justify-center flex-column"
style={{ height: '100%' }}
>
<img src="https://picsum.photos/45/45" alt="home-logo" />
<div className="text-md text-medium mt-3">
Know more about your data
</div>
{isDemo && <DemoPrompt demo={demoData} onSelect={onDemoSelect} />}
</div>
<Prompt
data={simulateData}
error={errorData?.extensions.data}
onSubmit={onSubmit}
onStop={onStop}
/>
</SiderLayout>
);
}
@@ -14,3 +14,11 @@
.button-disabled();
}
}
.adm-btn-no-style {
cursor: pointer;
background: transparent;
line-height: 1.2;
height: auto;
border: none;
}
+4
View File
@@ -23,3 +23,7 @@
.rounded {
border-radius: 4px !important;
}
.rounded-pill {
border-radius: 999px !important;
}
+8
View File
@@ -25,6 +25,14 @@
text-align: center !important;
}
.text-bold {
font-weight: 700 !important;
}
.text-medium {
font-weight: 500 !important;
}
.make-text-classes();
.text-truncate {
-5
View File
@@ -1,5 +0,0 @@
export enum COLLAPSE_CONTENT_TYPE {
NONE = 'none',
VIEW_SQL = 'view_sql',
PREVIEW_DATA = 'preview_data',
}
+17
View File
@@ -0,0 +1,17 @@
export enum COLLAPSE_CONTENT_TYPE {
NONE = 'none',
VIEW_SQL = 'view_sql',
PREVIEW_DATA = 'preview_data',
}
export enum PROCESS_STATE {
IDLE,
UNDERSTANDING,
SEARCHING,
GENERATING,
FINISHED,
UNDERSTANDING_FAILED,
SEARCHING_FAILED,
NO_RESULT,
}
+1 -1
View File
@@ -5,4 +5,4 @@ export * from './columnType';
export * from './modeling';
export * from './path';
export * from './diagram';
export * from './ask';
export * from './home';
+1 -1
View File
@@ -2,5 +2,5 @@ export enum Path {
Exploration = '/exploration',
Modeling = '/modeling',
Onboarding = '/setup',
ASK = '/ask',
Home = '/home',
}